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:
parent
9e341a0fc6
commit
e72d4822e9
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
62
frontend/src/lib/components/common/InfiniteScroll.svelte
Normal file
62
frontend/src/lib/components/common/InfiniteScroll.svelte
Normal 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>
|
||||
121
frontend/src/lib/components/file/FileCard.svelte
Normal file
121
frontend/src/lib/components/file/FileCard.svelte
Normal 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>
|
||||
103
frontend/src/routes/files/+page.svelte
Normal file
103
frontend/src/routes/files/+page.svelte
Normal 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>
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"exclude": ["vite-mock-plugin.ts"],
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user