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}
+

+ {: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}
+ {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 ``;
+}
+
+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