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({ @@ -203,7 +203,7 @@ export default function PostContent({
const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null)
const [posting, setPosting] = useState(false)
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 [createCustomEventOpen, setCreateCustomEventOpen] = useState(false)
@ -1490,8 +1490,22 @@ export default function PostContent({ @@ -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) => {
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
setUploadProgresses((prev) => [
...prev,
{
file,
progress: 0,
cancel,
phase: fileLooksLikeUploadableMedia(file) ? 'compressing' : 'uploading'
}
])
// Track file for media upload
if (fileLooksLikeUploadableMedia(file)) {
const mapKey = `${file.name}-${file.size}-${file.lastModified}`
@ -1541,7 +1555,11 @@ export default function PostContent({ @@ -1541,7 +1555,11 @@ export default function PostContent({
const handleUploadProgress = (file: File, progress: number) => {
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({ @@ -2763,6 +2781,7 @@ export default function PostContent({
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
onUploadSuccess={handleMediaUploadSuccess}
onUploadCompressPhase={handleUploadCompressPhase}
kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
@ -3024,17 +3043,29 @@ export default function PostContent({ @@ -3024,17 +3043,29 @@ export default function PostContent({
</div>
)}
{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 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...')}
</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-full bg-primary transition-[width] duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
{phase === 'compressing' ? (
<div
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>
<button
@ -3078,6 +3109,7 @@ export default function PostContent({ @@ -3078,6 +3109,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska"
className="sr-only"
>
@ -3093,6 +3125,7 @@ export default function PostContent({ @@ -3093,6 +3125,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
accept="audio/*,.mka,audio/x-matroska"
>
<Button
@ -3111,6 +3144,7 @@ export default function PostContent({ @@ -3111,6 +3144,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
accept="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 { @@ -19,6 +19,8 @@ export interface ClipboardAndDropHandlerOptions {
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadEnd?: (file: File) => 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>({
@ -138,7 +140,9 @@ async function uploadFiles( @@ -138,7 +140,9 @@ async function uploadFiles(
mediaUpload
.upload(file, {
onProgress: (p) => options.onUploadProgress?.(file, p),
signal: abortController?.signal
signal: abortController?.signal,
onCompressStart: () => options.onUploadCompressPhase?.(file, 'compressing'),
onCompressEnd: () => options.onUploadCompressPhase?.(file, 'uploading')
})
.then((result) => {
options.onUploadEnd?.(file)

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

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

11
src/components/PostEditor/Uploader.tsx

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

3
src/i18n/locales/de.ts

@ -1858,6 +1858,8 @@ export default { @@ -1858,6 +1858,8 @@ export default {
'🔞 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).',
'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',
'Invalid article link.': 'Invalid article link.',
Likes: 'Likes',
@ -1874,6 +1876,7 @@ export default { @@ -1874,6 +1876,7 @@ export default {
'Synthetic event (no author)': 'Synthetic event (no author)',
'Topic is required': 'Topic is required',
'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',
startupSessionHydrating: 'startupSessionHydrating'
}

3
src/i18n/locales/en.ts

@ -1930,6 +1930,8 @@ export default { @@ -1930,6 +1930,8 @@ export default {
'🔞 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).',
'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',
'Invalid article link.': 'Invalid article link.',
Likes: 'Likes',
@ -1946,6 +1948,7 @@ export default { @@ -1946,6 +1948,7 @@ export default {
'Synthetic event (no author)': 'Synthetic event (no author)',
'Topic is required': 'Topic is required',
'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',
startupSessionHydrating: 'startupSessionHydrating'
}

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

@ -395,6 +395,8 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi @@ -395,6 +395,8 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
let frames = 0
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 = () => {
if (settled) return
@ -416,7 +418,11 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi @@ -416,7 +418,11 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
finish()
return
}
requestAnimationFrame(step)
if (frames % YIELD_EVERY_FRAMES === 0) {
setTimeout(() => requestAnimationFrame(step), 0)
} else {
requestAnimationFrame(step)
}
}
requestAnimationFrame(step)
})

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

@ -11,6 +11,10 @@ import storage from './local-storage.service' @@ -11,6 +11,10 @@ import storage from './local-storage.service'
type UploadOptions = {
onProgress?: (progressPercent: number) => void
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'
@ -34,7 +38,13 @@ class MediaUploadService { @@ -34,7 +38,13 @@ class MediaUploadService {
}
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 {
const diag =

Loading…
Cancel
Save