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:
parent
004ff0b45e
commit
d6e9223f61
@ -11,6 +11,7 @@
|
||||
onOrderToggle: () => void;
|
||||
onFilterToggle: () => void;
|
||||
onUpload?: () => void;
|
||||
onTrash?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@ -22,6 +23,7 @@
|
||||
onOrderToggle,
|
||||
onFilterToggle,
|
||||
onUpload,
|
||||
onTrash,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@ -43,6 +45,14 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onTrash}
|
||||
<button class="icon-btn trash-btn" onclick={onTrash} title="Trash">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<path d="M2 4h11M5 4V2.5h5V4M5.5 7v4.5M9.5 7v4.5M3 4l.8 9h7.4l.8-9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
class="sort-select"
|
||||
|
||||
@ -242,6 +242,7 @@
|
||||
onOrderToggle={() => fileSorting.toggleOrder()}
|
||||
onFilterToggle={() => (filterOpen = !filterOpen)}
|
||||
onUpload={() => uploader?.open()}
|
||||
onTrash={() => goto('/files/trash')}
|
||||
/>
|
||||
|
||||
{#if filterOpen}
|
||||
|
||||
405
frontend/src/routes/files/trash/+page.svelte
Normal file
405
frontend/src/routes/files/trash/+page.svelte
Normal file
@ -0,0 +1,405 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { tick } from 'svelte';
|
||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import { selectionStore, selectionActive, selectionCount } from '$lib/stores/selection';
|
||||
import { appSettings } from '$lib/stores/appSettings';
|
||||
import type { File, FileCursorPage } from '$lib/api/types';
|
||||
|
||||
let scrollContainer = $state<HTMLElement | undefined>();
|
||||
|
||||
let LIMIT = $derived($appSettings.fileLoadLimit);
|
||||
|
||||
let files = $state<File[]>([]);
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let hasMore = $state(true);
|
||||
let error = $state('');
|
||||
let initialLoaded = $state(false);
|
||||
|
||||
// confirmation dialogs
|
||||
let confirmRestore = $state(false);
|
||||
let confirmPermDelete = $state(false);
|
||||
let actionBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!initialLoaded && !loading) void loadMore();
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: String(LIMIT), trash: 'true' });
|
||||
if (nextCursor) params.set('cursor', nextCursor);
|
||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||
files = [...files, ...(res.items ?? [])];
|
||||
nextCursor = res.next_cursor ?? null;
|
||||
hasMore = !!res.next_cursor;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load trash';
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
initialLoaded = true;
|
||||
}
|
||||
await tick();
|
||||
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||
void loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selection ----
|
||||
let lastSelectedIdx = $state<number | null>(null);
|
||||
let dragSelecting = $state(false);
|
||||
let dragMode = $state<'select' | 'deselect'>('select');
|
||||
|
||||
function handleTap(file: File, idx: number, e: MouseEvent) {
|
||||
// In trash, tap always selects (no detail page)
|
||||
if (e.shiftKey && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, idx);
|
||||
const to = Math.max(lastSelectedIdx, idx);
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (files[i]?.id) selectionStore.select(files[i].id!);
|
||||
}
|
||||
} else {
|
||||
if (!$selectionActive) selectionStore.enter();
|
||||
if (file.id) selectionStore.toggle(file.id);
|
||||
}
|
||||
lastSelectedIdx = idx;
|
||||
}
|
||||
|
||||
function handleLongPress(file: File, idx: number, pointerType: string) {
|
||||
const alreadySelected = $selectionStore.ids.has(file.id!);
|
||||
if (alreadySelected) {
|
||||
selectionStore.deselect(file.id!);
|
||||
dragMode = 'deselect';
|
||||
} else {
|
||||
selectionStore.select(file.id!);
|
||||
dragMode = 'select';
|
||||
}
|
||||
lastSelectedIdx = idx;
|
||||
if (pointerType === 'touch') dragSelecting = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!dragSelecting) return;
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
const card = el?.closest<HTMLElement>('[data-file-index]');
|
||||
if (!card) return;
|
||||
const idx = parseInt(card.dataset.fileIndex ?? '');
|
||||
if (isNaN(idx) || !files[idx]?.id) return;
|
||||
if (dragMode === 'select') selectionStore.select(files[idx].id!);
|
||||
else selectionStore.deselect(files[idx].id!);
|
||||
lastSelectedIdx = idx;
|
||||
}
|
||||
function onTouchEnd() { dragSelecting = false; }
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
document.addEventListener('touchcancel', onTouchEnd);
|
||||
return () => {
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Actions ----
|
||||
async function restoreSelected() {
|
||||
const ids = [...$selectionStore.ids];
|
||||
confirmRestore = false;
|
||||
actionBusy = true;
|
||||
selectionStore.exit();
|
||||
try {
|
||||
await Promise.all(ids.map((id) => api.post(`/files/${id}/restore`, {})));
|
||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||
} catch {
|
||||
// partial failure: reload
|
||||
} finally {
|
||||
actionBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function permDeleteSelected() {
|
||||
const ids = [...$selectionStore.ids];
|
||||
confirmPermDelete = false;
|
||||
actionBusy = true;
|
||||
selectionStore.exit();
|
||||
try {
|
||||
await Promise.all(ids.map((id) => api.delete(`/files/${id}/permanent`)));
|
||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||
} catch {
|
||||
// partial failure: reload
|
||||
} finally {
|
||||
actionBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') selectionStore.exit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Trash | Tanabata</title></svelte:head>
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<button class="back-btn" onclick={() => { selectionStore.exit(); goto('/files'); }}>
|
||||
← Files
|
||||
</button>
|
||||
<span class="title">Trash</span>
|
||||
<button
|
||||
class="select-btn"
|
||||
class:active={$selectionActive}
|
||||
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
||||
>
|
||||
{$selectionActive ? 'Cancel' : 'Select'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main bind:this={scrollContainer}>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid">
|
||||
{#each files as file, i (file.id)}
|
||||
<FileCard
|
||||
{file}
|
||||
index={i}
|
||||
selected={$selectionStore.ids.has(file.id ?? '')}
|
||||
selectionMode={$selectionActive}
|
||||
onTap={(e) => handleTap(file, i, e)}
|
||||
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
|
||||
|
||||
{#if !loading && !hasMore && files.length === 0}
|
||||
<div class="empty">Trash is empty.</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#if $selectionActive}
|
||||
<div class="sel-bar" role="toolbar" aria-label="Trash selection actions">
|
||||
<button class="sel-count" onclick={() => selectionStore.exit()} title="Clear selection">
|
||||
<span class="sel-num">{$selectionCount}</span>
|
||||
<span class="sel-label">selected</span>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="sel-spacer"></div>
|
||||
<button class="sel-action restore" onclick={() => (confirmRestore = true)} disabled={actionBusy}>
|
||||
Restore
|
||||
</button>
|
||||
<button class="sel-action perm-delete" onclick={() => (confirmPermDelete = true)} disabled={actionBusy}>
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if confirmRestore}
|
||||
<ConfirmDialog
|
||||
message={`Restore ${$selectionStore.ids.size} file(s)?`}
|
||||
confirmLabel="Restore"
|
||||
onConfirm={restoreSelected}
|
||||
onCancel={() => (confirmRestore = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if confirmPermDelete}
|
||||
<ConfirmDialog
|
||||
message={`Permanently delete ${$selectionStore.ids.size} file(s)? This cannot be undone.`}
|
||||
confirmLabel="Delete permanently"
|
||||
danger
|
||||
onConfirm={permDeleteSelected}
|
||||
onCancel={() => (confirmPermDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.back-btn:hover { color: var(--color-accent); }
|
||||
|
||||
.title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.select-btn {
|
||||
margin-left: auto;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-btn:hover { color: var(--color-text-primary); border-color: var(--color-accent); }
|
||||
|
||||
.select-btn.active {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 10px calc(60px + 10px);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.grid::after {
|
||||
content: '';
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
padding: 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 60px 20px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ---- Trash selection bar ---- */
|
||||
.sel-bar {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 65px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
||||
padding: 12px 14px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
animation: slide-up 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.sel-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sel-count:hover {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sel-num {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sel-label { font-size: 0.85rem; }
|
||||
|
||||
.sel-spacer { flex: 1; }
|
||||
|
||||
.sel-action {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sel-action:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.sel-action.restore {
|
||||
color: #7ECBA1;
|
||||
}
|
||||
|
||||
.sel-action.restore:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
||||
}
|
||||
|
||||
.sel-action.perm-delete {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.sel-action.perm-delete:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
}
|
||||
</style>
|
||||
@ -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,13 +488,40 @@ 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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user