Compare commits
2 Commits
1f591f3a3f
...
9b1aa40522
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b1aa40522 | |||
| d79e76e9b7 |
350
frontend/src/lib/components/file/BulkTagEditor.svelte
Normal file
350
frontend/src/lib/components/file/BulkTagEditor.svelte
Normal 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>
|
||||
@ -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',
|
||||
});
|
||||
@ -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>
|
||||
|
||||
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 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user