feat(frontend): implement pool views and add-to-pool from file list

- Add /pools list page with search, sort, load-more pagination
- Add /pools/new create form (name, notes, public toggle)
- Add /pools/[id] detail page: metadata editing, ordered file grid,
  drag-to-reorder, filter bar, file selection/removal, add-files overlay
- Add pool sort store (poolSorting) to sorting.ts
- Wire "Add to pool" button in SelectionBar: bottom-sheet pool picker
  loads pool list, user picks one, selected files are POSTed to pool
- Add full pool mock API handlers in vite-mock-plugin.ts (CRUD + file
  management: add, remove, reorder with cursor pagination)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Masahiko AMANO 2026-04-06 08:31:06 +03:00
parent 1f591f3a3f
commit d79e76e9b7
6 changed files with 2131 additions and 3 deletions

View File

@ -48,4 +48,11 @@ export type CategorySortField = 'name' | 'color' | 'created';
export const categorySorting = makeSortStore<CategorySortField>('sort:categories', {
sort: 'name',
order: 'asc',
});
export type PoolSortField = 'name' | 'created';
export const poolSorting = makeSortStore<PoolSortField>('sort:pools', {
sort: 'created',
order: 'desc',
});

View File

@ -13,11 +13,50 @@
import { selectionStore, selectionActive } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage } from '$lib/api/types';
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
let uploader = $state<{ open: () => void } | undefined>();
let confirmDeleteFiles = $state(false);
// ---- Add to pool picker ----
let poolPickerOpen = $state(false);
let pools = $state<Pool[]>([]);
let poolsLoading = $state(false);
let poolPickerSearch = $state('');
let poolPickerError = $state('');
async function openPoolPicker() {
poolPickerOpen = true;
poolPickerError = '';
poolsLoading = true;
poolPickerSearch = '';
try {
const res = await api.get<PoolOffsetPage>('/pools?limit=200&sort=name&order=asc');
pools = res.items ?? [];
} catch {
poolPickerError = 'Failed to load pools';
} finally {
poolsLoading = false;
}
}
async function addToPool(poolId: string) {
const ids = [...$selectionStore.ids];
poolPickerOpen = false;
selectionStore.exit();
try {
await api.post(`/pools/${poolId}/files`, { file_ids: ids });
} catch {
// silently ignore
}
}
let filteredPools = $derived(
poolPickerSearch.trim()
? pools.filter((p) => p.name?.toLowerCase().includes(poolPickerSearch.toLowerCase()))
: pools
);
function handleUploaded(file: File) {
files = [file, ...files];
}
@ -230,11 +269,53 @@
{#if $selectionActive}
<SelectionBar
onEditTags={() => {/* TODO */}}
onAddToPool={() => {/* TODO */}}
onAddToPool={openPoolPicker}
onDelete={() => (confirmDeleteFiles = true)}
/>
{/if}
{#if poolPickerOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div>
<div class="picker-sheet" role="dialog" aria-label="Add to pool">
<div class="picker-header">
<span class="picker-title">Add {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''} to pool</span>
<button class="picker-close" onclick={() => (poolPickerOpen = false)} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="picker-search-wrap">
<input
class="picker-search"
type="search"
placeholder="Search pools…"
bind:value={poolPickerSearch}
autocomplete="off"
/>
</div>
{#if poolPickerError}
<p class="picker-error">{poolPickerError}</p>
{:else if poolsLoading}
<p class="picker-empty">Loading…</p>
{:else if filteredPools.length === 0}
<p class="picker-empty">No pools found.</p>
{:else}
<ul class="picker-list">
{#each filteredPools as pool (pool.id)}
<li>
<button class="picker-item" onclick={() => pool.id && addToPool(pool.id)}>
<span class="picker-item-name">{pool.name}</span>
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if confirmDeleteFiles}
<ConfirmDialog
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
@ -296,4 +377,130 @@
padding: 60px 20px;
font-size: 0.95rem;
}
/* ---- Pool picker ---- */
.picker-backdrop {
position: fixed;
inset: 0;
z-index: 110;
background: rgba(0, 0, 0, 0.5);
}
.picker-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 111;
background-color: var(--color-bg-secondary);
border-radius: 14px 14px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
max-height: 70dvh;
display: flex;
flex-direction: column;
animation: slide-up 0.18s ease-out;
}
@keyframes slide-up {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.picker-header {
display: flex;
align-items: center;
padding: 14px 16px 10px;
gap: 8px;
}
.picker-title {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
}
.picker-close {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: flex;
align-items: center;
}
.picker-close:hover {
color: var(--color-text-primary);
}
.picker-search-wrap {
padding: 0 14px 10px;
}
.picker-search {
width: 100%;
box-sizing: border-box;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.picker-search:focus {
border-color: var(--color-accent);
}
.picker-list {
list-style: none;
margin: 0;
padding: 0 8px 12px;
overflow-y: auto;
flex: 1;
}
.picker-item {
display: flex;
align-items: center;
width: 100%;
text-align: left;
padding: 11px 10px;
border-radius: 8px;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
gap: 8px;
}
.picker-item:hover {
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.picker-item-name {
flex: 1;
font-size: 0.95rem;
color: var(--color-text-primary);
}
.picker-item-count {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.picker-empty,
.picker-error {
text-align: center;
padding: 20px;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.picker-error {
color: var(--color-danger);
}
</style>

View File

@ -0,0 +1,452 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client';
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
import type { Pool, PoolOffsetPage } from '$lib/api/types';
const LIMIT = 50;
const SORT_OPTIONS: { value: PoolSortField; label: string }[] = [
{ value: 'name', label: 'Name' },
{ value: 'created', label: 'Created' },
];
let pools = $state<Pool[]>([]);
let total = $state(0);
let offset = $state(0);
let loading = $state(false);
let initialLoaded = $state(false);
let error = $state('');
let search = $state('');
let sortState = $derived($poolSorting);
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
let prevKey = $state('');
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
pools = [];
offset = 0;
total = 0;
initialLoaded = false;
}
});
$effect(() => {
if (!initialLoaded && !loading) void load();
});
async function load() {
if (loading) return;
loading = true;
error = '';
try {
const params = new URLSearchParams({
limit: String(LIMIT),
offset: String(offset),
sort: sortState.sort,
order: sortState.order,
});
if (search.trim()) params.set('search', search.trim());
const page = await api.get<PoolOffsetPage>(`/pools?${params}`);
pools = offset === 0 ? (page.items ?? []) : [...pools, ...(page.items ?? [])];
total = page.total ?? 0;
offset = pools.length;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load pools';
} finally {
loading = false;
initialLoaded = true;
}
}
let hasMore = $derived(pools.length < total);
function formatCount(n: number): string {
return n === 1 ? '1 file' : `${n} files`;
}
</script>
<svelte:head>
<title>Pools | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<h1 class="page-title">Pools</h1>
<div class="controls">
<select
class="sort-select"
value={sortState.sort}
onchange={(e) => poolSorting.setSort((e.currentTarget as HTMLSelectElement).value as PoolSortField)}
>
{#each SORT_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<button
class="icon-btn"
onclick={() => poolSorting.toggleOrder()}
title={sortState.order === 'asc' ? 'Ascending' : 'Descending'}
>
{#if sortState.order === 'asc'}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/if}
</button>
<button class="new-btn" onclick={() => goto('/pools/new')}>+ New</button>
</div>
</header>
<div class="search-bar">
<div class="search-wrap">
<input
class="search-input"
type="search"
placeholder="Search pools…"
value={search}
oninput={(e) => (search = (e.currentTarget as HTMLInputElement).value)}
autocomplete="off"
/>
{#if search}
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
<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>
{/if}
</div>
</div>
<main>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="pool-list">
{#each pools as pool (pool.id)}
<button class="pool-card" onclick={() => goto(`/pools/${pool.id}`)}>
<div class="pool-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.7"/>
<rect x="11" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<div class="pool-info">
<span class="pool-name">{pool.name}</span>
<span class="pool-meta">
{formatCount(pool.file_count ?? 0)}
{#if pool.creator_name}· {pool.creator_name}{/if}
{#if pool.is_public}<span class="badge-public">public</span>{/if}
</span>
</div>
<svg class="chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/each}
</div>
{#if loading}
<div class="loading-row">
<span class="spinner" role="status" aria-label="Loading"></span>
</div>
{/if}
{#if hasMore && !loading}
<button class="load-more" onclick={load}>Load more</button>
{/if}
{#if !loading && pools.length === 0}
<div class="empty">
{search ? 'No pools match your search.' : 'No pools yet.'}
{#if !search}
<a href="/pools/new">Create one</a>
{/if}
</div>
{/if}
</main>
</div>
<style>
.page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.top-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
flex-shrink: 0;
}
.page-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
flex: 1;
}
.controls {
display: flex;
align-items: center;
gap: 4px;
}
.sort-select {
height: 28px;
padding: 0 8px;
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-primary);
font-size: 0.82rem;
font-family: inherit;
cursor: pointer;
outline: none;
}
.icon-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
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);
cursor: pointer;
}
.icon-btn:hover {
color: var(--color-text-primary);
border-color: var(--color-accent);
}
.new-btn {
height: 28px;
padding: 0 12px;
border-radius: 6px;
border: none;
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-size: 0.82rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.new-btn:hover {
background-color: var(--color-accent-hover);
}
.search-bar {
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
flex-shrink: 0;
}
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
box-sizing: border-box;
height: 34px;
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-primary);
font-size: 0.875rem;
font-family: inherit;
outline: none;
}
.search-input:focus {
border-color: var(--color-accent);
}
.search-clear {
position: absolute;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
}
.search-clear:hover {
color: var(--color-text-primary);
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
main {
flex: 1;
overflow-y: auto;
padding: 12px 12px calc(60px + 12px);
}
.pool-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.pool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-family: inherit;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background-color 0.15s;
}
.pool-card:hover {
border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
background-color: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-elevated));
}
.pool-icon {
color: var(--color-accent);
flex-shrink: 0;
display: flex;
}
.pool-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.pool-name {
font-size: 0.95rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-meta {
font-size: 0.78rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 5px;
}
.badge-public {
display: inline-block;
padding: 1px 5px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
color: var(--color-accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.chevron {
color: var(--color-text-muted);
flex-shrink: 0;
opacity: 0.5;
}
.loading-row {
display: flex;
justify-content: center;
padding: 20px;
}
.spinner {
display: block;
width: 28px;
height: 28px;
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.load-more {
display: block;
margin: 16px auto 0;
padding: 8px 24px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
background: none;
color: var(--color-accent);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.load-more:hover {
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.error {
color: var(--color-danger);
font-size: 0.875rem;
padding: 8px 0;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 60px 20px;
font-size: 0.95rem;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.empty a {
color: var(--color-accent);
text-decoration: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,182 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client';
import type { Pool } from '$lib/api/types';
let name = $state('');
let notes = $state('');
let isPublic = $state(false);
let saving = $state(false);
let error = $state('');
async function submit() {
if (!name.trim() || saving) return;
saving = true;
error = '';
try {
const pool = await api.post<Pool>('/pools', {
name: name.trim(),
notes: notes.trim() || null,
is_public: isPublic,
});
goto(`/pools/${pool.id}`);
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to create pool';
saving = false;
}
}
</script>
<svelte:head>
<title>New Pool | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<button class="back-btn" onclick={() => goto('/pools')} aria-label="Back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h1 class="page-title">New Pool</h1>
</header>
<main>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
<div class="field">
<label class="label" for="name">Name <span class="required">*</span></label>
<input
id="name"
class="input"
type="text"
bind:value={name}
required
placeholder="Pool name"
autocomplete="off"
/>
</div>
<div class="field">
<label class="label" for="notes">Notes</label>
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
</div>
<div class="toggle-row">
<span class="label">Public</span>
<button
type="button"
class="toggle"
class:on={isPublic}
onclick={() => (isPublic = !isPublic)}
role="switch"
aria-checked={isPublic}
aria-label="Public"
>
<span class="thumb"></span>
</button>
</div>
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
{saving ? 'Creating…' : 'Create pool'}
</button>
</form>
</main>
</div>
<style>
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.top-bar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; min-height: 44px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
flex-shrink: 0;
}
.back-btn {
display: flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 8px;
border: none; background: none;
color: var(--color-text-primary); cursor: pointer;
}
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
main {
flex: 1; overflow-y: auto;
padding: 16px 14px calc(60px + 16px);
}
.form { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; }
.label {
font-size: 0.75rem; font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase; letter-spacing: 0.05em;
}
.required { color: var(--color-danger); }
.input {
width: 100%; box-sizing: border-box;
height: 36px; padding: 0 10px;
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-primary);
font-size: 0.875rem; font-family: inherit; outline: none;
}
.input:focus { border-color: var(--color-accent); }
.textarea {
width: 100%; box-sizing: border-box; padding: 8px 10px;
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-primary);
font-size: 0.875rem; font-family: inherit;
resize: vertical; outline: none; min-height: 70px;
}
.textarea:focus { border-color: var(--color-accent); }
.toggle-row {
display: flex; align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.toggle-row .label { margin: 0; }
.toggle {
position: relative; width: 44px; height: 26px;
border-radius: 13px; border: none;
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
}
.toggle.on { background-color: var(--color-accent); }
.thumb {
position: absolute; top: 3px; left: 3px;
width: 20px; height: 20px; border-radius: 50%;
background-color: #fff; transition: transform 0.2s;
}
.toggle.on .thumb { transform: translateX(18px); }
.submit-btn {
height: 42px; border-radius: 8px; border: none;
background-color: var(--color-accent);
color: var(--color-bg-primary);
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
}
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
.submit-btn:disabled { opacity: 0.4; cursor: default; }
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0 0 8px; }
</style>

View File

@ -190,6 +190,55 @@ const tagRules = new Map<string, Map<string, boolean>>();
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;
@ -609,9 +658,152 @@ export function mockApiPlugin(): Plugin {
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') {
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
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);
}
// Fallback: 404