.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
FileBlock.tsxapps/web/src/components/files/FileBlock.tsxraw← files31,427 B← Back to the app
1"use client";
2 
3/**
4 * TASK-0103 — the file block renderer (all kinds + all states).
5 *
6 * A `file` block references an uploaded file by `properties.fileId`. The
7 * block renders from the SERVER-VERIFIED record (`GET /files/:id`):
8 *
9 *   - kind   = f(mimeType) — the post-scan MIME, never the extension
10 *   - URLs   = the fresh signed URLs the API mints per read (15-min contract:
11 *              the detail is re-fetched whenever `now + 60 s > expiresAt`)
12 *
13 * States (spec Requirements §3):
14 *   - not READY (UPLOADING/SCANNING) → skeleton + status label
15 *     (live upload progress + cancel when this tab started the transfer)
16 *   - VIRUS_DETECTED → red "Blocked by virus scan" card, NO download
17 *   - FAILED         → error card with the server message + Remove
18 *   - READY          → per kind:
19 *       image   inline, max-width 100 %, bottom-right resize handle
20 *               (50/75/100 % presets + free drag → `properties.widthPercent`),
21 *               caption toggle + inline editable text (`properties.caption`)
22 *       video   native <video controls> (poster = thumbnail)
23 *       audio   native <audio controls> + icon
24 *       pdf     sandboxed iframe with the signed preview URL +
25 *               "Open in new tab" + Download
26 *       doc/other  download card (type icon, name, human size, version count,
27 *               Download button, Copy link)
28 *
29 * A11y (Requirements §7): progress is announced via aria-live="polite" in
30 * 25 % steps; cancel is a real <button>; video/audio carry text alternatives
31 * (name + duration); the download affordance is a real <button>.
32 */
33import { useCallback, useEffect, useRef, useState } from "react";
34import { useQuery, useQueryClient } from "@tanstack/react-query";
35import {
36  Download,
37  ExternalLink,
38  File as FileIcon,
39  FileAudio,
40  FileText,
41  FileVideo,
42  GripVertical,
43  Link as LinkIcon,
44  Loader2,
45  MoreHorizontal,
46  Music,
47  Pencil,
48  RefreshCw,
49  ShieldAlert,
50  ShieldCheck,
51  Trash2,
52  X,
53} from "lucide-react";
54import type { Block } from "@/lib/types";
55import { blockApi } from "@/lib/api";
56import { API_BASE } from "@/lib/constants";
57import { filesApi, humanSize, kindForMime, type FileDetail, type FileKind } from "@/lib/files/api";
58import { isTerminalStatus, type UploadProgress } from "@/lib/files/upload";
59import { useUploadStore } from "@/lib/files/upload-store";
60import { toast } from "@/stores/ui-store";
61import { usePage } from "@/hooks/use-pages";
62import { useFileUploads } from "@/lib/files/use-file-uploads";
63import { Dialog, DialogFooter } from "@/components/dialogs/dialog";
64import { ConfirmDialog } from "@/components/ui/confirm-dialog";
65import { FilePermissionsDialog } from "@/components/dialogs/file-permissions-dialog";
66 
67interface FileBlockProps {
68  block: Block;
69}
70 
71const KIND_ICONS: Record<FileKind, typeof FileIcon> = {
72  image: FileText,
73  video: FileVideo,
74  audio: FileAudio,
75  pdf: FileText,
76  doc: FileText,
77  other: FileIcon,
78};
79 
80export function FileBlock({ block }: FileBlockProps) {
81  const fileId = block.properties.fileId as string | undefined;
82  // Pre-Phase-9 block: a direct `src` with no `fileId` (legacy card, so
83  // existing content never breaks — AGENTS.md #16).
84  if (!fileId && block.properties.src) return <LegacyFileCard block={block} />;
85  if (!fileId) {
86    // A `file` block with no uploaded file yet — the "Add file" entry point
87    // (slash menu "File" inserts an empty file block; the button opens the
88    // native file input, multi-select, and uploads to THIS block in place).
89    return <FilePickerBlock block={block} />;
90  }
91  return <LiveFileBlock block={block} fileId={fileId} />;
92}
93 
94/* ========================================================== add-file */
95 
96/** The file-block "Add file" button (upload path c, spec §1). */
97function FilePickerBlock({ block }: { block: Block }) {
98  const { data: page } = usePage(block.pageId);
99  const workspaceId = page?.workspaceId ?? null;
100  const { start } = useFileUploads(workspaceId, block.pageId);
101  const inputRef = useRef<HTMLInputElement>(null);
102 
103  // The FIRST file replaces this block; subsequent files (multi-select) land
104  // as sibling blocks after it (the API positions them via the batch insert).
105  const onFilesMulti = async (list: FileList | null) => {
106    const files = Array.from(list ?? []);
107    const first = files[0];
108    if (!first) return;
109    const rest = files.slice(1);
110    // first file → this block, in place; the rest → new blocks after it
111    // (each insert appends after the current last sibling of the parent).
112    await start(first, { parentId: block.parentId, position: null, updateBlockId: block.id });
113    for (const f of rest) {
114      await start(f, { parentId: block.parentId, position: null });
115    }
116  };
117 
118  return (
119    <div className="flex items-center gap-3 rounded-md border border-dashed border-border p-3">
120      <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
121        <FileIcon className="size-4 text-muted-foreground" aria-hidden />
122      </span>
123      <span className="min-w-0 flex-1 text-sm text-muted-foreground">No file yet</span>
124      <button
125        type="button"
126        onClick={() => inputRef.current?.click()}
127        className="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
128      >
129        Add file
130      </button>
131      <input
132        ref={inputRef}
133        data-testid="file-input"
134        type="file"
135        className="sr-only"
136        aria-label="Choose a file to attach"
137        multiple
138        onChange={(e) => void onFilesMulti(e.target.files)}
139      />
140    </div>
141  );
142}
143 
144/* ================================================================ live */
145 
146function LiveFileBlock({ block, fileId }: { block: Block; fileId: string }) {
147  // The fresh signed URLs live on GET /files/:id. Refetch cadence:
148  //   - non-terminal status: every 2 s (the pipeline is working)
149  //   - READY but a signed URL is within 60 s of expiry: every 5 s (re-mint)
150  //   - otherwise: stale (react-query refetches on focus/mount)
151  const query = useQuery({
152    queryKey: ["files", fileId],
153    queryFn: () => filesApi.get(fileId),
154    refetchInterval: (query) => {
155      const data = query.state.data as FileDetail | undefined;
156      if (!data) return 2000;
157      if (!isTerminalStatus(data.status)) return 2000;
158      // A quarantined (VIRUS_DETECTED) file is never signed — no re-mint.
159      if (!data.expiresAt) return false;
160      const expiresMs = new Date(data.expiresAt).getTime() - Date.now();
161      return expiresMs < 60_000 ? 5000 : false;
162    },
163    staleTime: 30_000,
164  });
165 
166  // Live progress (this tab started the transfer) — shown until settled.
167  const live = useUploadStore((s) => s.active[fileId]);
168  const qc = useQueryClient();
169 
170  const [captionOpen, setCaptionOpen] = useState(false);
171  // TASK-0104 context-menu state (Replace / Rename / Permissions / Delete).
172  const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
173  const [renameOpen, setRenameOpen] = useState(false);
174  const [permsOpen, setPermsOpen] = useState(false);
175  const [trashConfirmOpen, setTrashConfirmOpen] = useState(false);
176 
177  // Error states are derived from the server record: `query.isError` (no
178  // access / not found) and `detail.status === "FAILED"` (pipeline error).
179  const detail = query.data;
180 
181  // Persist a properties patch (the API REPLACES properties → merge).
182  const patchProps = useCallback(
183    async (patch: Record<string, unknown>) => {
184      await blockApi.update(block.id, { properties: { ...block.properties, ...patch } });
185    },
186    [block.id, block.properties],
187  );
188 
189  // TASK-0104 context-menu actions. The upload orchestrator + workspace come
190  // from the page (same path as FilePickerBlock); the actions are defined here
191  // (hooks only) and rendered in the READY branch.
192  const { data: page } = usePage(block.pageId);
193  const workspaceId = page?.workspaceId ?? null;
194  const { start } = useFileUploads(workspaceId, block.pageId);
195  const replaceInputRef = useRef<HTMLInputElement>(null);
196 
197  const closeMenu = () => setMenuAnchor(null);
198 
199  const doRename = async (name: string) => {
200    const trimmed = name.trim();
201    if (!trimmed) return;
202    try {
203      await filesApi.rename(fileId, trimmed, { workspaceId: workspaceId ?? undefined });
204      await patchProps({ name: trimmed });
205      qc.invalidateQueries({ queryKey: ["files", fileId] });
206      toast("success", "File renamed");
207      setRenameOpen(false);
208    } catch (e) {
209      toast("error", "Rename failed", e instanceof Error ? e.message : undefined);
210    }
211  };
212 
213  const doTrash = async () => {
214    try {
215      await filesApi.trash(fileId, { workspaceId: workspaceId ?? undefined });
216      // The block keeps its fileId; the file is now in the workspace trash
217      // (restorable). Remove the block so the page no longer points at it.
218      await blockApi.remove(block.id).catch(() => undefined);
219      qc.invalidateQueries({ queryKey: ["files", "browser"] });
220      toast("success", "Moved to trash", "Restore it from the Files → Trash tab.");
221      setTrashConfirmOpen(false);
222    } catch (e) {
223      toast("error", "Delete failed", e instanceof Error ? e.message : undefined);
224    }
225  };
226 
227  const onReplaceFile = (list: FileList | null) => {
228    const file = list?.[0];
229    if (!file) return;
230    void start(file, {
231      parentId: block.parentId,
232      position: null,
233      updateBlockId: block.id,
234      replaceFileId: fileId,
235    }).then(() => toast("success", "New version uploaded"));
236  };
237 
238  if (live) {
239    return <UploadProgressCard block={block} fileId={fileId} progress={live} />;
240  }
241 
242  if (!detail || !query.data) {
243    if (query.isError) {
244      return (
245        <ErrorCard
246          message="File not found or you no longer have access."
247          onRemove={() => void blockApi.remove(block.id)}
248        />
249      );
250    }
251    return <Skeleton status="Loading…" />;
252  }
253 
254  if (detail.status === "VIRUS_DETECTED") {
255    return (
256      <div
257        role="alert"
258        className="flex items-start gap-3 rounded-md border border-red-300 bg-red-50 p-3 text-sm"
259      >
260        <ShieldAlert className="mt-0.5 size-4 shrink-0 text-red-600" aria-hidden />
261        <div className="min-w-0">
262          <p className="font-medium text-red-800">Blocked by virus scan — contact an admin</p>
263          <p className="truncate text-red-700">{detail.name}</p>
264        </div>
265      </div>
266    );
267  }
268 
269  if (detail.status === "FAILED") {
270    const err = (detail.metadata?.pipeline as { error?: string } | undefined)?.error;
271    return (
272      <ErrorCard
273        message={err ?? "Processing failed — the file could not be prepared."}
274        onRemove={() => void blockApi.remove(block.id)}
275      />
276    );
277  }
278 
279  if (detail.status !== "READY") {
280    return <Skeleton status={detail.status === "SCANNING" ? "Scanning…" : "Waiting for upload…"} />;
281  }
282 
283  // READY — render per kind.
284  const kind = kindForMime(detail.mimeType);
285  const props = block.properties;
286 
287  return (
288    <div data-testid={`file-card-${detail.id}`} className="group/file w-full">
289      <div className="relative">
290        {/* TASK-0104 context menu (hover affordance; also keyboard-reachable). */}
291        <div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity group-hover/file:opacity-100 focus-within:opacity-100">
292          <button
293            type="button"
294            aria-label={`Actions for ${detail.name}`}
295            aria-haspopup="menu"
296            aria-expanded={menuAnchor !== null}
297            onClick={(e) => {
298              e.stopPropagation();
299              setMenuAnchor({ x: e.clientX, y: e.clientY });
300            }}
301            className="flex size-7 items-center justify-center rounded-md border border-border bg-background/90 text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
302          >
303            <MoreHorizontal className="size-4" aria-hidden />
304          </button>
305        </div>
306        {kind === "image" && <ImageRender detail={detail} props={props} patchProps={patchProps} />}
307        {kind === "video" && <VideoRender detail={detail} />}
308        {kind === "audio" && <AudioRender detail={detail} />}
309        {kind === "pdf" && <PdfRender detail={detail} />}
310        {(kind === "doc" || kind === "other") && (
311          <DocCard detail={detail} onCopyLink={() => void copyShareLink(detail.id)} />
312        )}
313      </div>
314      {(kind === "image" || kind === "video") && (
315        <CaptionRow
316          open={captionOpen}
317          onToggle={() => setCaptionOpen((v) => !v)}
318          caption={props.caption ?? ""}
319          onSave={(caption) => void patchProps({ caption: caption || undefined })}
320        />
321      )}
322 
323      {menuAnchor && (
324        <FileBlockMenu
325          x={menuAnchor.x}
326          y={menuAnchor.y}
327          onClose={closeMenu}
328          onReplace={() => {
329            closeMenu();
330            replaceInputRef.current?.click();
331          }}
332          onRename={() => {
333            closeMenu();
334            setRenameOpen(true);
335          }}
336          onPermissions={() => {
337            closeMenu();
338            setPermsOpen(true);
339          }}
340          onDelete={() => {
341            closeMenu();
342            setTrashConfirmOpen(true);
343          }}
344        />
345      )}
346 
347      {/* native file input for "Replace" (same file → new version) */}
348      <input
349        ref={replaceInputRef}
350        type="file"
351        className="sr-only"
352        aria-label={`Choose a replacement for ${detail.name}`}
353        onChange={(e) => {
354          onReplaceFile(e.target.files);
355          e.target.value = "";
356        }}
357      />
358 
359      <RenameFileDialog open={renameOpen} initialName={detail.name} onClose={() => setRenameOpen(false)} onSave={(name) => void doRename(name)} />
360 
361      <ConfirmDialog
362        open={trashConfirmOpen}
363        title="Delete this file?"
364        description={
365          <>
366            <span className="font-medium text-foreground">{detail.name}</span> moves to the workspace trash (the
367            current version is kept). Restore it from Files → Trash, or purge it there to delete for good.
368          </>
369        }
370        confirmLabel="Delete"
371        destructive
372        busy={false}
373        onConfirm={() => void doTrash()}
374        onClose={() => setTrashConfirmOpen(false)}
375      />
376 
377      {permsOpen && (
378        <FilePermissionsDialog
379          open={permsOpen}
380          fileId={fileId}
381          fileName={detail.name}
382          workspaceId={workspaceId ?? ""}
383          onClose={() => setPermsOpen(false)}
384        />
385      )}
386    </div>
387  );
388}
389 
390/* ============================================== TASK-0104: context menu */
391 
392function FileBlockMenu({
393  x,
394  y,
395  onClose,
396  onReplace,
397  onRename,
398  onPermissions,
399  onDelete,
400}: {
401  x: number;
402  y: number;
403  onClose: () => void;
404  onReplace: () => void;
405  onRename: () => void;
406  onPermissions: () => void;
407  onDelete: () => void;
408}) {
409  const ref = useRef<HTMLDivElement>(null);
410 
411  useEffect(() => {
412    const onKey = (e: KeyboardEvent) => {
413      if (e.key === "Escape") onClose();
414    };
415    const onClick = (e: MouseEvent) => {
416      if (ref.current && !ref.current.contains(e.target as Node)) onClose();
417    };
418    document.addEventListener("keydown", onKey);
419    document.addEventListener("mousedown", onClick);
420    return () => {
421      document.removeEventListener("keydown", onKey);
422      document.removeEventListener("mousedown", onClick);
423    };
424  }, [onClose]);
425 
426  // Keep the menu on-screen (mirrors the editor ContextMenu).
427  const style = {
428    top: Math.min(y, (typeof window !== "undefined" ? window.innerHeight : 800) - 220),
429    left: Math.min(x, (typeof window !== "undefined" ? window.innerWidth : 1200) - 230),
430  };
431 
432  const item = "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent";
433 
434  return (
435    <div
436      ref={ref}
437      role="menu"
438      aria-label="File actions"
439      style={style}
440      className="fixed z-[95] w-52 rounded-md border border-border bg-background p-1 shadow-xl"
441    >
442      <button type="button" role="menuitem" onClick={onReplace} className={item}>
443        <RefreshCw className="size-4 text-muted-foreground" aria-hidden />
444        Replace
445      </button>
446      <button type="button" role="menuitem" onClick={onRename} className={item}>
447        <Pencil className="size-4 text-muted-foreground" aria-hidden />
448        Rename
449      </button>
450      <button type="button" role="menuitem" onClick={onPermissions} className={item}>
451        <ShieldCheck className="size-4 text-muted-foreground" aria-hidden />
452        Permissions…
453      </button>
454      <div className="my-1 border-t border-border" aria-hidden />
455      <button
456        type="button"
457        role="menuitem"
458        onClick={onDelete}
459        className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
460      >
461        <Trash2 className="size-4" aria-hidden />
462        Delete
463      </button>
464    </div>
465  );
466}
467 
468/** Inline rename (reuses the shared Dialog shell). */
469function RenameFileDialog({
470  open,
471  initialName,
472  onClose,
473  onSave,
474}: {
475  open: boolean;
476  initialName: string;
477  onClose: () => void;
478  onSave: (name: string) => void;
479}) {
480  const [name, setName] = useState(initialName);
481  useEffect(() => {
482    if (open) setName(initialName);
483  }, [open, initialName]);
484  return (
485    <Dialog
486      open={open}
487      title="Rename file"
488      onClose={onClose}
489      footer={
490        <DialogFooter
491          onCancel={onClose}
492          onAction={() => onSave(name)}
493          actionLabel="Rename"
494          disabled={!name.trim() || name.trim() === initialName}
495        />
496      }
497    >
498      <form
499        onSubmit={(e) => {
500          e.preventDefault();
501          onSave(name);
502        }}
503      >
504        <label htmlFor="file-rename-name" className="mb-1 block text-sm font-medium">
505          File name
506        </label>
507        <input
508          id="file-rename-name"
509          type="text"
510          value={name}
511          onChange={(e) => setName(e.target.value)}
512          autoFocus
513          className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"
514        />
515      </form>
516    </Dialog>
517  );
518}
519 
520/* ===================================================== per-kind renders */
521 
522function ImageRender({
523  detail,
524  props,
525  patchProps,
526}: {
527  detail: FileDetail;
528  props: Block["properties"];
529  patchProps: (patch: Record<string, unknown>) => Promise<void>;
530}) {
531  const widthPercent = clampPercent((props.widthPercent as number | undefined) ?? 100);
532  const frameRef = useRef<HTMLDivElement>(null);
533  const [dragging, setDragging] = useState(false);
534 
535  const onHandlePointerMove = (e: React.PointerEvent) => {
536    if (!dragging || !frameRef.current) return;
537    const rect = frameRef.current.getBoundingClientRect();
538    const pct = ((e.clientX - rect.left) / rect.width) * 100;
539    // live local preview only; persistence happens on pointer up
540    frameRef.current.querySelector<HTMLElement>("[data-image-frame]")?.style.setProperty(
541      "width",
542      `${clampPercent(pct)}%`,
543    );
544  };
545 
546  return (
547    <div ref={frameRef} className="relative inline-block max-w-full align-top" onPointerMove={onHandlePointerMove}>
548      {/* eslint-disable-next-line @next/next/no-img-element */}
549      <img
550        data-image-frame
551        src={detail.previewUrl}
552        alt={props.caption || detail.name}
553        style={{ width: `${widthPercent}%` }}
554        className="block max-w-full rounded-md"
555        loading="lazy"
556      />
557      <div
558        role="group"
559        aria-label={`Image width ${widthPercent}% — use the presets or the drag handle`}
560        className="mt-1 flex items-center gap-1"
561      >
562        {[50, 75, 100].map((p) => (
563          <button
564            key={p}
565            type="button"
566            aria-pressed={widthPercent === p}
567            onClick={() => void patchProps({ widthPercent: p })}
568            className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent"
569          >
570            {p}%
571          </button>
572        ))}
573        <button
574          type="button"
575          aria-label="Drag to resize"
576          onPointerDown={(e) => {
577            (e.target as HTMLElement).setPointerCapture(e.pointerId);
578            setDragging(true);
579          }}
580          onPointerUp={(e) => {
581            (e.target as HTMLElement).releasePointerCapture(e.pointerId);
582            setDragging(false);
583            const frame = frameRef.current?.querySelector<HTMLElement>("[data-image-frame]");
584            const pct = frame ? (parseFloat(frame.style.width || "100") || 100) : widthPercent;
585            void patchProps({ widthPercent: Math.round(clampPercent(pct)) });
586          }}
587          className="ml-1 cursor-ew-resize rounded p-1 text-muted-foreground hover:bg-accent"
588        >
589          <GripVertical className="size-3.5" aria-hidden />
590        </button>
591      </div>
592    </div>
593  );
594}
595 
596function VideoRender({ detail }: { detail: FileDetail }) {
597  const duration = (detail.metadata?.media as { duration?: number } | undefined)?.duration;
598  return (
599    <video
600      controls
601      src={detail.previewUrl}
602      poster={detail.thumbnailUrl ?? undefined}
603      className="max-h-[70vh] max-w-full rounded-md bg-black"
604      aria-label={`${detail.name}${typeof duration === "number" ? `, ${Math.round(duration)} seconds` : ""}`}
605    >
606      <track kind="captions" />
607    </video>
608  );
609}
610 
611function AudioRender({ detail }: { detail: FileDetail }) {
612  const duration = (detail.metadata?.media as { duration?: number } | undefined)?.duration;
613  return (
614    <div className="flex items-center gap-3 rounded-md border border-border p-3">
615      <Music className="size-5 shrink-0 text-muted-foreground" aria-hidden />
616      <audio
617        controls
618        src={detail.previewUrl}
619        className="h-9 min-w-0 flex-1"
620        aria-label={`${detail.name}${typeof duration === "number" ? `, ${Math.round(duration)} seconds` : ""}`}
621      />
622    </div>
623  );
624}
625 
626function PdfRender({ detail }: { detail: FileDetail }) {
627  return (
628    <div className="w-full overflow-hidden rounded-md border border-border">
629      <iframe
630        title={detail.name}
631        src={detail.previewUrl}
632        sandbox="allow-scripts allow-popups"
633        referrerPolicy="no-referrer"
634        className="h-[70vh] w-full bg-white"
635      />
636      <div className="flex items-center gap-2 border-t border-border p-2">
637        <a
638          href={detail.previewUrl}
639          target="_blank"
640          rel="noopener noreferrer"
641          className="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
642        >
643          <ExternalLink className="size-3" aria-hidden /> Open in new tab
644        </a>
645        <DownloadButton detail={detail} />
646      </div>
647    </div>
648  );
649}
650 
651function DocCard({ detail, onCopyLink }: { detail: FileDetail; onCopyLink: () => void }) {
652  const Icon = KIND_ICONS[kindForMime(detail.mimeType)];
653  return (
654    <div className="flex items-center gap-3 rounded-md border border-border p-3">
655      <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
656        <Icon className="size-4 text-muted-foreground" aria-hidden />
657      </span>
658      <span className="min-w-0 flex-1">
659        <span data-testid={`file-name-${detail.id}`} className="block truncate text-sm font-medium">{detail.name}</span>
660        <span className="block text-xs text-muted-foreground">
661          {[detail.mimeType, humanSize(detail.size), detail.versions.length > 1 ? `${detail.versions.length} versions` : null]
662            .filter(Boolean)
663            .join(" · ")}
664        </span>
665      </span>
666      <div className="flex shrink-0 items-center gap-1">
667        <button
668          type="button"
669          title="Copy share link"
670          aria-label="Copy share link"
671          onClick={onCopyLink}
672          className="rounded-md border border-border p-1.5 text-muted-foreground hover:bg-accent"
673        >
674          <LinkIcon className="size-3.5" aria-hidden />
675        </button>
676        <DownloadButton detail={detail} />
677      </div>
678    </div>
679  );
680}
681 
682function DownloadButton({ detail }: { detail: FileDetail }) {
683  return (
684    <button
685      type="button"
686      data-testid={`file-download-${detail.id}`}
687      onClick={() => {
688        // The 302 endpoint audits file.downloaded; a signed GET would skip
689        // the audit. Filename comes from the server record (signed into the
690        // disposition), never client-constructed. (downloadUrl is always set
691        // here — a quarantined file renders the red card, never this button.)
692        if (!detail.downloadUrl) return;
693        window.location.href = detail.downloadUrl;
694      }}
695      className="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
696    >
697      <Download className="size-3" aria-hidden /> Download
698    </button>
699  );
700}
701 
702/* ======================================================= state surfaces */
703 
704function Skeleton({ status }: { status: string }) {
705  return (
706    <div className="flex items-center gap-3 rounded-md border border-border p-4" role="status" aria-live="polite">
707      <Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
708      <span className="text-sm text-muted-foreground">{status}</span>
709    </div>
710  );
711}
712 
713function ErrorCard({ message, onRemove }: { message: string; onRemove: () => void }) {
714  return (
715    <div
716      role="alert"
717      className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm"
718    >
719      <ShieldAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
720      <div className="min-w-0 flex-1">
721        <p className="text-muted-foreground">{message}</p>
722      </div>
723      <button
724        type="button"
725        onClick={onRemove}
726        className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent"
727      >
728        <Trash2 className="size-3" aria-hidden /> Remove
729      </button>
730    </div>
731  );
732}
733 
734function UploadProgressCard({
735  block,
736  fileId,
737  progress,
738}: {
739  block: Block;
740  fileId: string;
741  progress: UploadProgress;
742}) {
743  const cancelFn = useUploadStore((s) => s.cancelFns[fileId]);
744  // 25 % steps for screen readers (Requirements §7).
745  const announced = Math.min(100, Math.floor(progress.fraction * 100) / 25) * 25;
746  return (
747    <div className="w-full rounded-md border border-border p-3" role="status" aria-live="polite" aria-atomic="true">
748      <p className="sr-only">Uploading {block.properties.name ?? "file"}: {announced} percent</p>
749      <div className="flex items-center gap-3">
750        <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
751        <div className="min-w-0 flex-1">
752          <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
753            <div
754              className="h-full rounded-full bg-primary transition-[width] duration-150"
755              style={{ width: `${Math.round(progress.fraction * 100)}%` }}
756            />
757          </div>
758          <p className="mt-1 text-xs text-muted-foreground">
759            {progress.fraction < 1
760              ? `${Math.round(progress.fraction * 100)} % · ${humanSize(progress.loaded)} of ${humanSize(progress.total)}${progress.speedBps > 0 ? ` · ${humanSize(progress.speedBps)}/s` : ""}`
761              : "Uploading complete — processing…"}
762          </p>
763        </div>
764        {progress.phase === "uploading" && cancelFn ? (
765          <button
766            type="button"
767            onClick={cancelFn}
768            className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent"
769          >
770            <X className="size-3" aria-hidden /> Cancel
771          </button>
772        ) : null}
773      </div>
774    </div>
775  );
776}
777 
778function CaptionRow({
779  open,
780  onToggle,
781  caption,
782  onSave,
783}: {
784  open: boolean;
785  onToggle: () => void;
786  caption: string;
787  onSave: (caption: string) => void;
788}) {
789  const [draft, setDraft] = useState(caption);
790  useEffect(() => setDraft(caption), [caption, open]);
791  return (
792    <div className="mt-1">
793      {open ? (
794        <input
795          autoFocus
796          value={draft}
797          onChange={(e) => setDraft(e.target.value)}
798          onBlur={() => onSave(draft)}
799          onKeyDown={(e) => {
800            if (e.key === "Enter" || e.key === "Escape") onSave(draft);
801          }}
802          placeholder="Add a caption…"
803          aria-label="Caption"
804          className="w-full rounded border border-border bg-transparent px-1 py-0.5 text-center text-xs text-muted-foreground outline-none focus:border-primary"
805        />
806      ) : (
807        <>
808          {caption ? (
809            <p className="text-center text-xs text-muted-foreground">{caption}</p>
810          ) : null}
811          <button
812            type="button"
813            onClick={onToggle}
814            className="mx-auto block rounded px-1.5 py-0.5 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:opacity-100 focus:opacity-100 group-hover:opacity-100"
815          >
816            + Caption
817          </button>
818        </>
819      )}
820    </div>
821  );
822}
823 
824/* =========================================================== utilities */
825 
826/**
827 * Pre-Phase-9 file blocks carried a direct `properties.src` (no `fileId`) —
828 * render them as a plain download card so existing content never breaks
829 * (AGENTS.md #16: never silently remove existing functionality).
830 */
831function LegacyFileCard({ block }: { block: Block }) {
832  const { src, name, size, mimeType } = block.properties;
833  const sizeLabel = typeof size === "number" ? humanSize(size) : undefined;
834  return (
835    <div className="flex items-center gap-3 rounded-md border border-border p-3">
836      <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
837        <FileIcon className="size-4 text-muted-foreground" aria-hidden />
838      </span>
839      <span className="min-w-0 flex-1">
840        <span className="block truncate text-sm font-medium">{name ?? "File"}</span>
841        <span className="block text-xs text-muted-foreground">{[mimeType, sizeLabel].filter(Boolean).join(" · ")}</span>
842      </span>
843      {src ? (
844        <a
845          href={src}
846          target="_blank"
847          rel="noopener noreferrer"
848          download={typeof name === "string" ? name : undefined}
849          className="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
850        >
851          Download
852        </a>
853      ) : null}
854    </div>
855  );
856}
857 
858function clampPercent(n: number): number {
859  if (!Number.isFinite(n)) return 100;
860  return Math.min(100, Math.max(25, n));
861}
862 
863async function copyShareLink(fileId: string): Promise<void> {
864  // The share link is the API endpoint itself (spec §3: "share link =
865  // GET /files/:id — public only if the page is shared"). It is
866  // permission-checked on read; no client-side URL construction.
867  const url = `${API_BASE}/files/${fileId}`;
868  try {
869    await navigator.clipboard.writeText(url);
870    toast("success", "Link copied");
871  } catch {
872    toast("error", "Copy failed", "This file's link requires workspace access.");
873  }
874}
875