feat(frontend): implement category list, create, and edit pages
- /categories: list with colored pills, search + clear, sort/order controls
- /categories/new: create form with name, color picker, notes, is_public
- /categories/[id]: edit form + tags-in-category section with load more
- sorting.ts: add categorySorting store (name/color/created, persisted)
- mock: category CRUD, GET /categories/{id}/tags, search/sort/offset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21f3acadf0
commit
1931adcd38
@ -42,3 +42,10 @@ export const tagSorting = makeSortStore<TagSortField>('sort:tags', {
|
||||
sort: 'created',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
export type CategorySortField = 'name' | 'color' | 'created';
|
||||
|
||||
export const categorySorting = makeSortStore<CategorySortField>('sort:categories', {
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
});
|
||||
389
frontend/src/routes/categories/+page.svelte
Normal file
389
frontend/src/routes/categories/+page.svelte
Normal file
@ -0,0 +1,389 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
||||
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
const SORT_OPTIONS: { value: CategorySortField; label: string }[] = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'color', label: 'Color' },
|
||||
{ value: 'created', label: 'Created' },
|
||||
];
|
||||
|
||||
let categories = $state<Category[]>([]);
|
||||
let total = $state(0);
|
||||
let offset = $state(0);
|
||||
let loading = $state(false);
|
||||
let initialLoaded = $state(false);
|
||||
let error = $state('');
|
||||
let search = $state('');
|
||||
|
||||
let sortState = $derived($categorySorting);
|
||||
|
||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||
let prevKey = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (resetKey !== prevKey) {
|
||||
prevKey = resetKey;
|
||||
categories = [];
|
||||
offset = 0;
|
||||
total = 0;
|
||||
initialLoaded = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!initialLoaded && !loading) void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(LIMIT),
|
||||
offset: String(offset),
|
||||
sort: sortState.sort,
|
||||
order: sortState.order,
|
||||
});
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
const page = await api.get<CategoryOffsetPage>(`/categories?${params}`);
|
||||
categories = offset === 0 ? (page.items ?? []) : [...categories, ...(page.items ?? [])];
|
||||
total = page.total ?? 0;
|
||||
offset = categories.length;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load categories';
|
||||
} finally {
|
||||
loading = false;
|
||||
initialLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
let hasMore = $derived(categories.length < total);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Categories | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="top-bar">
|
||||
<h1 class="page-title">Categories</h1>
|
||||
|
||||
<div class="controls">
|
||||
<select
|
||||
class="sort-select"
|
||||
value={sortState.sort}
|
||||
onchange={(e) => categorySorting.setSort((e.currentTarget as HTMLSelectElement).value as CategorySortField)}
|
||||
>
|
||||
{#each SORT_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => categorySorting.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('/categories/new')}>+ New</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search categories…"
|
||||
value={search}
|
||||
oninput={(e) => (search = (e.currentTarget as HTMLInputElement).value)}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if search}
|
||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="category-grid">
|
||||
{#each categories as cat (cat.id)}
|
||||
<button
|
||||
class="category-pill"
|
||||
style={cat.color ? `background-color: #${cat.color}` : ''}
|
||||
onclick={() => goto(`/categories/${cat.id}`)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-row">
|
||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMore && !loading}
|
||||
<button class="load-more" onclick={load}>Load more</button>
|
||||
{/if}
|
||||
|
||||
{#if !loading && categories.length === 0}
|
||||
<div class="empty">
|
||||
{search ? 'No categories match your search.' : 'No categories yet.'}
|
||||
{#if !search}
|
||||
<a href="/categories/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);
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background-color: var(--color-tag-default);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-pill:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.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>
|
||||
390
frontend/src/routes/categories/[id]/+page.svelte
Normal file
390
frontend/src/routes/categories/[id]/+page.svelte
Normal file
@ -0,0 +1,390 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
|
||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||
|
||||
let categoryId = $derived(page.params.id);
|
||||
|
||||
let category = $state<Category | null>(null);
|
||||
let tags = $state<Tag[]>([]);
|
||||
let tagsTotal = $state(0);
|
||||
let tagsOffset = $state(0);
|
||||
let tagsLoading = $state(false);
|
||||
|
||||
let name = $state('');
|
||||
let notes = $state('');
|
||||
let color = $state('#9592B5');
|
||||
let isPublic = $state(false);
|
||||
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let loadError = $state('');
|
||||
let saveError = $state('');
|
||||
let loaded = $state(false);
|
||||
|
||||
const TAGS_LIMIT = 100;
|
||||
|
||||
$effect(() => {
|
||||
const id = categoryId;
|
||||
loaded = false;
|
||||
loadError = '';
|
||||
tags = [];
|
||||
tagsOffset = 0;
|
||||
tagsTotal = 0;
|
||||
void api.get<Category>(`/categories/${id}`).then((cat) => {
|
||||
category = cat;
|
||||
name = cat.name ?? '';
|
||||
notes = cat.notes ?? '';
|
||||
color = cat.color ? `#${cat.color}` : '#9592B5';
|
||||
isPublic = cat.is_public ?? false;
|
||||
loaded = true;
|
||||
}).catch((e) => {
|
||||
loadError = e instanceof ApiError ? e.message : 'Failed to load category';
|
||||
});
|
||||
void loadTags(id, 0);
|
||||
});
|
||||
|
||||
async function loadTags(id: string, startOffset: number) {
|
||||
tagsLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(TAGS_LIMIT),
|
||||
offset: String(startOffset),
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
});
|
||||
const p = await api.get<TagOffsetPage>(`/categories/${id}/tags?${params}`);
|
||||
tags = startOffset === 0 ? (p.items ?? []) : [...tags, ...(p.items ?? [])];
|
||||
tagsTotal = p.total ?? 0;
|
||||
tagsOffset = tags.length;
|
||||
} catch {
|
||||
// non-fatal — tags section just stays empty
|
||||
} finally {
|
||||
tagsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let tagsHasMore = $derived(tags.length < tagsTotal);
|
||||
|
||||
async function save() {
|
||||
if (!name.trim() || saving) return;
|
||||
saving = true;
|
||||
saveError = '';
|
||||
try {
|
||||
await api.patch(`/categories/${categoryId}`, {
|
||||
name: name.trim(),
|
||||
notes: notes.trim() || null,
|
||||
color: color.slice(1),
|
||||
is_public: isPublic,
|
||||
});
|
||||
goto('/categories');
|
||||
} catch (e) {
|
||||
saveError = e instanceof ApiError ? e.message : 'Failed to save category';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory() {
|
||||
if (deleting) return;
|
||||
if (!confirm(`Delete category "${name}"? Tags in this category will be unassigned.`)) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await api.delete(`/categories/${categoryId}`);
|
||||
goto('/categories');
|
||||
} catch (e) {
|
||||
saveError = e instanceof ApiError ? e.message : 'Failed to delete category';
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{category?.name ?? 'Category'} | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="top-bar">
|
||||
<button class="back-btn" onclick={() => goto('/categories')} 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">{category?.name ?? 'Category'}</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="Category 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="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={deleteCategory} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Tags in this category -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
Tags
|
||||
{#if tagsTotal > 0}<span class="count">({tagsTotal})</span>{/if}
|
||||
</h2>
|
||||
|
||||
{#if tagsLoading && tags.length === 0}
|
||||
<div class="loading-row">
|
||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||
</div>
|
||||
{:else if tags.length === 0}
|
||||
<p class="empty-tags">No tags in this category.</p>
|
||||
{:else}
|
||||
<div class="tag-grid">
|
||||
{#each tags as tag (tag.id)}
|
||||
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} size="sm" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if tagsHasMore}
|
||||
<button
|
||||
class="load-more"
|
||||
onclick={() => loadTags(categoryId, tagsOffset)}
|
||||
disabled={tagsLoading}
|
||||
>
|
||||
{tagsLoading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</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); }
|
||||
|
||||
.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);
|
||||
display: flex; gap: 6px; align-items: baseline;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tag-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-tags {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding: 6px 20px;
|
||||
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.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.load-more:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
||||
</style>
|
||||
199
frontend/src/routes/categories/new/+page.svelte
Normal file
199
frontend/src/routes/categories/new/+page.svelte
Normal file
@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
|
||||
let name = $state('');
|
||||
let notes = $state('');
|
||||
let color = $state('#9592B5');
|
||||
let isPublic = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim() || saving) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
try {
|
||||
await api.post('/categories', {
|
||||
name: name.trim(),
|
||||
notes: notes.trim() || null,
|
||||
color: color.slice(1),
|
||||
is_public: isPublic,
|
||||
});
|
||||
goto('/categories');
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to create category';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Category | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="top-bar">
|
||||
<button class="back-btn" onclick={() => goto('/categories')} 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 Category</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="Category 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="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 category'}
|
||||
</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); }
|
||||
|
||||
.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>
|
||||
@ -502,9 +502,101 @@ export function mockApiPlugin(): Plugin {
|
||||
return json(res, 201, newTag);
|
||||
}
|
||||
|
||||
// GET /categories/{id}/tags
|
||||
const catTagsMatch = path.match(/^\/categories\/([^/]+)\/tags$/);
|
||||
if (method === 'GET' && catTagsMatch) {
|
||||
const catId = catTagsMatch[1];
|
||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
|
||||
const offset = Number(qs.get('offset') ?? 0);
|
||||
const all = MOCK_TAGS.filter((t) => t.category_id === catId);
|
||||
all.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const items = all.slice(offset, offset + limit);
|
||||
return json(res, 200, { items, total: all.length, offset, limit });
|
||||
}
|
||||
|
||||
// GET /categories/{id}
|
||||
const catGetMatch = path.match(/^\/categories\/([^/]+)$/);
|
||||
if (method === 'GET' && catGetMatch) {
|
||||
const cat = MOCK_CATEGORIES.find((c) => c.id === catGetMatch[1]);
|
||||
if (!cat) return json(res, 404, { code: 'not_found', message: 'Category not found' });
|
||||
return json(res, 200, cat);
|
||||
}
|
||||
|
||||
// PATCH /categories/{id}
|
||||
const catPatchMatch = path.match(/^\/categories\/([^/]+)$/);
|
||||
if (method === 'PATCH' && catPatchMatch) {
|
||||
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catPatchMatch[1]);
|
||||
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Category not found' });
|
||||
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
|
||||
Object.assign(MOCK_CATEGORIES[idx], body);
|
||||
// Sync category_name/color on affected tags
|
||||
const cat = MOCK_CATEGORIES[idx];
|
||||
for (const t of MOCK_TAGS) {
|
||||
if (t.category_id === cat.id) {
|
||||
t.category_name = cat.name;
|
||||
t.category_color = cat.color;
|
||||
}
|
||||
}
|
||||
return json(res, 200, MOCK_CATEGORIES[idx]);
|
||||
}
|
||||
|
||||
// DELETE /categories/{id}
|
||||
const catDelMatch = path.match(/^\/categories\/([^/]+)$/);
|
||||
if (method === 'DELETE' && catDelMatch) {
|
||||
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catDelMatch[1]);
|
||||
if (idx >= 0) {
|
||||
const catId = MOCK_CATEGORIES[idx].id;
|
||||
MOCK_CATEGORIES.splice(idx, 1);
|
||||
for (const t of MOCK_TAGS) {
|
||||
if (t.category_id === catId) {
|
||||
t.category_id = null;
|
||||
t.category_name = null;
|
||||
t.category_color = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return noContent(res);
|
||||
}
|
||||
|
||||
// GET /categories
|
||||
if (method === 'GET' && path === '/categories') {
|
||||
return json(res, 200, { items: MOCK_CATEGORIES, total: MOCK_CATEGORIES.length, offset: 0, limit: 50 });
|
||||
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') ?? 50), 500);
|
||||
const offset = Number(qs.get('offset') ?? 0);
|
||||
|
||||
let filtered = search
|
||||
? MOCK_CATEGORIES.filter((c) => c.name.toLowerCase().includes(search))
|
||||
: [...MOCK_CATEGORIES];
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let av: string, bv: string;
|
||||
if (sort === 'color') { av = a.color; bv = b.color; }
|
||||
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 /categories
|
||||
if (method === 'POST' && path === '/categories') {
|
||||
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
|
||||
const newCat = {
|
||||
id: `00000000-0000-7000-8002-${String(Date.now()).slice(-12)}`,
|
||||
name: body.name ?? 'Unnamed',
|
||||
color: body.color ?? '9592B5',
|
||||
notes: body.notes ?? null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
MOCK_CATEGORIES.unshift(newCat);
|
||||
return json(res, 201, newCat);
|
||||
}
|
||||
|
||||
// GET /pools
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user