feat(frontend): implement file gallery page with infinite scroll

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 <noreply@anthropic.com>
This commit is contained in:
Masahiko AMANO 2026-04-05 03:34:33 +03:00
parent 9e341a0fc6
commit e72d4822e9
7 changed files with 362 additions and 2 deletions

View File

@ -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",

View File

@ -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",

View File

@ -0,0 +1,62 @@
<script lang="ts">
interface Props {
loading?: boolean;
hasMore?: boolean;
onLoadMore: () => void;
}
let { loading = false, hasMore = true, onLoadMore }: Props = $props();
let sentinel = $state<HTMLDivElement | undefined>();
$effect(() => {
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading && hasMore) {
onLoadMore();
}
},
{ rootMargin: '300px' },
);
observer.observe(sentinel);
return () => observer.disconnect();
});
</script>
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}
<div class="loading-row">
<span class="spinner" role="status" aria-label="Loading"></span>
</div>
{/if}
<style>
.sentinel {
height: 1px;
}
.loading-row {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0;
}
.spinner {
display: block;
width: 32px;
height: 32px;
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,121 @@
<script lang="ts">
import { get } from 'svelte/store';
import { authStore } from '$lib/stores/auth';
import type { File } from '$lib/api/types';
interface Props {
file: File;
onclick?: (file: File) => void;
}
let { file, onclick }: Props = $props();
let imgSrc = $state<string | null>(null);
let failed = $state(false);
$effect(() => {
const token = get(authStore).accessToken;
let objectUrl: string | null = null;
let cancelled = false;
fetch(`/api/v1/files/${file.id}/thumbnail`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((res) => (res.ok ? res.blob() : null))
.then((blob) => {
if (cancelled || !blob) {
if (!cancelled) failed = true;
return;
}
objectUrl = URL.createObjectURL(blob);
imgSrc = objectUrl;
})
.catch(() => {
if (!cancelled) failed = true;
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
});
function handleClick() {
onclick?.(file);
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="card"
class:loaded={!!imgSrc}
onclick={handleClick}
title={file.original_name ?? undefined}
>
{#if imgSrc}
<img src={imgSrc} alt={file.original_name ?? ''} class="thumb" />
{:else if failed}
<div class="placeholder failed" aria-label="Failed to load"></div>
{:else}
<div class="placeholder loading" aria-label="Loading"></div>
{/if}
<div class="overlay"></div>
</div>
<style>
.card {
position: relative;
width: 160px;
height: 160px;
max-width: calc(33vw - 7px);
max-height: calc(33vw - 7px);
overflow: hidden;
cursor: pointer;
background-color: var(--color-bg-elevated);
flex-shrink: 0;
}
.thumb {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
.placeholder {
width: 100%;
height: 100%;
}
.placeholder.loading {
background: linear-gradient(
90deg,
var(--color-bg-elevated) 25%,
color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-elevated)) 50%,
var(--color-bg-elevated) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
.placeholder.failed {
background-color: color-mix(in srgb, var(--color-danger) 15%, var(--color-bg-elevated));
}
.overlay {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.1);
transition: background-color 0.15s;
}
.card:hover .overlay {
background-color: rgba(0, 0, 0, 0.3);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>

View File

@ -0,0 +1,103 @@
<script lang="ts">
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import type { File, FileCursorPage } from '$lib/api/types';
const LIMIT = 50;
let files = $state<File[]>([]);
let nextCursor = $state<string | null>(null);
let loading = $state(false);
let hasMore = $state(true);
let error = $state('');
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
error = '';
try {
const params = new URLSearchParams({ limit: String(LIMIT) });
if (nextCursor) params.set('cursor', nextCursor);
const page = await api.get<FileCursorPage>(`/files?${params}`);
files = [...files, ...(page.items ?? [])];
nextCursor = page.next_cursor ?? null;
hasMore = !!page.next_cursor;
} catch (err) {
error = err instanceof ApiError ? err.message : 'Failed to load files';
hasMore = false;
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Files | Tanabata</title>
</svelte:head>
<div class="page">
<main>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="grid">
{#each files as file (file.id)}
<FileCard {file} />
{/each}
</div>
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
{#if !loading && !hasMore && files.length === 0}
<div class="empty">No files yet.</div>
{/if}
</main>
</div>
<style>
.page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
main {
flex: 1;
overflow-y: auto;
padding: 10px 10px calc(60px + 10px); /* clear fixed navbar */
}
.grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-content: flex-start;
align-items: flex-start;
gap: 2px;
}
/* phantom last item so justify-content doesn't stretch final row */
.grid::after {
content: '';
flex: auto;
}
.error {
color: var(--color-danger);
padding: 12px;
font-size: 0.875rem;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 60px 20px;
font-size: 0.95rem;
}
</style>

View File

@ -1,5 +1,6 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"exclude": ["vite-mock-plugin.ts"],
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,

View File

@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160">
<rect width="160" height="160" fill="${color}"/>
<text x="80" y="88" text-anchor="middle" font-family="monospace" font-size="18" fill="rgba(0,0,0,0.4)">${label}</text>
</svg>`;
}
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