Browse Source

bug-fix uploader

imwald
Silberengel 3 weeks ago
parent
commit
f7013fe73f
  1. 52
      src/components/PostEditor/PostContent.tsx
  2. 6
      src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts
  3. 8
      src/components/PostEditor/PostTextarea/index.tsx
  4. 11
      src/components/PostEditor/Uploader.tsx
  5. 3
      src/i18n/locales/de.ts
  6. 3
      src/i18n/locales/en.ts
  7. 8
      src/lib/compress-upload-media.ts
  8. 12
      src/services/media-upload.service.ts

52
src/components/PostEditor/PostContent.tsx

@ -203,7 +203,7 @@ export default function PostContent({
const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null) const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null)
const [posting, setPosting] = useState(false) const [posting, setPosting] = useState(false)
const [uploadProgresses, setUploadProgresses] = useState< const [uploadProgresses, setUploadProgresses] = useState<
{ file: File; progress: number; cancel: () => void }[] { file: File; progress: number; cancel: () => void; phase: 'compressing' | 'uploading' }[]
>([]) >([])
const [showMoreOptions, setShowMoreOptions] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false)
const [createCustomEventOpen, setCreateCustomEventOpen] = useState(false) const [createCustomEventOpen, setCreateCustomEventOpen] = useState(false)
@ -1490,8 +1490,22 @@ export default function PostContent({
} }
} }
const handleUploadCompressPhase = useCallback((file: File, phase: 'compressing' | 'uploading') => {
setUploadProgresses((prev) =>
prev.map((row) => (row.file === file ? { ...row, phase } : row))
)
}, [])
const handleUploadStart = (file: File, cancel: () => void) => { const handleUploadStart = (file: File, cancel: () => void) => {
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) setUploadProgresses((prev) => [
...prev,
{
file,
progress: 0,
cancel,
phase: fileLooksLikeUploadableMedia(file) ? 'compressing' : 'uploading'
}
])
// Track file for media upload // Track file for media upload
if (fileLooksLikeUploadableMedia(file)) { if (fileLooksLikeUploadableMedia(file)) {
const mapKey = `${file.name}-${file.size}-${file.lastModified}` const mapKey = `${file.name}-${file.size}-${file.lastModified}`
@ -1541,7 +1555,11 @@ export default function PostContent({
const handleUploadProgress = (file: File, progress: number) => { const handleUploadProgress = (file: File, progress: number) => {
setUploadProgresses((prev) => setUploadProgresses((prev) =>
prev.map((item) => (item.file === file ? { ...item, progress } : item)) prev.map((item) =>
item.file === file
? { ...item, progress, phase: progress > 0 ? 'uploading' : item.phase }
: item
)
) )
} }
@ -2763,6 +2781,7 @@ export default function PostContent({
onUploadProgress={handleUploadProgress} onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onUploadSuccess={handleMediaUploadSuccess} onUploadSuccess={handleMediaUploadSuccess}
onUploadCompressPhase={handleUploadCompressPhase}
kind={getDeterminedKind} kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined} highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined} pollCreateData={isPoll ? pollCreateData : undefined}
@ -3024,17 +3043,29 @@ export default function PostContent({
</div> </div>
)} )}
{uploadProgresses.length > 0 && {uploadProgresses.length > 0 &&
uploadProgresses.map(({ file, progress, cancel }, index) => ( uploadProgresses.map(({ file, progress, cancel, phase }, index) => (
<div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2"> <div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-xs text-muted-foreground mb-1"> <div className="truncate text-xs text-muted-foreground mb-0.5">
{file.name ?? t('Uploading...')} {file.name ?? t('Uploading...')}
</div> </div>
<div className="text-[11px] text-muted-foreground mb-1 leading-snug">
{phase === 'compressing'
? t('Compressing on your device before upload (large videos can take several minutes)…')
: t('Uploading to media server…')}
</div>
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden"> <div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
<div {phase === 'compressing' ? (
className="h-full bg-primary transition-[width] duration-200 ease-out" <div
style={{ width: `${progress}%` }} className="h-full w-1/3 max-w-[45%] animate-pulse rounded-full bg-primary motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-60"
/> aria-hidden
/>
) : (
<div
className="h-full bg-primary transition-[width] duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
)}
</div> </div>
</div> </div>
<button <button
@ -3078,6 +3109,7 @@ export default function PostContent({
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress} onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska" accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska"
className="sr-only" className="sr-only"
> >
@ -3093,6 +3125,7 @@ export default function PostContent({
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress} onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
accept="audio/*,.mka,audio/x-matroska" accept="audio/*,.mka,audio/x-matroska"
> >
<Button <Button
@ -3111,6 +3144,7 @@ export default function PostContent({
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress} onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
accept="image/*" accept="image/*"
> >
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}> <Button type="button" variant="ghost" size="icon" title={t('Upload Image')}>

6
src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts

@ -19,6 +19,8 @@ export interface ClipboardAndDropHandlerOptions {
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadEnd?: (file: File) => void onUploadEnd?: (file: File) => void
onUploadProgress?: (file: File, progress: number) => void onUploadProgress?: (file: File, progress: number) => void
/** Same as `Uploader.onUploadCompressPhase` — keeps the post editor progress row in sync during local compression. */
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
} }
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
@ -138,7 +140,9 @@ async function uploadFiles(
mediaUpload mediaUpload
.upload(file, { .upload(file, {
onProgress: (p) => options.onUploadProgress?.(file, p), onProgress: (p) => options.onUploadProgress?.(file, p),
signal: abortController?.signal signal: abortController?.signal,
onCompressStart: () => options.onUploadCompressPhase?.(file, 'compressing'),
onCompressEnd: () => options.onUploadCompressPhase?.(file, 'uploading')
}) })
.then((result) => { .then((result) => {
options.onUploadEnd?.(file) options.onUploadEnd?.(file)

8
src/components/PostEditor/PostTextarea/index.tsx

@ -54,6 +54,7 @@ const PostTextarea = forwardRef<
onUploadProgress?: (file: File, progress: number) => void onUploadProgress?: (file: File, progress: number) => void
onUploadEnd?: (file: File) => void onUploadEnd?: (file: File) => void
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
kind?: number kind?: number
highlightData?: HighlightData highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData pollCreateData?: import('@/types').TPollCreateData
@ -84,6 +85,7 @@ const PostTextarea = forwardRef<
onUploadProgress, onUploadProgress,
onUploadEnd, onUploadEnd,
onUploadSuccess, onUploadSuccess,
onUploadCompressPhase,
kind = 1, kind = 1,
highlightData, highlightData,
pollCreateData, pollCreateData,
@ -100,6 +102,8 @@ const PostTextarea = forwardRef<
const { t } = useTranslation() const { t } = useTranslation()
const onUploadSuccessRef = useRef(onUploadSuccess) const onUploadSuccessRef = useRef(onUploadSuccess)
onUploadSuccessRef.current = onUploadSuccess onUploadSuccessRef.current = onUploadSuccess
const onUploadCompressPhaseRef = useRef(onUploadCompressPhase)
onUploadCompressPhaseRef.current = onUploadCompressPhase
const [activeTab, setActiveTab] = useState('preview') const [activeTab, setActiveTab] = useState('preview')
const [draftEventJson, setDraftEventJson] = useState<string>('') const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false) const [isLoadingJson, setIsLoadingJson] = useState(false)
@ -164,7 +168,9 @@ const PostTextarea = forwardRef<
}, },
onUploadEnd: (file) => onUploadEnd?.(file), onUploadEnd: (file) => onUploadEnd?.(file),
onUploadProgress: (file, p) => onUploadProgress?.(file, p), onUploadProgress: (file, p) => onUploadProgress?.(file, p),
onUploadSuccess: (result) => onUploadSuccessRef.current?.(result) onUploadSuccess: (result) => onUploadSuccessRef.current?.(result),
onUploadCompressPhase: (file, phase) =>
onUploadCompressPhaseRef.current?.(file, phase)
}) })
], ],
editorProps: { editorProps: {

11
src/components/PostEditor/Uploader.tsx

@ -7,8 +7,9 @@ export default function Uploader({
children, children,
onUploadSuccess, onUploadSuccess,
onUploadStart, onUploadStart,
onUploadEnd, onUploadEnd,
onProgress, onProgress,
onUploadCompressPhase,
className, className,
accept = 'image/*', accept = 'image/*',
maxFileSizeMb maxFileSizeMb
@ -18,6 +19,8 @@ export default function Uploader({
onUploadStart?: (file: File, cancel: () => void) => void onUploadStart?: (file: File, cancel: () => void) => void
onUploadEnd?: (file: File) => void onUploadEnd?: (file: File) => void
onProgress?: (file: File, progress: number) => void onProgress?: (file: File, progress: number) => void
/** After local compression (before network upload). */
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
className?: string className?: string
accept?: string accept?: string
/** Reject files whose size (before compression) exceeds this limit and show a toast. */ /** Reject files whose size (before compression) exceeds this limit and show a toast. */
@ -48,7 +51,9 @@ export default function Uploader({
const abortController = abortControllerMap.get(file) const abortController = abortControllerMap.get(file)
const result = await mediaUpload.upload(file, { const result = await mediaUpload.upload(file, {
onProgress: (p) => onProgress?.(file, p), onProgress: (p) => onProgress?.(file, p),
signal: abortController?.signal signal: abortController?.signal,
onCompressStart: () => onUploadCompressPhase?.(file, 'compressing'),
onCompressEnd: () => onUploadCompressPhase?.(file, 'uploading')
}) })
onUploadSuccess({ ...result, file }) onUploadSuccess({ ...result, file })
onUploadEnd?.(file) onUploadEnd?.(file)

3
src/i18n/locales/de.ts

@ -1858,6 +1858,8 @@ export default {
'🔞 NSFW 🔞': '🔞 NSFW 🔞', '🔞 NSFW 🔞': '🔞 NSFW 🔞',
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).': 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).':
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).', 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).',
'Compressing on your device before upload (large videos can take several minutes)…':
'Wird auf deinem Gerät vor dem Upload komprimiert (große Videos können mehrere Minuten dauern)…',
'Failed to refresh': 'Failed to refresh', 'Failed to refresh': 'Failed to refresh',
'Invalid article link.': 'Invalid article link.', 'Invalid article link.': 'Invalid article link.',
Likes: 'Likes', Likes: 'Likes',
@ -1874,6 +1876,7 @@ export default {
'Synthetic event (no author)': 'Synthetic event (no author)', 'Synthetic event (no author)': 'Synthetic event (no author)',
'Topic is required': 'Topic is required', 'Topic is required': 'Topic is required',
'Type a topic or pick from the list': 'Type a topic or pick from the list', 'Type a topic or pick from the list': 'Type a topic or pick from the list',
'Uploading to media server…': 'Wird zum Medienserver hochgeladen…',
profileEditorRefreshCacheHint: 'profileEditorRefreshCacheHint', profileEditorRefreshCacheHint: 'profileEditorRefreshCacheHint',
startupSessionHydrating: 'startupSessionHydrating' startupSessionHydrating: 'startupSessionHydrating'
} }

3
src/i18n/locales/en.ts

@ -1930,6 +1930,8 @@ export default {
'🔞 NSFW 🔞': '🔞 NSFW 🔞', '🔞 NSFW 🔞': '🔞 NSFW 🔞',
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).': 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).':
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).', 'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).',
'Compressing on your device before upload (large videos can take several minutes)…':
'Compressing on your device before upload (large videos can take several minutes)…',
'Failed to refresh': 'Failed to refresh', 'Failed to refresh': 'Failed to refresh',
'Invalid article link.': 'Invalid article link.', 'Invalid article link.': 'Invalid article link.',
Likes: 'Likes', Likes: 'Likes',
@ -1946,6 +1948,7 @@ export default {
'Synthetic event (no author)': 'Synthetic event (no author)', 'Synthetic event (no author)': 'Synthetic event (no author)',
'Topic is required': 'Topic is required', 'Topic is required': 'Topic is required',
'Type a topic or pick from the list': 'Type a topic or pick from the list', 'Type a topic or pick from the list': 'Type a topic or pick from the list',
'Uploading to media server…': 'Uploading to media server…',
profileEditorRefreshCacheHint: 'profileEditorRefreshCacheHint', profileEditorRefreshCacheHint: 'profileEditorRefreshCacheHint',
startupSessionHydrating: 'startupSessionHydrating' startupSessionHydrating: 'startupSessionHydrating'
} }

8
src/lib/compress-upload-media.ts

@ -395,6 +395,8 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
let frames = 0 let frames = 0
const maxFrames = Math.min(Math.ceil(durationSec * 100) + 2000, 500_000) const maxFrames = Math.min(Math.ceil(durationSec * 100) + 2000, 500_000)
/** Yield to the event loop so React can paint (compression is CPU-heavy). */
const YIELD_EVERY_FRAMES = 30
const step = () => { const step = () => {
if (settled) return if (settled) return
@ -416,7 +418,11 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
finish() finish()
return return
} }
requestAnimationFrame(step) if (frames % YIELD_EVERY_FRAMES === 0) {
setTimeout(() => requestAnimationFrame(step), 0)
} else {
requestAnimationFrame(step)
}
} }
requestAnimationFrame(step) requestAnimationFrame(step)
}) })

12
src/services/media-upload.service.ts

@ -11,6 +11,10 @@ import storage from './local-storage.service'
type UploadOptions = { type UploadOptions = {
onProgress?: (progressPercent: number) => void onProgress?: (progressPercent: number) => void
signal?: AbortSignal signal?: AbortSignal
/** Fires synchronously before client-side compression (images/audio/video). */
onCompressStart?: () => void
/** Fires after compression finishes (or throws), before the HTTP upload. */
onCompressEnd?: () => void
} }
export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted' export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted'
@ -34,7 +38,13 @@ class MediaUploadService {
} }
async upload(file: File, options?: UploadOptions) { async upload(file: File, options?: UploadOptions) {
const toUpload = await compressMediaForUpload(file, { signal: options?.signal }) options?.onCompressStart?.()
let toUpload: File
try {
toUpload = await compressMediaForUpload(file, { signal: options?.signal })
} finally {
options?.onCompressEnd?.()
}
try { try {
const diag = const diag =

Loading…
Cancel
Save