From 1931adcd3840adf4fd7edccf7cb748d9f07c77e8 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 23:38:52 +0300 Subject: [PATCH] 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 --- frontend/src/lib/stores/sorting.ts | 7 + frontend/src/routes/categories/+page.svelte | 389 +++++++++++++++++ .../src/routes/categories/[id]/+page.svelte | 390 ++++++++++++++++++ .../src/routes/categories/new/+page.svelte | 199 +++++++++ frontend/vite-mock-plugin.ts | 94 ++++- 5 files changed, 1078 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/categories/+page.svelte create mode 100644 frontend/src/routes/categories/[id]/+page.svelte create mode 100644 frontend/src/routes/categories/new/+page.svelte diff --git a/frontend/src/lib/stores/sorting.ts b/frontend/src/lib/stores/sorting.ts index 41d2d55..f565908 100644 --- a/frontend/src/lib/stores/sorting.ts +++ b/frontend/src/lib/stores/sorting.ts @@ -41,4 +41,11 @@ export const fileSorting = makeSortStore('sort:files', { export const tagSorting = makeSortStore('sort:tags', { sort: 'created', order: 'desc', +}); + +export type CategorySortField = 'name' | 'color' | 'created'; + +export const categorySorting = makeSortStore('sort:categories', { + sort: 'name', + order: 'asc', }); \ No newline at end of file diff --git a/frontend/src/routes/categories/+page.svelte b/frontend/src/routes/categories/+page.svelte new file mode 100644 index 0000000..2be4cec --- /dev/null +++ b/frontend/src/routes/categories/+page.svelte @@ -0,0 +1,389 @@ + + + + Categories | Tanabata + + +
+
+

Categories

+ +
+ + + + + +
+
+ + + +
+ {#if error} + + {/if} + +
+ {#each categories as cat (cat.id)} + + {/each} +
+ + {#if loading} +
+ +
+ {/if} + + {#if hasMore && !loading} + + {/if} + + {#if !loading && categories.length === 0} +
+ {search ? 'No categories match your search.' : 'No categories yet.'} + {#if !search} + Create one + {/if} +
+ {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/categories/[id]/+page.svelte b/frontend/src/routes/categories/[id]/+page.svelte new file mode 100644 index 0000000..9301010 --- /dev/null +++ b/frontend/src/routes/categories/[id]/+page.svelte @@ -0,0 +1,390 @@ + + + + {category?.name ?? 'Category'} | Tanabata + + +
+
+ +

{category?.name ?? 'Category'}

+
+ +
+ {#if loadError} + + {:else if !loaded} +
+ +
+ {:else} + {#if saveError} + + {/if} + +
{ e.preventDefault(); void save(); }}> +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ Public + +
+ +
+ + +
+
+ + +
+

+ Tags + {#if tagsTotal > 0}({tagsTotal}){/if} +

+ + {#if tagsLoading && tags.length === 0} +
+ +
+ {:else if tags.length === 0} +

No tags in this category.

+ {:else} +
+ {#each tags as tag (tag.id)} + goto(`/tags/${tag.id}`)} size="sm" /> + {/each} +
+ + {#if tagsHasMore} + + {/if} + {/if} +
+ {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/categories/new/+page.svelte b/frontend/src/routes/categories/new/+page.svelte new file mode 100644 index 0000000..6fd0527 --- /dev/null +++ b/frontend/src/routes/categories/new/+page.svelte @@ -0,0 +1,199 @@ + + + + New Category | Tanabata + + +
+
+ +

New Category

+
+ +
+ {#if error} + + {/if} + +
{ e.preventDefault(); void submit(); }}> +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ Public + +
+ + +
+
+
+ + \ No newline at end of file diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index 1b2374d..43e9b9a 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -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; + 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; + 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