feat: 💨
This commit is contained in:
@@ -32,6 +32,14 @@ export default function Image({
|
|||||||
const [imageUrl, setImageUrl] = useState(url)
|
const [imageUrl, setImageUrl] = useState(url)
|
||||||
const [tried, setTried] = useState(new Set())
|
const [tried, setTried] = useState(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageUrl(url)
|
||||||
|
setIsLoading(true)
|
||||||
|
setHasError(false)
|
||||||
|
setDisplayBlurHash(true)
|
||||||
|
setTried(new Set())
|
||||||
|
}, [url])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (blurHash) {
|
if (blurHash) {
|
||||||
const { numX, numY } = decodeBlurHashSize(blurHash)
|
const { numX, numY } = decodeBlurHashSize(blurHash)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { toast } from 'sonner'
|
|||||||
import EmojiPickerDialog from '../EmojiPickerDialog'
|
import EmojiPickerDialog from '../EmojiPickerDialog'
|
||||||
import Mentions from './Mentions'
|
import Mentions from './Mentions'
|
||||||
import PollEditor from './PollEditor'
|
import PollEditor from './PollEditor'
|
||||||
import { usePostEditor } from './PostEditorProvider'
|
|
||||||
import PostOptions from './PostOptions'
|
import PostOptions from './PostOptions'
|
||||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||||
import SendOnlyToSwitch from './SendOnlyToSwitch'
|
import SendOnlyToSwitch from './SendOnlyToSwitch'
|
||||||
@@ -37,13 +36,12 @@ export default function PostContent({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey, publish, checkLogin } = useNostr()
|
const { pubkey, publish, checkLogin } = useNostr()
|
||||||
const { addReplies } = useReply()
|
const { addReplies } = useReply()
|
||||||
const { uploadingFiles, setUploadingFiles } = usePostEditor()
|
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
const [uploadProgresses, setUploadProgresses] = useState<
|
||||||
const [uploadFileName, setUploadFileName] = useState<string | null>(null)
|
{ file: File; progress: number; cancel: () => void }[]
|
||||||
const cancelRef = useRef<(() => void) | null>(null)
|
>([])
|
||||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||||
const [addClientTag, setAddClientTag] = useState(false)
|
const [addClientTag, setAddClientTag] = useState(false)
|
||||||
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
||||||
@@ -61,7 +59,7 @@ export default function PostContent({
|
|||||||
!!pubkey &&
|
!!pubkey &&
|
||||||
!!text &&
|
!!text &&
|
||||||
!posting &&
|
!posting &&
|
||||||
!uploadingFiles &&
|
!uploadProgresses.length &&
|
||||||
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2)
|
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,6 +159,20 @@ export default function PostContent({
|
|||||||
setIsPoll((prev) => !prev)
|
setIsPoll((prev) => !prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUploadStart = (file: File, cancel: () => void) => {
|
||||||
|
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadProgress = (file: File, progress: number) => {
|
||||||
|
setUploadProgresses((prev) =>
|
||||||
|
prev.map((item) => (item.file === file ? { ...item, progress } : item))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadEnd = (file: File) => {
|
||||||
|
setUploadProgresses((prev) => prev.filter((item) => item.file !== file))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{parentEvent && (
|
{parentEvent && (
|
||||||
@@ -178,17 +190,9 @@ export default function PostContent({
|
|||||||
parentEvent={parentEvent}
|
parentEvent={parentEvent}
|
||||||
onSubmit={() => post()}
|
onSubmit={() => post()}
|
||||||
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
||||||
onUploadStart={(file) => {
|
onUploadStart={handleUploadStart}
|
||||||
setUploadFileName(file.name)
|
onUploadProgress={handleUploadProgress}
|
||||||
setUploadProgress(0)
|
onUploadEnd={handleUploadEnd}
|
||||||
}}
|
|
||||||
onUploadProgress={(p) => setUploadProgress(p)}
|
|
||||||
onUploadEnd={() => {
|
|
||||||
setUploadProgress(null)
|
|
||||||
setUploadFileName(null)
|
|
||||||
cancelRef.current = null
|
|
||||||
}}
|
|
||||||
onProvideCancel={(cancel) => (cancelRef.current = cancel)}
|
|
||||||
/>
|
/>
|
||||||
{isPoll && (
|
{isPoll && (
|
||||||
<PollEditor
|
<PollEditor
|
||||||
@@ -197,6 +201,33 @@ export default function PostContent({
|
|||||||
setIsPoll={setIsPoll}
|
setIsPoll={setIsPoll}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{uploadProgresses.length > 0 &&
|
||||||
|
uploadProgresses.map(({ file, progress, cancel }, index) => (
|
||||||
|
<div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs text-muted-foreground mb-1">
|
||||||
|
{file.name ?? t('Uploading...')}
|
||||||
|
</div>
|
||||||
|
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
cancel?.()
|
||||||
|
handleUploadEnd(file)
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title={t('Cancel')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{!isPoll && (
|
{!isPoll && (
|
||||||
<SendOnlyToSwitch
|
<SendOnlyToSwitch
|
||||||
parentEvent={parentEvent}
|
parentEvent={parentEvent}
|
||||||
@@ -204,52 +235,18 @@ export default function PostContent({
|
|||||||
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
|
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{uploadProgress !== null && (
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-xs text-muted-foreground mb-1">
|
|
||||||
{uploadFileName ?? t('Uploading...')}
|
|
||||||
</div>
|
|
||||||
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-primary transition-[width] duration-200 ease-out"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => cancelRef.current?.()}
|
|
||||||
className="p-1 text-muted-foreground hover:text-foreground"
|
|
||||||
title={t('Cancel')}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Uploader
|
<Uploader
|
||||||
onUploadSuccess={({ url }) => {
|
onUploadSuccess={({ url }) => {
|
||||||
textareaRef.current?.appendText(url, true)
|
textareaRef.current?.appendText(url, true)
|
||||||
}}
|
}}
|
||||||
onUploadingChange={(uploading) =>
|
onUploadStart={handleUploadStart}
|
||||||
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1))
|
onUploadEnd={handleUploadEnd}
|
||||||
}
|
onProgress={handleUploadProgress}
|
||||||
onUploadStart={(file) => {
|
|
||||||
setUploadFileName(file.name)
|
|
||||||
setUploadProgress(0)
|
|
||||||
}}
|
|
||||||
onUploadEnd={() => {
|
|
||||||
setUploadProgress(null)
|
|
||||||
setUploadFileName(null)
|
|
||||||
cancelRef.current = null
|
|
||||||
}}
|
|
||||||
onProgress={(p) => setUploadProgress(p)}
|
|
||||||
onProvideCancel={(cancel) => (cancelRef.current = cancel)}
|
|
||||||
accept="image/*,video/*,audio/*"
|
accept="image/*,video/*,audio/*"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" disabled={uploadingFiles > 0}>
|
<Button variant="ghost" size="icon">
|
||||||
<ImageUp />
|
<ImageUp />
|
||||||
</Button>
|
</Button>
|
||||||
</Uploader>
|
</Uploader>
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createContext, Dispatch, SetStateAction, useContext, useState } from 'react'
|
|
||||||
|
|
||||||
type TPostEditorContext = {
|
|
||||||
uploadingFiles: number
|
|
||||||
setUploadingFiles: Dispatch<SetStateAction<number>>
|
|
||||||
}
|
|
||||||
|
|
||||||
const PostEditorContext = createContext<TPostEditorContext | undefined>(undefined)
|
|
||||||
|
|
||||||
export const usePostEditor = () => {
|
|
||||||
const context = useContext(PostEditorContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('usePostEditor must be used within a PostEditorProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PostEditorProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [uploadingFiles, setUploadingFiles] = useState(0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PostEditorContext.Provider value={{ uploadingFiles, setUploadingFiles }}>
|
|
||||||
{children}
|
|
||||||
</PostEditorContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import mediaUpload from '@/services/media-upload.service'
|
import mediaUpload from '@/services/media-upload.service'
|
||||||
import { Extension } from '@tiptap/core'
|
import { Extension } from '@tiptap/core'
|
||||||
import { EditorView } from '@tiptap/pm/view'
|
import { EditorView } from '@tiptap/pm/view'
|
||||||
import { Slice } from '@tiptap/pm/model'
|
|
||||||
import { Plugin, TextSelection } from 'prosemirror-state'
|
import { Plugin, TextSelection } from 'prosemirror-state'
|
||||||
|
|
||||||
const DRAGOVER_CLASS_LIST = [
|
const DRAGOVER_CLASS_LIST = [
|
||||||
@@ -13,12 +12,9 @@ const DRAGOVER_CLASS_LIST = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export interface ClipboardAndDropHandlerOptions {
|
export interface ClipboardAndDropHandlerOptions {
|
||||||
onUploadStart?: (file: File) => void
|
onUploadStart?: (file: File, cancel: () => void) => void
|
||||||
onUploadSuccess?: (file: File, result: any) => void
|
onUploadEnd?: (file: File) => void
|
||||||
onUploadError?: (file: File, error: any) => void
|
|
||||||
onUploadEnd?: () => void
|
|
||||||
onUploadProgress?: (file: File, progress: number) => void
|
onUploadProgress?: (file: File, progress: number) => void
|
||||||
onProvideCancel?: (cancel: () => void) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
|
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
|
||||||
@@ -57,7 +53,7 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleDrop(view: EditorView, event: DragEvent, _slice: Slice, _moved: boolean) {
|
handleDrop(view: EditorView, event: DragEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||||
@@ -68,7 +64,7 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
)
|
)
|
||||||
if (!mediaFiles.length) return false
|
if (!mediaFiles.length) return false
|
||||||
|
|
||||||
uploadFile(view, mediaFiles, options)
|
uploadFiles(view, mediaFiles, options)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
handlePaste(view, event) {
|
handlePaste(view, event) {
|
||||||
@@ -82,7 +78,7 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
) {
|
) {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (file) {
|
if (file) {
|
||||||
uploadFile(view, [file], options)
|
uploadFiles(view, [file], options)
|
||||||
handled = true
|
handled = true
|
||||||
}
|
}
|
||||||
} else if (item.kind === 'string' && item.type === 'text/plain') {
|
} else if (item.kind === 'string' && item.type === 'text/plain') {
|
||||||
@@ -116,16 +112,22 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function uploadFile(
|
async function uploadFiles(
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
files: File[],
|
files: File[],
|
||||||
options: ClipboardAndDropHandlerOptions
|
options: ClipboardAndDropHandlerOptions
|
||||||
) {
|
) {
|
||||||
|
const abortControllers = new Map<File, AbortController>()
|
||||||
|
files.forEach((file) => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
abortControllers.set(file, abortController)
|
||||||
|
options.onUploadStart?.(file, () => abortController.abort())
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const name = file.name
|
const name = file.name
|
||||||
|
|
||||||
options.onUploadStart?.(file)
|
|
||||||
|
|
||||||
const placeholder = `[Uploading "${name}"...]`
|
const placeholder = `[Uploading "${name}"...]`
|
||||||
const uploadingNode = view.state.schema.text(placeholder)
|
const uploadingNode = view.state.schema.text(placeholder)
|
||||||
const hardBreakNode = view.state.schema.nodes.hardBreak.create()
|
const hardBreakNode = view.state.schema.nodes.hardBreak.create()
|
||||||
@@ -133,16 +135,15 @@ async function uploadFile(
|
|||||||
tr = tr.insert(tr.selection.from, hardBreakNode)
|
tr = tr.insert(tr.selection.from, hardBreakNode)
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = abortControllers.get(file)
|
||||||
options.onProvideCancel?.(() => abortController.abort())
|
|
||||||
|
|
||||||
mediaUpload
|
mediaUpload
|
||||||
.upload(file, {
|
.upload(file, {
|
||||||
onProgress: (p) => options.onUploadProgress?.(file, p),
|
onProgress: (p) => options.onUploadProgress?.(file, p),
|
||||||
signal: abortController.signal
|
signal: abortController?.signal
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
options.onUploadSuccess?.(file, result)
|
options.onUploadEnd?.(file)
|
||||||
const urlNode = view.state.schema.text(result.url)
|
const urlNode = view.state.schema.text(result.url)
|
||||||
|
|
||||||
const tr = view.state.tr
|
const tr = view.state.tr
|
||||||
@@ -175,11 +176,10 @@ async function uploadFile(
|
|||||||
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
|
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
|
||||||
view.dispatch(insertTr)
|
view.dispatch(insertTr)
|
||||||
}
|
}
|
||||||
options.onUploadEnd?.()
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Upload failed:', error)
|
console.error('Upload failed:', error)
|
||||||
options.onUploadError?.(file, error)
|
options.onUploadEnd?.(file)
|
||||||
|
|
||||||
const tr = view.state.tr
|
const tr = view.state.tr
|
||||||
let didReplace = false
|
let didReplace = false
|
||||||
@@ -200,7 +200,6 @@ async function uploadFile(
|
|||||||
if (didReplace) {
|
if (didReplace) {
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
}
|
}
|
||||||
options.onUploadEnd?.()
|
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { EditorContent, useEditor } from '@tiptap/react'
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
|
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { usePostEditor } from '../PostEditorProvider'
|
|
||||||
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
|
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
|
||||||
import CustomMention from './CustomMention'
|
import CustomMention from './CustomMention'
|
||||||
import Preview from './Preview'
|
import Preview from './Preview'
|
||||||
@@ -33,12 +32,13 @@ const PostTextarea = forwardRef<
|
|||||||
parentEvent?: Event
|
parentEvent?: Event
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
onUploadStart?: (file: File) => void
|
onUploadStart?: (file: File, cancel: () => void) => void
|
||||||
onUploadProgress?: (progress: number, file: File) => void
|
onUploadProgress?: (file: File, progress: number) => void
|
||||||
onUploadEnd?: () => void
|
onUploadEnd?: (file: File) => void
|
||||||
onProvideCancel?: (cancel: () => void) => void
|
|
||||||
}
|
}
|
||||||
>(({
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
text = '',
|
text = '',
|
||||||
setText,
|
setText,
|
||||||
defaultContent,
|
defaultContent,
|
||||||
@@ -47,11 +47,11 @@ const PostTextarea = forwardRef<
|
|||||||
className,
|
className,
|
||||||
onUploadStart,
|
onUploadStart,
|
||||||
onUploadProgress,
|
onUploadProgress,
|
||||||
onUploadEnd,
|
onUploadEnd
|
||||||
onProvideCancel
|
},
|
||||||
}, ref) => {
|
ref
|
||||||
|
) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { setUploadingFiles } = usePostEditor()
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
Document,
|
Document,
|
||||||
@@ -60,21 +60,18 @@ const PostTextarea = forwardRef<
|
|||||||
History,
|
History,
|
||||||
HardBreak,
|
HardBreak,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
placeholder:
|
||||||
|
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
||||||
}),
|
}),
|
||||||
CustomMention.configure({
|
CustomMention.configure({
|
||||||
suggestion
|
suggestion
|
||||||
}),
|
}),
|
||||||
ClipboardAndDropHandler.configure({
|
ClipboardAndDropHandler.configure({
|
||||||
onUploadStart: (file) => {
|
onUploadStart: (file, cancel) => {
|
||||||
setUploadingFiles((prev) => prev + 1)
|
onUploadStart?.(file, cancel)
|
||||||
onUploadStart?.(file)
|
|
||||||
},
|
},
|
||||||
onUploadSuccess: () => setUploadingFiles((prev) => prev - 1),
|
onUploadEnd: (file) => onUploadEnd?.(file),
|
||||||
onUploadError: () => setUploadingFiles((prev) => prev - 1),
|
onUploadProgress: (file, p) => onUploadProgress?.(file, p)
|
||||||
onUploadEnd: () => onUploadEnd?.(),
|
|
||||||
onUploadProgress: (file, p) => onUploadProgress?.(p, file),
|
|
||||||
onProvideCancel: (cancel) => onProvideCancel?.(cancel)
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -151,6 +148,7 @@ const PostTextarea = forwardRef<
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
PostTextarea.displayName = 'PostTextarea'
|
PostTextarea.displayName = 'PostTextarea'
|
||||||
export default PostTextarea
|
export default PostTextarea
|
||||||
|
|||||||
@@ -1,62 +1,57 @@
|
|||||||
import mediaUpload from '@/services/media-upload.service'
|
import mediaUpload, { UPLOAD_ABORTED_ERROR_MSG } from '@/services/media-upload.service'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function Uploader({
|
export default function Uploader({
|
||||||
children,
|
children,
|
||||||
onUploadSuccess,
|
onUploadSuccess,
|
||||||
onUploadingChange,
|
|
||||||
onUploadStart,
|
onUploadStart,
|
||||||
onUploadEnd,
|
onUploadEnd,
|
||||||
onProgress,
|
onProgress,
|
||||||
onProvideCancel,
|
|
||||||
className,
|
className,
|
||||||
accept = 'image/*'
|
accept = 'image/*'
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||||
onUploadingChange?: (uploading: boolean) => void
|
onUploadStart?: (file: File, cancel: () => void) => void
|
||||||
onUploadStart?: (file: File) => void
|
onUploadEnd?: (file: File) => void
|
||||||
onUploadEnd?: () => void
|
onProgress?: (file: File, progress: number) => void
|
||||||
onProgress?: (progress: number, file: File) => void
|
|
||||||
onProvideCancel?: (cancel: () => void) => void
|
|
||||||
className?: string
|
className?: string
|
||||||
accept?: string
|
accept?: string
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const abortControllerRef = useRef<AbortController | null>(null)
|
|
||||||
|
|
||||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return
|
if (!event.target.files) return
|
||||||
|
|
||||||
onUploadingChange?.(true)
|
const abortControllerMap = new Map<File, AbortController>()
|
||||||
try {
|
|
||||||
for (const file of event.target.files) {
|
for (const file of event.target.files) {
|
||||||
abortControllerRef.current = new AbortController()
|
const abortController = new AbortController()
|
||||||
const cancel = () => abortControllerRef.current?.abort()
|
abortControllerMap.set(file, abortController)
|
||||||
onProvideCancel?.(cancel)
|
onUploadStart?.(file, () => abortController.abort())
|
||||||
onUploadStart?.(file)
|
}
|
||||||
|
|
||||||
|
for (const file of event.target.files) {
|
||||||
|
try {
|
||||||
|
const abortController = abortControllerMap.get(file)
|
||||||
const result = await mediaUpload.upload(file, {
|
const result = await mediaUpload.upload(file, {
|
||||||
onProgress: (p) => onProgress?.(p, file),
|
onProgress: (p) => onProgress?.(file, p),
|
||||||
signal: abortControllerRef.current.signal
|
signal: abortController?.signal
|
||||||
})
|
})
|
||||||
onUploadSuccess(result)
|
onUploadSuccess(result)
|
||||||
abortControllerRef.current = null
|
onUploadEnd?.(file)
|
||||||
onUploadEnd?.()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading file', error)
|
console.error('Error uploading file', error)
|
||||||
const message = (error as Error).message
|
const message = (error as Error).message
|
||||||
if (message !== 'Upload aborted') {
|
if (message !== UPLOAD_ABORTED_ERROR_MSG) {
|
||||||
toast.error(`Failed to upload file: ${message}`)
|
toast.error(`Failed to upload file: ${message}`)
|
||||||
}
|
}
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
abortControllerRef.current = null
|
onUploadEnd?.(file)
|
||||||
onUploadEnd?.()
|
}
|
||||||
} finally {
|
|
||||||
onUploadingChange?.(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import postEditor from '@/services/post-editor.service'
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { Dispatch, useMemo } from 'react'
|
import { Dispatch, useMemo } from 'react'
|
||||||
import PostContent from './PostContent'
|
import PostContent from './PostContent'
|
||||||
import { PostEditorProvider } from './PostEditorProvider'
|
|
||||||
import Title from './Title'
|
import Title from './Title'
|
||||||
|
|
||||||
export default function PostEditor({
|
export default function PostEditor({
|
||||||
@@ -36,13 +35,11 @@ export default function PostEditor({
|
|||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<PostEditorProvider>
|
|
||||||
<PostContent
|
<PostContent
|
||||||
defaultContent={defaultContent}
|
defaultContent={defaultContent}
|
||||||
parentEvent={parentEvent}
|
parentEvent={parentEvent}
|
||||||
close={() => setOpen(false)}
|
close={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</PostEditorProvider>
|
|
||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function ProfileBanner({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||||
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
|
const [bannerUrl, setBannerUrl] = useState(banner ?? defaultBanner)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (banner) {
|
if (banner) {
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
<div className="relative bg-cover bg-center rounded-lg mb-2">
|
<div className="relative bg-cover bg-center rounded-lg mb-2">
|
||||||
<Uploader
|
<Uploader
|
||||||
onUploadSuccess={onBannerUploadSuccess}
|
onUploadSuccess={onBannerUploadSuccess}
|
||||||
onUploadingChange={(uploading) => setTimeout(() => setUploadingBanner(uploading), 50)}
|
onUploadStart={() => setUploadingBanner(true)}
|
||||||
|
onUploadEnd={() => setUploadingBanner(false)}
|
||||||
className="w-full relative cursor-pointer"
|
className="w-full relative cursor-pointer"
|
||||||
>
|
>
|
||||||
<ProfileBanner
|
<ProfileBanner
|
||||||
@@ -146,7 +147,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
</Uploader>
|
</Uploader>
|
||||||
<Uploader
|
<Uploader
|
||||||
onUploadSuccess={onAvatarUploadSuccess}
|
onUploadSuccess={onAvatarUploadSuccess}
|
||||||
onUploadingChange={(uploading) => setTimeout(() => setUploadingAvatar(uploading), 50)}
|
onUploadStart={() => setUploadingAvatar(true)}
|
||||||
|
onUploadEnd={() => setUploadingAvatar(false)}
|
||||||
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
|
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
|
||||||
>
|
>
|
||||||
<Avatar className="w-full h-full">
|
<Avatar className="w-full h-full">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type UploadOptions = {
|
|||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted'
|
||||||
|
|
||||||
class MediaUploadService {
|
class MediaUploadService {
|
||||||
static instance: MediaUploadService
|
static instance: MediaUploadService
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ class MediaUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.signal?.aborted) {
|
if (options?.signal?.aborted) {
|
||||||
throw new Error('Upload aborted')
|
throw new Error(UPLOAD_ABORTED_ERROR_MSG)
|
||||||
}
|
}
|
||||||
|
|
||||||
options?.onProgress?.(0)
|
options?.onProgress?.(0)
|
||||||
@@ -115,6 +117,9 @@ class MediaUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async uploadByNip96(service: string, file: File, options?: UploadOptions) {
|
private async uploadByNip96(service: string, file: File, options?: UploadOptions) {
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw new Error(UPLOAD_ABORTED_ERROR_MSG)
|
||||||
|
}
|
||||||
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
|
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
|
||||||
if (!uploadUrl) {
|
if (!uploadUrl) {
|
||||||
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
|
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
|
||||||
@@ -133,6 +138,9 @@ class MediaUploadService {
|
|||||||
this.nip96ServiceUploadUrlMap.set(service, uploadUrl)
|
this.nip96ServiceUploadUrlMap.set(service, uploadUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw new Error(UPLOAD_ABORTED_ERROR_MSG)
|
||||||
|
}
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
@@ -148,10 +156,10 @@ class MediaUploadService {
|
|||||||
const handleAbort = () => {
|
const handleAbort = () => {
|
||||||
try {
|
try {
|
||||||
xhr.abort()
|
xhr.abort()
|
||||||
} catch (_) {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
reject(new Error('Upload aborted'))
|
reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
|
||||||
}
|
}
|
||||||
if (options?.signal) {
|
if (options?.signal) {
|
||||||
if (options.signal.aborted) {
|
if (options.signal.aborted) {
|
||||||
|
|||||||
Reference in New Issue
Block a user