feat(frontend): implement tag list, create, and edit pages
- /tags: list with search + clear button, sort/order controls, offset pagination Fix infinite requests when search matches no tags (track initialLoaded flag) - /tags/new: create form with name, notes, color picker, category, is_public - /tags/[id]: edit form + TagRuleEditor for implied-tag rules + delete - TagBadge: colored pill with optional onclick and size prop - TagRuleEditor: manage implied-tag rules (search to add, × to remove) - Mock: tag/category CRUD, rules CRUD, search/sort, 5 mock categories Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b9cace2997
commit
f7d7e8ce37
56
frontend/src/lib/components/tag/TagBadge.svelte
Normal file
56
frontend/src/lib/components/tag/TagBadge.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from '$lib/api/types';
|
||||
|
||||
interface Props {
|
||||
tag: Tag;
|
||||
onclick?: () => void;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
let { tag, onclick, size = 'md' }: Props = $props();
|
||||
|
||||
const color = tag.color ?? tag.category_color;
|
||||
const style = color ? `background-color: #${color}` : '';
|
||||
</script>
|
||||
|
||||
{#if onclick}
|
||||
<button class="badge {size}" {style} {onclick} type="button">
|
||||
{tag.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge {size}" {style}>{tag.name}</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
font-family: inherit;
|
||||
background-color: var(--color-tag-default);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.badge.md {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge.sm {
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
button.badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.badge:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
</style>
|
||||
233
frontend/src/lib/components/tag/TagRuleEditor.svelte
Normal file
233
frontend/src/lib/components/tag/TagRuleEditor.svelte
Normal file
@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types';
|
||||
import TagBadge from './TagBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
tagId: string;
|
||||
rules: TagRule[];
|
||||
onRulesChange: (rules: TagRule[]) => void;
|
||||
}
|
||||
|
||||
let { tagId, rules, onRulesChange }: Props = $props();
|
||||
|
||||
let allTags = $state<Tag[]>([]);
|
||||
let search = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => {
|
||||
allTags = p.items ?? [];
|
||||
});
|
||||
});
|
||||
|
||||
// IDs already used in rules
|
||||
let usedIds = $derived(new Set(rules.map((r) => r.then_tag_id)));
|
||||
|
||||
let filteredTags = $derived(
|
||||
allTags.filter(
|
||||
(t) =>
|
||||
t.id !== tagId &&
|
||||
!usedIds.has(t.id) &&
|
||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
||||
),
|
||||
);
|
||||
|
||||
function tagForId(id: string | undefined) {
|
||||
return allTags.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
async function addRule(thenTagId: string) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
|
||||
then_tag_id: thenTagId,
|
||||
is_active: true,
|
||||
apply_to_existing: false,
|
||||
});
|
||||
onRulesChange([...rules, rule]);
|
||||
search = '';
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to add rule';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRule(thenTagId: string) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await api.delete(`/tags/${tagId}/rules/${thenTagId}`);
|
||||
onRulesChange(rules.filter((r) => r.then_tag_id !== thenTagId));
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to remove rule';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor" class:busy>
|
||||
<p class="desc">
|
||||
When this tag is applied, also apply:
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Current rules -->
|
||||
{#if rules.length > 0}
|
||||
<div class="rule-list">
|
||||
{#each rules as rule (rule.then_tag_id)}
|
||||
{@const t = tagForId(rule.then_tag_id)}
|
||||
<div class="rule-row">
|
||||
{#if t}
|
||||
<TagBadge tag={t} size="sm" />
|
||||
{:else}
|
||||
<span class="unknown">{rule.then_tag_name ?? rule.then_tag_id}</span>
|
||||
{/if}
|
||||
<button
|
||||
class="remove-btn"
|
||||
onclick={() => removeRule(rule.then_tag_id!)}
|
||||
aria-label="Remove rule"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No rules — when this tag is applied, nothing extra happens.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Add rule -->
|
||||
<div class="add-section">
|
||||
<div class="section-label">Add rule</div>
|
||||
<input
|
||||
class="search"
|
||||
type="search"
|
||||
placeholder="Search tags to add…"
|
||||
bind:value={search}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if search.trim()}
|
||||
<div class="tag-pick">
|
||||
{#each filteredTags as t (t.id)}
|
||||
<TagBadge tag={t} size="sm" onclick={() => addRule(t.id!)} />
|
||||
{:else}
|
||||
<span class="empty">No matching tags</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor.busy {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.unknown {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.add-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.tag-pick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
374
frontend/src/routes/tags/+page.svelte
Normal file
374
frontend/src/routes/tags/+page.svelte
Normal file
@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'created', label: 'Created' },
|
||||
{ value: 'color', label: 'Color' },
|
||||
{ value: 'category_name', label: 'Category' },
|
||||
];
|
||||
|
||||
let tags = $state<Tag[]>([]);
|
||||
let total = $state(0);
|
||||
let offset = $state(0);
|
||||
let loading = $state(false);
|
||||
let initialLoaded = $state(false); // true once first page loaded for current key
|
||||
let error = $state('');
|
||||
let search = $state('');
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
let sortState = $derived($tagSorting);
|
||||
|
||||
// Reset + reload on sort or search change
|
||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||
let prevKey = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (resetKey !== prevKey) {
|
||||
prevKey = resetKey;
|
||||
tags = [];
|
||||
offset = 0;
|
||||
total = 0;
|
||||
initialLoaded = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger load after reset (only once per key)
|
||||
$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<TagOffsetPage>(`/tags?${params}`);
|
||||
tags = offset === 0 ? (page.items ?? []) : [...tags, ...(page.items ?? [])];
|
||||
total = page.total ?? 0;
|
||||
offset = tags.length;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load tags';
|
||||
} finally {
|
||||
loading = false;
|
||||
initialLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch(e: Event) {
|
||||
search = (e.currentTarget as HTMLInputElement).value;
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => {}, 0); // reactive reset already handles it
|
||||
}
|
||||
|
||||
let hasMore = $derived(tags.length < total);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tags | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="top-bar">
|
||||
<h1 class="page-title">Tags</h1>
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
class="sort-select"
|
||||
value={sortState.sort}
|
||||
onchange={(e) => tagSorting.setSort((e.currentTarget as HTMLSelectElement).value as TagSortField)}
|
||||
>
|
||||
{#each SORT_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => tagSorting.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('/tags/new')}>+ New</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search tags…"
|
||||
value={search}
|
||||
oninput={onSearch}
|
||||
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="tag-grid">
|
||||
{#each tags as tag (tag.id)}
|
||||
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} />
|
||||
{/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 && tags.length === 0}
|
||||
<div class="empty">
|
||||
{search ? 'No tags match your search.' : 'No tags yet.'}
|
||||
{#if !search}
|
||||
<a href="/tags/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);
|
||||
}
|
||||
|
||||
.tag-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.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>
|
||||
317
frontend/src/routes/tags/[id]/+page.svelte
Normal file
317
frontend/src/routes/tags/[id]/+page.svelte
Normal file
@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import type { Category, CategoryOffsetPage, Tag, TagRule } from '$lib/api/types';
|
||||
import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte';
|
||||
|
||||
let tagId = $derived(page.params.id);
|
||||
|
||||
let tag = $state<Tag | null>(null);
|
||||
let categories = $state<Category[]>([]);
|
||||
let rules = $state<TagRule[]>([]);
|
||||
|
||||
let name = $state('');
|
||||
let notes = $state('');
|
||||
let color = $state('#444455');
|
||||
let categoryId = $state('');
|
||||
let isPublic = $state(false);
|
||||
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let loadError = $state('');
|
||||
let saveError = $state('');
|
||||
|
||||
let loaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const id = tagId;
|
||||
loaded = false;
|
||||
loadError = '';
|
||||
void Promise.all([
|
||||
api.get<Tag>(`/tags/${id}`),
|
||||
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc'),
|
||||
api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[]),
|
||||
]).then(([t, cats, r]) => {
|
||||
tag = t;
|
||||
categories = cats.items ?? [];
|
||||
rules = r;
|
||||
|
||||
name = t.name ?? '';
|
||||
notes = t.notes ?? '';
|
||||
color = t.color ? `#${t.color}` : '#444455';
|
||||
categoryId = t.category_id ?? '';
|
||||
isPublic = t.is_public ?? false;
|
||||
loaded = true;
|
||||
}).catch((e) => {
|
||||
loadError = e instanceof ApiError ? e.message : 'Failed to load tag';
|
||||
});
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!name.trim() || saving) return;
|
||||
saving = true;
|
||||
saveError = '';
|
||||
try {
|
||||
await api.patch(`/tags/${tagId}`, {
|
||||
name: name.trim(),
|
||||
notes: notes.trim() || null,
|
||||
color: color.slice(1),
|
||||
category_id: categoryId || null,
|
||||
is_public: isPublic,
|
||||
});
|
||||
goto('/tags');
|
||||
} catch (e) {
|
||||
saveError = e instanceof ApiError ? e.message : 'Failed to save tag';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag() {
|
||||
if (deleting) return;
|
||||
if (!confirm(`Delete tag "${name}"? This cannot be undone.`)) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await api.delete(`/tags/${tagId}`);
|
||||
goto('/tags');
|
||||
} catch (e) {
|
||||
saveError = e instanceof ApiError ? e.message : 'Failed to delete tag';
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{tag?.name ?? 'Tag'} | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="top-bar">
|
||||
<button class="back-btn" onclick={() => goto('/tags')} 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">{tag?.name ?? 'Tag'}</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{#if loadError}
|
||||
<p class="error" role="alert">{loadError}</p>
|
||||
{:else if !loaded}
|
||||
<div class="loading-row">
|
||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||
</div>
|
||||
{:else}
|
||||
{#if saveError}
|
||||
<p class="error" role="alert">{saveError}</p>
|
||||
{/if}
|
||||
|
||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
||||
<div class="row-fields">
|
||||
<div class="field" style="flex: 1">
|
||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||
<input
|
||||
id="name"
|
||||
class="input"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="Tag name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="field color-field">
|
||||
<label class="label" for="color">Color</label>
|
||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||
</div>
|
||||
</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="field">
|
||||
<label class="label" for="category">Category</label>
|
||||
<select id="category" class="input" bind:value={categoryId}>
|
||||
<option value="">— None —</option>
|
||||
{#each categories as cat (cat.id)}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<div class="action-row">
|
||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" class="delete-btn" onclick={deleteTag} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Tag rules -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Implied tags</h2>
|
||||
<TagRuleEditor {tagId} {rules} onRulesChange={(r) => (rules = r)} />
|
||||
</section>
|
||||
|
||||
{/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 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); display: flex; flex-direction: column; gap: 24px; }
|
||||
|
||||
.loading-row { display: flex; justify-content: center; padding: 40px; }
|
||||
|
||||
.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); } }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
.color-field { flex-shrink: 0; }
|
||||
|
||||
.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); }
|
||||
|
||||
.color-input {
|
||||
width: 50px; height: 36px; padding: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
select.input { cursor: pointer; color-scheme: dark; }
|
||||
|
||||
.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); }
|
||||
|
||||
.action-row { display: flex; gap: 8px; }
|
||||
|
||||
.submit-btn {
|
||||
flex: 1; 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; }
|
||||
|
||||
.delete-btn {
|
||||
height: 42px; padding: 0 18px; border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
||||
background: none; color: var(--color-danger);
|
||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
||||
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
||||
</style>
|
||||
221
frontend/src/routes/tags/new/+page.svelte
Normal file
221
frontend/src/routes/tags/new/+page.svelte
Normal file
@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||
|
||||
let name = $state('');
|
||||
let notes = $state('');
|
||||
let color = $state('#444455');
|
||||
let categoryId = $state('');
|
||||
let isPublic = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let categories = $state<Category[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc').then((p) => {
|
||||
categories = p.items ?? [];
|
||||
});
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim() || saving) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
try {
|
||||
await api.post('/tags', {
|
||||
name: name.trim(),
|
||||
notes: notes.trim() || null,
|
||||
color: color.slice(1), // strip #
|
||||
category_id: categoryId || null,
|
||||
is_public: isPublic,
|
||||
});
|
||||
goto('/tags');
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to create tag';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Tag | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="top-bar">
|
||||
<button class="back-btn" onclick={() => goto('/tags')} 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 Tag</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
||||
<div class="row-fields">
|
||||
<div class="field" style="flex: 1">
|
||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||
<input
|
||||
id="name"
|
||||
class="input"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="Tag name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="field color-field">
|
||||
<label class="label" for="color">Color</label>
|
||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||
</div>
|
||||
</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="field">
|
||||
<label class="label" for="category">Category</label>
|
||||
<select id="category" class="input" bind:value={categoryId}>
|
||||
<option value="">— None —</option>
|
||||
{#each categories as cat (cat.id)}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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 tag'}
|
||||
</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; }
|
||||
|
||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
.color-field { flex-shrink: 0; }
|
||||
|
||||
.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); }
|
||||
|
||||
.color-input {
|
||||
width: 50px; height: 36px; padding: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
select.input { cursor: pointer; color-scheme: dark; }
|
||||
|
||||
.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 {
|
||||
width: 100%; 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; }
|
||||
</style>
|
||||
@ -124,15 +124,67 @@ const TAG_COLORS = [
|
||||
'E07090', '70B0E0', 'C0A060', '80C080', 'D080B0',
|
||||
];
|
||||
|
||||
const MOCK_TAGS = TAG_NAMES.map((name, i) => ({
|
||||
const MOCK_CATEGORIES = [
|
||||
{ id: '00000000-0000-7000-8002-000000000001', name: 'Style', color: '9592B5', notes: null, created_at: new Date().toISOString() },
|
||||
{ id: '00000000-0000-7000-8002-000000000002', name: 'Subject', color: '4DC7ED', notes: null, created_at: new Date().toISOString() },
|
||||
{ id: '00000000-0000-7000-8002-000000000003', name: 'Location', color: '7ECBA1', notes: null, created_at: new Date().toISOString() },
|
||||
{ id: '00000000-0000-7000-8002-000000000004', name: 'Season', color: 'E08C5A', notes: null, created_at: new Date().toISOString() },
|
||||
{ id: '00000000-0000-7000-8002-000000000005', name: 'Color', color: 'DB6060', notes: null, created_at: new Date().toISOString() },
|
||||
];
|
||||
|
||||
// Assign some tags to categories
|
||||
const CATEGORY_ASSIGNMENTS: Record<string, string> = {};
|
||||
TAG_NAMES.forEach((name, i) => {
|
||||
if (['film', 'analog', 'polaroid', 'bokeh', 'silhouette', 'long-exposure', 'tilt-shift', 'fisheye', 'telephoto', 'wide-angle', 'macro', 'infrared', 'hdr', 'composite'].includes(name))
|
||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[0].id; // Style
|
||||
else if (['portrait', 'wildlife', 'people', 'children', 'elderly', 'cat', 'dog', 'bird', 'horse', 'flower', 'tree', 'insect', 'reptile', 'mammal'].includes(name))
|
||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[1].id; // Subject
|
||||
else if (['asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert', 'forest', 'mountain', 'ocean', 'lake', 'river', 'city', 'village'].includes(name))
|
||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[2].id; // Location
|
||||
else if (['spring', 'summer', 'autumn', 'winter'].includes(name))
|
||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[3].id; // Season
|
||||
else if (['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink', 'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted'].includes(name))
|
||||
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color
|
||||
});
|
||||
|
||||
function getCategoryForId(catId: string | null) {
|
||||
if (!catId) return null;
|
||||
return MOCK_CATEGORIES.find((c) => c.id === catId) ?? null;
|
||||
}
|
||||
|
||||
type MockTag = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
notes: string | null;
|
||||
category_id: string | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const mockTagsArr: MockTag[] = TAG_NAMES.map((name, i) => {
|
||||
const catId = CATEGORY_ASSIGNMENTS[name] ?? null;
|
||||
const cat = getCategoryForId(catId);
|
||||
return {
|
||||
id: `00000000-0000-7000-8001-${String(i + 1).padStart(12, '0')}`,
|
||||
name,
|
||||
color: TAG_COLORS[i % TAG_COLORS.length],
|
||||
category_id: null,
|
||||
category_name: null,
|
||||
category_color: null,
|
||||
notes: null,
|
||||
category_id: catId,
|
||||
category_name: cat?.name ?? null,
|
||||
category_color: cat?.color ?? null,
|
||||
is_public: false,
|
||||
created_at: new Date(Date.now() - i * 3_600_000).toISOString(),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
// Backwards-compatible reference for existing file-tag lookups
|
||||
const MOCK_TAGS = mockTagsArr;
|
||||
|
||||
// Tag rules: Map<tagId, Set<thenTagId>>
|
||||
const tagRules = new Map<string, Set<string>>();
|
||||
|
||||
// Mutable in-memory state for file metadata and tags
|
||||
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
|
||||
@ -325,14 +377,120 @@ export function mockApiPlugin(): Plugin {
|
||||
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
||||
}
|
||||
|
||||
// GET /tags/{id}/rules
|
||||
const tagRulesGetMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
|
||||
if (method === 'GET' && tagRulesGetMatch) {
|
||||
const tid = tagRulesGetMatch[1];
|
||||
const ruleIds = [...(tagRules.get(tid) ?? new Set<string>())];
|
||||
const items = ruleIds.map((thenId) => {
|
||||
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||
return { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: true };
|
||||
});
|
||||
return json(res, 200, items);
|
||||
}
|
||||
|
||||
// POST /tags/{id}/rules
|
||||
const tagRulesPostMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
|
||||
if (method === 'POST' && tagRulesPostMatch) {
|
||||
const tid = tagRulesPostMatch[1];
|
||||
const body = (await readBody(req)) as Record<string, unknown>;
|
||||
const thenId = body.then_tag_id as string;
|
||||
if (!tagRules.has(tid)) tagRules.set(tid, new Set());
|
||||
tagRules.get(tid)!.add(thenId);
|
||||
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||
return json(res, 201, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: true });
|
||||
}
|
||||
|
||||
// DELETE /tags/{id}/rules/{then_id}
|
||||
const tagRulesDelMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
|
||||
if (method === 'DELETE' && tagRulesDelMatch) {
|
||||
const [, tid, thenId] = tagRulesDelMatch;
|
||||
tagRules.get(tid)?.delete(thenId);
|
||||
return noContent(res);
|
||||
}
|
||||
|
||||
// GET /tags/{id}
|
||||
const tagGetMatch = path.match(/^\/tags\/([^/]+)$/);
|
||||
if (method === 'GET' && tagGetMatch) {
|
||||
const t = MOCK_TAGS.find((x) => x.id === tagGetMatch[1]);
|
||||
if (!t) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
|
||||
return json(res, 200, t);
|
||||
}
|
||||
|
||||
// PATCH /tags/{id}
|
||||
const tagPatchMatch = path.match(/^\/tags\/([^/]+)$/);
|
||||
if (method === 'PATCH' && tagPatchMatch) {
|
||||
const idx = MOCK_TAGS.findIndex((x) => x.id === tagPatchMatch[1]);
|
||||
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
|
||||
const body = (await readBody(req)) as Partial<MockTag>;
|
||||
const catId = body.category_id ?? MOCK_TAGS[idx].category_id;
|
||||
const cat = getCategoryForId(catId);
|
||||
Object.assign(MOCK_TAGS[idx], {
|
||||
...body,
|
||||
category_name: cat?.name ?? null,
|
||||
category_color: cat?.color ?? null,
|
||||
});
|
||||
return json(res, 200, MOCK_TAGS[idx]);
|
||||
}
|
||||
|
||||
// DELETE /tags/{id}
|
||||
const tagDelMatch = path.match(/^\/tags\/([^/]+)$/);
|
||||
if (method === 'DELETE' && tagDelMatch) {
|
||||
const idx = MOCK_TAGS.findIndex((x) => x.id === tagDelMatch[1]);
|
||||
if (idx >= 0) MOCK_TAGS.splice(idx, 1);
|
||||
return noContent(res);
|
||||
}
|
||||
|
||||
// GET /tags
|
||||
if (method === 'GET' && path === '/tags') {
|
||||
return json(res, 200, { items: MOCK_TAGS, total: MOCK_TAGS.length, offset: 0, limit: 200 });
|
||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||
const search = qs.get('search')?.toLowerCase() ?? '';
|
||||
const sort = qs.get('sort') ?? 'name';
|
||||
const order = qs.get('order') ?? 'asc';
|
||||
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
|
||||
const offset = Number(qs.get('offset') ?? 0);
|
||||
|
||||
let filtered = search
|
||||
? MOCK_TAGS.filter((t) => t.name.toLowerCase().includes(search))
|
||||
: [...MOCK_TAGS];
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let av: string, bv: string;
|
||||
if (sort === 'color') { av = a.color; bv = b.color; }
|
||||
else if (sort === 'category_name') { av = a.category_name ?? ''; bv = b.category_name ?? ''; }
|
||||
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
|
||||
else { av = a.name; bv = b.name; }
|
||||
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 /tags
|
||||
if (method === 'POST' && path === '/tags') {
|
||||
const body = (await readBody(req)) as Partial<MockTag>;
|
||||
const catId = body.category_id ?? null;
|
||||
const cat = getCategoryForId(catId);
|
||||
const newTag: MockTag = {
|
||||
id: `00000000-0000-7000-8001-${String(Date.now()).slice(-12)}`,
|
||||
name: body.name ?? 'Unnamed',
|
||||
color: body.color ?? '444455',
|
||||
notes: body.notes ?? null,
|
||||
category_id: catId,
|
||||
category_name: cat?.name ?? null,
|
||||
category_color: cat?.color ?? null,
|
||||
is_public: body.is_public ?? false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
MOCK_TAGS.unshift(newTag);
|
||||
return json(res, 201, newTag);
|
||||
}
|
||||
|
||||
// GET /categories
|
||||
if (method === 'GET' && path === '/categories') {
|
||||
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
|
||||
return json(res, 200, { items: MOCK_CATEGORIES, total: MOCK_CATEGORIES.length, offset: 0, limit: 50 });
|
||||
}
|
||||
|
||||
// GET /pools
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user