Files
tanabata/frontend/vite-mock-plugin.ts
T
H1K0 e801eec47d feat(frontend): bidirectional lazy load for anchored grid returns
Returning to the grid at a deep position (deep link / hard reload to a
file, then back → /files?anchor=<id>) used to load only a tiny forward
window at the anchor. Now the grid fills the viewport around the anchor
and pages in both directions as the user scrolls.

- loadAroundAnchor fetches a window centred on the anchor and pre-fills a
  few pages each way sequentially, then centres on the anchor once. Doing
  the initial fill explicitly (rather than via the sentinels) keeps the
  pages contiguous and leaves the sentinels out of range, so there's no
  mount-time load storm.
- loading starts true when the URL carries an ?anchor, so the child
  InfiniteScroll sentinels (whose effects run before this page's reset
  effect on mount) can't fire a stray page-1 loadMore that interleaves
  with loadAroundAnchor.
- loadPrev pages backward (direction=backward) and prepends, then shifts
  the scroller down by the added height via flushSync (no paint between
  prepend and correction) so the viewport stays visually fixed.
- InfiniteScroll gains an `edge` prop; a top instance (shown only when
  hasPrev) drives upward loading. Both loaders share the `loading` guard.
- Mock: honour direction=backward and emit prev_cursor; the Go backend
  already supports backward keyset pagination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:16:32 +03:00

1081 lines
42 KiB
TypeScript

/**
* Dev-only Vite plugin that intercepts /api/v1/* and returns mock responses.
* Login: any username + password "password" succeeds.
*/
import type { Plugin } from 'vite';
import type { IncomingMessage, ServerResponse } from 'http';
function readBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve) => {
let data = '';
req.on('data', (chunk) => (data += chunk));
req.on('end', () => {
try {
resolve(data ? JSON.parse(data) : {});
} catch {
resolve({});
}
});
});
}
function json(res: ServerResponse, status: number, body: unknown) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
});
res.end(payload);
}
function noContent(res: ServerResponse) {
res.writeHead(204);
res.end();
}
const MOCK_ACCESS_TOKEN = 'mock-access-token';
const MOCK_REFRESH_TOKEN = 'mock-refresh-token';
const TOKEN_PAIR = {
access_token: MOCK_ACCESS_TOKEN,
refresh_token: MOCK_REFRESH_TOKEN,
expires_in: 900,
};
const ME = {
id: 1,
name: 'admin',
is_admin: true,
can_create: true,
is_blocked: false,
};
type MockUser = {
id: number;
name: string;
is_admin: boolean;
can_create: boolean;
is_blocked: boolean;
};
const mockUsersArr: MockUser[] = [
{ id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false },
{ id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false },
{ id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false },
{ id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true },
{ id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false },
];
const AUDIT_ACTIONS = [
'file_create', 'file_edit', 'file_delete', 'file_tag_add', 'file_tag_remove',
'tag_create', 'tag_edit', 'tag_delete', 'pool_create', 'pool_edit', 'pool_delete',
'category_create', 'category_edit',
];
const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category'];
type MockAuditEntry = {
id: number;
user_id: number;
user_name: string;
action: string;
object_type: string | null;
object_id: string | null;
details: Record<string, unknown> | null;
performed_at: string;
};
const mockAuditLog: MockAuditEntry[] = Array.from({ length: 80 }, (_, i) => {
const user = mockUsersArr[i % mockUsersArr.length];
const action = AUDIT_ACTIONS[i % AUDIT_ACTIONS.length];
const objType = AUDIT_OBJECT_TYPES[i % AUDIT_OBJECT_TYPES.length];
return {
id: i + 1,
user_id: user.id,
user_name: user.name,
action,
object_type: objType,
object_id: `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`,
details: null,
performed_at: new Date(Date.now() - i * 1_800_000).toISOString(),
};
});
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>`;
}
type MockFile = {
id: string;
original_name: string;
mime_type: string;
mime_extension: string;
content_datetime: string;
notes: string | null;
metadata: unknown;
exif: Record<string, unknown>;
phash: number | null;
creator_id: number;
creator_name: string;
is_public: boolean;
is_deleted: boolean;
created_at: string;
position?: number;
};
// Trash — pre-seeded with a few deleted files
const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
const mimes = ['image/jpeg', 'image/png', 'image/webp'];
const exts = ['jpg', 'png', 'webp' ];
const mi = i % mimes.length;
const id = `00000000-0000-7000-8000-trash${String(i + 1).padStart(7, '0')}`;
return {
id,
original_name: `deleted-${String(i + 1).padStart(3, '0')}.${exts[mi]}`,
mime_type: mimes[mi],
mime_extension: exts[mi],
content_datetime: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
notes: null,
metadata: null,
exif: {},
phash: null,
creator_id: 1,
creator_name: 'admin',
is_public: false,
is_deleted: true,
created_at: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
position: 0,
};
});
const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, 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(),
};
});
const TAG_NAMES = [
'nature', 'portrait', 'travel', 'architecture', 'food', 'street', 'macro',
'landscape', 'wildlife', 'urban', 'abstract', 'black-and-white', 'night',
'golden-hour', 'blue-hour', 'aerial', 'underwater', 'infrared', 'long-exposure',
'panorama', 'astrophotography', 'documentary', 'editorial', 'fashion', 'wedding',
'newborn', 'maternity', 'family', 'pet', 'sport', 'concert', 'theatre',
'interior', 'exterior', 'product', 'still-life', 'automotive', 'aviation',
'marine', 'industrial', 'medical', 'scientific', 'satellite', 'drone',
'film', 'analog', 'polaroid', 'tilt-shift', 'fisheye', 'telephoto',
'wide-angle', 'bokeh', 'silhouette', 'reflection', 'shadow', 'texture',
'pattern', 'color', 'minimal', 'surreal', 'conceptual', 'fine-art',
'photojournalism', 'war', 'protest', 'people', 'crowd', 'solitude',
'children', 'elderly', 'culture', 'tradition', 'festival', 'religion',
'asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert',
'forest', 'mountain', 'ocean', 'lake', 'river', 'waterfall', 'cave',
'volcano', 'canyon', 'glacier', 'field', 'garden', 'park', 'city',
'village', 'ruins', 'bridge', 'road', 'railway', 'harbor', 'airport',
'market', 'cafe', 'restaurant', 'bar', 'museum', 'library', 'school',
'hospital', 'church', 'mosque', 'temple', 'shrine', 'cemetery', 'stadium',
'spring', 'summer', 'autumn', 'winter', 'rain', 'snow', 'fog', 'storm',
'sunrise', 'sunset', 'cloudy', 'clear', 'rainbow', 'lightning', 'wind',
'cat', 'dog', 'bird', 'horse', 'fish', 'insect', 'reptile', 'mammal',
'flower', 'tree', 'grass', 'moss', 'mushroom', 'fruit', 'vegetable',
'fire', 'water', 'earth', 'air', 'smoke', 'ice', 'stone', 'wood', 'metal',
'glass', 'fabric', 'paper', 'plastic', 'ceramic', 'leather', 'concrete',
'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink',
'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted',
'raw', 'edited', 'hdr', 'composite', 'retouched', 'unedited', 'scanned',
'selfie', 'candid', 'posed', 'staged', 'spontaneous', 'planned', 'series',
];
const TAG_COLORS = [
'7ECBA1', '9592B5', '4DC7ED', 'E08C5A', 'DB6060',
'F5E872', 'A67CB8', '5A9ED4', 'C4A44A', '6DB89E',
'E07090', '70B0E0', 'C0A060', '80C080', 'D080B0',
];
const MOCK_CATEGORIES = [
{ id: '00000000-0000-7000-8002-000000000001', name: 'Style', color: '9592B5', notes: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8002-000000000002', name: 'Subject', color: '4DC7ED', notes: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8002-000000000003', name: 'Location', color: '7ECBA1', notes: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8002-000000000004', name: 'Season', color: 'E08C5A', notes: null, created_at: new Date().toISOString() },
{ id: '00000000-0000-7000-8002-000000000005', name: 'Color', color: 'DB6060', notes: null, created_at: new Date().toISOString() },
];
// Assign some tags to categories
const CATEGORY_ASSIGNMENTS: Record<string, string> = {};
TAG_NAMES.forEach((name, i) => {
if (['film', 'analog', 'polaroid', 'bokeh', 'silhouette', 'long-exposure', 'tilt-shift', 'fisheye', 'telephoto', 'wide-angle', 'macro', 'infrared', 'hdr', 'composite'].includes(name))
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[0].id; // Style
else if (['portrait', 'wildlife', 'people', 'children', 'elderly', 'cat', 'dog', 'bird', 'horse', 'flower', 'tree', 'insect', 'reptile', 'mammal'].includes(name))
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[1].id; // Subject
else if (['asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert', 'forest', 'mountain', 'ocean', 'lake', 'river', 'city', 'village'].includes(name))
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[2].id; // Location
else if (['spring', 'summer', 'autumn', 'winter'].includes(name))
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[3].id; // Season
else if (['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink', 'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted'].includes(name))
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color
});
function getCategoryForId(catId: string | null) {
if (!catId) return null;
return MOCK_CATEGORIES.find((c) => c.id === catId) ?? null;
}
type MockTag = {
id: string;
name: string;
color: string;
notes: string | null;
category_id: string | null;
category_name: string | null;
category_color: string | null;
is_public: boolean;
created_at: string;
};
const mockTagsArr: MockTag[] = TAG_NAMES.map((name, i) => {
const catId = CATEGORY_ASSIGNMENTS[name] ?? null;
const cat = getCategoryForId(catId);
return {
id: `00000000-0000-7000-8001-${String(i + 1).padStart(12, '0')}`,
name,
color: TAG_COLORS[i % TAG_COLORS.length],
notes: null,
category_id: catId,
category_name: cat?.name ?? null,
category_color: cat?.color ?? null,
is_public: false,
created_at: new Date(Date.now() - i * 3_600_000).toISOString(),
};
});
// Backwards-compatible reference for existing file-tag lookups
const MOCK_TAGS = mockTagsArr;
// Tag rules: Map<tagId, Map<thenTagId, is_active>>
const tagRules = new Map<string, Map<string, boolean>>();
// Mutable in-memory state for file metadata and tags
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
const fileTags = new Map<string, Set<string>>(); // fileId → Set<tagId>
type MockPool = {
id: string;
name: string;
notes: string | null;
is_public: boolean;
file_count: number;
creator_id: number;
creator_name: string;
created_at: string;
};
type PoolFile = {
id: string;
original_name: string;
mime_type: string;
mime_extension: string;
content_datetime: string;
notes: string | null;
metadata: null;
exif: Record<string, unknown>;
phash: null;
creator_id: number;
creator_name: string;
is_public: boolean;
is_deleted: boolean;
created_at: string;
position: number;
};
const mockPoolsArr: MockPool[] = [
{ id: '00000000-0000-7000-8003-000000000001', name: 'Best of 2024', notes: 'Top picks from last year', is_public: false, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 10 * 86400000).toISOString() },
{ id: '00000000-0000-7000-8003-000000000002', name: 'Portfolio', notes: null, is_public: true, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 5 * 86400000).toISOString() },
{ id: '00000000-0000-7000-8003-000000000003', name: 'Work in Progress', notes: 'Drafts and experiments', is_public: false, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 2 * 86400000).toISOString() },
];
// Pool files: Map<poolId, PoolFile[]> ordered by position
const poolFilesMap = new Map<string, PoolFile[]>();
// Seed some files into first two pools
function seedPoolFiles() {
const p1Files: PoolFile[] = MOCK_FILES.slice(0, 8).map((f, i) => ({ ...f, metadata: null, exif: {}, phash: null, position: i + 1 }));
const p2Files: PoolFile[] = MOCK_FILES.slice(5, 14).map((f, i) => ({ ...f, metadata: null, exif: {}, phash: null, position: i + 1 }));
poolFilesMap.set(mockPoolsArr[0].id, p1Files);
poolFilesMap.set(mockPoolsArr[1].id, p2Files);
mockPoolsArr[0].file_count = p1Files.length;
mockPoolsArr[1].file_count = p2Files.length;
}
seedPoolFiles();
function getMockFile(id: string) {
const base = MOCK_FILES.find((f) => f.id === id);
if (!base) return null;
return { ...base, ...(fileOverrides.get(id) ?? {}) };
}
export function mockApiPlugin(): Plugin {
return {
name: 'mock-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = req.url ?? '';
const method = req.method ?? 'GET';
if (!url.startsWith('/api/v1')) {
return next();
}
const path = url.replace('/api/v1', '').split('?')[0];
// POST /auth/login
if (method === 'POST' && path === '/auth/login') {
const body = (await readBody(req)) as Record<string, string>;
if (body.password === 'password') {
return json(res, 200, TOKEN_PAIR);
}
return json(res, 401, { code: 'unauthorized', message: 'Invalid credentials' });
}
// POST /auth/refresh
if (method === 'POST' && path === '/auth/refresh') {
return json(res, 200, TOKEN_PAIR);
}
// POST /auth/logout
if (method === 'POST' && path === '/auth/logout') {
return noContent(res);
}
// GET /auth/sessions
if (method === 'GET' && path === '/auth/sessions') {
return json(res, 200, {
items: [
{
id: 1,
user_agent: 'Mock Browser',
started_at: new Date().toISOString(),
expires_at: null,
last_activity: new Date().toISOString(),
is_current: true,
},
],
total: 1,
});
}
// GET /users/me
if (method === 'GET' && path === '/users/me') {
return json(res, 200, ME);
}
// PATCH /users/me
if (method === 'PATCH' && path === '/users/me') {
const body = (await readBody(req)) as { name?: string; password?: string };
if (body.name) ME.name = body.name;
return json(res, 200, ME);
}
// DELETE /auth/sessions/{id}
const sessionDelMatch = path.match(/^\/auth\/sessions\/(\d+)$/);
if (method === 'DELETE' && sessionDelMatch) {
return noContent(res);
}
// 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/{id}/preview (same SVG, just bigger)
const previewMatch = path.match(/^\/files\/([^/]+)\/preview$/);
if (method === 'GET' && previewMatch) {
const id = previewMatch[1];
const color = THUMB_COLORS[id.charCodeAt(id.length - 1) % THUMB_COLORS.length];
const label = id.slice(-4);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
<rect width="800" height="600" fill="${color}"/>
<text x="400" y="315" text-anchor="middle" font-family="monospace" font-size="48" fill="rgba(0,0,0,0.35)">${label}</text>
</svg>`;
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) });
return res.end(svg);
}
// GET /files/{id}/tags
const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/);
if (method === 'GET' && fileTagsGetMatch) {
const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set<string>();
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
}
// PUT /files/{id}/tags/{tag_id} — add tag
const fileTagPutMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/);
if (method === 'PUT' && fileTagPutMatch) {
const [, fid, tid] = fileTagPutMatch;
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
fileTags.get(fid)!.add(tid);
const ids = fileTags.get(fid)!;
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
}
// DELETE /files/{id}/tags/{tag_id} — remove tag
const fileTagDelMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/);
if (method === 'DELETE' && fileTagDelMatch) {
const [, fid, tid] = fileTagDelMatch;
fileTags.get(fid)?.delete(tid);
return noContent(res);
}
// GET /files/{id} — single file
const fileGetMatch = path.match(/^\/files\/([^/]+)$/);
if (method === 'GET' && fileGetMatch) {
const f = getMockFile(fileGetMatch[1]);
if (!f) return json(res, 404, { code: 'not_found', message: 'File not found' });
return json(res, 200, f);
}
// PATCH /files/{id} — update metadata
const filePatchMatch = path.match(/^\/files\/([^/]+)$/);
if (method === 'PATCH' && filePatchMatch) {
const id = filePatchMatch[1];
const base = getMockFile(id);
if (!base) return json(res, 404, { code: 'not_found', message: 'File not found' });
const body = (await readBody(req)) as Record<string, unknown>;
fileOverrides.set(id, { ...(fileOverrides.get(id) ?? {}), ...body });
return json(res, 200, getMockFile(id));
}
// POST /files/bulk/common-tags
if (method === 'POST' && path === '/files/bulk/common-tags') {
const body = (await readBody(req)) as { file_ids?: string[] };
const ids = body.file_ids ?? [];
if (ids.length === 0) return json(res, 200, { common_tag_ids: [], partial_tag_ids: [] });
const sets = ids.map((fid) => fileTags.get(fid) ?? new Set<string>());
const allTagIds = new Set<string>();
sets.forEach((s) => s.forEach((t) => allTagIds.add(t)));
const common: string[] = [];
const partial: string[] = [];
allTagIds.forEach((tid) => {
if (sets.every((s) => s.has(tid))) common.push(tid);
else partial.push(tid);
});
return json(res, 200, { common_tag_ids: common, partial_tag_ids: partial });
}
// POST /files/bulk/tags
if (method === 'POST' && path === '/files/bulk/tags') {
const body = (await readBody(req)) as { file_ids?: string[]; action?: string; tag_ids?: string[] };
const fileIds = body.file_ids ?? [];
const tagIds = body.tag_ids ?? [];
const action = body.action ?? 'add';
for (const fid of fileIds) {
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
const set = fileTags.get(fid)!;
for (const tid of tagIds) {
if (action === 'add') set.add(tid);
else set.delete(tid);
}
}
return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] });
}
// POST /files/bulk/delete — soft delete (move to trash)
if (method === 'POST' && path === '/files/bulk/delete') {
const body = (await readBody(req)) as { file_ids?: string[] };
const ids = new Set(body.file_ids ?? []);
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
if (ids.has(MOCK_FILES[i].id)) {
const [f] = MOCK_FILES.splice(i, 1);
MOCK_TRASH.unshift({ ...f, is_deleted: true });
}
}
return noContent(res);
}
// POST /files/{id}/restore
const fileRestoreMatch = path.match(/^\/files\/([^/]+)\/restore$/);
if (method === 'POST' && fileRestoreMatch) {
const id = fileRestoreMatch[1];
const idx = MOCK_TRASH.findIndex((f) => f.id === id);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'File not in trash' });
const [f] = MOCK_TRASH.splice(idx, 1);
const restored = { ...f, is_deleted: false };
MOCK_FILES.unshift(restored);
fileOverrides.delete(id);
return json(res, 200, restored);
}
// DELETE /files/{id}/permanent
const filePermMatch = path.match(/^\/files\/([^/]+)\/permanent$/);
if (method === 'DELETE' && filePermMatch) {
const id = filePermMatch[1];
const idx = MOCK_TRASH.findIndex((f) => f.id === id);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'File not in trash' });
MOCK_TRASH.splice(idx, 1);
fileOverrides.delete(id);
return noContent(res);
}
// POST /files — upload (mock: drain body, return a new fake file)
if (method === 'POST' && path === '/files') {
// Drain the multipart body without parsing it
await new Promise<void>((resolve) => {
req.on('data', () => {});
req.on('end', resolve);
});
const idx = MOCK_FILES.length;
const id = `00000000-0000-7000-8000-${String(Date.now()).slice(-12)}`;
const ct = req.headers['content-type'] ?? '';
// Extract filename from Content-Disposition if present (best-effort)
const nameMatch = ct.match(/name="([^"]+)"/);
const newFile = {
id,
original_name: nameMatch ? nameMatch[1] : `upload-${idx + 1}.jpg`,
mime_type: 'image/jpeg',
mime_extension: 'jpg',
content_datetime: new Date().toISOString(),
notes: null,
metadata: null,
exif: {},
phash: null,
creator_id: 1,
creator_name: 'admin',
is_public: false,
is_deleted: false,
created_at: new Date().toISOString(),
};
MOCK_FILES.unshift(newFile);
return json(res, 201, newFile);
}
// GET /files (cursor pagination + anchor support + trash)
if (method === 'GET' && path === '/files') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const trashMode = qs.get('trash') === 'true';
if (trashMode) {
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_TRASH.slice(offset, offset + limit);
const nextOffset = offset + slice.length;
const next_cursor = nextOffset < MOCK_TRASH.length
? Buffer.from(String(nextOffset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
}
const anchor = qs.get('anchor');
const cursor = qs.get('cursor');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
if (anchor) {
// Anchor mode: return the anchor file surrounded by neighbors
const anchorIdx = MOCK_FILES.findIndex((f) => f.id === anchor);
if (anchorIdx < 0) return json(res, 404, { code: 'not_found', message: 'Anchor not found' });
const from = Math.max(0, anchorIdx - Math.floor(limit / 2));
const slice = MOCK_FILES.slice(from, from + limit);
const next_cursor = from + slice.length < MOCK_FILES.length
? Buffer.from(String(from + slice.length)).toString('base64') : null;
const prev_cursor = from > 0
? Buffer.from(String(from)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
const direction = qs.get('direction') ?? 'forward';
if (direction === 'backward' && cursor) {
// Cursor marks the current top boundary; return the page before it.
const end = Number(Buffer.from(cursor, 'base64').toString());
const start = Math.max(0, end - limit);
const slice = MOCK_FILES.slice(start, end);
const prev_cursor = start > 0
? Buffer.from(String(start)).toString('base64') : null;
const next_cursor = Buffer.from(String(end)).toString('base64');
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
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;
const prev_cursor = offset > 0
? Buffer.from(String(offset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor });
}
// GET /tags/{id}/rules
const tagRulesGetMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
if (method === 'GET' && tagRulesGetMatch) {
const tid = tagRulesGetMatch[1];
const ruleMap = tagRules.get(tid) ?? new Map<string, boolean>();
const items = [...ruleMap.entries()].map(([thenId, isActive]) => {
const t = MOCK_TAGS.find((x) => x.id === thenId);
return { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive };
});
return json(res, 200, items);
}
// POST /tags/{id}/rules
const tagRulesPostMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
if (method === 'POST' && tagRulesPostMatch) {
const tid = tagRulesPostMatch[1];
const body = (await readBody(req)) as Record<string, unknown>;
const thenId = body.then_tag_id as string;
const isActive = body.is_active !== false;
if (!tagRules.has(tid)) tagRules.set(tid, new Map());
tagRules.get(tid)!.set(thenId, isActive);
const t = MOCK_TAGS.find((x) => x.id === thenId);
return json(res, 201, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive });
}
// PATCH /tags/{id}/rules/{then_id} — activate / deactivate
const tagRulesPatchMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
if (method === 'PATCH' && tagRulesPatchMatch) {
const [, tid, thenId] = tagRulesPatchMatch;
const body = (await readBody(req)) as Record<string, unknown>;
const isActive = body.is_active as boolean;
const ruleMap = tagRules.get(tid);
if (!ruleMap?.has(thenId)) return json(res, 404, { code: 'not_found', message: 'Rule not found' });
ruleMap.set(thenId, isActive);
const t = MOCK_TAGS.find((x) => x.id === thenId);
return json(res, 200, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive });
}
// DELETE /tags/{id}/rules/{then_id}
const tagRulesDelMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
if (method === 'DELETE' && tagRulesDelMatch) {
const [, tid, thenId] = tagRulesDelMatch;
tagRules.get(tid)?.delete(thenId);
return noContent(res);
}
// GET /tags/{id}
const tagGetMatch = path.match(/^\/tags\/([^/]+)$/);
if (method === 'GET' && tagGetMatch) {
const t = MOCK_TAGS.find((x) => x.id === tagGetMatch[1]);
if (!t) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
return json(res, 200, t);
}
// PATCH /tags/{id}
const tagPatchMatch = path.match(/^\/tags\/([^/]+)$/);
if (method === 'PATCH' && tagPatchMatch) {
const idx = MOCK_TAGS.findIndex((x) => x.id === tagPatchMatch[1]);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
const body = (await readBody(req)) as Partial<MockTag>;
const catId = body.category_id ?? MOCK_TAGS[idx].category_id;
const cat = getCategoryForId(catId);
Object.assign(MOCK_TAGS[idx], {
...body,
category_name: cat?.name ?? null,
category_color: cat?.color ?? null,
});
return json(res, 200, MOCK_TAGS[idx]);
}
// DELETE /tags/{id}
const tagDelMatch = path.match(/^\/tags\/([^/]+)$/);
if (method === 'DELETE' && tagDelMatch) {
const idx = MOCK_TAGS.findIndex((x) => x.id === tagDelMatch[1]);
if (idx >= 0) MOCK_TAGS.splice(idx, 1);
return noContent(res);
}
// GET /tags
if (method === 'GET' && path === '/tags') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const search = qs.get('search')?.toLowerCase() ?? '';
const sort = qs.get('sort') ?? 'name';
const order = qs.get('order') ?? 'asc';
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
const offset = Number(qs.get('offset') ?? 0);
let filtered = search
? MOCK_TAGS.filter((t) => t.name.toLowerCase().includes(search))
: [...MOCK_TAGS];
filtered.sort((a, b) => {
let av: string, bv: string;
if (sort === 'color') { av = a.color; bv = b.color; }
else if (sort === 'category_name') { av = a.category_name ?? ''; bv = b.category_name ?? ''; }
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
else { av = a.name; bv = b.name; }
const cmp = av.localeCompare(bv);
return order === 'desc' ? -cmp : cmp;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// POST /tags
if (method === 'POST' && path === '/tags') {
const body = (await readBody(req)) as Partial<MockTag>;
const catId = body.category_id ?? null;
const cat = getCategoryForId(catId);
const newTag: MockTag = {
id: `00000000-0000-7000-8001-${String(Date.now()).slice(-12)}`,
name: body.name ?? 'Unnamed',
color: body.color ?? '444455',
notes: body.notes ?? null,
category_id: catId,
category_name: cat?.name ?? null,
category_color: cat?.color ?? null,
is_public: body.is_public ?? false,
created_at: new Date().toISOString(),
};
MOCK_TAGS.unshift(newTag);
return json(res, 201, newTag);
}
// GET /categories/{id}/tags
const catTagsMatch = path.match(/^\/categories\/([^/]+)\/tags$/);
if (method === 'GET' && catTagsMatch) {
const catId = catTagsMatch[1];
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
const offset = Number(qs.get('offset') ?? 0);
const all = MOCK_TAGS.filter((t) => t.category_id === catId);
all.sort((a, b) => a.name.localeCompare(b.name));
const items = all.slice(offset, offset + limit);
return json(res, 200, { items, total: all.length, offset, limit });
}
// GET /categories/{id}
const catGetMatch = path.match(/^\/categories\/([^/]+)$/);
if (method === 'GET' && catGetMatch) {
const cat = MOCK_CATEGORIES.find((c) => c.id === catGetMatch[1]);
if (!cat) return json(res, 404, { code: 'not_found', message: 'Category not found' });
return json(res, 200, cat);
}
// PATCH /categories/{id}
const catPatchMatch = path.match(/^\/categories\/([^/]+)$/);
if (method === 'PATCH' && catPatchMatch) {
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catPatchMatch[1]);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Category not found' });
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
Object.assign(MOCK_CATEGORIES[idx], body);
// Sync category_name/color on affected tags
const cat = MOCK_CATEGORIES[idx];
for (const t of MOCK_TAGS) {
if (t.category_id === cat.id) {
t.category_name = cat.name;
t.category_color = cat.color;
}
}
return json(res, 200, MOCK_CATEGORIES[idx]);
}
// DELETE /categories/{id}
const catDelMatch = path.match(/^\/categories\/([^/]+)$/);
if (method === 'DELETE' && catDelMatch) {
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catDelMatch[1]);
if (idx >= 0) {
const catId = MOCK_CATEGORIES[idx].id;
MOCK_CATEGORIES.splice(idx, 1);
for (const t of MOCK_TAGS) {
if (t.category_id === catId) {
t.category_id = null;
t.category_name = null;
t.category_color = null;
}
}
}
return noContent(res);
}
// GET /categories
if (method === 'GET' && path === '/categories') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const search = qs.get('search')?.toLowerCase() ?? '';
const sort = qs.get('sort') ?? 'name';
const order = qs.get('order') ?? 'asc';
const limit = Math.min(Number(qs.get('limit') ?? 50), 500);
const offset = Number(qs.get('offset') ?? 0);
let filtered = search
? MOCK_CATEGORIES.filter((c) => c.name.toLowerCase().includes(search))
: [...MOCK_CATEGORIES];
filtered.sort((a, b) => {
let av: string, bv: string;
if (sort === 'color') { av = a.color; bv = b.color; }
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
else { av = a.name; bv = b.name; }
const cmp = av.localeCompare(bv);
return order === 'desc' ? -cmp : cmp;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// POST /categories
if (method === 'POST' && path === '/categories') {
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
const newCat = {
id: `00000000-0000-7000-8002-${String(Date.now()).slice(-12)}`,
name: body.name ?? 'Unnamed',
color: body.color ?? '9592B5',
notes: body.notes ?? null,
created_at: new Date().toISOString(),
};
MOCK_CATEGORIES.unshift(newCat);
return json(res, 201, newCat);
}
// GET /pools/{id}/files
const poolFilesGetMatch = path.match(/^\/pools\/([^/]+)\/files$/);
if (method === 'GET' && poolFilesGetMatch) {
const pid = poolFilesGetMatch[1];
if (!mockPoolsArr.find((p) => p.id === pid))
return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const cursor = qs.get('cursor');
const files = poolFilesMap.get(pid) ?? [];
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
const slice = files.slice(offset, offset + limit);
const nextOffset = offset + slice.length;
const next_cursor = nextOffset < files.length
? Buffer.from(String(nextOffset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
}
// POST /pools/{id}/files/remove
const poolFilesRemoveMatch = path.match(/^\/pools\/([^/]+)\/files\/remove$/);
if (method === 'POST' && poolFilesRemoveMatch) {
const pid = poolFilesRemoveMatch[1];
const pool = mockPoolsArr.find((p) => p.id === pid);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as { file_ids?: string[] };
const toRemove = new Set(body.file_ids ?? []);
const files = poolFilesMap.get(pid) ?? [];
const updated = files.filter((f) => !toRemove.has(f.id));
// Reassign positions
updated.forEach((f, i) => { f.position = i + 1; });
poolFilesMap.set(pid, updated);
pool.file_count = updated.length;
return noContent(res);
}
// PUT /pools/{id}/files/reorder
const poolReorderMatch = path.match(/^\/pools\/([^/]+)\/files\/reorder$/);
if (method === 'PUT' && poolReorderMatch) {
const pid = poolReorderMatch[1];
const pool = mockPoolsArr.find((p) => p.id === pid);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as { file_ids?: string[] };
const order = body.file_ids ?? [];
const files = poolFilesMap.get(pid) ?? [];
const byId = new Map(files.map((f) => [f.id, f]));
const reordered: PoolFile[] = [];
for (const id of order) {
const f = byId.get(id);
if (f) reordered.push(f);
}
reordered.forEach((f, i) => { f.position = i + 1; });
poolFilesMap.set(pid, reordered);
return noContent(res);
}
// POST /pools/{id}/files — add files
const poolFilesAddMatch = path.match(/^\/pools\/([^/]+)\/files$/);
if (method === 'POST' && poolFilesAddMatch) {
const pid = poolFilesAddMatch[1];
const pool = mockPoolsArr.find((p) => p.id === pid);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as { file_ids?: string[] };
const files = poolFilesMap.get(pid) ?? [];
const existing = new Set(files.map((f) => f.id));
let pos = files.length;
for (const fid of (body.file_ids ?? [])) {
if (existing.has(fid)) continue;
const base = MOCK_FILES.find((f) => f.id === fid);
if (!base) continue;
pos++;
files.push({ ...base, metadata: null, exif: {}, phash: null, position: pos });
existing.add(fid);
}
poolFilesMap.set(pid, files);
pool.file_count = files.length;
return noContent(res);
}
// GET /pools/{id}
const poolGetMatch = path.match(/^\/pools\/([^/]+)$/);
if (method === 'GET' && poolGetMatch) {
const pool = mockPoolsArr.find((p) => p.id === poolGetMatch[1]);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
return json(res, 200, pool);
}
// PATCH /pools/{id}
const poolPatchMatch = path.match(/^\/pools\/([^/]+)$/);
if (method === 'PATCH' && poolPatchMatch) {
const pool = mockPoolsArr.find((p) => p.id === poolPatchMatch[1]);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as Partial<MockPool>;
Object.assign(pool, body);
return json(res, 200, pool);
}
// DELETE /pools/{id}
const poolDelMatch = path.match(/^\/pools\/([^/]+)$/);
if (method === 'DELETE' && poolDelMatch) {
const idx = mockPoolsArr.findIndex((p) => p.id === poolDelMatch[1]);
if (idx >= 0) {
poolFilesMap.delete(mockPoolsArr[idx].id);
mockPoolsArr.splice(idx, 1);
}
return noContent(res);
}
// GET /pools
if (method === 'GET' && path === '/pools') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const search = qs.get('search')?.toLowerCase() ?? '';
const sort = qs.get('sort') ?? 'created';
const order = qs.get('order') ?? 'desc';
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const offset = Number(qs.get('offset') ?? 0);
let filtered = search
? mockPoolsArr.filter((p) => p.name.toLowerCase().includes(search))
: [...mockPoolsArr];
filtered.sort((a, b) => {
const av = sort === 'name' ? a.name : a.created_at;
const bv = sort === 'name' ? b.name : b.created_at;
const cmp = av.localeCompare(bv);
return order === 'desc' ? -cmp : cmp;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// POST /pools
if (method === 'POST' && path === '/pools') {
const body = (await readBody(req)) as Partial<MockPool>;
const newPool: MockPool = {
id: `00000000-0000-7000-8003-${String(Date.now()).slice(-12)}`,
name: body.name ?? 'Unnamed',
notes: body.notes ?? null,
is_public: body.is_public ?? false,
file_count: 0,
creator_id: 1,
creator_name: 'admin',
created_at: new Date().toISOString(),
};
mockPoolsArr.unshift(newPool);
return json(res, 201, newPool);
}
// GET /users/{id}
const userGetMatch = path.match(/^\/users\/(\d+)$/);
if (method === 'GET' && userGetMatch) {
const u = mockUsersArr.find((x) => x.id === Number(userGetMatch[1]));
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
return json(res, 200, u);
}
// PATCH /users/{id}
const userPatchMatch = path.match(/^\/users\/(\d+)$/);
if (method === 'PATCH' && userPatchMatch) {
const u = mockUsersArr.find((x) => x.id === Number(userPatchMatch[1]));
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
const body = (await readBody(req)) as Partial<MockUser>;
Object.assign(u, body);
return json(res, 200, u);
}
// DELETE /users/{id}
const userDelMatch = path.match(/^\/users\/(\d+)$/);
if (method === 'DELETE' && userDelMatch) {
const idx = mockUsersArr.findIndex((x) => x.id === Number(userDelMatch[1]));
if (idx >= 0) mockUsersArr.splice(idx, 1);
return noContent(res);
}
// GET /users
if (method === 'GET' && path === '/users') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const offset = Number(qs.get('offset') ?? 0);
const items = mockUsersArr.slice(offset, offset + limit);
return json(res, 200, { items, total: mockUsersArr.length, offset, limit });
}
// POST /users
if (method === 'POST' && path === '/users') {
const body = (await readBody(req)) as Partial<MockUser> & { password?: string };
const newUser: MockUser = {
id: Math.max(...mockUsersArr.map((u) => u.id)) + 1,
name: body.name ?? 'unnamed',
is_admin: body.is_admin ?? false,
can_create: body.can_create ?? false,
is_blocked: false,
};
mockUsersArr.push(newUser);
return json(res, 201, newUser);
}
// GET /audit
if (method === 'GET' && path === '/audit') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const offset = Number(qs.get('offset') ?? 0);
const filterUserId = qs.get('user_id') ? Number(qs.get('user_id')) : null;
const filterAction = qs.get('action') ?? '';
const filterObjectType = qs.get('object_type') ?? '';
const filterObjectId = qs.get('object_id') ?? '';
const filterFrom = qs.get('from') ? new Date(qs.get('from')!).getTime() : null;
const filterTo = qs.get('to') ? new Date(qs.get('to')!).getTime() : null;
let filtered = mockAuditLog.filter((e) => {
if (filterUserId !== null && e.user_id !== filterUserId) return false;
if (filterAction && e.action !== filterAction) return false;
if (filterObjectType && e.object_type !== filterObjectType) return false;
if (filterObjectId && e.object_id !== filterObjectId) return false;
const t = new Date(e.performed_at).getTime();
if (filterFrom !== null && t < filterFrom) return false;
if (filterTo !== null && t > filterTo) return false;
return true;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// Fallback: 404
return json(res, 404, { code: 'not_found', message: `Mock: no handler for ${method} ${path}` });
});
},
};
}