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;
|
onOrderToggle: () => void;
|
||||||
onFilterToggle: () => void;
|
onFilterToggle: () => void;
|
||||||
onUpload?: () => void;
|
onUpload?: () => void;
|
||||||
|
onTrash?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -22,6 +23,7 @@
|
|||||||
onOrderToggle,
|
onOrderToggle,
|
||||||
onFilterToggle,
|
onFilterToggle,
|
||||||
onUpload,
|
onUpload,
|
||||||
|
onTrash,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -43,6 +45,14 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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">
|
<div class="controls">
|
||||||
<select
|
<select
|
||||||
class="sort-select"
|
class="sort-select"
|
||||||
|
|||||||
@ -242,6 +242,7 @@
|
|||||||
onOrderToggle={() => fileSorting.toggleOrder()}
|
onOrderToggle={() => fileSorting.toggleOrder()}
|
||||||
onFilterToggle={() => (filterOpen = !filterOpen)}
|
onFilterToggle={() => (filterOpen = !filterOpen)}
|
||||||
onUpload={() => uploader?.open()}
|
onUpload={() => uploader?.open()}
|
||||||
|
onTrash={() => goto('/files/trash')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if filterOpen}
|
{#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>`;
|
</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 MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
|
||||||
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
|
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
|
||||||
const exts = ['jpg', 'png', 'webp', '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 : [] });
|
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') {
|
if (method === 'POST' && path === '/files/bulk/delete') {
|
||||||
const body = (await readBody(req)) as { file_ids?: string[] };
|
const body = (await readBody(req)) as { file_ids?: string[] };
|
||||||
const ids = new Set(body.file_ids ?? []);
|
const ids = new Set(body.file_ids ?? []);
|
||||||
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
|
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);
|
return noContent(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,9 +557,20 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 201, newFile);
|
return json(res, 201, newFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /files (cursor pagination + anchor support)
|
// GET /files (cursor pagination + anchor support + trash)
|
||||||
if (method === 'GET' && path === '/files') {
|
if (method === 'GET' && path === '/files') {
|
||||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
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 anchor = qs.get('anchor');
|
||||||
const cursor = qs.get('cursor');
|
const cursor = qs.get('cursor');
|
||||||
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user