0e7890a465
Run gofmt -w across the backend, normalising the manually-aligned := blocks to the gofmt standard. No code behaviour changes. Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit default config (tabs, single quotes) so formatting is reproducible, then run it over the whole tree. Add format / format:check npm scripts and a .prettierignore (build output, generated schema.ts, static assets). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1476 lines
42 KiB
TypeScript
1476 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}`
|
|
});
|
|
});
|
|
}
|
|
};
|
|
}
|