From 1eddcfc8bf0820ba43481e21e34b04e3aac144ba Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Tue, 14 Jul 2026 09:02:02 -0400 Subject: [PATCH 1/8] docs: metadata search UI design spec (#159) --- .../2026-07-14-metadata-search-ui-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-metadata-search-ui-design.md diff --git a/docs/superpowers/specs/2026-07-14-metadata-search-ui-design.md b/docs/superpowers/specs/2026-07-14-metadata-search-ui-design.md new file mode 100644 index 0000000..c68d2fb --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-metadata-search-ui-design.md @@ -0,0 +1,174 @@ +# Metadata Search UI — Design + +**Date:** 2026-07-14 +**Status:** Approved design, pre-implementation +**Tracking:** [#159](https://github.com/buildinternet/uploads/issues/159) — "Web search UI" follow-up to per-file metadata (#157/#158) + +## Summary + +Per-file metadata shipped in #157 with a proven filter path over the CLI and MCP, +but it is only reachable from the terminal today. This adds a **metadata search +surface to the web**, folded into the existing per-workspace file browser on +`/account/workspaces`. A user adds `key=value` filter chips; matching files render +in a flat results list. It requires one thin **session-authed API endpoint** plus +two new front-end components; the existing folder browser is left untouched. + +## Goals + +- Let a signed-in member find files in a workspace by their `gh.*`/custom metadata, + using the same AND-of-equality semantics the CLI/MCP already expose. +- Reuse the proven `validateMetadataFilters` / `findObjectsByMetadata` helpers — no + new query engine, no new validation rules. +- Keep the change additive: no regression to folder browse, visibility toggles, or + deep-link restore. + +## Non-goals (explicitly deferred) + +- **Cross-workspace search.** The metadata index and its API are per-workspace; + search stays per-workspace. +- **Value/key autocomplete.** There is no "distinct metadata values" endpoint, and + adding one is out of scope. The filter UI is manual key/value entry. +- **Visibility toggle in results.** Metadata-filtered listings carry no `visibility` + annotation (it lives in R2 custom metadata; hydrating costs a HEAD per result — + documented caveat in #159). Results are read-only; the toggle stays in browse mode. + +## Current state + +- `/account/workspaces` ([`workspaces.astro`](../../../apps/web/src/pages/account/workspaces.astro)) + is a vanilla-JS page that renders a `
  • ` per workspace and, for each + non-communal workspace, mounts a **React island** `AccountFileBrowser` into the + `[data-files]` slot. +- [`AccountFileBrowser.tsx`](../../../apps/web/src/components/AccountFileBrowser.tsx) + is folder-aware, backed by `files-sdk`'s `useFiles` hook against + `/me/workspaces/:name/file-browser`. It also owns the per-row public/private + visibility toggle. +- The `meta.=` filter lives on the **token-authed** + `/v1/:workspace/files` route ([`files.ts`](../../../apps/api/src/routes/files.ts), + `requireScope("files:read")`) — a browser cookie session cannot reach it. +- The `/me/*` routes ([`me.ts`](../../../apps/api/src/routes/me.ts)) are + session-cookie-authenticated (`sessionAuth`, `requireSessionUser`). +- Deep-link restore for `?ws=&path=` is handled by + [`workspace-browse-url.ts`](../../../apps/web/src/lib/workspace-browse-url.ts). + +## Architecture + +### Component structure + +| Component | Responsibility | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WorkspaceFiles.tsx` (new) | Wrapper mounted per workspace. Owns `mode: "browse" \| "search"`. Always renders the filter bar; renders `` when no filters are active, `` when filters are present. | +| `MetadataSearchResults.tsx` (new) | Filter-chip state, fetch against the new search endpoint, and the flat results list with its loading/empty/error/truncated states. | +| `AccountFileBrowser.tsx` | **Unchanged** — remains the sole owner of folder browse + visibility. | + +`workspaces.astro` changes by one line: mount `WorkspaceFiles` instead of +`AccountFileBrowser`, passing the same props (`apiOrigin`, `workspace`, +`hasPublicUrl`, `initialPrefix`, `onPrefixChange`) plus the initial filter state +parsed from the URL. + +Rationale: keeping `AccountFileBrowser` single-purpose (per the isolation +principle) means search cannot regress folder browse, and each piece is testable +alone. The wrapper holds only the mode decision. + +### Backend — new session endpoint + +Add to [`me.ts`](../../../apps/api/src/routes/me.ts): + +``` +GET /me/workspaces/:name/files/search?meta.=&... + → 200 { items: [{ key, url, embedUrl, metadata }], truncated: boolean } +``` + +- **Auth/gating:** inherits `sessionAuth` + `requireSessionUser` from the `/me/*` + mount; resolves the workspace via the existing `memberWorkspaceOr404`. Communal + workspace → `{ items: [], truncated: false }` (consistent with the existing + `/files` communal short-circuit). +- **Filter parsing/validation:** collect `meta.*` params exactly as the token route + does (`files.ts` lines ~146–162): reject a repeated same-key param with + `ValidationError` code `file_metadata_duplicate_filter`, then call the shared + `validateMetadataFilters(filters)` (key format, filter count cap). No new + validation rules. +- **Query:** `findObjectsByMetadata(DB, workspaceName, filters, { prefix, limit })`, + the same helper the token route calls. `prefix` optional (reserved; not surfaced + in the UI initially). +- **Limit/truncation:** cap `limit` at **100**. Request `limit + 1` internally (or + compare returned length to the cap) to set `truncated: true` when more matches + exist, so the UI can prompt the user to narrow. +- **Response shape:** `{ key, url, embedUrl, metadata }` per item — mirrors the + token route's projection (`objectPublicUrls` → `url`/`embedUrl`). No `visibility` + (accepted caveat). + +**Routing safety:** `:name` is a single-segment param (`[a-z0-9-]`), so the +`files/search` static suffix does **not** hit the #158 `{.+}`-param-then-static-suffix +trap. Still verify on a preview worker per that lesson. + +New endpoint (dedicated `files/search`) rather than overloading the existing +`GET /me/workspaces/:name/files` return contract — clearer shape, no risk to any +existing `/files` caller. + +## Filter UX — key/value chips + +- A **"Filter by metadata"** bar above the file area: a `key` input, a `value` + input, and an **Add** button. Each added pair becomes a removable chip rendered + as `key=value ×`. Multiple chips **AND** together (matches the API). +- **Client-side validation before firing:** mirror the shared key regex + (`^[a-z][a-z0-9._-]{0,63}$`, per `META_KEY_RE`) and value bounds (1–512 printable + ASCII) so malformed input is rejected inline instead of round-tripping a 400. + Enforce the same filter-count cap the API does. +- **Empty-state hints:** since `gh.*` dominates real usage, the empty search state + suggests example keys (`gh.repo`, `app`, `page`) as click-to-fill chips — cheap + discoverability without an autocomplete endpoint. +- Removing the last chip returns the island to `browse` mode (re-shows + `AccountFileBrowser`). + +## Results list + +Flat list (no folder navigation): + +- **Per row:** a thumbnail for image keys (kind inferred from the key's extension, + since the result shape carries no `contentType`) via + ``; otherwise a neutral file glyph. Then the + filename (last key segment), the matched metadata as small chips, and actions: + - **Open ↗** → `url` (`rel="noopener noreferrer"`, new tab) + - **Copy link** → reuses the copy-with-feedback pattern already in this page + (`navigator.clipboard.writeText` + transient "copied ✓" label). +- **States:** + - _Loading:_ spinner / "Searching…". + - _Empty:_ "No files match these filters." + the example-key hints. + - _Error:_ message + retry (mirrors the workspace-list retry affordance). + - _Truncated:_ "Showing the first 100 matches — add filters to narrow." when the + endpoint returns `truncated: true`. + +## URL sync & state + +- Extend [`workspace-browse-url.ts`](../../../apps/web/src/lib/workspace-browse-url.ts) + (or add a sibling `readSearchLocation`/`replaceSearchLocation`) so active filters + encode into the query string, e.g. `?ws=acme&meta.app=web&meta.page=settings`. + On load, presence of any `meta.*` param mounts the deep-linked workspace directly + into `search` mode with those chips restored — reusing the existing eager-mount + path that `?path=` already triggers. +- Filters are per-workspace island state; switching workspaces does not carry + filters across (matches the per-workspace API). + +## Error handling + +- Endpoint uses the standard `AppError`/`ValidationError` typed-error path already + in `me.ts`; a bad filter surfaces as a typed 400, not an untyped 500. +- Front-end `credentialedFetch` (same 8s-timeout wrapper `AccountFileBrowser` + already uses) guards network hiccups; failures land in the results _error_ state. + +## Testing + +- **API** (`apps/api/src/routes/me.test.ts`): new-route cases — filter AND + semantics, duplicate-key → 400 `file_metadata_duplicate_filter`, key-format + rejection, communal → empty, truncation flag, member gating (non-member → 404). +- **Web:** component test for `MetadataSearchResults` — chip add/remove → + constructed query string; `WorkspaceFiles` browse↔search mode switch on + first-chip-added / last-chip-removed. +- **Manual:** verify `GET /me/workspaces/:name/files/search` resolves on a **preview + worker** (not just vitest), per the #158 routing lesson. + +## Rollout + +Additive and behind the existing signed-in `/account/workspaces` surface. No +migration. The endpoint reuses shipped D1 index data (backfill already run in prod +per #159), so search returns real results on deploy. From 6ba5a2f6140c57eb2c71d835a669b3ce08e86e3f Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Tue, 14 Jul 2026 09:17:27 -0400 Subject: [PATCH 2/8] docs: metadata search UI implementation plan (#159) --- .../plans/2026-07-14-metadata-search-ui.md | 1104 +++++++++++++++++ 1 file changed, 1104 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-metadata-search-ui.md diff --git a/docs/superpowers/plans/2026-07-14-metadata-search-ui.md b/docs/superpowers/plans/2026-07-14-metadata-search-ui.md new file mode 100644 index 0000000..26eadd7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-metadata-search-ui.md @@ -0,0 +1,1104 @@ +# Metadata Search UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a signed-in member search a workspace's files by their `gh.*`/custom metadata from the web, folded into the existing file browser on `/account/workspaces`. + +**Architecture:** A new session-authed API endpoint (`GET /me/workspaces/:name/files/search`) reuses the shipped `validateMetadataFilters`/`findObjectsByMetadata` helpers. On the web, testable logic lives in pure `lib/` modules (URL sync, filter validation, API client) with unit tests; two new React components (`WorkspaceFiles` wrapper + `MetadataSearchResults`) render the filter chips and results and are verified on a preview worker. The existing `AccountFileBrowser` is untouched. + +**Tech Stack:** Hono (Cloudflare Workers API), Astro + React islands (web), `@uploads/ui` primitives, vitest. + +## Global Constraints + +- **Filter semantics:** AND-of-equality across `meta.=` pairs (mirror the token route in `apps/api/src/routes/files.ts`). Repeated same-key param → `ValidationError` code `file_metadata_duplicate_filter`. +- **Key format:** `^[a-z][a-z0-9._-]{0,63}$` (`META_KEY_RE`). Max 24 filters (`META_MAX_KEYS`). Value 1–512 printable ASCII (`META_VALUE_MAX`). +- **Result cap:** 100 items; return `truncated: true` when more matches exist. +- **No visibility in results** (accepted caveat — it isn't in the metadata index). +- **Per-workspace only.** No cross-workspace search, no key/value autocomplete. +- **Auth:** the endpoint lives under the `/me/*` mount (`sessionAuth` + `requireSessionUser`); authorize via `memberWorkspaceOr404`. Communal workspace → empty result. +- **Web fetches to `/me/*` must send `credentials: "include"`.** +- **Routing:** verify the new route resolves on a real preview worker, not just vitest (#158 lesson). +- Commit after each task. Follow existing file conventions (inline styles in `.astro`, `@uploads/ui` primitives in `.tsx`). + +--- + +### Task 1: API search endpoint + +**Files:** + +- Modify: `apps/api/src/routes/me.ts` (imports + new route, insert after the existing `/workspaces/:name/files` handler ~line 175) +- Test: `apps/api/src/routes/me.test.ts` (new `describe` block + a metadata-seeded D1 helper) + +**Interfaces:** + +- Consumes: `findObjectsByMetadata(db, workspace, filters, { prefix?, limit? }) => Promise }>>` and `validateMetadataFilters(filters: Record) => void` from `../file-metadata`; `objectPublicUrls(env, cfg, key) => { url, embedUrl }` and `storageConfig(env, record)` from `../storage`; `loadWorkspaceRecord`, `memberWorkspaceOr404`, `requireUserId` (already in `me.ts`). +- Produces: `GET /me/workspaces/:name/files/search?meta.=&... → 200 { items: Array<{ key: string; url: string|null; embedUrl: string|null; metadata: Record }>, truncated: boolean }`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/routes/me.test.ts`. First, a metadata-seeded D1 helper near `galleriesDb()` (reuse the existing `SQLiteD1`/`SQLiteStatement` classes and `DatabaseSync` imports already in the file): + +```ts +function metadataDb( + rows: Array<{ workspace: string; key: string; meta: Record }>, +): SQLiteD1 { + const db = new DatabaseSync(":memory:"); + db.exec( + readFileSync( + fileURLToPath( + new NodeURL("../../migrations/20260713210559_file_metadata.sql", import.meta.url), + ), + "utf8", + ), + ); + const insert = db.prepare( + "INSERT INTO file_metadata (workspace, object_key, meta_key, meta_value) VALUES (?, ?, ?, ?)", + ); + for (const row of rows) { + for (const [k, v] of Object.entries(row.meta)) insert.run(row.workspace, row.key, k, v); + } + return new SQLiteD1(db); +} + +const R2_RECORD = { + provider: "r2", + bucket: "shared", + binding: "UPLOADS_DEFAULT", + prefix: "acme/", + publicBaseUrl: "https://storage.uploads.sh", +}; +``` + +Then the test block: + +```ts +describe("GET /me/workspaces/:name/files/search", () => { + it("returns files matching an ANDed metadata filter", async () => { + const db = metadataDb([ + { + workspace: "acme", + key: "f/x/shot.png", + meta: { "gh.repo": "buildinternet/uploads", app: "web" }, + }, + { workspace: "acme", key: "f/y/other.png", meta: { "gh.repo": "buildinternet/uploads" } }, + ]); + const env = memberEnv({ workspace: "acme", db, bucket: new FakeR2Bucket(), record: R2_RECORD }); + const res = await app().request( + "/me/workspaces/acme/files/search?meta.gh.repo=buildinternet/uploads&meta.app=web", + {}, + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: { key: string; url: string; metadata: Record }[]; + truncated: boolean; + }; + expect(body.truncated).toBe(false); + expect(body.items).toHaveLength(1); + expect(body.items[0]).toMatchObject({ + key: "f/x/shot.png", + url: "https://storage.uploads.sh/acme/f/x/shot.png", + }); + }); + + it("rejects a repeated filter key with file_metadata_duplicate_filter", async () => { + const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD }); + const res = await app().request( + "/me/workspaces/acme/files/search?meta.app=web&meta.app=api", + {}, + env, + ); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe("file_metadata_duplicate_filter"); + }); + + it("rejects a malformed filter key with file_metadata_invalid_key", async () => { + const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD }); + const res = await app().request("/me/workspaces/acme/files/search?meta.BadKey=x", {}, env); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe("file_metadata_invalid_key"); + }); + + it("requires at least one meta.* filter", async () => { + const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD }); + const res = await app().request("/me/workspaces/acme/files/search", {}, env); + expect(res.status).toBe(400); + }); + + it("short-circuits the communal workspace with an empty result", async () => { + const env = memberEnv({ workspace: "default", db: metadataDb([]) }); + const res = await app().request("/me/workspaces/default/files/search?meta.app=web", {}, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ items: [], truncated: false }); + }); + + it("404s for a workspace the caller is not a member of", async () => { + const env = memberEnv({ workspace: "acme", db: metadataDb([]), record: R2_RECORD }); + const res = await app().request("/me/workspaces/other/files/search?meta.app=web", {}, env); + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --filter @uploads/api test -- me.test.ts` +Expected: FAIL — the `/files/search` route 404s (no handler), so status assertions fail. + +- [ ] **Step 3: Add the endpoint** + +In `apps/api/src/routes/me.ts`, extend the storage import (line 22) to include `objectPublicUrls`: + +```ts +import { objectPublicUrls, publicUrl, storage, storageConfig } from "../storage"; +``` + +Add `findObjectsByMetadata` + `validateMetadataFilters` imports (new import block near the top, alongside the other `../` imports): + +```ts +import { findObjectsByMetadata, validateMetadataFilters } from "../file-metadata"; +``` + +Insert this handler immediately after the existing `.get("/workspaces/:name/files", …)` handler: + +```ts + // Metadata search — the session-authed twin of the token route's + // `GET /v1/:workspace/files?meta.*` (files.ts). Same AND-of-equality + // semantics and shared validators; scoped to one workspace, member-gated. + // Results carry no `visibility` (it isn't in the D1 index — accepted caveat). + .get("/workspaces/:name/files/search", async (c) => { + const name = c.req.param("name"); + const ws = await memberWorkspaceOr404(c.env, requireUserId(c), name); + if (ws.communal) return c.json({ items: [], truncated: false }); + + const record = await loadWorkspaceRecord(c.env, name); + if (!record) { + throw new NotFoundError("workspace not found", { code: "workspace_not_found" }); + } + + const query = c.req.query(); + const metaParamKeys = Object.keys(query).filter((k) => k.startsWith("meta.")); + if (metaParamKeys.length === 0) { + throw new ValidationError("at least one meta.* filter is required", { + code: "file_metadata_invalid_key", + }); + } + const filters: Record = {}; + for (const param of metaParamKeys) { + const key = param.slice("meta.".length); + const values = c.req.queries(param) ?? []; + if (values.length > 1) { + throw new ValidationError(`repeated metadata filter for key: ${key}`, { + code: "file_metadata_duplicate_filter", + details: { key }, + }); + } + filters[key] = values[0] ?? query[param]; + } + validateMetadataFilters(filters); + + const SEARCH_LIMIT = 100; + const [cfg, matches] = await Promise.all([ + storageConfig(c.env, record), + findObjectsByMetadata(c.env.DB, name, filters, { + prefix: query.prefix, + limit: SEARCH_LIMIT + 1, + }), + ]); + const truncated = matches.length > SEARCH_LIMIT; + const page = truncated ? matches.slice(0, SEARCH_LIMIT) : matches; + return c.json({ + items: page.map((match) => { + const urls = objectPublicUrls(c.env, cfg, match.key); + return { key: match.key, url: urls.url, embedUrl: urls.embedUrl, metadata: match.metadata }; + }), + truncated, + }); + }) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm --filter @uploads/api test -- me.test.ts` +Expected: PASS (all six new cases). + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @uploads/api types` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/routes/me.ts apps/api/src/routes/me.test.ts +git commit -m "feat(api): session-authed metadata search endpoint (#159)" +``` + +--- + +### Task 2: Filter validation + URL sync helpers + +**Files:** + +- Create: `apps/web/src/lib/workspace-search-url.ts` +- Test: `apps/web/src/lib/workspace-search-url.test.ts` + +**Interfaces:** + +- Produces: + - `interface MetaFilter { key: string; value: string }` + - `isValidMetaKey(key: string): boolean` + - `isValidMetaValue(value: string): boolean` + - `readSearchFilters(search: string): MetaFilter[]` — parse `meta.*` params (first wins on dupes, invalid dropped) + - `buildSearchQuery(filters: MetaFilter[]): string` — `meta.key=value&…` (no leading `?`) + - `replaceSearchLocation(workspace: string, filters: MetaFilter[]): void` — writes `ws` + `meta.*` into the address bar via `history.replaceState`, clearing `path` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/web/src/lib/workspace-search-url.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + buildSearchQuery, + isValidMetaKey, + isValidMetaValue, + readSearchFilters, +} from "./workspace-search-url"; + +describe("isValidMetaKey", () => { + it("accepts lowercase dotted keys", () => { + expect(isValidMetaKey("gh.repo")).toBe(true); + expect(isValidMetaKey("app")).toBe(true); + }); + it("rejects uppercase, leading digit, and overly long keys", () => { + expect(isValidMetaKey("BadKey")).toBe(false); + expect(isValidMetaKey("1app")).toBe(false); + expect(isValidMetaKey("a".repeat(65))).toBe(false); + }); +}); + +describe("isValidMetaValue", () => { + it("accepts 1–512 printable ASCII", () => { + expect(isValidMetaValue("buildinternet/uploads")).toBe(true); + }); + it("rejects empty, over-long, and control chars", () => { + expect(isValidMetaValue("")).toBe(false); + expect(isValidMetaValue("x".repeat(513))).toBe(false); + expect(isValidMetaValue("a\tb")).toBe(false); + }); +}); + +describe("readSearchFilters", () => { + it("parses meta.* params, first-wins on duplicates, drops invalid", () => { + expect( + readSearchFilters("?ws=acme&meta.gh.repo=a/b&meta.app=web&meta.app=api&meta.BAD=x"), + ).toEqual([ + { key: "gh.repo", value: "a/b" }, + { key: "app", value: "web" }, + ]); + }); + it("returns empty when there are no meta params", () => { + expect(readSearchFilters("?ws=acme&path=f/")).toEqual([]); + }); +}); + +describe("buildSearchQuery", () => { + it("serializes filters to a query string", () => { + expect( + buildSearchQuery([ + { key: "gh.repo", value: "a/b" }, + { key: "app", value: "web" }, + ]), + ).toBe("meta.gh.repo=a%2Fb&meta.app=web"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --filter @uploads/web test -- workspace-search-url.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement the helpers** + +Create `apps/web/src/lib/workspace-search-url.ts`: + +```ts +/** + * Query-param sync for the account file browser's metadata search mode: + * /account/workspaces?ws=&meta.gh.repo=owner/name&meta.app=web + * + * Sibling to workspace-browse-url.ts (which owns `ws` + `path`). Search mode + * replaces `path` with one or more `meta.*` pairs. Validation mirrors the + * API's META_KEY_RE / META_VALUE_MAX so bad input is caught before a request. + */ +import { isBrowseWorkspace } from "./workspace-browse-url"; + +export interface MetaFilter { + key: string; + value: string; +} + +/** Mirrors apps/api's META_KEY_RE (file-metadata.ts). */ +const META_KEY_RE = /^[a-z][a-z0-9._-]{0,63}$/; +/** Mirrors apps/api's META_VALUE_MAX. */ +const META_VALUE_MAX = 512; + +export function isValidMetaKey(key: string): boolean { + return META_KEY_RE.test(key); +} + +export function isValidMetaValue(value: string): boolean { + if (value.length < 1 || value.length > META_VALUE_MAX) return false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code > 0x7e) return false; // printable ASCII only + } + return true; +} + +/** Parse `meta.*` params; first value wins per key, invalid pairs dropped. */ +export function readSearchFilters(search: string): MetaFilter[] { + const raw = search.startsWith("?") ? search.slice(1) : search; + const params = new URLSearchParams(raw); + const seen = new Set(); + const out: MetaFilter[] = []; + for (const [param, value] of params) { + if (!param.startsWith("meta.")) continue; + const key = param.slice("meta.".length); + if (seen.has(key)) continue; + seen.add(key); + if (isValidMetaKey(key) && isValidMetaValue(value)) out.push({ key, value }); + } + return out; +} + +/** Serialize filters to `meta.key=value&…` (no leading `?`). */ +export function buildSearchQuery(filters: MetaFilter[]): string { + const params = new URLSearchParams(); + for (const { key, value } of filters) params.set(`meta.${key}`, value); + return params.toString(); +} + +/** + * Write `ws` + `meta.*` into the address bar (no history entry). Clears + * `path` (search and folder-browse are mutually exclusive) and all prior + * `meta.*` params. Empty filters + empty workspace clears search entirely. + */ +export function replaceSearchLocation(workspace: string, filters: MetaFilter[]): void { + if (typeof window === "undefined") return; + const next = new URL(window.location.href); + for (const param of [...next.searchParams.keys()]) { + if (param.startsWith("meta.")) next.searchParams.delete(param); + } + next.searchParams.delete("path"); + const ws = isBrowseWorkspace(workspace) ? workspace : ""; + if (ws) next.searchParams.set("ws", ws); + else next.searchParams.delete("ws"); + for (const { key, value } of filters) { + if (isValidMetaKey(key) && isValidMetaValue(value)) next.searchParams.set(`meta.${key}`, value); + } + const target = `${next.pathname}${next.search}${next.hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (target !== current) window.history.replaceState(window.history.state, "", target); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm --filter @uploads/web test -- workspace-search-url.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/workspace-search-url.ts apps/web/src/lib/workspace-search-url.test.ts +git commit -m "feat(web): metadata search URL + filter-validation helpers (#159)" +``` + +--- + +### Task 3: API client — `searchWorkspaceFiles` + +**Files:** + +- Modify: `apps/web/src/lib/api-client.ts` (append new types + function) +- Test: `apps/web/src/lib/api-client.test.ts` (append a `describe`) + +**Interfaces:** + +- Consumes: `MetaFilter`, `buildSearchQuery` from `./workspace-search-url`. +- Produces: + - `interface SearchFileItem { key: string; url: string | null; embedUrl: string | null; metadata: Record }` + - `type SearchFilesResult = { kind: "ok"; items: SearchFileItem[]; truncated: boolean } | { kind: "unavailable"; reason: "server" | "malformed" }` + - `searchWorkspaceFiles(apiOrigin: string, name: string, filters: MetaFilter[]): Promise` + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/web/src/lib/api-client.test.ts` (extend the top import to include `searchWorkspaceFiles`): + +```ts +describe("searchWorkspaceFiles", () => { + it("returns matching items and the truncated flag", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + items: [ + { + key: "f/x.png", + url: "https://s/acme/f/x.png", + embedUrl: null, + metadata: { app: "web" }, + }, + ], + truncated: true, + }), + ), + ); + await expect( + searchWorkspaceFiles("http://127.0.0.1:8787", "acme", [{ key: "app", value: "web" }]), + ).resolves.toEqual({ + kind: "ok", + items: [ + { key: "f/x.png", url: "https://s/acme/f/x.png", embedUrl: null, metadata: { app: "web" } }, + ], + truncated: true, + }); + }); + + it("reports a server error as unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 500 })), + ); + await expect( + searchWorkspaceFiles("http://127.0.0.1:8787", "acme", [{ key: "app", value: "web" }]), + ).resolves.toEqual({ kind: "unavailable", reason: "server" }); + }); + + it("reports a malformed body as unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ nope: true })), + ); + await expect( + searchWorkspaceFiles("http://127.0.0.1:8787", "acme", [{ key: "app", value: "web" }]), + ).resolves.toEqual({ kind: "unavailable", reason: "malformed" }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --filter @uploads/web test -- api-client.test.ts` +Expected: FAIL — `searchWorkspaceFiles` is not exported. + +- [ ] **Step 3: Implement the function** + +Append to `apps/web/src/lib/api-client.ts` (add `import type { MetaFilter } from "./workspace-search-url";` and `import { buildSearchQuery } from "./workspace-search-url";` at the top with the other imports): + +```ts +export interface SearchFileItem { + key: string; + url: string | null; + embedUrl: string | null; + metadata: Record; +} + +export type SearchFilesResult = + | { kind: "ok"; items: SearchFileItem[]; truncated: boolean } + | { kind: "unavailable"; reason: "server" | "malformed" }; + +function isSearchFileItem(value: unknown): value is SearchFileItem { + if (!value || typeof value !== "object") return false; + const item = value as Record; + return ( + typeof item.key === "string" && + (item.url === null || typeof item.url === "string") && + (item.embedUrl === null || typeof item.embedUrl === "string") && + typeof item.metadata === "object" && + item.metadata !== null + ); +} + +/** GET /me/workspaces/:name/files/search — session-authed metadata search. */ +export async function searchWorkspaceFiles( + apiOrigin: string, + name: string, + filters: MetaFilter[], +): Promise { + const query = buildSearchQuery(filters); + const url = `${trimOrigin(apiOrigin)}/me/workspaces/${encodeURIComponent(name)}/files/search?${query}`; + let response: Response; + try { + response = await fetch(url, { credentials: "include", cache: "no-store" }); + } catch { + return { kind: "unavailable", reason: "server" }; + } + if (!response.ok) return { kind: "unavailable", reason: "server" }; + let body: unknown; + try { + body = await response.json(); + } catch { + return { kind: "unavailable", reason: "malformed" }; + } + const b = body as { items?: unknown; truncated?: unknown }; + if ( + !Array.isArray(b.items) || + typeof b.truncated !== "boolean" || + !b.items.every(isSearchFileItem) + ) { + return { kind: "unavailable", reason: "malformed" }; + } + return { kind: "ok", items: b.items, truncated: b.truncated }; +} +``` + +Note: `trimOrigin` already exists at the top of `api-client.ts` (line ~11) — reuse it, do not redefine. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm --filter @uploads/web test -- api-client.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @uploads/web types` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/lib/api-client.ts apps/web/src/lib/api-client.test.ts +git commit -m "feat(web): searchWorkspaceFiles API client (#159)" +``` + +--- + +### Task 4: `MetadataSearchResults` component + +**Files:** + +- Create: `apps/web/src/components/MetadataSearchResults.tsx` + +**Interfaces:** + +- Consumes: `MetaFilter` from `../lib/workspace-search-url`; `searchWorkspaceFiles`, `SearchFileItem` from `../lib/api-client`; `Badge`, `Button`, `Callout` from `@uploads/ui`. +- Produces: + ```ts + interface MetadataSearchResultsProps { + apiOrigin: string; + workspace: string; + filters: MetaFilter[]; + onRemoveFilter: (key: string) => void; + } + export function MetadataSearchResults(props: MetadataSearchResultsProps): JSX.Element; + ``` + +This component owns the results fetch + render for a given filter set. Filter _entry_ (the add-filter bar) lives in Task 5's wrapper; here we render the active-filter chips (removable) and the results. No test harness for React rendering exists in this repo, so this task is verified via the preview in Task 5. + +- [ ] **Step 1: Create the component** + +Create `apps/web/src/components/MetadataSearchResults.tsx`: + +```tsx +import { Badge, Button, Callout } from "@uploads/ui"; +import { useEffect, useState } from "react"; +import { searchWorkspaceFiles, type SearchFileItem } from "../lib/api-client"; +import type { MetaFilter } from "../lib/workspace-search-url"; + +interface MetadataSearchResultsProps { + apiOrigin: string; + workspace: string; + filters: MetaFilter[]; + onRemoveFilter: (key: string) => void; +} + +type State = + | { status: "loading" } + | { status: "error" } + | { status: "ok"; items: SearchFileItem[]; truncated: boolean }; + +const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i; +const filename = (key: string) => key.split("/").filter(Boolean).pop() ?? key; + +export function MetadataSearchResults({ + apiOrigin, + workspace, + filters, + onRemoveFilter, +}: MetadataSearchResultsProps) { + const [state, setState] = useState({ status: "loading" }); + // Re-fetch whenever the filter set changes. Serialize the filters into the + // dependency so add/remove re-runs the search. + const key = filters.map((f) => `${f.key}=${f.value}`).join("&"); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + void searchWorkspaceFiles(apiOrigin, workspace, filters).then((result) => { + if (cancelled) return; + setState( + result.kind === "ok" + ? { status: "ok", items: result.items, truncated: result.truncated } + : { status: "error" }, + ); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiOrigin, workspace, key]); + + const copyLink = async (url: string, button: HTMLButtonElement) => { + try { + await navigator.clipboard.writeText(url); + const previous = button.textContent; + button.textContent = "copied ✓"; + setTimeout(() => (button.textContent = previous), 1500); + } catch { + /* clipboard blocked — leave the label */ + } + }; + + return ( +
    +
    + {filters.map((f) => ( + + {f.key}={f.value} + + + ))} +
    + + {state.status === "loading" &&

    Searching…

    } + {state.status === "error" && ( + Search is temporarily unavailable. Try again. + )} + {state.status === "ok" && state.items.length === 0 && ( +

    No files match these filters.

    + )} + {state.status === "ok" && state.items.length > 0 && ( + <> + {state.truncated && ( +

    + Showing the first 100 matches — add a filter to narrow. +

    + )} +
      + {state.items.map((item) => ( +
    • +
      + {item.url && IMAGE_EXT.test(item.key) ? ( + + ) : ( + + )} +
      +
      + {filename(item.key)} + + {Object.entries(item.metadata).map(([k, v]) => ( + + {k}={v} + + ))} + +
      +
      + {item.url && ( + <> + + Open ↗ + + + + )} +
      +
    • + ))} +
    + + )} +
    + ); +} +``` + +Note: confirm `Callout`'s `tone`/`variant` prop name and `Button`'s `variant` values against `packages/ui/src/Callout.tsx` and `Button.tsx` before finalizing; adjust prop names to match. If `Badge` does not accept children with an inline remove button cleanly, wrap the chip in a `` instead of `Badge`. + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @uploads/web types` +Expected: no errors. Fix any prop-name mismatches surfaced against the real `@uploads/ui` component signatures. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/MetadataSearchResults.tsx +git commit -m "feat(web): MetadataSearchResults component (#159)" +``` + +--- + +### Task 5: `WorkspaceFiles` wrapper + mount swap + styles + preview verification + +**Files:** + +- Create: `apps/web/src/components/WorkspaceFiles.tsx` +- Modify: `apps/web/src/pages/account/workspaces.astro` (imports, mount `WorkspaceFiles`, on-load search restore, `