From e72d4822e944f021aef2806b06503aa6ac545074 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 03:34:33 +0300 Subject: [PATCH] feat(frontend): implement file gallery page with infinite scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds InfiniteScroll component (IntersectionObserver, 300px margin, CSS spinner). Adds FileCard component (fetch thumbnail with JWT auth header, blob URL, shimmer placeholder). Adds files/+page.svelte with 160×160 flex-wrap grid and cursor pagination. Updates mock plugin with 75 sample files, cursor pagination, and colored SVG thumbnail handler. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 18 +++ frontend/package.json | 1 + .../components/common/InfiniteScroll.svelte | 62 +++++++++ .../src/lib/components/file/FileCard.svelte | 121 ++++++++++++++++++ frontend/src/routes/files/+page.svelte | 103 +++++++++++++++ frontend/tsconfig.json | 1 + frontend/vite-mock-plugin.ts | 58 ++++++++- 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/common/InfiniteScroll.svelte create mode 100644 frontend/src/lib/components/file/FileCard.svelte create mode 100644 frontend/src/routes/files/+page.svelte diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c54b435..3b222a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.5.2", "openapi-typescript": "^7.13.0", "svelte": "^5.54.0", "svelte-check": "^4.4.2", @@ -1345,6 +1346,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2453,6 +2464,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 701d9b5..36f00ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.5.2", "openapi-typescript": "^7.13.0", "svelte": "^5.54.0", "svelte-check": "^4.4.2", diff --git a/frontend/src/lib/components/common/InfiniteScroll.svelte b/frontend/src/lib/components/common/InfiniteScroll.svelte new file mode 100644 index 0000000..7320bcc --- /dev/null +++ b/frontend/src/lib/components/common/InfiniteScroll.svelte @@ -0,0 +1,62 @@ + + + + +{#if loading} +
+ +
+{/if} + + \ No newline at end of file diff --git a/frontend/src/lib/components/file/FileCard.svelte b/frontend/src/lib/components/file/FileCard.svelte new file mode 100644 index 0000000..a11d6d9 --- /dev/null +++ b/frontend/src/lib/components/file/FileCard.svelte @@ -0,0 +1,121 @@ + + + +
+ {#if imgSrc} + {file.original_name + {:else if failed} +
+ {:else} +
+ {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte new file mode 100644 index 0000000..3ff67ce --- /dev/null +++ b/frontend/src/routes/files/+page.svelte @@ -0,0 +1,103 @@ + + + + Files | Tanabata + + +
+
+ {#if error} + + {/if} + +
+ {#each files as file (file.id)} + + {/each} +
+ + + + {#if !loading && !hasMore && files.length === 0} +
No files yet.
+ {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2c2ed3c..07e9ed1 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "./.svelte-kit/tsconfig.json", + "exclude": ["vite-mock-plugin.ts"], "compilerOptions": { "rewriteRelativeImportExtensions": true, "allowJs": true, diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index f9ad4fa..92cfede 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -50,6 +50,43 @@ const ME = { is_blocked: false, }; +const THUMB_COLORS = [ + '#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1', + '#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E', +]; + +function mockThumbSvg(id: string): string { + const color = THUMB_COLORS[id.charCodeAt(id.length - 1) % THUMB_COLORS.length]; + const label = id.slice(-4); + return ` + + ${label} +`; +} + +const MOCK_FILES = Array.from({ length: 75 }, (_, i) => { + const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4']; + const exts = ['jpg', 'png', 'webp', 'mp4' ]; + const mi = i % mimes.length; + const id = `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`; + return { + id, + original_name: `photo-${String(i + 1).padStart(3, '0')}.${exts[mi]}`, + mime_type: mimes[mi], + mime_extension: exts[mi], + content_datetime: new Date(Date.now() - i * 3_600_000).toISOString(), + notes: null, + metadata: null, + exif: {}, + phash: null, + creator_id: 1, + creator_name: 'admin', + is_public: false, + is_deleted: false, + created_at: new Date(Date.now() - i * 3_600_000).toISOString(), + }; +}); + export function mockApiPlugin(): Plugin { return { name: 'mock-api', @@ -105,9 +142,26 @@ export function mockApiPlugin(): Plugin { return json(res, 200, ME); } - // GET /files + // GET /files/{id}/thumbnail + const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/); + if (method === 'GET' && thumbMatch) { + const svg = mockThumbSvg(thumbMatch[1]); + res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) }); + return res.end(svg); + } + + // GET /files (cursor pagination — page through MOCK_FILES in chunks of 50) if (method === 'GET' && path === '/files') { - return json(res, 200, { items: [], next_cursor: null, prev_cursor: null }); + const qs = new URLSearchParams(url.split('?')[1] ?? ''); + const cursor = qs.get('cursor'); + const limit = Math.min(Number(qs.get('limit') ?? 50), 200); + 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; + return json(res, 200, { items: slice, next_cursor, prev_cursor: null }); } // GET /tags