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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user