mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 10:01:12 +08:00
feat(upload): add multi-file upload progress queue
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Env>()
|
||||
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)
|
||||
|
||||
@@ -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<Matter | null> {
|
||||
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<Matter[]> {
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
|
||||
@@ -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<PreviewFile | null>
|
||||
download?: (item: StorageObject) => Promise<void> | void
|
||||
upload?: (file: File, onProgress?: (pct: number) => void) => Promise<void>
|
||||
upload?: (file: File, ctx: UploadRunnerContext) => Promise<void>
|
||||
}
|
||||
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}
|
||||
>
|
||||
|
||||
@@ -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> = {}): ImageHosting {
|
||||
}
|
||||
}
|
||||
|
||||
function makeUploadCtx(overrides: Partial<UploadRunnerContext> = {}): 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$/)
|
||||
|
||||
@@ -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<void> {
|
||||
async function uploadImage(file: File, ctx: UploadRunnerContext): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof badgeVariants>) {
|
||||
return <span data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -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<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
@@ -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<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -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<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport data-slot="scroll-area-viewport" className="size-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -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<void>
|
||||
uploadFn?: (file: File, ctx: UploadRunnerContext) => Promise<void>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ async function uploadFile(
|
||||
parent: string,
|
||||
prompt: Prompt | undefined,
|
||||
showApplyToAll: boolean,
|
||||
ctx: UploadRunnerContext,
|
||||
): Promise<boolean | 'cancelled'> {
|
||||
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<UploadDropzoneHandle, UploadDropzoneProps>(
|
||||
({ 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,
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>) => (opts ? `${key}:${JSON.stringify(opts)}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
CheckCircle2: () => <span data-testid="check-icon" />,
|
||||
Loader2: () => <span data-testid="loader-icon" />,
|
||||
UploadCloud: () => <span data-testid="upload-icon" />,
|
||||
X: () => <span data-testid="cancel-icon" />,
|
||||
XCircle: () => <span data-testid="x-circle-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="upload-popover" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function EnqueueLongFile() {
|
||||
const queue = useUploadQueue()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
queue.enqueue([
|
||||
{
|
||||
file: new File(['content'], 'really-long-file-name-that-should-not-overflow-the-uploader-panel.txt', {
|
||||
type: 'text/plain',
|
||||
}),
|
||||
run: async (ctx) => {
|
||||
ctx.setStatus('uploading')
|
||||
ctx.onProgress({ loaded: 3, total: 7 })
|
||||
await new Promise(() => undefined)
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
enqueue
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('UploadStatusButton', () => {
|
||||
it('renders the uploader icon and empty state when there are no tasks', () => {
|
||||
const { getByLabelText, getByText } = render(
|
||||
<UploadQueueProvider>
|
||||
<UploadStatusButton />
|
||||
</UploadQueueProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<UploadQueueProvider>
|
||||
<EnqueueLongFile />
|
||||
<UploadStatusButton />
|
||||
</UploadQueueProvider>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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>) => void
|
||||
}
|
||||
|
||||
export interface UploadQueueItemInput {
|
||||
file: File
|
||||
run: (ctx: UploadRunnerContext) => Promise<void>
|
||||
}
|
||||
|
||||
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<void>
|
||||
controller?: AbortController
|
||||
cleanup?: () => Promise<void>
|
||||
}
|
||||
|
||||
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<UploadQueueContextValue | null>(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<UploadTask[]>([])
|
||||
const [isOpen, setOpen] = useState(false)
|
||||
const tasksRef = useRef<InternalUploadTask[]>([])
|
||||
const batchCallbacksRef = useRef<Map<string, { ids: Set<string>; 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<InternalUploadTask>) => {
|
||||
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 <UploadQueueContext.Provider value={value}>{children}</UploadQueueContext.Provider>
|
||||
}
|
||||
|
||||
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 <CheckCircle2 className="size-4 text-emerald-600" />
|
||||
if (status === 'failed') return <XCircle className="size-4 text-destructive" />
|
||||
if (status === 'cancelled') return <XCircle className="size-4 text-muted-foreground" />
|
||||
return <Loader2 className="size-4 animate-spin text-primary" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover open={isOpen} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="relative" aria-label={t('uploadPanel.toggle')}>
|
||||
<UploadCloud />
|
||||
{(activeCount > 0 || failedCount > 0) && (
|
||||
<Badge
|
||||
variant={failedCount > 0 ? 'destructive' : 'default'}
|
||||
className="-right-1.5 -top-1.5 absolute h-4 min-w-4 rounded-full px-1 text-[10px]"
|
||||
>
|
||||
{activeCount || failedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
data-testid="upload-popover"
|
||||
className="flex max-h-[min(28rem,calc(100vh-4rem))] w-[min(calc(100vw-2rem),24rem)] flex-col overflow-hidden p-0"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-sm">{t('uploadPanel.title')}</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('uploadPanel.summary', { active: activeCount, completed: completedCount, total: tasks.length })}
|
||||
</p>
|
||||
</div>
|
||||
{hasActiveUploads && (
|
||||
<Button variant="ghost" size="sm" className="h-auto px-1 py-0 text-xs" onClick={cancelAll}>
|
||||
{t('uploadPanel.cancelAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="m-0" />
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">{t('uploadPanel.empty')}</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="upload-task-list"
|
||||
className="max-h-[min(20rem,calc(100vh-9rem))] min-h-0 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={task.id}
|
||||
className="min-w-0 overflow-hidden border-b px-4 py-3 transition-colors last:border-b-0 hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
{statusIcon(task.status)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<p className="min-w-0 flex-1 truncate font-medium text-sm">{task.fileName}</p>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[10px]',
|
||||
statusPillClass(task.status),
|
||||
)}
|
||||
>
|
||||
{t(`uploadPanel.status.${task.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-muted-foreground text-xs">
|
||||
{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'))}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => cancel(task.id)}
|
||||
aria-label={t('uploadPanel.cancel')}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={pct} className="mt-2 h-1.5" />
|
||||
{task.status === 'failed' && task.error && (
|
||||
<p className="mt-1.5 line-clamp-2 text-destructive text-xs">{task.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "已删除的文件将显示在这里。",
|
||||
|
||||
+110
-21
@@ -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<string, string> = {}
|
||||
|
||||
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<string, string>)['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<string, string>)['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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+55
-7
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RouterContext>()({
|
||||
component: () => (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<Outlet />
|
||||
<UploadQueueProvider>
|
||||
<Outlet />
|
||||
</UploadQueueProvider>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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() {
|
||||
<header className="flex h-14 min-w-0 shrink-0 items-center gap-3 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<GlobalSearchBar />
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<UploadStatusButton />
|
||||
<NotificationBell />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user