diff --git a/e2e/upload.spec.ts b/e2e/upload.spec.ts new file mode 100644 index 00000000..d0577982 --- /dev/null +++ b/e2e/upload.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test' +import { signUpAndGoToFiles } from './helpers' + +function makeFiles(count: number) { + return Array.from({ length: count }, (_, index) => ({ + name: `very-long-mobile-upload-file-name-${index}-${Date.now()}-that-should-truncate-in-the-uploader-panel.txt`, + mimeType: 'text/plain', + buffer: Buffer.from(`upload fixture ${index}`), + })) +} + +async function expectNoHorizontalOverflow(page: import('@playwright/test').Page) { + const hasHScroll = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ) + expect(hasHScroll).toBe(false) +} + +test.describe('Uploader responsive behavior', () => { + test('mobile: multi-file upload opens uploader, truncates long names, and scrolls vertically @mobile', async ({ + page, + }) => { + test.slow() + await signUpAndGoToFiles(page) + + await page.route('**/*', async (route) => { + if (route.request().method() === 'PUT') { + await route.fulfill({ status: 200, body: '' }) + return + } + await route.continue() + }) + + const files = makeFiles(12) + await page.locator('input[type="file"]').first().setInputFiles(files) + + const popover = page.getByTestId('upload-popover') + await expect(popover).toBeVisible({ timeout: 10000 }) + await expect(popover).toContainText('Uploads') + await expect(popover).toContainText(files[0].name) + + const taskList = page.getByTestId('upload-task-list') + await expect(taskList).toBeVisible() + + await expectNoHorizontalOverflow(page) + + const layout = await taskList.evaluate((el) => { + const styles = window.getComputedStyle(el) + return { + overflowX: styles.overflowX, + overflowY: styles.overflowY, + scrollsVertically: el.scrollHeight > el.clientHeight, + } + }) + expect(layout.overflowX).toBe('hidden') + expect(layout.overflowY).toBe('auto') + expect(layout.scrollsVertically).toBe(true) + + const firstFileName = page.getByText(files[0].name) + await expect(firstFileName).toBeVisible() + const fileNameLayout = await firstFileName.evaluate((el) => { + const styles = window.getComputedStyle(el) + return { + overflow: styles.overflow, + textOverflow: styles.textOverflow, + whiteSpace: styles.whiteSpace, + } + }) + expect(fileNameLayout.overflow).toBe('hidden') + expect(fileNameLayout.textOverflow).toBe('ellipsis') + expect(fileNameLayout.whiteSpace).toBe('nowrap') + }) +}) diff --git a/server/routes/objects.integration.test.ts b/server/routes/objects.integration.test.ts index 0c179a8d..f2dfc57d 100644 --- a/server/routes/objects.integration.test.ts +++ b/server/routes/objects.integration.test.ts @@ -293,6 +293,47 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) + it('PATCH /api/objects/:id (action: cancel) deletes a draft upload and cleans up S3', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'draft-cancel', name: 'cancel.txt', status: 'draft' }) + + const res = await app.request('/api/objects/draft-cancel', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel' }), + }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { id: string; cancelled: boolean } + expect(body).toEqual({ id: 'draft-cancel', cancelled: true }) + expect(S3Service.prototype.deleteObject).toHaveBeenCalledWith( + expect.objectContaining({ id: validStorage.id }), + 'some/key.txt', + ) + + const check = await app.request('/api/objects/draft-cancel', { headers }) + expect(check.status).toBe(404) + }) + + it('PATCH /api/objects/:id (action: cancel) returns 404 for active object', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'active-cancel', name: 'active.txt', status: 'active' }) + + const res = await app.request('/api/objects/active-cancel', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel' }), + }) + + expect(res.status).toBe(404) + }) + it('DELETE /api/objects/:id rejects active object (must trash first)', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) diff --git a/server/routes/objects.ts b/server/routes/objects.ts index 969ff9a0..9780ae9e 100644 --- a/server/routes/objects.ts +++ b/server/routes/objects.ts @@ -14,6 +14,7 @@ import type { Env } from '../middleware/platform' import { batchMove, batchTrash, + cancelDraftMatter, collectForPurge, confirmUpload, copyMatter, @@ -210,6 +211,21 @@ const app = new Hono() throw e } } + case 'cancel': { + const matter = await cancelDraftMatter(db, c.req.param('id'), orgId) + if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404) + if (matter.object) { + const storage = (await getStorage(db, matter.storageId)) as unknown as S3Storage | null + if (storage) { + try { + await s3.deleteObject(storage, matter.object) + } catch { + // Best-effort cleanup: the browser may abort before S3 writes anything. + } + } + } + return c.json({ id: matter.id, cancelled: true }) + } case 'trash': { const matter = await trashMatter(db, orgId, c.req.param('id'), userId) if (!matter) return c.json({ error: 'Not found' }, 404) diff --git a/server/services/matter.ts b/server/services/matter.ts index 33661fa9..d9ebd106 100644 --- a/server/services/matter.ts +++ b/server/services/matter.ts @@ -380,6 +380,14 @@ export async function deleteMatter(db: Database, id: string, orgId: string): Pro return existing } +export async function cancelDraftMatter(db: Database, id: string, orgId: string): Promise { + const existing = await getMatter(db, id, orgId) + if (!existing || existing.status !== 'draft') return null + + await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft'))) + return existing +} + // ─── Batch Operations ──────────────────────────────────────────────────────── export async function getMatters(db: Database, orgId: string, ids: string[]): Promise { diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index 0bd36068..b28ef1f1 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -72,6 +72,9 @@ export const patchMatterSchema = z.discriminatedUnion('action', [ action: z.literal('confirm'), onConflict: conflictStrategySchema.optional(), }), + z.object({ + action: z.literal('cancel'), + }), z.object({ action: z.literal('trash'), }), diff --git a/src/components/files/file-manager.tsx b/src/components/files/file-manager.tsx index e7c65462..544cc81d 100644 --- a/src/components/files/file-manager.tsx +++ b/src/components/files/file-manager.tsx @@ -19,6 +19,7 @@ import { FilePreviewDialog } from '@/components/preview/file-preview-dialog' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { UploadDropzone, type UploadDropzoneHandle } from '@/components/upload/upload-dropzone' +import type { UploadRunnerContext } from '@/components/upload/upload-queue' import { getObject, listObjectsByPath } from '@/lib/api' import { cn } from '@/lib/utils' import { getColumns } from './columns' @@ -111,7 +112,7 @@ interface FileManagerProps { list: (path: string, opts: { filterType?: string; search?: string }) => Promise<{ items: StorageObject[] }> getPreviewFile?: (item: StorageObject) => Promise download?: (item: StorageObject) => Promise | void - upload?: (file: File, onProgress?: (pct: number) => void) => Promise + upload?: (file: File, ctx: UploadRunnerContext) => Promise } capabilities?: { selection?: boolean @@ -531,7 +532,7 @@ export function FileManager({ ref={dropzoneRef} parent={currentPath} onUploadComplete={dataSource?.upload ? () => query.refetch() : () => mutations.invalidate()} - uploadFn={dataSource?.upload ? (file) => dataSource.upload!(file) : undefined} + uploadFn={dataSource?.upload ? (file, ctx) => dataSource.upload!(file, ctx) : undefined} conflictPrompt={dataSource?.upload ? undefined : conflict.prompt} onConflictBatchStart={dataSource?.upload ? undefined : conflict.reset} > diff --git a/src/components/image-host/image-host-data-source.test.ts b/src/components/image-host/image-host-data-source.test.ts index 13df7e3e..1fcc20c1 100644 --- a/src/components/image-host/image-host-data-source.test.ts +++ b/src/components/image-host/image-host-data-source.test.ts @@ -15,6 +15,7 @@ vi.mock('@/lib/api', () => ({ deleteIhostImage: vi.fn(), })) +import type { UploadRunnerContext } from '@/components/upload/upload-queue' import { confirmIhostImage, createIhostImagePresign, deleteIhostImage, listIhostImages, uploadToS3 } from '@/lib/api' import { imageHostDataSource } from './image-host-data-source' @@ -42,6 +43,16 @@ function makeImageHosting(overrides: Partial = {}): ImageHosting { } } +function makeUploadCtx(overrides: Partial = {}): UploadRunnerContext { + return { + signal: new AbortController().signal, + onProgress: vi.fn(), + setStatus: vi.fn(), + registerCleanup: vi.fn(), + ...overrides, + } +} + // --------------------------------------------------------------------------- // list() // --------------------------------------------------------------------------- @@ -118,7 +129,7 @@ describe('imageHostDataSource.list', () => { expect(ihostItem.token).toBe('tok_xyz') }) - it('computes url as /r/${token}.${ext} for png mime', async () => { + it('computes url from token and extension for png mime', async () => { const img = makeImageHosting({ token: 'tok_abc', mime: 'image/png' }) vi.mocked(listIhostImages).mockResolvedValue({ items: [img], nextCursor: null }) @@ -251,7 +262,7 @@ describe('imageHostDataSource.upload', () => { const file = new File(['data'], 'photo.png', { type: 'image/png' }) Object.defineProperty(file, 'size', { value: 1024 }) - await imageHostDataSource.upload(file) + await imageHostDataSource.upload(file, makeUploadCtx()) expect(createIhostImagePresign).toHaveBeenCalledWith(expect.objectContaining({ mime: 'image/png', size: 1024 })) }) @@ -259,15 +270,19 @@ describe('imageHostDataSource.upload', () => { it('calls uploadToS3 with the presigned url and file', async () => { const file = new File(['data'], 'photo.png', { type: 'image/png' }) - await imageHostDataSource.upload(file) + await imageHostDataSource.upload(file, makeUploadCtx()) - expect(uploadToS3).toHaveBeenCalledWith('https://s3/presigned', file) + expect(uploadToS3).toHaveBeenCalledWith( + 'https://s3/presigned', + file, + expect.objectContaining({ onProgress: expect.any(Function), signal: expect.any(AbortSignal) }), + ) }) it('calls confirmIhostImage with the draft id', async () => { const file = new File(['data'], 'photo.png', { type: 'image/png' }) - await imageHostDataSource.upload(file) + await imageHostDataSource.upload(file, makeUploadCtx()) expect(confirmIhostImage).toHaveBeenCalledWith('draft-1') }) @@ -293,7 +308,7 @@ describe('imageHostDataSource.upload', () => { }) const file = new File(['data'], 'photo.png', { type: 'image/png' }) - await imageHostDataSource.upload(file) + await imageHostDataSource.upload(file, makeUploadCtx()) expect(order).toEqual(['presign', 's3', 'confirm']) }) @@ -301,7 +316,7 @@ describe('imageHostDataSource.upload', () => { it('generates path with timestamp and base filename', async () => { const file = new File(['data'], 'my photo.png', { type: 'image/png' }) - await imageHostDataSource.upload(file) + await imageHostDataSource.upload(file, makeUploadCtx()) const call = vi.mocked(createIhostImagePresign).mock.calls[0][0] // path should match: ${timestamp}_${sanitized_base}.${ext} @@ -311,7 +326,7 @@ describe('imageHostDataSource.upload', () => { it('generates path with jpg ext for jpeg mime', async () => { const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' }) - await imageHostDataSource.upload(file) + await imageHostDataSource.upload(file, makeUploadCtx()) const call = vi.mocked(createIhostImagePresign).mock.calls[0][0] expect(call.path).toMatch(/\.jpg$/) diff --git a/src/components/image-host/image-host-data-source.ts b/src/components/image-host/image-host-data-source.ts index e70c9fb1..f9a7243b 100644 --- a/src/components/image-host/image-host-data-source.ts +++ b/src/components/image-host/image-host-data-source.ts @@ -1,6 +1,7 @@ import { DirType } from '@shared/constants' import type { AllowedImageMime } from '@shared/schemas' import type { ImageHosting, StorageObject } from '@shared/types' +import type { UploadRunnerContext } from '@/components/upload/upload-queue' import { confirmIhostImage, createIhostImagePresign, deleteIhostImage, listIhostImages, uploadToS3 } from '@/lib/api' // Extended StorageObject that carries image-host specific fields. @@ -56,10 +57,20 @@ function deriveDefaultPath(file: File): string { return `${ts}_${base}.${ext}` } -async function uploadImage(file: File): Promise { +async function uploadImage(file: File, ctx: UploadRunnerContext): Promise { + ctx.setStatus('preparing') const path = deriveDefaultPath(file) const draft = await createIhostImagePresign({ path, mime: file.type as AllowedImageMime, size: file.size }) - await uploadToS3(draft.uploadUrl, file) + ctx.registerCleanup(async () => { + await deleteIhostImage(draft.id) + }) + if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError') + + ctx.setStatus('uploading') + await uploadToS3(draft.uploadUrl, file, { onProgress: ctx.onProgress, signal: ctx.signal }) + if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError') + + ctx.setStatus('confirming') await confirmIhostImage(draft.id) } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 00000000..a6eb971a --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,27 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 overflow-hidden', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground', + secondary: 'border-transparent bg-secondary text-secondary-foreground', + destructive: 'border-transparent bg-destructive text-white', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ className, variant, ...props }: React.ComponentProps<'span'> & VariantProps) { + return +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..96949da6 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Popover as PopoverPrimitive } from 'radix-ui' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Popover({ ...props }: React.ComponentProps) { + return +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return +} + +export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 00000000..5b3da72f --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,25 @@ +'use client' + +import { Progress as ProgressPrimitive } from 'radix-ui' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Progress({ className, value, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..3546024e --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,45 @@ +'use client' + +import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function ScrollArea({ className, children, ...props }: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/src/components/upload/upload-dropzone.tsx b/src/components/upload/upload-dropzone.tsx index 7889daca..bef82d51 100644 --- a/src/components/upload/upload-dropzone.tsx +++ b/src/components/upload/upload-dropzone.tsx @@ -3,10 +3,10 @@ import { Upload } from 'lucide-react' import { forwardRef, useCallback, useImperativeHandle } from 'react' import { useDropzone } from 'react-dropzone' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' import type { Prompt } from '@/components/files/hooks/use-conflict-resolver' import { withConflictRetry } from '@/components/files/hooks/use-conflict-resolver' -import { confirmUpload, createObject, isNameConflictError, uploadToS3 } from '../../lib/api' +import { cancelUpload, confirmUpload, createObject, isNameConflictError, uploadToS3 } from '../../lib/api' +import { type UploadRunnerContext, useUploadQueue } from './upload-queue' interface UploadDropzoneProps { parent: string @@ -20,7 +20,7 @@ interface UploadDropzoneProps { * flow (create-draft → S3 PUT → confirm) and calls this instead. * Used by Image Host to upload via /api/ihost/images. */ - uploadFn?: (file: File) => Promise + uploadFn?: (file: File, ctx: UploadRunnerContext) => Promise children: React.ReactNode } @@ -38,7 +38,9 @@ async function uploadFile( parent: string, prompt: Prompt | undefined, showApplyToAll: boolean, + ctx: UploadRunnerContext, ): Promise { + ctx.setStatus('preparing') // Step 1: create draft (resolves conflict against existing actives BEFORE the S3 PUT). const created = prompt ? await withConflictRetry( @@ -64,11 +66,18 @@ async function uploadFile( }) if (!created) return 'cancelled' if (!created.uploadUrl) throw new Error('No upload URL returned') + ctx.registerCleanup(async () => { + await cancelUpload(created.id) + }) + if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError') - await uploadToS3(created.uploadUrl, file) + ctx.setStatus('uploading') + await uploadToS3(created.uploadUrl, file, { onProgress: ctx.onProgress, signal: ctx.signal }) + if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError') // Step 2: confirm. Another client may have activated the same name during our // S3 PUT — repeat the resolver here so replace/rename still works. + ctx.setStatus('confirming') try { await confirmUpload(created.id) } catch (e) { @@ -80,78 +89,65 @@ async function uploadFile( return true } +function makeQueuedPrompt(prompt: Prompt): Prompt { + let tail = Promise.resolve() + return (args) => { + const run = tail.then(() => prompt(args)) + tail = run.then( + () => undefined, + () => undefined, + ) + return run + } +} + export const UploadDropzone = forwardRef( ({ parent, onUploadComplete, conflictPrompt, onConflictBatchStart, uploadFn, children }, ref) => { const { t } = useTranslation() + const uploadQueue = useUploadQueue() const onDrop = useCallback( async (files: File[]) => { + if (files.length === 0) return + // Custom upload path (e.g. image host) if (uploadFn) { - let anySuccess = false - for (const file of files) { - const p = uploadFn(file) - toast.promise(p, { - loading: t('files.uploading', { name: file.name }), - success: t('files.uploadSuccess', { name: file.name }), - error: t('files.uploadFailed', { name: file.name }), - }) - try { - await p - anySuccess = true - } catch { - // Toast already surfaced the error — continue. - } - } - if (anySuccess) onUploadComplete() + uploadQueue.enqueue( + files.map((file) => ({ + file, + run: (ctx) => uploadFn(file, ctx), + })), + (hadSuccess) => { + if (hadSuccess) onUploadComplete() + }, + ) return } // Default object-upload path onConflictBatchStart?.() const showApplyToAll = files.length > 1 - let anySuccess = false - let processed = 0 + const queuedPrompt = conflictPrompt ? makeQueuedPrompt(conflictPrompt) : undefined - // Process sequentially so the resolver's "apply to all" sticks across files. - for (const file of files) { - const p = uploadFile(file, parent, conflictPrompt, showApplyToAll) - toast.promise( - p.then((result) => { - if (result === 'cancelled') throw new Error('cancelled') - return result - }), - { - loading: t('files.uploading', { name: file.name }), - success: t('files.uploadSuccess', { name: file.name }), - error: (err: Error) => - err.message === 'cancelled' ? null : t('files.uploadFailed', { name: file.name }), + uploadQueue.enqueue( + files.map((file) => ({ + file, + run: async (ctx) => { + const result = await uploadFile(file, parent, queuedPrompt, showApplyToAll, ctx) + if (result === 'cancelled') throw new DOMException('Upload cancelled', 'AbortError') }, - ) - try { - const result = await p - processed++ - if (result === true) anySuccess = true - if (result === 'cancelled') { - // Finder-style: cancelling a conflict aborts the rest of the batch. - // Tell the user what just happened so remaining files aren't a mystery. - const remaining = files.length - processed - if (remaining > 0) toast.info(t('files.uploadBatchCancelled', { count: remaining })) - break - } - } catch { - processed++ - // Toast already surfaced the error — continue with next file. - } - } - - if (anySuccess) onUploadComplete() + })), + (hadSuccess) => { + if (hadSuccess) onUploadComplete() + }, + ) }, - [parent, uploadFn, onUploadComplete, conflictPrompt, onConflictBatchStart, t], + [parent, uploadFn, onUploadComplete, conflictPrompt, onConflictBatchStart, uploadQueue], ) const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ onDrop, + multiple: true, noClick: true, noKeyboard: true, }) diff --git a/src/components/upload/upload-queue.test.tsx b/src/components/upload/upload-queue.test.tsx new file mode 100644 index 00000000..221bc052 --- /dev/null +++ b/src/components/upload/upload-queue.test.tsx @@ -0,0 +1,95 @@ +import { cleanup, render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { UploadQueueProvider, UploadStatusButton, useUploadQueue } from './upload-queue' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + }), +})) + +vi.mock('lucide-react', () => ({ + CheckCircle2: () => , + Loader2: () => , + UploadCloud: () => , + X: () => , + XCircle: () => , +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), +})) + +function EnqueueLongFile() { + const queue = useUploadQueue() + return ( + + ) +} + +afterEach(cleanup) + +describe('UploadStatusButton', () => { + it('renders the uploader icon and empty state when there are no tasks', () => { + const { getByLabelText, getByText } = render( + + + , + ) + + expect(getByLabelText('uploadPanel.toggle')).toBeTruthy() + expect(getByText('uploadPanel.empty')).toBeTruthy() + }) + + it('uses constrained popover and truncating file row layout for long names', async () => { + const { getByText, getByTestId } = render( + + + + , + ) + + getByText('enqueue').click() + + await waitFor(() => + expect(getByText('really-long-file-name-that-should-not-overflow-the-uploader-panel.txt')).toBeTruthy(), + ) + + const popover = getByTestId('upload-popover') + expect(popover.className).toContain('max-h-[min(28rem,calc(100vh-4rem))]') + expect(popover.className).toContain('overflow-hidden') + + const filename = getByText('really-long-file-name-that-should-not-overflow-the-uploader-panel.txt') + expect(filename.className).toContain('min-w-0') + expect(filename.className).toContain('flex-1') + expect(filename.className).toContain('truncate') + expect(filename.closest('.min-w-0')).toBeTruthy() + expect(filename.closest('.overflow-y-auto')).toBeTruthy() + expect(filename.closest('.overflow-x-hidden')).toBeTruthy() + }) +}) diff --git a/src/components/upload/upload-queue.tsx b/src/components/upload/upload-queue.tsx new file mode 100644 index 00000000..d922b57e --- /dev/null +++ b/src/components/upload/upload-queue.tsx @@ -0,0 +1,395 @@ +import { CheckCircle2, Loader2, UploadCloud, X, XCircle } from 'lucide-react' +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Progress } from '@/components/ui/progress' +import { Separator } from '@/components/ui/separator' +import { formatSize } from '@/lib/format' +import { cn } from '@/lib/utils' + +const MAX_CONCURRENT_UPLOADS = 3 + +export type UploadTaskStatus = + | 'queued' + | 'preparing' + | 'uploading' + | 'confirming' + | 'completed' + | 'failed' + | 'cancelled' + +export interface UploadProgressUpdate { + loaded: number + total: number +} + +export interface UploadRunnerContext { + signal: AbortSignal + onProgress: (progress: UploadProgressUpdate) => void + setStatus: (status: UploadTaskStatus) => void + registerCleanup: (cleanup: () => Promise) => void +} + +export interface UploadQueueItemInput { + file: File + run: (ctx: UploadRunnerContext) => Promise +} + +export interface UploadTask { + id: string + fileName: string + size: number + status: UploadTaskStatus + loaded: number + total: number + speed: number + etaSeconds: number | null + error?: string + createdAt: number + updatedAt: number +} + +interface InternalUploadTask extends UploadTask { + run: (ctx: UploadRunnerContext) => Promise + controller?: AbortController + cleanup?: () => Promise +} + +interface UploadQueueContextValue { + tasks: UploadTask[] + isOpen: boolean + setOpen: (open: boolean) => void + enqueue: (items: UploadQueueItemInput[], onBatchComplete?: (hadSuccess: boolean) => void) => void + cancel: (id: string) => void + cancelAll: () => void + hasActiveUploads: boolean +} + +const UploadQueueContext = createContext(null) + +function isActive(status: UploadTaskStatus) { + return status === 'queued' || status === 'preparing' || status === 'uploading' || status === 'confirming' +} + +function isRunning(status: UploadTaskStatus) { + return status === 'preparing' || status === 'uploading' || status === 'confirming' +} + +function makeTaskId() { + return `upload_${Date.now()}_${Math.random().toString(36).slice(2)}` +} + +function isAbortError(err: unknown) { + return err instanceof DOMException && err.name === 'AbortError' +} + +export function UploadQueueProvider({ children }: { children: ReactNode }) { + const [tasks, setTasks] = useState([]) + const [isOpen, setOpen] = useState(false) + const tasksRef = useRef([]) + const batchCallbacksRef = useRef; onDone: (hadSuccess: boolean) => void }>>(new Map()) + + const publish = useCallback(() => { + setTasks( + tasksRef.current.map(({ run: _run, controller: _controller, cleanup: _cleanup, ...task }) => ({ + ...task, + })), + ) + }, []) + + const updateTask = useCallback( + (id: string, patch: Partial) => { + const task = tasksRef.current.find((item) => item.id === id) + if (!task) return + Object.assign(task, patch, { updatedAt: Date.now() }) + publish() + }, + [publish], + ) + + const settleBatches = useCallback(() => { + for (const [batchId, batch] of batchCallbacksRef.current) { + const batchTasks = tasksRef.current.filter((task) => batch.ids.has(task.id)) + if (batchTasks.length === 0 || batchTasks.some((task) => isActive(task.status))) continue + batchCallbacksRef.current.delete(batchId) + batch.onDone(batchTasks.some((task) => task.status === 'completed')) + } + }, []) + + const maybeStartNext = useCallback(() => { + const runningCount = tasksRef.current.filter((task) => isRunning(task.status)).length + const slots = MAX_CONCURRENT_UPLOADS - runningCount + if (slots <= 0) return + + const nextTasks = tasksRef.current.filter((task) => task.status === 'queued').slice(0, slots) + for (const task of nextTasks) { + const controller = new AbortController() + task.controller = controller + task.status = 'preparing' + task.updatedAt = Date.now() + + const startedAt = Date.now() + task + .run({ + signal: controller.signal, + onProgress: (progress) => { + const now = Date.now() + const loaded = Math.max(0, Math.min(progress.loaded, progress.total || task.size)) + const total = progress.total || task.size + const seconds = Math.max((now - startedAt) / 1000, 0.001) + const speed = loaded / seconds + const remaining = Math.max(total - loaded, 0) + updateTask(task.id, { + loaded, + total, + speed, + etaSeconds: speed > 0 && remaining > 0 ? remaining / speed : null, + }) + }, + setStatus: (status) => { + updateTask(task.id, { status }) + if (status === 'uploading') setOpen(true) + }, + registerCleanup: (cleanup) => updateTask(task.id, { cleanup }), + }) + .then(() => { + updateTask(task.id, { + status: controller.signal.aborted ? 'cancelled' : 'completed', + loaded: task.total, + speed: 0, + etaSeconds: null, + }) + }) + .catch((err) => { + updateTask(task.id, { + status: controller.signal.aborted || isAbortError(err) ? 'cancelled' : 'failed', + error: err instanceof Error ? err.message : String(err), + speed: 0, + etaSeconds: null, + }) + }) + .finally(async () => { + if (controller.signal.aborted && task.cleanup) { + await task.cleanup().catch(() => undefined) + } + task.controller = undefined + settleBatches() + maybeStartNext() + }) + } + publish() + }, [publish, settleBatches, updateTask]) + + const enqueue = useCallback( + (items: UploadQueueItemInput[], onBatchComplete?: (hadSuccess: boolean) => void) => { + if (items.length === 0) return + const newTasks: InternalUploadTask[] = items.map((item) => ({ + id: makeTaskId(), + fileName: item.file.name, + size: item.file.size, + status: 'queued', + loaded: 0, + total: item.file.size, + speed: 0, + etaSeconds: null, + createdAt: Date.now(), + updatedAt: Date.now(), + run: item.run, + })) + tasksRef.current = [...newTasks, ...tasksRef.current] + if (onBatchComplete) { + batchCallbacksRef.current.set(makeTaskId(), { + ids: new Set(newTasks.map((task) => task.id)), + onDone: onBatchComplete, + }) + } + setOpen(true) + publish() + maybeStartNext() + }, + [maybeStartNext, publish], + ) + + const cancel = useCallback( + (id: string) => { + const task = tasksRef.current.find((item) => item.id === id) + if (!task || !isActive(task.status)) return + if (task.status === 'queued') { + updateTask(id, { status: 'cancelled' }) + settleBatches() + return + } + task.controller?.abort() + updateTask(id, { status: 'cancelled' }) + }, + [settleBatches, updateTask], + ) + + const cancelAll = useCallback(() => { + for (const task of tasksRef.current) { + if (isActive(task.status)) cancel(task.id) + } + }, [cancel]) + + const hasActiveUploads = tasks.some((task) => isActive(task.status)) + + useEffect(() => { + if (!hasActiveUploads) return + const onBeforeUnload = (event: BeforeUnloadEvent) => { + cancelAll() + event.preventDefault() + event.returnValue = '' + } + window.addEventListener('beforeunload', onBeforeUnload) + return () => window.removeEventListener('beforeunload', onBeforeUnload) + }, [cancelAll, hasActiveUploads]) + + const value = useMemo( + () => ({ tasks, isOpen, setOpen, enqueue, cancel, cancelAll, hasActiveUploads }), + [cancel, cancelAll, enqueue, hasActiveUploads, isOpen, tasks], + ) + + return {children} +} + +export function useUploadQueue() { + const ctx = useContext(UploadQueueContext) + if (!ctx) throw new Error('useUploadQueue must be used within UploadQueueProvider') + return ctx +} + +function formatEta(seconds: number | null, fallback: string) { + if (seconds == null || !Number.isFinite(seconds)) return fallback + if (seconds < 1) return '<1s' + const rounded = Math.ceil(seconds) + if (rounded < 60) return `${rounded}s` + const minutes = Math.floor(rounded / 60) + const rest = rounded % 60 + return `${minutes}m ${String(rest).padStart(2, '0')}s` +} + +function statusIcon(status: UploadTaskStatus) { + if (status === 'completed') return + if (status === 'failed') return + if (status === 'cancelled') return + return +} + +function statusPillClass(status: UploadTaskStatus) { + if (status === 'completed') return 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400' + if (status === 'failed') return 'bg-destructive/10 text-destructive' + if (status === 'cancelled') return 'bg-muted text-muted-foreground' + return 'bg-primary/10 text-primary' +} + +export function UploadStatusButton() { + const { t } = useTranslation() + const { tasks, isOpen, setOpen, cancel, cancelAll, hasActiveUploads } = useUploadQueue() + + const activeCount = tasks.filter((task) => isActive(task.status)).length + const failedCount = tasks.filter((task) => task.status === 'failed').length + const completedCount = tasks.filter((task) => task.status === 'completed').length + + return ( + + + + + +
+
+

{t('uploadPanel.title')}

+

+ {t('uploadPanel.summary', { active: activeCount, completed: completedCount, total: tasks.length })} +

+
+ {hasActiveUploads && ( + + )} +
+ + {tasks.length === 0 ? ( +
{t('uploadPanel.empty')}
+ ) : ( +
+ {tasks.map((task) => { + const pct = task.total > 0 ? Math.round((Math.min(task.loaded, task.total) / task.total) * 100) : 0 + const canCancel = isActive(task.status) + return ( +
+
+
+ {statusIcon(task.status)} +
+
+
+
+
+

{task.fileName}

+ + {t(`uploadPanel.status.${task.status}`)} + +
+

+ {formatSize(Math.min(task.loaded, task.total))} / {formatSize(task.total)} + {task.speed > 0 ? ` · ${formatSize(task.speed)}/s` : ''} + {task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled' + ? ` · ${formatEta(task.etaSeconds, t('uploadPanel.etaUnknown'))}` + : ''} +

+
+ {canCancel && ( + + )} +
+ + {task.status === 'failed' && task.error && ( +

{task.error}

+ )} +
+
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 516d10e5..0e46d395 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -80,6 +80,21 @@ "files.conflictReplaceHint": "The existing file will be moved to Trash.", "files.conflictApplyToAll": "Apply to all remaining items", "files.uploadBatchCancelled": "{{count}} remaining upload(s) cancelled", + "uploadPanel.toggle": "Upload progress", + "uploadPanel.title": "Uploads", + "uploadPanel.empty": "No uploads yet", + "uploadPanel.summary": "{{active}} active, {{completed}} complete, {{total}} total", + "uploadPanel.cancel": "Cancel upload", + "uploadPanel.cancelAll": "Cancel all", + "uploadPanel.waiting": "Waiting", + "uploadPanel.etaUnknown": "Calculating", + "uploadPanel.status.queued": "Queued", + "uploadPanel.status.preparing": "Preparing", + "uploadPanel.status.uploading": "Uploading", + "uploadPanel.status.confirming": "Confirming", + "uploadPanel.status.completed": "Complete", + "uploadPanel.status.failed": "Failed", + "uploadPanel.status.cancelled": "Cancelled", "files.count": "{{count}} items", "trash.title": "Trash", "trash.placeholder": "Deleted files will appear here.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 61a49a94..2ff8d831 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -80,6 +80,21 @@ "files.conflictReplaceHint": "原文件将被移至回收站。", "files.conflictApplyToAll": "对所有剩余项应用此选择", "files.uploadBatchCancelled": "已取消剩余 {{count}} 个上传", + "uploadPanel.toggle": "上传进度", + "uploadPanel.title": "上传", + "uploadPanel.empty": "暂无上传任务", + "uploadPanel.summary": "{{active}} 个进行中,{{completed}} 个已完成,共 {{total}} 个", + "uploadPanel.cancel": "取消上传", + "uploadPanel.cancelAll": "全部取消", + "uploadPanel.waiting": "等待中", + "uploadPanel.etaUnknown": "计算中", + "uploadPanel.status.queued": "排队中", + "uploadPanel.status.preparing": "准备中", + "uploadPanel.status.uploading": "上传中", + "uploadPanel.status.confirming": "确认中", + "uploadPanel.status.completed": "已完成", + "uploadPanel.status.failed": "失败", + "uploadPanel.status.cancelled": "已取消", "files.count": "{{count}} 个项目", "trash.title": "回收站", "trash.placeholder": "已删除的文件将显示在这里。", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index bfc5b365..3c2ca781 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -5,6 +5,7 @@ import { batchMoveObjects, batchTrashObjects, buildShareObjectUrl, + cancelUpload, confirmIhostImage, confirmUpload, connectCloud, @@ -330,6 +331,28 @@ describe('api', () => { }) }) + describe('cancelUpload', () => { + it('patches with action: cancel', async () => { + const payload = { id: 'id1', cancelled: true } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await cancelUpload('id1') + + expect(result).toEqual(payload) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/objects/id1') + expect(init.method).toBe('PATCH') + const body = typeof init.body === 'string' ? JSON.parse(init.body) : null + expect(body).toMatchObject({ action: 'cancel' }) + }) + + it('throws on error response', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404)) + + await expect(cancelUpload('missing')).rejects.toThrow('not found') + }) + }) + describe('deleteObject', () => { it('sends DELETE request and returns deleted flag', async () => { const payload = { id: 'id1', deleted: true } @@ -376,44 +399,110 @@ describe('api', () => { }) describe('uploadToS3', () => { + class MockXMLHttpRequest { + static instances: MockXMLHttpRequest[] = [] + upload = { onprogress: null as ((event: ProgressEvent) => void) | null } + onload: (() => void) | null = null + onerror: (() => void) | null = null + onabort: (() => void) | null = null + status = 200 + method = '' + url = '' + body: unknown + headers: Record = {} + + constructor() { + MockXMLHttpRequest.instances.push(this) + } + + open(method: string, url: string) { + this.method = method + this.url = url + } + + setRequestHeader(key: string, value: string) { + this.headers[key] = value + } + + send(body: unknown) { + this.body = body + } + + abort() { + this.onabort?.() + } + } + + beforeEach(() => { + MockXMLHttpRequest.instances = [] + vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest) + }) + it('PUTs file to presigned URL with correct content-type', async () => { - vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true)) - const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) - await uploadToS3('https://s3/presigned', file) + const promise = uploadToS3('https://s3/presigned', file) + const xhr = MockXMLHttpRequest.instances[0] + xhr.onload?.() + await promise - const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(url).toBe('https://s3/presigned') - expect(init.method).toBe('PUT') - expect(init.body).toBe(file) - expect((init.headers as Record)['Content-Type']).toBe('text/plain') + expect(xhr.url).toBe('https://s3/presigned') + expect(xhr.method).toBe('PUT') + expect(xhr.body).toBe(file) + expect(xhr.headers['Content-Type']).toBe('text/plain') }) it('falls back to application/octet-stream when file type is empty', async () => { - vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true)) - const file = new File(['data'], 'blob') // no type - await uploadToS3('https://s3/presigned', file) + const promise = uploadToS3('https://s3/presigned', file) + const xhr = MockXMLHttpRequest.instances[0] + xhr.onload?.() + await promise - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect((init.headers as Record)['Content-Type']).toBe('application/octet-stream') + expect(xhr.headers['Content-Type']).toBe('application/octet-stream') }) it('does not pass credentials on S3 upload', async () => { - vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true)) - const file = new File(['x'], 'x.bin') - await uploadToS3('https://s3/presigned', file) + const promise = uploadToS3('https://s3/presigned', file) + const xhr = MockXMLHttpRequest.instances[0] + xhr.onload?.() + await promise - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect((init as RequestInit).credentials).toBeUndefined() + expect('withCredentials' in xhr).toBe(false) }) it('throws when S3 upload fails', async () => { - vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, false, 403)) - const file = new File(['x'], 'x.bin') - await expect(uploadToS3('https://s3/presigned', file)).rejects.toThrow('Upload failed') + const promise = uploadToS3('https://s3/presigned', file) + const xhr = MockXMLHttpRequest.instances[0] + xhr.status = 403 + xhr.onload?.() + await expect(promise).rejects.toThrow('Upload failed') + }) + + it('reports upload progress', async () => { + const onProgress = vi.fn() + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 10 }) + + const promise = uploadToS3('https://s3/presigned', file, { onProgress }) + const xhr = MockXMLHttpRequest.instances[0] + xhr.upload.onprogress?.({ loaded: 4, total: 10, lengthComputable: true } as ProgressEvent) + xhr.onload?.() + await promise + + expect(onProgress).toHaveBeenCalledWith({ loaded: 4, total: 10 }) + expect(onProgress).toHaveBeenLastCalledWith({ loaded: 10, total: 10 }) + }) + + it('rejects with AbortError when aborted', async () => { + const controller = new AbortController() + const file = new File(['x'], 'x.bin') + const promise = uploadToS3('https://s3/presigned', file, { signal: controller.signal }) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) }) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 3d59868b..dd332994 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -139,6 +139,12 @@ export function confirmUpload(id: string, onConflict?: ConflictStrategy) { ) } +export function cancelUpload(id: string) { + return unwrap<{ id: string; cancelled: boolean }>( + objects[':id'].$patch({ param: { id }, json: { action: 'cancel' as const } }), + ) +} + export function deleteObject(id: string) { return unwrap<{ id: string; deleted: boolean; purged?: number }>(objects[':id'].$delete({ param: { id } })) } @@ -633,14 +639,56 @@ export async function getSession(): Promise<{ session: unknown; user: unknown } return res.json() } +export interface UploadProgress { + loaded: number + total: number +} + +export interface UploadToS3Options { + onProgress?: (progress: UploadProgress) => void + signal?: AbortSignal +} + // S3 direct upload (external presigned URL, not our API) -export function uploadToS3(url: string, file: File): Promise { - return fetch(url, { - method: 'PUT', - body: file, - headers: { 'Content-Type': file.type || 'application/octet-stream' }, - }).then((res) => { - if (!res.ok) throw new Error('Upload failed') +export function uploadToS3(url: string, file: File, options: UploadToS3Options = {}): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const abort = () => { + xhr.abort() + reject(new DOMException('Upload cancelled', 'AbortError')) + } + + if (options.signal?.aborted) { + reject(new DOMException('Upload cancelled', 'AbortError')) + return + } + + options.signal?.addEventListener('abort', abort, { once: true }) + xhr.upload.onprogress = (event) => { + options.onProgress?.({ + loaded: event.loaded, + total: event.lengthComputable ? event.total : file.size, + }) + } + xhr.onload = () => { + options.signal?.removeEventListener('abort', abort) + if (xhr.status >= 200 && xhr.status < 300) { + options.onProgress?.({ loaded: file.size, total: file.size }) + resolve() + return + } + reject(new Error('Upload failed')) + } + xhr.onerror = () => { + options.signal?.removeEventListener('abort', abort) + reject(new Error('Upload failed')) + } + xhr.onabort = () => { + options.signal?.removeEventListener('abort', abort) + } + xhr.open('PUT', url) + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') + xhr.send(file) }) } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 54b4c62f..fa474a01 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' import { ThemeProvider } from 'next-themes' import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' +import { UploadQueueProvider } from '@/components/upload/upload-queue' interface RouterContext { queryClient: QueryClient @@ -12,7 +13,9 @@ export const Route = createRootRouteWithContext()({ component: () => ( - + + + diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx index faa39de5..1c772e65 100644 --- a/src/routes/_authenticated/route.tsx +++ b/src/routes/_authenticated/route.tsx @@ -3,6 +3,7 @@ import { AppSidebar } from '@/components/layout/app-sidebar' import { GlobalSearchBar } from '@/components/layout/global-search-bar' import { NotificationBell } from '@/components/notifications/notification-bell' import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { UploadStatusButton } from '@/components/upload/upload-queue' import { getSession } from '@/lib/api' export const Route = createFileRoute('/_authenticated')({ @@ -30,7 +31,8 @@ function AuthenticatedLayout() {
-
+
+