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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user