feat(upload): add multi-file upload progress queue

This commit is contained in:
saltbo
2026-04-30 22:42:59 -04:00
parent 7c7ab03c1c
commit 8b67eec1c5
21 changed files with 1062 additions and 97 deletions
+73
View File
@@ -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')
})
})
+41
View File
@@ -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)
+16
View File
@@ -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)
+8
View File
@@ -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[]> {
+3
View File
@@ -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'),
}),
+3 -2
View File
@@ -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)
}
+27
View File
@@ -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 }
+42
View File
@@ -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 }
+25
View File
@@ -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 }
+45
View File
@@ -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 }
+51 -55
View File
@@ -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()
})
})
+395
View File
@@ -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>
)
}
+15
View File
@@ -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.",
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
+4 -1
View 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 -1
View File
@@ -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>