feat: add media upload progress bar (#479)
This commit is contained in:
@@ -11,7 +11,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import postEditorCache from '@/services/post-editor-cache.service'
|
||||
import { TPollCreateData } from '@/types'
|
||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile } from 'lucide-react'
|
||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -41,6 +41,9 @@ export default function PostContent({
|
||||
const [text, setText] = useState('')
|
||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
const [uploadFileName, setUploadFileName] = useState<string | null>(null)
|
||||
const cancelRef = useRef<(() => void) | null>(null)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
||||
@@ -175,6 +178,17 @@ export default function PostContent({
|
||||
parentEvent={parentEvent}
|
||||
onSubmit={() => post()}
|
||||
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
||||
onUploadStart={(file) => {
|
||||
setUploadFileName(file.name)
|
||||
setUploadProgress(0)
|
||||
}}
|
||||
onUploadProgress={(p) => setUploadProgress(p)}
|
||||
onUploadEnd={() => {
|
||||
setUploadProgress(null)
|
||||
setUploadFileName(null)
|
||||
cancelRef.current = null
|
||||
}}
|
||||
onProvideCancel={(cancel) => (cancelRef.current = cancel)}
|
||||
/>
|
||||
{isPoll && (
|
||||
<PollEditor
|
||||
@@ -190,6 +204,29 @@ export default function PostContent({
|
||||
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 gap-2 items-center">
|
||||
<Uploader
|
||||
@@ -199,10 +236,21 @@ export default function PostContent({
|
||||
onUploadingChange={(uploading) =>
|
||||
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1))
|
||||
}
|
||||
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/*"
|
||||
>
|
||||
<Button variant="ghost" size="icon" disabled={uploadingFiles > 0}>
|
||||
{uploadingFiles > 0 ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
<ImageUp />
|
||||
</Button>
|
||||
</Uploader>
|
||||
{/* I'm not sure why, but after triggering the virtual keyboard,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import { Plugin, TextSelection } from 'prosemirror-state'
|
||||
|
||||
const DRAGOVER_CLASS_LIST = [
|
||||
@@ -15,6 +16,9 @@ export interface ClipboardAndDropHandlerOptions {
|
||||
onUploadStart?: (file: File) => void
|
||||
onUploadSuccess?: (file: File, result: any) => void
|
||||
onUploadError?: (file: File, error: any) => void
|
||||
onUploadEnd?: () => void
|
||||
onUploadProgress?: (file: File, progress: number) => void
|
||||
onProvideCancel?: (cancel: () => void) => void
|
||||
}
|
||||
|
||||
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
|
||||
@@ -24,7 +28,10 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
||||
return {
|
||||
onUploadStart: undefined,
|
||||
onUploadSuccess: undefined,
|
||||
onUploadError: undefined
|
||||
onUploadError: undefined,
|
||||
onUploadEnd: undefined,
|
||||
onUploadProgress: undefined,
|
||||
onProvideCancel: undefined
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,30 +42,35 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
dragenter(view, event) {
|
||||
event.preventDefault()
|
||||
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
|
||||
return true
|
||||
},
|
||||
dragover(view, event) {
|
||||
event.preventDefault()
|
||||
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
|
||||
return false
|
||||
return true
|
||||
},
|
||||
dragleave(view) {
|
||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||
return false
|
||||
},
|
||||
drop(view, event) {
|
||||
event.preventDefault()
|
||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||
|
||||
const items = Array.from(event.dataTransfer?.files ?? [])
|
||||
const mediaFiles = items.filter(
|
||||
(item) => item.type.includes('image') || item.type.includes('video')
|
||||
)
|
||||
if (!mediaFiles.length) return false
|
||||
|
||||
uploadFile(view, mediaFiles, options)
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
handleDrop(view: EditorView, event: DragEvent, _slice: Slice, _moved: boolean) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||
|
||||
const items = Array.from(event.dataTransfer?.files ?? [])
|
||||
const mediaFiles = items.filter(
|
||||
(item) => item.type.includes('image') || item.type.includes('video')
|
||||
)
|
||||
if (!mediaFiles.length) return false
|
||||
|
||||
uploadFile(view, mediaFiles, options)
|
||||
return true
|
||||
},
|
||||
handlePaste(view, event) {
|
||||
const items = Array.from(event.clipboardData?.items ?? [])
|
||||
let handled = false
|
||||
@@ -121,8 +133,14 @@ async function uploadFile(
|
||||
tr = tr.insert(tr.selection.from, hardBreakNode)
|
||||
view.dispatch(tr)
|
||||
|
||||
const abortController = new AbortController()
|
||||
options.onProvideCancel?.(() => abortController.abort())
|
||||
|
||||
mediaUpload
|
||||
.upload(file)
|
||||
.upload(file, {
|
||||
onProgress: (p) => options.onUploadProgress?.(file, p),
|
||||
signal: abortController.signal
|
||||
})
|
||||
.then((result) => {
|
||||
options.onUploadSuccess?.(file, result)
|
||||
const urlNode = view.state.schema.text(result.url)
|
||||
@@ -157,6 +175,7 @@ async function uploadFile(
|
||||
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
|
||||
view.dispatch(insertTr)
|
||||
}
|
||||
options.onUploadEnd?.()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Upload failed:', error)
|
||||
@@ -181,7 +200,7 @@ async function uploadFile(
|
||||
if (didReplace) {
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
options.onUploadEnd?.()
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,8 +33,23 @@ const PostTextarea = forwardRef<
|
||||
parentEvent?: Event
|
||||
onSubmit?: () => void
|
||||
className?: string
|
||||
onUploadStart?: (file: File) => void
|
||||
onUploadProgress?: (progress: number, file: File) => void
|
||||
onUploadEnd?: () => void
|
||||
onProvideCancel?: (cancel: () => void) => void
|
||||
}
|
||||
>(({ text = '', setText, defaultContent, parentEvent, onSubmit, className }, ref) => {
|
||||
>(({
|
||||
text = '',
|
||||
setText,
|
||||
defaultContent,
|
||||
parentEvent,
|
||||
onSubmit,
|
||||
className,
|
||||
onUploadStart,
|
||||
onUploadProgress,
|
||||
onUploadEnd,
|
||||
onProvideCancel
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { setUploadingFiles } = usePostEditor()
|
||||
const editor = useEditor({
|
||||
@@ -51,9 +66,15 @@ const PostTextarea = forwardRef<
|
||||
suggestion
|
||||
}),
|
||||
ClipboardAndDropHandler.configure({
|
||||
onUploadStart: () => setUploadingFiles((prev) => prev + 1),
|
||||
onUploadStart: (file) => {
|
||||
setUploadingFiles((prev) => prev + 1)
|
||||
onUploadStart?.(file)
|
||||
},
|
||||
onUploadSuccess: () => setUploadingFiles((prev) => prev - 1),
|
||||
onUploadError: () => setUploadingFiles((prev) => prev - 1)
|
||||
onUploadError: () => setUploadingFiles((prev) => prev - 1),
|
||||
onUploadEnd: () => onUploadEnd?.(),
|
||||
onUploadProgress: (file, p) => onUploadProgress?.(p, file),
|
||||
onProvideCancel: (cancel) => onProvideCancel?.(cancel)
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
|
||||
@@ -6,16 +6,25 @@ export default function Uploader({
|
||||
children,
|
||||
onUploadSuccess,
|
||||
onUploadingChange,
|
||||
onUploadStart,
|
||||
onUploadEnd,
|
||||
onProgress,
|
||||
onProvideCancel,
|
||||
className,
|
||||
accept = 'image/*'
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||
onUploadingChange?: (uploading: boolean) => void
|
||||
onUploadStart?: (file: File) => void
|
||||
onUploadEnd?: () => void
|
||||
onProgress?: (progress: number, file: File) => void
|
||||
onProvideCancel?: (cancel: () => void) => void
|
||||
className?: string
|
||||
accept?: string
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return
|
||||
@@ -23,15 +32,29 @@ export default function Uploader({
|
||||
onUploadingChange?.(true)
|
||||
try {
|
||||
for (const file of event.target.files) {
|
||||
const result = await mediaUpload.upload(file)
|
||||
abortControllerRef.current = new AbortController()
|
||||
const cancel = () => abortControllerRef.current?.abort()
|
||||
onProvideCancel?.(cancel)
|
||||
onUploadStart?.(file)
|
||||
const result = await mediaUpload.upload(file, {
|
||||
onProgress: (p) => onProgress?.(p, file),
|
||||
signal: abortControllerRef.current.signal
|
||||
})
|
||||
onUploadSuccess(result)
|
||||
abortControllerRef.current = null
|
||||
onUploadEnd?.()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file', error)
|
||||
toast.error(`Failed to upload file: ${(error as Error).message}`)
|
||||
const message = (error as Error).message
|
||||
if (message !== 'Upload aborted') {
|
||||
toast.error(`Failed to upload file: ${message}`)
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
abortControllerRef.current = null
|
||||
onUploadEnd?.()
|
||||
} finally {
|
||||
onUploadingChange?.(false)
|
||||
}
|
||||
@@ -45,8 +68,8 @@ export default function Uploader({
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={handleUploadClick} className={className}>
|
||||
{children}
|
||||
<div className={className}>
|
||||
<div onClick={handleUploadClick}>{children}</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
|
||||
Reference in New Issue
Block a user