Files UI — upload, block rendering, picker, browser
The interaction contract for the Phase-9 Files feature in
apps/web(TASK-0101/0102/0103/0104). The API contract lives in ../api/files.md §8–§9 (authoritative). Component map:
lib/files/api.ts—filesApi(list / get / session / complete / cancel)lib/files/upload.ts— the single upload state machine (all 3 paths)lib/files/upload-store.ts— zustand live-progress store (per fileId)lib/files/use-file-uploads.ts— the hook that wires the machine to a pagelib/files/picker.ts— pure picker filter/sort (unit-tested)components/files/FileBlock.tsx— thefileblock renderer (all kinds)components/files/FilePicker.tsx— the page-level "Files" panelcomponents/files/FileBrowser.tsx+…/files/page.tsx— the workspace file browser routecomponents/files/PreviewModal.tsx— the browser row-click preview
1. The three upload paths → one service
Every path calls one function —
uploadFile(file, { pageId, blockId?, replaceFileId?, workspaceId?, maxFileSize, onProgress? })
in lib/files/upload.ts — so the retry, cancel, pre-check and polling logic
exists exactly once.
| Path | Trigger | Where the block lands |
|---|---|---|
| (a) drag-drop | OS file dragged onto the page canvas | a new file block at the drop position (nearest block rect) |
| (b) paste | Ctrl/⌘+V an image from the clipboard |
a file block after the selected block (or page end) |
| (c) block button | the file block's "Add file" (slash menu "File") |
the same block, in place; multi-select appends siblings |
The shared state machine:
pre-check (size vs plan limit, NO request if over)
→ openSession (fires onSession(fileId) → the caller inserts/updates the
`file` block so the progress bar renders BEFORE bytes move)
→ XHR PUT (not fetch: XHR is the only browser API with byte-level
`upload.progress`; signed headers sent EXACTLY)
403 (expired signature) → auto-retry ONCE with a fresh session
→ complete (server flips SCANNING + enqueues the worker)
→ poll GET /files/:id (2 s, max 60 s) until READY / VIRUS_DETECTED / FAILED
Progress UI (on the block): a bar at 0–100 %, byte count, and speed
(B/s), driven by upload.addEventListener("progress") → useUploadStore.
The progress bar is visible from the moment the session opens (the block is
inserted in onSession), not only once bytes start.
Cancel (a real <button> on the progress card): aborts the XHR, calls
POST /files/uploads/:id/cancel (the server deletes the partial object +
row), and the default UX removes the block.
Failure UX (spec §2): over-limit → a toast naming the real plan limit ("…larger than the 50 MB per-file limit on your plan") with no request sent; MIME/quota/virus → an error state on the block with the server message + Remove; virus additionally shows the red blocked card with no download affordance.
2. File block rendering (FileBlock.tsx)
A file block references an uploaded file by properties.fileId and renders
from the server-verified record (GET /files/:id):
- kind is derived from the post-scan
mimeType(kindForMime), never the file extension. - URLs are the fresh signed URLs the API mints per read. Refetch cadence: every 2 s while non-terminal, then every 5 s while a signed URL is within 60 s of expiry, else stale (re-minted on focus).
2.1 State matrix
| status | Rendering |
|---|---|
UPLOADING |
"Waiting for upload…" skeleton (or the live progress card if this tab started it) |
SCANNING |
"Scanning…" skeleton |
VIRUS_DETECTED |
red card "Blocked by virus scan — contact an admin", no download |
FAILED |
error card with metadata.pipeline.error + Remove |
READY |
per-kind render (§2.2) |
| 404 / no access | "File not found or you no longer have access." + Remove (spec §6 "No access" card) |
2.2 Per-kind render (status READY)
| kind | Component | Notes |
|---|---|---|
image |
inline <img> (previewUrl) |
width = properties.widthPercent (25–100); 50/75/100 % preset buttons (aria-pressed) + a bottom-right drag handle (pointer-capture, persisted on release). Caption toggle below → inline editable text stored on properties.caption. |
video |
native <video controls> |
poster = thumbnailUrl; aria-label = name + duration. |
audio |
native <audio controls> |
+ a music icon; aria-label = name + duration. |
pdf |
sandboxed <iframe> |
sandbox="allow-scripts allow-popups" + referrerPolicy="no-referrer"; "Open in new tab" + Download. |
doc / other |
download card | file-type icon (by mime), name, human size, "N versions" (if > 1), a real Download <button> (the 302 endpoint, audited) + "Copy link". |
Download: DownloadButton navigates to the downloadUrl (the 302
endpoint), which audits file.downloaded; the filename comes from the server
record (signed into the disposition), never client-constructed.
Copy link: copies ${API_BASE}/files/:id (the API endpoint itself —
permission-checked on read; public only if the page is shared). Spec §3.
Resize + caption storage (spec §Backend): widthPercent + caption live
on the block's properties (the BlockProperties.widthPercent field was added
in TASK-0103; the API replaces the whole properties object on PATCH, so
every writer merges {...block.properties, ...patch}). No schema change.
Legacy blocks (pre-Phase-9, a direct properties.src with no fileId)
render a plain download card (LegacyFileCard) so existing content never
breaks (AGENTS.md #16).
3. File picker (page panel, §4)
The "Files" panel opens from the page header. It lists files in scope:
- scope chip — "In this page" (
scope=page&pageId=) / "In workspace" (scope=workspace). - search box — client-side name filter (
filterByName, pure + tested). - sort —
updated(default) /name/size/date(sortPickerFiles, pure + tested inlib/files/picker.ts). - grid — thumbnail (image) or kind icon, name, human size, uploader.
- click → insert a
fileblock at the cursor (after the selected block's position, else page end). No re-upload — the block references the existingfileId. - empty state — "No files here yet — drag one in."
- a11y — roving tabindex grid (arrows/Tab move, Enter inserts, Esc closes),
role="listbox"/role="option",aria-selected.
4. File browser (workspace route, §5)
/w/:workspace/files (sidebar "Files").
- table (≥ md): name, kind icon, size, uploader, page (link → opens the page), status pill, updated date. Mobile (≤ md): a card list.
- filters: kind (image/video/audio/pdf/doc/other) + status.
- sort: recently updated (default) / newest / largest / name.
- pagination: cursor, 50/page (
useInfiniteQuery, "Load more"). - row click → preview modal: image lightbox (full size) / pdf iframe / video player / audio player / doc download card; a Download button (attachment) + "Open in new tab" (pdf). Closes on Escape / backdrop.
- empty + error states.
Permissions (§6): items the user lacks VIEW on are absent — the list
endpoint filters server-side; the UI only renders what it is given. (Per-page
gating for private pages is TASK-0104; a stale URL to an inaccessible file
renders the "No access" card from the block renderer.)
5. State matrix (block + upload, combined)
| condition | block shows |
|---|---|
| session opening, bytes not started | progress card, 0 %, live |
| PUT in flight | progress bar + % + speed + Cancel button |
| complete → worker running | "Scanning…" skeleton (2 s poll) |
| READY, image | inline image + resize + caption |
| READY, video/audio | native player |
| READY, pdf | sandboxed iframe + Download |
| READY, doc/other | download card + Copy link |
| VIRUS_DETECTED | red blocked card, no download |
| FAILED | error card + Remove |
| over plan limit (any path) | toast naming the limit; no request sent |
| cancel mid-upload | block removed (default) |
6. Security in the UI
- Signed URLs are the only fetch path for content; the UI never assembles
a bucket URL from a raw key (the raw
keyis not even in the wire object). - PDF preview:
sandbox="allow-scripts allow-popups"+no-referrer. - kind is from the server-verified
mimeType, not the extension. - Download filename comes from the server record.
- The plan size limit is a client pre-check only; the server enforces it authoritatively at session creation.
7. TASK-0104 — versions, trash, permissions, storage
The four surfaces below are the TASK-0104 Frontend Changes (spec
docs/tasks/phase-09-files.md §Frontend Changes). The API contract is
../api/files.md §9; the wire shapes there (bare arrays for
/versions, /permissions, /trash) are authoritative.
7.1 File block context menu (FileBlock.tsx)
A READY file block exposes a context menu with:
- Replace — re-upload to the SAME file as a new version. Reuses the single
uploadFile(…)machine (§1) withreplaceFileId: <file.id>pre-seeded (thereplaceFileId?param the machine already accepts); the block keeps itsfileId(blocks referencing it keep working), the progress card renders in place, and the version bumps when the new object reachesREADY. - Rename —
PATCH /files/:id {name}; updates the block's label. - Permissions… — opens the permissions dialog (§7.3).
- Delete — soft trash:
DELETE /files/:id; the block then renders the "No access / trashed" state until restored.
7.2 File browser (FileBrowser.tsx)
Tabs (in addition to the existing list):
- Files (default) — the existing table/card list. Each row gains a
version menu: list
GET /files/:id/versions(version, size, who, when, acurrentmarker) + Rollback (POST /files/:id/rollback {version})- "Delete old versions" (admin action, confirm dialog →
POST /files/:id/versions/prune).
- "Delete old versions" (admin action, confirm dialog →
- Trash —
GET /files/trash; each row has Restore (POST /files/:id/restore) and Purge (a destructive confirm dialog — "This permanently deletes all N versions and cannot be undone" →DELETE /files/:id?purge=true). Purge is only offered when the user can actually purge (FULL_ACCESS grant or workspace Admin/Owner).
7.3 Permissions dialog (shared with the page model)
A dialog listing the file's explicit grants (GET /files/:id/permissions),
with an add grant row (granteeType user/team/role + granteeId +
level) and per-grant remove. Levels: NONE (an explicit deny — clearly
labeled), VIEW, COMMENT, EDIT, FULL_ACCESS. Saving is a replace
(PUT /files/:id/permissions with the full desired set). The default (no
explicit grant) is inherit from the page/workspace. Style mirrors
components/dialogs/share-dialog.tsx, but it targets the Permission grants
table (not the page-share model).
7.4 Workspace settings — storage usage bar
A bar in workspace settings showing X / Y bytes + % + plan label, from
GET /workspaces/:id/usage: usage.storageBytes (the authoritative counter,
a decimal string — Number(…) it) over limits.maxStorageBytes (the effective
plan quota), labeled by the workspace plan. Over-quota renders a full/red
state (the server still enforces authoritatively at session creation).