feat(frontend): implement trash view with restore and permanent delete

- New /files/trash page: same grid as files view, deleted files only
- Tap selects (no detail page for deleted files), long-press drag-selects
- Trash selection bar: Restore (bulk) and Delete permanently (bulk, confirmed)
- Trash icon added to files header, navigates to /files/trash
- Mock: MOCK_TRASH with 6 pre-seeded files; bulk/delete now moves to trash;
  handlers for POST /files/{id}/restore and DELETE /files/{id}/permanent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 00:56:55 +03:00
parent 004ff0b45e
commit d6e9223f61
4 changed files with 482 additions and 3 deletions
+66 -3
View File
@@ -114,6 +114,31 @@ function mockThumbSvg(id: string): string {
</svg>`;
}
// 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 = Array.from({ length: 75 }, (_, i) => {
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
const exts = ['jpg', 'png', 'webp', 'mp4' ];
@@ -463,16 +488,43 @@ export function mockApiPlugin(): Plugin {
return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] });
}
// POST /files/bulk/delete — soft delete (just remove from mock array)
// 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)) MOCK_FILES.splice(i, 1);
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
@@ -505,9 +557,20 @@ export function mockApiPlugin(): Plugin {
return json(res, 201, newFile);
}
// GET /files (cursor pagination + anchor support)
// 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);