From a5b610d4723513634aff33f12e3f73fb8a65dc9f Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 13:55:04 +0300 Subject: [PATCH] feat(frontend): implement file viewer page with metadata editing and tag picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - files/[id]/+page.svelte: full-screen preview (100dvh), sticky top bar, prev/next nav via anchor API, notes/datetime/is_public editing, TagPicker, EXIF display, keyboard navigation (←/→/Esc) - TagPicker.svelte: assigned tags with remove, searchable available tags to add - Fix infinite request loop: previewSrc read inside $effect tracked as dependency; wrapped in untrack() to prevent re-triggering on blob URL assignment - vite-mock-plugin: add GET/PATCH /files/{id}, preview endpoint, tags CRUD, anchor-based pagination, in-memory mutable state for file overrides and tags - files/+page.svelte: migrate from deprecated $app/stores to $app/state Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/file/TagPicker.svelte | 206 ++++++ frontend/src/routes/files/+page.svelte | 6 +- frontend/src/routes/files/[id]/+page.svelte | 588 ++++++++++++++++++ frontend/vite-mock-plugin.ts | 88 ++- 4 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/components/file/TagPicker.svelte create mode 100644 frontend/src/routes/files/[id]/+page.svelte diff --git a/frontend/src/lib/components/file/TagPicker.svelte b/frontend/src/lib/components/file/TagPicker.svelte new file mode 100644 index 0000000..53b33b4 --- /dev/null +++ b/frontend/src/lib/components/file/TagPicker.svelte @@ -0,0 +1,206 @@ + + +
+ + {#if fileTags.length > 0} + +
+ {#each filteredAssigned as tag (tag.id)} + + {/each} +
+ {/if} + + + + + + {#if filteredAvailable.length > 0} + +
+ {#each filteredAvailable as tag (tag.id)} + + {/each} +
+ {:else if search.trim()} +

No matching tags

+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 29316fa..07a7883 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -1,5 +1,5 @@ + + + + {file?.original_name ?? fileId} | Tanabata + + + + + +
+ +
+ + {file?.original_name ?? ''} +
+ + +
+ {#if previewSrc} + {file?.original_name + {:else if loading} +
+ {:else} +
+ {/if} + + + {#if prevFile} + + {/if} + {#if nextFile} + + {/if} +
+ + +
+ {#if error} + + {/if} + + {#if file} + +
+ {file.mime_type} + · + Added {formatDatetime(file.created_at)} +
+ + +
+ + +
+ +
+ + (dirty = true)} + /> +
+ +
+ Public + +
+ + + + +
+
Tags
+ +
+ + + {#if exifEntries.length > 0} +
+
EXIF
+
+ {#each exifEntries as [key, val]} +
{key}
+
{String(val)}
+ {/each} +
+
+ {/if} + {:else if !loading} +

File not found.

+ {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index 7b3be8b..abc01e0 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -134,6 +134,16 @@ const MOCK_TAGS = TAG_NAMES.map((name, i) => ({ created_at: new Date(Date.now() - i * 3_600_000).toISOString(), })); +// Mutable in-memory state for file metadata and tags +const fileOverrides = new Map>(); +const fileTags = new Map>(); // fileId → Set + +function getMockFile(id: string) { + const base = MOCK_FILES.find((f) => f.id === id); + if (!base) return null; + return { ...base, ...(fileOverrides.get(id) ?? {}) }; +} + export function mockApiPlugin(): Plugin { return { name: 'mock-api', @@ -197,17 +207,89 @@ export function mockApiPlugin(): Plugin { return res.end(svg); } - // GET /files (cursor pagination — page through MOCK_FILES in chunks of 50) + // GET /files/{id}/preview (same SVG, just bigger) + const previewMatch = path.match(/^\/files\/([^/]+)\/preview$/); + if (method === 'GET' && previewMatch) { + const id = previewMatch[1]; + const color = THUMB_COLORS[id.charCodeAt(id.length - 1) % THUMB_COLORS.length]; + const label = id.slice(-4); + const svg = ` + + ${label} +`; + res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) }); + return res.end(svg); + } + + // GET /files/{id}/tags + const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/); + if (method === 'GET' && fileTagsGetMatch) { + const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set(); + return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id))); + } + + // PUT /files/{id}/tags/{tag_id} — add tag + const fileTagPutMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/); + if (method === 'PUT' && fileTagPutMatch) { + const [, fid, tid] = fileTagPutMatch; + if (!fileTags.has(fid)) fileTags.set(fid, new Set()); + fileTags.get(fid)!.add(tid); + const ids = fileTags.get(fid)!; + return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id))); + } + + // DELETE /files/{id}/tags/{tag_id} — remove tag + const fileTagDelMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/); + if (method === 'DELETE' && fileTagDelMatch) { + const [, fid, tid] = fileTagDelMatch; + fileTags.get(fid)?.delete(tid); + return noContent(res); + } + + // GET /files/{id} — single file + const fileGetMatch = path.match(/^\/files\/([^/]+)$/); + if (method === 'GET' && fileGetMatch) { + const f = getMockFile(fileGetMatch[1]); + if (!f) return json(res, 404, { code: 'not_found', message: 'File not found' }); + return json(res, 200, f); + } + + // PATCH /files/{id} — update metadata + const filePatchMatch = path.match(/^\/files\/([^/]+)$/); + if (method === 'PATCH' && filePatchMatch) { + const id = filePatchMatch[1]; + const base = getMockFile(id); + if (!base) return json(res, 404, { code: 'not_found', message: 'File not found' }); + const body = (await readBody(req)) as Record; + fileOverrides.set(id, { ...(fileOverrides.get(id) ?? {}), ...body }); + return json(res, 200, getMockFile(id)); + } + + // GET /files (cursor pagination + anchor support) if (method === 'GET' && path === '/files') { const qs = new URLSearchParams(url.split('?')[1] ?? ''); + const anchor = qs.get('anchor'); const cursor = qs.get('cursor'); const limit = Math.min(Number(qs.get('limit') ?? 50), 200); + + if (anchor) { + // Anchor mode: return the anchor file surrounded by neighbors + const anchorIdx = MOCK_FILES.findIndex((f) => f.id === anchor); + if (anchorIdx < 0) return json(res, 404, { code: 'not_found', message: 'Anchor not found' }); + const from = Math.max(0, anchorIdx - Math.floor(limit / 2)); + const slice = MOCK_FILES.slice(from, from + limit); + const next_cursor = from + slice.length < MOCK_FILES.length + ? Buffer.from(String(from + slice.length)).toString('base64') : null; + const prev_cursor = from > 0 + ? Buffer.from(String(from)).toString('base64') : null; + return json(res, 200, { items: slice, next_cursor, prev_cursor }); + } + const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0; const slice = MOCK_FILES.slice(offset, offset + limit); const nextOffset = offset + slice.length; const next_cursor = nextOffset < MOCK_FILES.length - ? Buffer.from(String(nextOffset)).toString('base64') - : null; + ? Buffer.from(String(nextOffset)).toString('base64') : null; return json(res, 200, { items: slice, next_cursor, prev_cursor: null }); }