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:
2026-04-05 03:34:33 +03:00
parent 9e341a0fc6
commit e72d4822e9
7 changed files with 362 additions and 2 deletions
@@ -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>
@@ -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
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>