Compare commits

...

2 Commits

Author SHA1 Message Date
9b1aa40522 feat(frontend): implement bulk tag editing for multi-file selection
- Add BulkTagEditor component: loads common/partial tags via
  POST /files/bulk/common-tags and applies changes via POST /files/bulk/tags
- Common tags shown solid with × to remove from all files
- Partial tags shown with dashed border and ~ indicator; clicking promotes
  to common (adds to files that are missing it)
- Wire "Edit tags" button in SelectionBar to a bottom sheet with the editor
- Add mock handlers for /files/bulk/common-tags and /files/bulk/tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:40:53 +03:00
d79e76e9b7 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>
2026-04-06 08:31:06 +03:00
7 changed files with 2549 additions and 4 deletions

View File

@ -0,0 +1,350 @@
<script lang="ts">
import { api } from '$lib/api/client';
import type { Tag, TagOffsetPage } from '$lib/api/types';
interface Props {
fileIds: string[];
onDone: () => void;
}
let { fileIds, onDone }: Props = $props();
// Tags present on ALL selected files
let commonIds = $state(new Set<string>());
// Tags present on SOME but not all selected files
let partialIds = $state(new Set<string>());
// All available tags from /tags
let allTags = $state<Tag[]>([]);
let search = $state('');
let busy = $state(false);
let loading = $state(true);
let error = $state('');
$effect(() => {
load();
});
async function load() {
loading = true;
error = '';
try {
const [tagsRes, commonRes] = await Promise.all([
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc'),
api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>(
'/files/bulk/common-tags',
{ file_ids: fileIds },
),
]);
allTags = tagsRes.items ?? [];
commonIds = new Set(commonRes.common_tag_ids ?? []);
partialIds = new Set(commonRes.partial_tag_ids ?? []);
} catch {
error = 'Failed to load tags';
} finally {
loading = false;
}
}
// Assigned = common + partial (shown in assigned section)
let assignedIds = $derived(new Set([...commonIds, ...partialIds]));
let assignedTags = $derived(
allTags.filter(
(t) =>
assignedIds.has(t.id ?? '') &&
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
),
);
let availableTags = $derived(
allTags.filter(
(t) =>
!assignedIds.has(t.id ?? '') &&
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
),
);
function tagStyle(tag: Tag) {
const color = tag.color ?? tag.category_color;
return color ? `background-color: #${color}` : '';
}
async function add(tagId: string) {
if (busy) return;
busy = true;
try {
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] });
commonIds = new Set([...commonIds, tagId]);
partialIds.delete(tagId);
partialIds = new Set(partialIds);
} finally {
busy = false;
}
}
// Clicking a partial tag promotes it to common (adds to all files that don't have it)
async function promotePartial(tagId: string) {
if (busy) return;
busy = true;
try {
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] });
commonIds = new Set([...commonIds, tagId]);
partialIds.delete(tagId);
partialIds = new Set(partialIds);
} finally {
busy = false;
}
}
async function remove(tagId: string) {
if (busy) return;
busy = true;
try {
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'remove', tag_ids: [tagId] });
commonIds.delete(tagId);
partialIds.delete(tagId);
commonIds = new Set(commonIds);
partialIds = new Set(partialIds);
} finally {
busy = false;
}
}
</script>
<div class="editor" class:busy>
{#if loading}
<p class="status">Loading…</p>
{:else if error}
<p class="status err">{error}</p>
{:else}
<!-- Assigned tags -->
{#if assignedTags.length > 0}
<div class="section-label">
Assigned
<span class="hint">— partial tags shown with dashed border, click to apply to all</span>
</div>
<div class="tag-row">
{#each assignedTags as tag (tag.id)}
{@const isPartial = partialIds.has(tag.id ?? '')}
<div class="tag-wrap">
<button
class="tag assigned"
class:partial={isPartial}
style={tagStyle(tag)}
onclick={() => isPartial ? promotePartial(tag.id!) : remove(tag.id!)}
title={isPartial ? 'Partial — click to add to all files' : 'Click to remove from all files'}
>
{tag.name}
{#if isPartial}
<span class="partial-icon" aria-label="partial">~</span>
{:else}
<span class="remove" aria-label="remove">×</span>
{/if}
</button>
</div>
{/each}
</div>
{/if}
<!-- Search -->
<div class="search-wrap">
<input
class="search"
type="search"
placeholder="Search tags…"
bind:value={search}
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>
<!-- Available tags -->
{#if availableTags.length > 0}
<div class="section-label">Add tag</div>
<div class="tag-row available-row">
{#each availableTags as tag (tag.id)}
<button
class="tag available"
style={tagStyle(tag)}
onclick={() => add(tag.id!)}
title="Add to all selected files"
>
{tag.name}
</button>
{/each}
</div>
{:else if search.trim() && availableTags.length === 0 && assignedTags.length === 0}
<p class="empty">No matching tags</p>
{/if}
{/if}
</div>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.editor.busy {
opacity: 0.6;
pointer-events: none;
}
.status {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0;
padding: 8px 0;
}
.status.err {
color: var(--color-danger);
}
.section-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hint {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
font-size: 0.72rem;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.available-row {
max-height: 140px;
overflow-y: auto;
}
.tag-wrap {
display: contents;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 9px;
border-radius: 5px;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
border: 2px solid transparent;
background-color: var(--color-tag-default);
color: rgba(255, 255, 255, 0.9);
user-select: none;
}
/* Common tag — solid, slightly faded ×, full opacity */
.tag.assigned {
opacity: 0.95;
}
.tag.assigned:hover {
filter: brightness(1.15);
}
/* Partial tag — dashed border, reduced opacity */
.tag.assigned.partial {
opacity: 0.65;
border-style: dashed;
border-color: rgba(255, 255, 255, 0.55);
}
.tag.assigned.partial:hover {
opacity: 1;
filter: brightness(1.1);
}
.remove {
font-size: 1rem;
line-height: 1;
opacity: 0.7;
}
.partial-icon {
font-size: 0.9rem;
line-height: 1;
opacity: 0.85;
}
.tag.available {
opacity: 0.7;
}
.tag.available:hover {
opacity: 1;
filter: brightness(1.1);
}
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search {
width: 100%;
box-sizing: border-box;
height: 32px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.search:focus {
border-color: var(--color-accent);
}
.search-clear {
position: absolute;
right: 6px;
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);
}
.empty {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
</style>

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

@ -12,12 +12,55 @@
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
import { selectionStore, selectionActive } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import BulkTagEditor from '$lib/components/file/BulkTagEditor.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);
// ---- Bulk tag editor ----
let tagEditorOpen = $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];
}
@ -229,12 +272,72 @@
{#if $selectionActive}
<SelectionBar
onEditTags={() => {/* TODO */}}
onAddToPool={() => {/* TODO */}}
onEditTags={() => (tagEditorOpen = true)}
onAddToPool={openPoolPicker}
onDelete={() => (confirmDeleteFiles = true)}
/>
{/if}
{#if tagEditorOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
<div class="picker-header">
<span class="picker-title">Edit tags — {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''}</span>
<button class="picker-close" onclick={() => (tagEditorOpen = 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="tag-sheet-body">
<BulkTagEditor fileIds={[...$selectionStore.ids]} onDone={() => (tagEditorOpen = false)} />
</div>
</div>
{/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 +399,141 @@
padding: 60px 20px;
font-size: 0.95rem;
}
/* ---- Tag editor sheet ---- */
.tag-sheet {
max-height: 80dvh;
}
.tag-sheet-body {
padding: 0 14px 16px;
overflow-y: auto;
flex: 1;
}
/* ---- 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;
@ -317,6 +366,40 @@ export function mockApiPlugin(): Plugin {
return json(res, 200, getMockFile(id));
}
// POST /files/bulk/common-tags
if (method === 'POST' && path === '/files/bulk/common-tags') {
const body = (await readBody(req)) as { file_ids?: string[] };
const ids = body.file_ids ?? [];
if (ids.length === 0) return json(res, 200, { common_tag_ids: [], partial_tag_ids: [] });
const sets = ids.map((fid) => fileTags.get(fid) ?? new Set<string>());
const allTagIds = new Set<string>();
sets.forEach((s) => s.forEach((t) => allTagIds.add(t)));
const common: string[] = [];
const partial: string[] = [];
allTagIds.forEach((tid) => {
if (sets.every((s) => s.has(tid))) common.push(tid);
else partial.push(tid);
});
return json(res, 200, { common_tag_ids: common, partial_tag_ids: partial });
}
// POST /files/bulk/tags
if (method === 'POST' && path === '/files/bulk/tags') {
const body = (await readBody(req)) as { file_ids?: string[]; action?: string; tag_ids?: string[] };
const fileIds = body.file_ids ?? [];
const tagIds = body.tag_ids ?? [];
const action = body.action ?? 'add';
for (const fid of fileIds) {
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
const set = fileTags.get(fid)!;
for (const tid of tagIds) {
if (action === 'add') set.add(tid);
else set.delete(tid);
}
}
return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] });
}
// POST /files/bulk/delete — soft delete (just remove from mock array)
if (method === 'POST' && path === '/files/bulk/delete') {
const body = (await readBody(req)) as { file_ids?: string[] };
@ -609,9 +692,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