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',
|
'E07090', '70B0E0', 'C0A060', '80C080', 'D080B0',
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_TAGS = TAG_NAMES.map((name, i) => ({
|
const MOCK_CATEGORIES = [
|
||||||
id: `00000000-0000-7000-8001-${String(i + 1).padStart(12, '0')}`,
|
{ id: '00000000-0000-7000-8002-000000000001', name: 'Style', color: '9592B5', notes: null, created_at: new Date().toISOString() },
|
||||||
name,
|
{ id: '00000000-0000-7000-8002-000000000002', name: 'Subject', color: '4DC7ED', notes: null, created_at: new Date().toISOString() },
|
||||||
color: TAG_COLORS[i % TAG_COLORS.length],
|
{ id: '00000000-0000-7000-8002-000000000003', name: 'Location', color: '7ECBA1', notes: null, created_at: new Date().toISOString() },
|
||||||
category_id: null,
|
{ id: '00000000-0000-7000-8002-000000000004', name: 'Season', color: 'E08C5A', notes: null, created_at: new Date().toISOString() },
|
||||||
category_name: null,
|
{ id: '00000000-0000-7000-8002-000000000005', name: 'Color', color: 'DB6060', notes: null, created_at: new Date().toISOString() },
|
||||||
category_color: null,
|
];
|
||||||
created_at: new Date(Date.now() - i * 3_600_000).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],
|
||||||
|
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
|
// Mutable in-memory state for file metadata and tags
|
||||||
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
|
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 });
|
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
|
// GET /tags
|
||||||
if (method === 'GET' && path === '/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
|
// GET /categories
|
||||||
if (method === 'GET' && path === '/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
|
// GET /pools
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user