Add sub-categories to affirmation categories

Add self-referential parent/child relation on AffirmationCategory for
   one-level hierarchy. Update AffirmationForm with two-level category and
   sub-category dropdowns, server actions with find-or-create hierarchy
   logic, AffirmationList with Parent
This commit is contained in:
NeilClack
2026-02-23 10:53:15 -06:00
parent a230a1e36d
commit 3c201f9b66
14 changed files with 573 additions and 64 deletions
+1 -1
View File
@@ -110,7 +110,7 @@
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort(field)}
>
{fieldNameToLabel(field.includes('.') ? (field.split('.').pop() ?? field) : field)}
{fieldNameToLabel(field.includes('.') ? field.split('.')[0] : field)}
<span class="text-xs text-gray-400">{getSortIndicator(field)}</span>
</button>
</th>
@@ -4,6 +4,8 @@
type Category = {
id: string;
name: string;
parentId?: number | null;
children?: Category[];
};
type Frequency = {
@@ -18,6 +20,7 @@
price?: number;
categoryId?: string;
solfeggioFrequencyId?: string;
category?: { parentId?: number | null; parent?: { name: string } };
};
type FormData = {
@@ -27,6 +30,7 @@
price?: number;
categoryId?: string;
categoryName?: string;
subCategoryName?: string;
solfeggioFrequencyId?: string;
};
@@ -48,11 +52,74 @@
submitLabel = 'Save Affirmation'
}: Props = $props();
const currentCategory = $derived(
categories.find((c : Category) => c.id === (form?.categoryId ?? affirmation.categoryId))
);
const topLevelCategories = $derived(categories.filter((c) => !c.parentId));
let categoryInput = $derived(form?.categoryName ?? currentCategory?.name ?? '');
// Determine initial parent category name from form data or existing affirmation
function getInitialCategoryName(): string {
if (form?.categoryName) return form.categoryName;
if (affirmation.category?.parent) return affirmation.category.parent.name;
if (affirmation.categoryId) {
const cat = categories.find((c) => c.id === affirmation.categoryId);
if (cat?.parentId) {
const parent = categories.find((c) => String(c.id) === String(cat.parentId));
return parent?.name ?? '';
}
return cat?.name ?? '';
}
return '';
}
function getInitialSubCategoryName(): string {
if (form?.subCategoryName) return form.subCategoryName;
if (affirmation.categoryId) {
const cat = categories.find((c) => c.id === affirmation.categoryId);
if (cat?.parentId) return cat.name;
}
return '';
}
let categoryInput = $state(getInitialCategoryName());
let showNewCategory = $state(false);
let newCategoryName = $state('');
let subCategoryInput = $state(getInitialSubCategoryName());
let showNewSubCategory = $state(false);
let newSubCategoryName = $state('');
const selectedParent = $derived(topLevelCategories.find((c) => c.name === categoryInput));
const subCategories = $derived(selectedParent?.children ?? []);
function handleCategoryChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
if (value === '__new__') {
showNewCategory = true;
categoryInput = '';
} else {
showNewCategory = false;
newCategoryName = '';
categoryInput = value;
}
subCategoryInput = '';
showNewSubCategory = false;
newSubCategoryName = '';
}
function handleSubCategoryChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
if (value === '__new__') {
showNewSubCategory = true;
subCategoryInput = '';
} else {
showNewSubCategory = false;
newSubCategoryName = '';
subCategoryInput = value;
}
}
const effectiveCategoryName = $derived(showNewCategory ? newCategoryName : categoryInput);
const effectiveSubCategoryName = $derived(
showNewSubCategory ? newSubCategoryName : subCategoryInput
);
</script>
<form method="POST" {action} class="space-y-5">
@@ -60,6 +127,9 @@
<input type="hidden" name="id" value={form?.id ?? affirmation.id} />
{/if}
<input type="hidden" name="categoryName" value={effectiveCategoryName} />
<input type="hidden" name="subCategoryName" value={effectiveSubCategoryName} />
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-700">Name</label>
<input
@@ -102,24 +172,66 @@
</div>
<div>
<label for="categoryName" class="mb-1 block text-sm font-medium text-gray-700">Category</label>
<input
type="text"
id="categoryName"
name="categoryName"
bind:value={categoryInput}
list="categories"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2 outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
<datalist id="categories">
{#each categories as category}
<option value={category.name}>{category.name}</option>
<label for="categorySelect" class="mb-1 block text-sm font-medium text-gray-700">
Category
</label>
<select
id="categorySelect"
onchange={handleCategoryChange}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="" disabled selected={!categoryInput && !showNewCategory}>
Select a category
</option>
{#each topLevelCategories as category}
<option value={category.name} selected={category.name === categoryInput}>
{category.name}
</option>
{/each}
</datalist>
<p class="mt-1 text-sm text-gray-500">Select existing or type to create new</p>
<option value="__new__" selected={showNewCategory}>New...</option>
</select>
{#if showNewCategory}
<input
type="text"
placeholder="New category name"
bind:value={newCategoryName}
required
class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{/if}
</div>
{#if categoryInput || showNewCategory}
<div>
<label for="subCategorySelect" class="mb-1 block text-sm font-medium text-gray-700">
Sub-category
</label>
<select
id="subCategorySelect"
onchange={handleSubCategoryChange}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="" selected={!subCategoryInput && !showNewSubCategory}>None</option>
{#each subCategories as sub}
<option value={sub.name} selected={sub.name === subCategoryInput}>
{sub.name}
</option>
{/each}
<option value="__new__" selected={showNewSubCategory}>New...</option>
</select>
{#if showNewSubCategory}
<input
type="text"
placeholder="New sub-category name"
bind:value={newSubCategoryName}
class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{/if}
</div>
{/if}
<div>
<label for="price" class="mb-1 block text-sm font-medium text-gray-700">Price</label>
<div class="relative">
@@ -145,4 +257,4 @@
>
{submitLabel}
</button>
</form>
</form>
@@ -10,9 +10,16 @@
};
category?: {
name: string;
parent?: { name: string } | null;
};
};
function categoryDisplayName(category?: Affirmation['category']): string {
if (!category) return '';
if (category.parent) return `${category.parent.name} > ${category.name}`;
return category.name;
}
type SortKey = 'name' | 'category' | 'frequency' | 'price';
type SortDirection = 'asc' | 'desc';
@@ -28,7 +35,7 @@
let sortKey = $state<SortKey>('name');
let sortDirection = $state<SortDirection>('asc');
const categories = $derived([...new Set(affirmations.map((a) => a.category?.name).filter(Boolean))].sort());
const categories = $derived([...new Set(affirmations.map((a) => categoryDisplayName(a.category)).filter(Boolean))].sort());
const frequencies = $derived([...new Set(affirmations.map((a) => a.solfeggioFrequency?.name).filter(Boolean))].sort());
const filteredAndSorted = $derived(() => {
@@ -41,14 +48,14 @@
(a) =>
a.name.toLowerCase().includes(searchLower) ||
a.content.toLowerCase().includes(searchLower) ||
a.category?.name.toLowerCase().includes(searchLower) ||
categoryDisplayName(a.category).toLowerCase().includes(searchLower) ||
a.solfeggioFrequency?.name.toLowerCase().includes(searchLower)
);
}
// Category filter
if (categoryFilter) {
result = result.filter((a) => a.category?.name === categoryFilter);
result = result.filter((a) => categoryDisplayName(a.category) === categoryFilter);
}
// Frequency filter
@@ -67,8 +74,8 @@
bVal = b.name.toLowerCase();
break;
case 'category':
aVal = a.category?.name?.toLowerCase() ?? '';
bVal = b.category?.name?.toLowerCase() ?? '';
aVal = categoryDisplayName(a.category).toLowerCase();
bVal = categoryDisplayName(b.category).toLowerCase();
break;
case 'frequency':
aVal = a.solfeggioFrequency?.value ?? 0;
@@ -206,7 +213,7 @@
{#each filteredAndSorted() as affirmation}
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{affirmation.name}</td>
<td class="px-4 py-3 text-gray-600">{affirmation.category?.name ?? '—'}</td>
<td class="px-4 py-3 text-gray-600">{categoryDisplayName(affirmation.category) || '—'}</td>
<td class="px-4 py-3 text-gray-600">{affirmation.solfeggioFrequency?.name ?? '—'}</td>
<td class="px-4 py-3 text-right text-gray-600">${affirmation.price.toFixed(2)}</td>
<td class="px-4 py-3 text-right">