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:
parent
1f591f3a3f
commit
d79e76e9b7
@ -49,3 +49,10 @@ export const categorySorting = makeSortStore<CategorySortField>('sort:categories
|
|||||||
sort: 'name',
|
sort: 'name',
|
||||||
order: 'asc',
|
order: 'asc',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type PoolSortField = 'name' | 'created';
|
||||||
|
|
||||||
|
export const poolSorting = makeSortStore<PoolSortField>('sort:pools', {
|
||||||
|
sort: 'created',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
@ -13,11 +13,50 @@
|
|||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
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 uploader = $state<{ open: () => void } | undefined>();
|
||||||
let confirmDeleteFiles = $state(false);
|
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) {
|
function handleUploaded(file: File) {
|
||||||
files = [file, ...files];
|
files = [file, ...files];
|
||||||
}
|
}
|
||||||
@ -230,11 +269,53 @@
|
|||||||
{#if $selectionActive}
|
{#if $selectionActive}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
onEditTags={() => {/* TODO */}}
|
onEditTags={() => {/* TODO */}}
|
||||||
onAddToPool={() => {/* TODO */}}
|
onAddToPool={openPoolPicker}
|
||||||
onDelete={() => (confirmDeleteFiles = true)}
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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}
|
{#if confirmDeleteFiles}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
|
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
|
||||||
@ -296,4 +377,130 @@
|
|||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
font-size: 0.95rem;
|
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>
|
</style>
|
||||||
|
|||||||
452
frontend/src/routes/pools/+page.svelte
Normal file
452
frontend/src/routes/pools/+page.svelte
Normal 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>
|
||||||
1088
frontend/src/routes/pools/[id]/+page.svelte
Normal file
1088
frontend/src/routes/pools/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
182
frontend/src/routes/pools/new/+page.svelte
Normal file
182
frontend/src/routes/pools/new/+page.svelte
Normal 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>
|
||||||
@ -190,6 +190,55 @@ const tagRules = new Map<string, Map<string, boolean>>();
|
|||||||
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
|
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
|
||||||
const fileTags = new Map<string, Set<string>>(); // fileId → Set<tagId>
|
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) {
|
function getMockFile(id: string) {
|
||||||
const base = MOCK_FILES.find((f) => f.id === id);
|
const base = MOCK_FILES.find((f) => f.id === id);
|
||||||
if (!base) return null;
|
if (!base) return null;
|
||||||
@ -609,9 +658,152 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 201, 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
|
// GET /pools
|
||||||
if (method === 'GET' && path === '/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
|
// Fallback: 404
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user