Build user pages

This commit is contained in:
NeilClack
2026-02-25 13:19:26 -06:00
parent 12190d01dc
commit cc51f40591
53 changed files with 5204 additions and 4817 deletions
+4 -5
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
Rhema Wave Technologies User Portal — a SvelteKit web app managing affirmations (audio content tied to solfeggio frequencies), user accounts, license keys, and authentication. Rhema Wave Technologies User Portal — a SvelteKit web app managing proclamations (audio content tied to solfeggio frequencies), user accounts, license keys, and authentication.
## Tech Stack ## Tech Stack
@@ -48,13 +48,12 @@ npm run deploy # Apply pending Prisma migrations
### Key Patterns ### Key Patterns
- **Svelte 5 runes throughout** — use `$props()`, `$state()`, `$derived()`, `$effect()` (not Svelte 4 reactivity). - **Svelte 5 runes throughout** — use `$props()`, `$state()`, `$derived()`, `$effect()` (not Svelte 4 reactivity).
- **SvelteKit form actions** for mutations (create/edit affirmations, registration, account setup). - **SvelteKit form actions** for mutations (create/edit proclamations, registration, account setup).
- **Reusable forms** — `AffirmationForm.svelte` is shared between create and edit via an `action` prop. - **API routes** at `src/routes/api/v1/` (proclamations, license keys).
- **API routes** at `src/routes/api/v1/` (affirmations, license keys).
### Database Schema ### Database Schema
Core models: `User`, `Profile` (1:1 with User), `Address` (belongs to Profile), `Session`, `Account` (OAuth), `UserRole`, `Affirmation`, `SolfeggioFrequency`, `AffirmationCategory`, `LicenseKey`. Schema lives in `prisma/schema.prisma`. Core models: `User`, `Profile` (1:1 with User), `Address` (belongs to Profile), `Session`, `Account` (OAuth), `UserRole`, `Proclamation`, `SolfeggioFrequency`, `ProclamationCategory`, `LicenseKey`. Schema lives in `prisma/schema.prisma`.
## Testing ## Testing
@@ -0,0 +1,60 @@
/*
Warnings:
- You are about to drop the `Affirmation` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `AffirmationCategory` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Affirmation" DROP CONSTRAINT "Affirmation_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "Affirmation" DROP CONSTRAINT "Affirmation_solfeggioFrequencyId_fkey";
-- DropForeignKey
ALTER TABLE "AffirmationCategory" DROP CONSTRAINT "AffirmationCategory_parentId_fkey";
-- DropTable
DROP TABLE "Affirmation";
-- DropTable
DROP TABLE "AffirmationCategory";
-- CreateTable
CREATE TABLE "Proclamation" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT NOT NULL,
"solfeggioFrequencyId" INTEGER NOT NULL,
"categoryId" INTEGER NOT NULL,
"price" DOUBLE PRECISION NOT NULL DEFAULT 0.99,
CONSTRAINT "Proclamation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProclamationCategory" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"parentId" INTEGER,
CONSTRAINT "ProclamationCategory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Proclamation_name_key" ON "Proclamation"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Proclamation_content_key" ON "Proclamation"("content");
-- CreateIndex
CREATE UNIQUE INDEX "ProclamationCategory_name_key" ON "ProclamationCategory"("name");
-- AddForeignKey
ALTER TABLE "Proclamation" ADD CONSTRAINT "Proclamation_solfeggioFrequencyId_fkey" FOREIGN KEY ("solfeggioFrequencyId") REFERENCES "SolfeggioFrequency"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Proclamation" ADD CONSTRAINT "Proclamation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ProclamationCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProclamationCategory" ADD CONSTRAINT "ProclamationCategory_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ProclamationCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+18 -18
View File
@@ -109,31 +109,31 @@ model Verification {
@@map("verification") @@map("verification")
} }
model Affirmation { model Proclamation {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
content String @unique content String @unique
solfeggioFrequencyId Int solfeggioFrequencyId Int
solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id]) solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])
categoryId Int categoryId Int
category AffirmationCategory @relation(fields: [categoryId], references: [id]) category ProclamationCategory @relation(fields: [categoryId], references: [id])
price Float @default(0.99) price Float @default(0.99)
} }
model SolfeggioFrequency { model SolfeggioFrequency {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
value Float @unique value Float @unique
affirmations Affirmation[] proclamations Proclamation[]
} }
model AffirmationCategory { model ProclamationCategory {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
parentId Int? parentId Int?
parent AffirmationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id]) parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children AffirmationCategory[] @relation("CategoryHierarchy") children ProclamationCategory[] @relation("CategoryHierarchy")
affirmations Affirmation[] proclamations Proclamation[]
} }
model LicenseKey { model LicenseKey {
@@ -1,260 +0,0 @@
<script lang="ts">
import TipTap from '$lib/components/tiptap/TipTap.svelte';
type Category = {
id: string;
name: string;
parentId?: number | null;
children?: Category[];
};
type Frequency = {
id: string;
name: string;
};
type Affirmation = {
id?: string;
name?: string;
content?: string;
price?: number;
categoryId?: string;
solfeggioFrequencyId?: string;
category?: { parentId?: number | null; parent?: { name: string } };
};
type FormData = {
id?: string;
name?: string;
content?: string;
price?: number;
categoryId?: string;
categoryName?: string;
subCategoryName?: string;
solfeggioFrequencyId?: string;
};
type Props = {
affirmation?: Affirmation;
categories: Category[];
frequencies: Frequency[];
form?: FormData;
action: string;
submitLabel?: string;
};
let {
affirmation = {},
categories,
frequencies,
form,
action,
submitLabel = 'Save Affirmation'
}: Props = $props();
const topLevelCategories = $derived(categories.filter((c) => !c.parentId));
// 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">
{#if affirmation.id || form?.id}
<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
type="text"
id="name"
name="name"
value={form?.name ?? affirmation.name ?? ''}
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"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700" for="content">Content</label>
<TipTap initialContent={form?.content ?? affirmation.content ?? ''} name="content" />
</div>
<div>
<label for="solfeggioFrequencyId" class="mb-1 block text-sm font-medium text-gray-700">
Solfeggio Frequency
</label>
<select
id="solfeggioFrequencyId"
name="solfeggioFrequencyId"
required
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={!affirmation.solfeggioFrequencyId && !form?.solfeggioFrequencyId}>
Select a frequency
</option>
{#each frequencies as frequency}
<option
value={frequency.id}
selected={frequency.id === (form?.solfeggioFrequencyId ?? affirmation.solfeggioFrequencyId)}
>
{frequency.name}
</option>
{/each}
</select>
</div>
<div>
<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}
<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">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
id="price"
name="price"
value={form?.price ?? affirmation.price ?? ''}
defaultValue={0.00}
min="0"
step="0.01"
placeholder="0.00"
required
class="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-7 outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-4 focus:ring-blue-200"
>
{submitLabel}
</button>
</form>
@@ -1,62 +0,0 @@
<script lang="ts">
type Frequency = {
id?: number;
name?: string;
value?: number;
};
type FormData = {
id?: string;
name?: string;
value?: string;
};
type Props = {
frequency?: Frequency;
form?: FormData;
action: string;
submitLabel?: string;
};
let { frequency = {}, form, action, submitLabel = 'Save Frequency' }: Props = $props();
</script>
<form method="POST" {action} class="space-y-5">
{#if frequency.id || form?.id}
<input type="hidden" name="id" value={form?.id ?? frequency.id} />
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="name"
name="name"
value={form?.name ?? frequency.name ?? ''}
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"
/>
</div>
<div>
<label for="value" class="mb-1 block text-sm font-medium text-gray-700">Frequency (Hz)</label>
<input
type="number"
id="value"
name="value"
value={form?.value ?? frequency.value ?? ''}
min="0"
step="0.01"
placeholder="e.g. 396"
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"
/>
</div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-4 focus:ring-blue-200"
>
{submitLabel}
</button>
</form>
@@ -1,242 +0,0 @@
<script lang="ts">
type Affirmation = {
id: string;
name: string;
content: string;
price: number;
solfeggioFrequency?: {
name: string;
value: number;
};
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';
type Props = {
affirmations: Affirmation[];
};
let { affirmations }: Props = $props();
let search = $state('');
let categoryFilter = $state('');
let frequencyFilter = $state('');
let sortKey = $state<SortKey>('name');
let sortDirection = $state<SortDirection>('asc');
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(() => {
let result = affirmations;
// Search filter
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(
(a) =>
a.name.toLowerCase().includes(searchLower) ||
a.content.toLowerCase().includes(searchLower) ||
categoryDisplayName(a.category).toLowerCase().includes(searchLower) ||
a.solfeggioFrequency?.name.toLowerCase().includes(searchLower)
);
}
// Category filter
if (categoryFilter) {
result = result.filter((a) => categoryDisplayName(a.category) === categoryFilter);
}
// Frequency filter
if (frequencyFilter) {
result = result.filter((a) => a.solfeggioFrequency?.name === frequencyFilter);
}
// Sort
result = [...result].sort((a, b) => {
let aVal: string | number;
let bVal: string | number;
switch (sortKey) {
case 'name':
aVal = a.name.toLowerCase();
bVal = b.name.toLowerCase();
break;
case 'category':
aVal = categoryDisplayName(a.category).toLowerCase();
bVal = categoryDisplayName(b.category).toLowerCase();
break;
case 'frequency':
aVal = a.solfeggioFrequency?.value ?? 0;
bVal = b.solfeggioFrequency?.value ?? 0;
break;
case 'price':
aVal = a.price;
bVal = b.price;
break;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return result;
});
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = 'asc';
}
}
function getSortIndicator(key: SortKey) {
if (sortKey !== key) return '↕';
return sortDirection === 'asc' ? '↑' : '↓';
}
function clearFilters() {
search = '';
categoryFilter = '';
frequencyFilter = '';
}
const hasActiveFilters = $derived(search || categoryFilter || frequencyFilter);
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search affirmations..."
bind:value={search}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
<select
bind:value={categoryFilter}
class="min-w-40 rounded-lg border border-gray-300 bg-white px-3 py-2 pr-8 text-sm outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="">All Categories</option>
{#each categories as category}
<option value={category}>{category}</option>
{/each}
</select>
<select
bind:value={frequencyFilter}
class="min-w-40 rounded-lg border border-gray-300 bg-white px-3 py-2 pr-8 text-sm outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="">All Frequencies</option>
{#each frequencies as frequency}
<option value={frequency}>{frequency}</option>
{/each}
</select>
{#if hasActiveFilters}
<button
type="button"
onclick={clearFilters}
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
Clear filters
</button>
{/if}
<span class="ml-auto text-sm text-gray-500">
{filteredAndSorted().length} of {affirmations.length}
</span>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-left text-sm">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
<th class="px-4 py-3 font-medium text-gray-700">
<button
type="button"
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('name')}
>
Name
<span class="text-xs text-gray-400">{getSortIndicator('name')}</span>
</button>
</th>
<th class="px-4 py-3 font-medium text-gray-700">
<button
type="button"
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('category')}
>
Category
<span class="text-xs text-gray-400">{getSortIndicator('category')}</span>
</button>
</th>
<th class="px-4 py-3 font-medium text-gray-700">
<button
type="button"
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('frequency')}
>
Frequency
<span class="text-xs text-gray-400">{getSortIndicator('frequency')}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium text-gray-700">
<button
type="button"
class="ml-auto flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('price')}
>
Price
<span class="text-xs text-gray-400">{getSortIndicator('price')}</span>
</button>
</th>
<th class="px-4 py-3 font-medium text-gray-700"></th>
</tr>
</thead>
<tbody>
{#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">{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">
<a
href="/affirmations/{affirmation.id}"
class="text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-500">
{#if hasActiveFilters}
No affirmations match your filters
{:else}
No affirmations yet
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -1,176 +0,0 @@
<script lang="ts">
type Frequency = {
id: number;
name: string;
value: number;
_count?: {
affirmations: number;
};
};
type SortKey = 'name' | 'value' | 'affirmations';
type SortDirection = 'asc' | 'desc';
type Props = {
frequencies: Frequency[];
};
let { frequencies }: Props = $props();
let search = $state('');
let sortKey = $state<SortKey>('name');
let sortDirection = $state<SortDirection>('asc');
const filteredAndSorted = $derived(() => {
let result = frequencies;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(
(f) =>
f.name.toLowerCase().includes(searchLower) ||
f.value.toString().includes(searchLower)
);
}
result = [...result].sort((a, b) => {
let aVal: string | number;
let bVal: string | number;
switch (sortKey) {
case 'name':
aVal = a.name.toLowerCase();
bVal = b.name.toLowerCase();
break;
case 'value':
aVal = a.value;
bVal = b.value;
break;
case 'affirmations':
aVal = a._count?.affirmations ?? 0;
bVal = b._count?.affirmations ?? 0;
break;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return result;
});
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = 'asc';
}
}
function getSortIndicator(key: SortKey) {
if (sortKey !== key) return '\u21D5';
return sortDirection === 'asc' ? '\u2191' : '\u2193';
}
function clearFilters() {
search = '';
}
const hasActiveFilters = $derived(!!search);
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search frequencies..."
bind:value={search}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{#if hasActiveFilters}
<button
type="button"
onclick={clearFilters}
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
Clear filters
</button>
{/if}
<span class="ml-auto text-sm text-gray-500">
{filteredAndSorted().length} of {frequencies.length}
</span>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-left text-sm">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
<th class="px-4 py-3 font-medium text-gray-700">
<button
type="button"
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('name')}
>
Name
<span class="text-xs text-gray-400">{getSortIndicator('name')}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium text-gray-700">
<button
type="button"
class="ml-auto flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('value')}
>
Frequency (Hz)
<span class="text-xs text-gray-400">{getSortIndicator('value')}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium text-gray-700">
<button
type="button"
class="ml-auto flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort('affirmations')}
>
Affirmations
<span class="text-xs text-gray-400">{getSortIndicator('affirmations')}</span>
</button>
</th>
<th class="px-4 py-3 font-medium text-gray-700"></th>
</tr>
</thead>
<tbody>
{#each filteredAndSorted() as frequency}
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{frequency.name}</td>
<td class="px-4 py-3 text-right text-gray-600">{frequency.value} Hz</td>
<td class="px-4 py-3 text-right text-gray-600">
{frequency._count?.affirmations ?? 0}
</td>
<td class="px-4 py-3 text-right">
<a
href="/frequencies/{frequency.id}"
class="text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
</td>
</tr>
{:else}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-gray-500">
{#if hasActiveFilters}
No frequencies match your filters
{:else}
No frequencies yet
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
+8 -1
View File
@@ -1,15 +1,22 @@
<script lang="ts"> <script lang="ts">
import { authClient } from '$lib/auth-client'; import { authClient } from '$lib/auth-client';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
let { userRoleId = null }: { userRoleId: number | null } = $props();
const session = authClient.useSession(); const session = authClient.useSession();
let isAdmin = $derived(canAccessAdmin(userRoleId));
</script> </script>
<nav class="flex w-full justify-between px-8 py-6"> <nav class="flex w-full justify-between px-8 py-6">
<span><a href="/">Rhema Wave Technologies</a></span> <span><a href="/">Rhema Wave Technologies</a></span>
{#if $session.data} {#if $session.data}
<div> <div>
{#if isAdmin}
<a href="/admin" class="mx-2">Admin Panel</a>
{/if}
<a href="/dashboard" class="mx-2">Dashboard</a> <a href="/dashboard" class="mx-2">Dashboard</a>
<button <button
on:click={async () => { onclick={async () => {
authClient.signOut(); authClient.signOut();
}}>Log Out</button }}>Log Out</button
> >
+1 -1
View File
@@ -28,6 +28,6 @@ function getPrisma(): PrismaClient {
export const prisma = new Proxy({} as PrismaClient, { export const prisma = new Proxy({} as PrismaClient, {
get(_, prop) { get(_, prop) {
return (getPrisma() as Record<string | symbol, unknown>)[prop]; return (getPrisma() as unknown as Record<string | symbol, unknown>)[prop];
} }
}); });
+4 -4
View File
@@ -53,20 +53,20 @@ export type UserRole = Prisma.UserRoleModel
*/ */
export type Verification = Prisma.VerificationModel export type Verification = Prisma.VerificationModel
/** /**
* Model Affirmation * Model Proclamation
* *
*/ */
export type Affirmation = Prisma.AffirmationModel export type Proclamation = Prisma.ProclamationModel
/** /**
* Model SolfeggioFrequency * Model SolfeggioFrequency
* *
*/ */
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
/** /**
* Model AffirmationCategory * Model ProclamationCategory
* *
*/ */
export type AffirmationCategory = Prisma.AffirmationCategoryModel export type ProclamationCategory = Prisma.ProclamationCategoryModel
/** /**
* Model LicenseKey * Model LicenseKey
* *
+4 -4
View File
@@ -75,20 +75,20 @@ export type UserRole = Prisma.UserRoleModel
*/ */
export type Verification = Prisma.VerificationModel export type Verification = Prisma.VerificationModel
/** /**
* Model Affirmation * Model Proclamation
* *
*/ */
export type Affirmation = Prisma.AffirmationModel export type Proclamation = Prisma.ProclamationModel
/** /**
* Model SolfeggioFrequency * Model SolfeggioFrequency
* *
*/ */
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
/** /**
* Model AffirmationCategory * Model ProclamationCategory
* *
*/ */
export type AffirmationCategory = Prisma.AffirmationCategoryModel export type ProclamationCategory = Prisma.ProclamationCategoryModel
/** /**
* Model LicenseKey * Model LicenseKey
* *
File diff suppressed because one or more lines are too long
@@ -391,9 +391,9 @@ export const ModelName = {
Account: 'Account', Account: 'Account',
UserRole: 'UserRole', UserRole: 'UserRole',
Verification: 'Verification', Verification: 'Verification',
Affirmation: 'Affirmation', Proclamation: 'Proclamation',
SolfeggioFrequency: 'SolfeggioFrequency', SolfeggioFrequency: 'SolfeggioFrequency',
AffirmationCategory: 'AffirmationCategory', ProclamationCategory: 'ProclamationCategory',
LicenseKey: 'LicenseKey' LicenseKey: 'LicenseKey'
} as const } as const
@@ -410,7 +410,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "affirmation" | "solfeggioFrequency" | "affirmationCategory" | "licenseKey" modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -932,77 +932,77 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
Affirmation: { Proclamation: {
payload: Prisma.$AffirmationPayload<ExtArgs> payload: Prisma.$ProclamationPayload<ExtArgs>
fields: Prisma.AffirmationFieldRefs fields: Prisma.ProclamationFieldRefs
operations: { operations: {
findUnique: { findUnique: {
args: Prisma.AffirmationFindUniqueArgs<ExtArgs> args: Prisma.ProclamationFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> | null result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload> | null
} }
findUniqueOrThrow: { findUniqueOrThrow: {
args: Prisma.AffirmationFindUniqueOrThrowArgs<ExtArgs> args: Prisma.ProclamationFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>
} }
findFirst: { findFirst: {
args: Prisma.AffirmationFindFirstArgs<ExtArgs> args: Prisma.ProclamationFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> | null result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload> | null
} }
findFirstOrThrow: { findFirstOrThrow: {
args: Prisma.AffirmationFindFirstOrThrowArgs<ExtArgs> args: Prisma.ProclamationFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>
} }
findMany: { findMany: {
args: Prisma.AffirmationFindManyArgs<ExtArgs> args: Prisma.ProclamationFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>[]
} }
create: { create: {
args: Prisma.AffirmationCreateArgs<ExtArgs> args: Prisma.ProclamationCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>
} }
createMany: { createMany: {
args: Prisma.AffirmationCreateManyArgs<ExtArgs> args: Prisma.ProclamationCreateManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
createManyAndReturn: { createManyAndReturn: {
args: Prisma.AffirmationCreateManyAndReturnArgs<ExtArgs> args: Prisma.ProclamationCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>[]
} }
delete: { delete: {
args: Prisma.AffirmationDeleteArgs<ExtArgs> args: Prisma.ProclamationDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>
} }
update: { update: {
args: Prisma.AffirmationUpdateArgs<ExtArgs> args: Prisma.ProclamationUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>
} }
deleteMany: { deleteMany: {
args: Prisma.AffirmationDeleteManyArgs<ExtArgs> args: Prisma.ProclamationDeleteManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
updateMany: { updateMany: {
args: Prisma.AffirmationUpdateManyArgs<ExtArgs> args: Prisma.ProclamationUpdateManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
updateManyAndReturn: { updateManyAndReturn: {
args: Prisma.AffirmationUpdateManyAndReturnArgs<ExtArgs> args: Prisma.ProclamationUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>[]
} }
upsert: { upsert: {
args: Prisma.AffirmationUpsertArgs<ExtArgs> args: Prisma.ProclamationUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationPayload>
} }
aggregate: { aggregate: {
args: Prisma.AffirmationAggregateArgs<ExtArgs> args: Prisma.ProclamationAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateAffirmation> result: runtime.Types.Utils.Optional<Prisma.AggregateProclamation>
} }
groupBy: { groupBy: {
args: Prisma.AffirmationGroupByArgs<ExtArgs> args: Prisma.ProclamationGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AffirmationGroupByOutputType>[] result: runtime.Types.Utils.Optional<Prisma.ProclamationGroupByOutputType>[]
} }
count: { count: {
args: Prisma.AffirmationCountArgs<ExtArgs> args: Prisma.ProclamationCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AffirmationCountAggregateOutputType> | number result: runtime.Types.Utils.Optional<Prisma.ProclamationCountAggregateOutputType> | number
} }
} }
} }
@@ -1080,77 +1080,77 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
AffirmationCategory: { ProclamationCategory: {
payload: Prisma.$AffirmationCategoryPayload<ExtArgs> payload: Prisma.$ProclamationCategoryPayload<ExtArgs>
fields: Prisma.AffirmationCategoryFieldRefs fields: Prisma.ProclamationCategoryFieldRefs
operations: { operations: {
findUnique: { findUnique: {
args: Prisma.AffirmationCategoryFindUniqueArgs<ExtArgs> args: Prisma.ProclamationCategoryFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> | null result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload> | null
} }
findUniqueOrThrow: { findUniqueOrThrow: {
args: Prisma.AffirmationCategoryFindUniqueOrThrowArgs<ExtArgs> args: Prisma.ProclamationCategoryFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>
} }
findFirst: { findFirst: {
args: Prisma.AffirmationCategoryFindFirstArgs<ExtArgs> args: Prisma.ProclamationCategoryFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> | null result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload> | null
} }
findFirstOrThrow: { findFirstOrThrow: {
args: Prisma.AffirmationCategoryFindFirstOrThrowArgs<ExtArgs> args: Prisma.ProclamationCategoryFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>
} }
findMany: { findMany: {
args: Prisma.AffirmationCategoryFindManyArgs<ExtArgs> args: Prisma.ProclamationCategoryFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>[]
} }
create: { create: {
args: Prisma.AffirmationCategoryCreateArgs<ExtArgs> args: Prisma.ProclamationCategoryCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>
} }
createMany: { createMany: {
args: Prisma.AffirmationCategoryCreateManyArgs<ExtArgs> args: Prisma.ProclamationCategoryCreateManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
createManyAndReturn: { createManyAndReturn: {
args: Prisma.AffirmationCategoryCreateManyAndReturnArgs<ExtArgs> args: Prisma.ProclamationCategoryCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>[]
} }
delete: { delete: {
args: Prisma.AffirmationCategoryDeleteArgs<ExtArgs> args: Prisma.ProclamationCategoryDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>
} }
update: { update: {
args: Prisma.AffirmationCategoryUpdateArgs<ExtArgs> args: Prisma.ProclamationCategoryUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>
} }
deleteMany: { deleteMany: {
args: Prisma.AffirmationCategoryDeleteManyArgs<ExtArgs> args: Prisma.ProclamationCategoryDeleteManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
updateMany: { updateMany: {
args: Prisma.AffirmationCategoryUpdateManyArgs<ExtArgs> args: Prisma.ProclamationCategoryUpdateManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
updateManyAndReturn: { updateManyAndReturn: {
args: Prisma.AffirmationCategoryUpdateManyAndReturnArgs<ExtArgs> args: Prisma.ProclamationCategoryUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>[]
} }
upsert: { upsert: {
args: Prisma.AffirmationCategoryUpsertArgs<ExtArgs> args: Prisma.ProclamationCategoryUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AffirmationCategoryPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategoryPayload>
} }
aggregate: { aggregate: {
args: Prisma.AffirmationCategoryAggregateArgs<ExtArgs> args: Prisma.ProclamationCategoryAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateAffirmationCategory> result: runtime.Types.Utils.Optional<Prisma.AggregateProclamationCategory>
} }
groupBy: { groupBy: {
args: Prisma.AffirmationCategoryGroupByArgs<ExtArgs> args: Prisma.ProclamationCategoryGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AffirmationCategoryGroupByOutputType>[] result: runtime.Types.Utils.Optional<Prisma.ProclamationCategoryGroupByOutputType>[]
} }
count: { count: {
args: Prisma.AffirmationCategoryCountArgs<ExtArgs> args: Prisma.ProclamationCategoryCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AffirmationCategoryCountAggregateOutputType> | number result: runtime.Types.Utils.Optional<Prisma.ProclamationCategoryCountAggregateOutputType> | number
} }
} }
} }
@@ -1358,7 +1358,7 @@ export const VerificationScalarFieldEnum = {
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum] export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
export const AffirmationScalarFieldEnum = { export const ProclamationScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
content: 'content', content: 'content',
@@ -1367,7 +1367,7 @@ export const AffirmationScalarFieldEnum = {
price: 'price' price: 'price'
} as const } as const
export type AffirmationScalarFieldEnum = (typeof AffirmationScalarFieldEnum)[keyof typeof AffirmationScalarFieldEnum] export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
export const SolfeggioFrequencyScalarFieldEnum = { export const SolfeggioFrequencyScalarFieldEnum = {
@@ -1379,13 +1379,13 @@ export const SolfeggioFrequencyScalarFieldEnum = {
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum] export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
export const AffirmationCategoryScalarFieldEnum = { export const ProclamationCategoryScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
parentId: 'parentId' parentId: 'parentId'
} as const } as const
export type AffirmationCategoryScalarFieldEnum = (typeof AffirmationCategoryScalarFieldEnum)[keyof typeof AffirmationCategoryScalarFieldEnum] export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
export const LicenseKeyScalarFieldEnum = { export const LicenseKeyScalarFieldEnum = {
@@ -1593,9 +1593,9 @@ export type GlobalOmitConfig = {
account?: Prisma.AccountOmit account?: Prisma.AccountOmit
userRole?: Prisma.UserRoleOmit userRole?: Prisma.UserRoleOmit
verification?: Prisma.VerificationOmit verification?: Prisma.VerificationOmit
affirmation?: Prisma.AffirmationOmit proclamation?: Prisma.ProclamationOmit
solfeggioFrequency?: Prisma.SolfeggioFrequencyOmit solfeggioFrequency?: Prisma.SolfeggioFrequencyOmit
affirmationCategory?: Prisma.AffirmationCategoryOmit proclamationCategory?: Prisma.ProclamationCategoryOmit
licenseKey?: Prisma.LicenseKeyOmit licenseKey?: Prisma.LicenseKeyOmit
} }
@@ -58,9 +58,9 @@ export const ModelName = {
Account: 'Account', Account: 'Account',
UserRole: 'UserRole', UserRole: 'UserRole',
Verification: 'Verification', Verification: 'Verification',
Affirmation: 'Affirmation', Proclamation: 'Proclamation',
SolfeggioFrequency: 'SolfeggioFrequency', SolfeggioFrequency: 'SolfeggioFrequency',
AffirmationCategory: 'AffirmationCategory', ProclamationCategory: 'ProclamationCategory',
LicenseKey: 'LicenseKey' LicenseKey: 'LicenseKey'
} as const } as const
@@ -171,7 +171,7 @@ export const VerificationScalarFieldEnum = {
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum] export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
export const AffirmationScalarFieldEnum = { export const ProclamationScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
content: 'content', content: 'content',
@@ -180,7 +180,7 @@ export const AffirmationScalarFieldEnum = {
price: 'price' price: 'price'
} as const } as const
export type AffirmationScalarFieldEnum = (typeof AffirmationScalarFieldEnum)[keyof typeof AffirmationScalarFieldEnum] export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
export const SolfeggioFrequencyScalarFieldEnum = { export const SolfeggioFrequencyScalarFieldEnum = {
@@ -192,13 +192,13 @@ export const SolfeggioFrequencyScalarFieldEnum = {
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum] export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
export const AffirmationCategoryScalarFieldEnum = { export const ProclamationCategoryScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
parentId: 'parentId' parentId: 'parentId'
} as const } as const
export type AffirmationCategoryScalarFieldEnum = (typeof AffirmationCategoryScalarFieldEnum)[keyof typeof AffirmationCategoryScalarFieldEnum] export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
export const LicenseKeyScalarFieldEnum = { export const LicenseKeyScalarFieldEnum = {
+2 -2
View File
@@ -15,8 +15,8 @@ export type * from './models/Session.ts'
export type * from './models/Account.ts' export type * from './models/Account.ts'
export type * from './models/UserRole.ts' export type * from './models/UserRole.ts'
export type * from './models/Verification.ts' export type * from './models/Verification.ts'
export type * from './models/Affirmation.ts' export type * from './models/Proclamation.ts'
export type * from './models/SolfeggioFrequency.ts' export type * from './models/SolfeggioFrequency.ts'
export type * from './models/AffirmationCategory.ts' export type * from './models/ProclamationCategory.ts'
export type * from './models/LicenseKey.ts' export type * from './models/LicenseKey.ts'
export type * from './commonInputTypes.ts' export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -204,14 +204,14 @@ export type SolfeggioFrequencyWhereInput = {
id?: Prisma.IntFilter<"SolfeggioFrequency"> | number id?: Prisma.IntFilter<"SolfeggioFrequency"> | number
name?: Prisma.StringFilter<"SolfeggioFrequency"> | string name?: Prisma.StringFilter<"SolfeggioFrequency"> | string
value?: Prisma.FloatFilter<"SolfeggioFrequency"> | number value?: Prisma.FloatFilter<"SolfeggioFrequency"> | number
affirmations?: Prisma.AffirmationListRelationFilter proclamations?: Prisma.ProclamationListRelationFilter
} }
export type SolfeggioFrequencyOrderByWithRelationInput = { export type SolfeggioFrequencyOrderByWithRelationInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
name?: Prisma.SortOrder name?: Prisma.SortOrder
value?: Prisma.SortOrder value?: Prisma.SortOrder
affirmations?: Prisma.AffirmationOrderByRelationAggregateInput proclamations?: Prisma.ProclamationOrderByRelationAggregateInput
} }
export type SolfeggioFrequencyWhereUniqueInput = Prisma.AtLeast<{ export type SolfeggioFrequencyWhereUniqueInput = Prisma.AtLeast<{
@@ -221,7 +221,7 @@ export type SolfeggioFrequencyWhereUniqueInput = Prisma.AtLeast<{
AND?: Prisma.SolfeggioFrequencyWhereInput | Prisma.SolfeggioFrequencyWhereInput[] AND?: Prisma.SolfeggioFrequencyWhereInput | Prisma.SolfeggioFrequencyWhereInput[]
OR?: Prisma.SolfeggioFrequencyWhereInput[] OR?: Prisma.SolfeggioFrequencyWhereInput[]
NOT?: Prisma.SolfeggioFrequencyWhereInput | Prisma.SolfeggioFrequencyWhereInput[] NOT?: Prisma.SolfeggioFrequencyWhereInput | Prisma.SolfeggioFrequencyWhereInput[]
affirmations?: Prisma.AffirmationListRelationFilter proclamations?: Prisma.ProclamationListRelationFilter
}, "id" | "name" | "value"> }, "id" | "name" | "value">
export type SolfeggioFrequencyOrderByWithAggregationInput = { export type SolfeggioFrequencyOrderByWithAggregationInput = {
@@ -247,27 +247,27 @@ export type SolfeggioFrequencyScalarWhereWithAggregatesInput = {
export type SolfeggioFrequencyCreateInput = { export type SolfeggioFrequencyCreateInput = {
name: string name: string
value: number value: number
affirmations?: Prisma.AffirmationCreateNestedManyWithoutSolfeggioFrequencyInput proclamations?: Prisma.ProclamationCreateNestedManyWithoutSolfeggioFrequencyInput
} }
export type SolfeggioFrequencyUncheckedCreateInput = { export type SolfeggioFrequencyUncheckedCreateInput = {
id?: number id?: number
name: string name: string
value: number value: number
affirmations?: Prisma.AffirmationUncheckedCreateNestedManyWithoutSolfeggioFrequencyInput proclamations?: Prisma.ProclamationUncheckedCreateNestedManyWithoutSolfeggioFrequencyInput
} }
export type SolfeggioFrequencyUpdateInput = { export type SolfeggioFrequencyUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
value?: Prisma.FloatFieldUpdateOperationsInput | number value?: Prisma.FloatFieldUpdateOperationsInput | number
affirmations?: Prisma.AffirmationUpdateManyWithoutSolfeggioFrequencyNestedInput proclamations?: Prisma.ProclamationUpdateManyWithoutSolfeggioFrequencyNestedInput
} }
export type SolfeggioFrequencyUncheckedUpdateInput = { export type SolfeggioFrequencyUncheckedUpdateInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
value?: Prisma.FloatFieldUpdateOperationsInput | number value?: Prisma.FloatFieldUpdateOperationsInput | number
affirmations?: Prisma.AffirmationUncheckedUpdateManyWithoutSolfeggioFrequencyNestedInput proclamations?: Prisma.ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyNestedInput
} }
export type SolfeggioFrequencyCreateManyInput = { export type SolfeggioFrequencyCreateManyInput = {
@@ -320,53 +320,53 @@ export type SolfeggioFrequencySumOrderByAggregateInput = {
value?: Prisma.SortOrder value?: Prisma.SortOrder
} }
export type SolfeggioFrequencyCreateNestedOneWithoutAffirmationsInput = { export type SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput = {
create?: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutAffirmationsInput> create?: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutProclamationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutProclamationsInput>
connectOrCreate?: Prisma.SolfeggioFrequencyCreateOrConnectWithoutAffirmationsInput connectOrCreate?: Prisma.SolfeggioFrequencyCreateOrConnectWithoutProclamationsInput
connect?: Prisma.SolfeggioFrequencyWhereUniqueInput connect?: Prisma.SolfeggioFrequencyWhereUniqueInput
} }
export type SolfeggioFrequencyUpdateOneRequiredWithoutAffirmationsNestedInput = { export type SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput = {
create?: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutAffirmationsInput> create?: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutProclamationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutProclamationsInput>
connectOrCreate?: Prisma.SolfeggioFrequencyCreateOrConnectWithoutAffirmationsInput connectOrCreate?: Prisma.SolfeggioFrequencyCreateOrConnectWithoutProclamationsInput
upsert?: Prisma.SolfeggioFrequencyUpsertWithoutAffirmationsInput upsert?: Prisma.SolfeggioFrequencyUpsertWithoutProclamationsInput
connect?: Prisma.SolfeggioFrequencyWhereUniqueInput connect?: Prisma.SolfeggioFrequencyWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.SolfeggioFrequencyUpdateToOneWithWhereWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUpdateWithoutAffirmationsInput>, Prisma.SolfeggioFrequencyUncheckedUpdateWithoutAffirmationsInput> update?: Prisma.XOR<Prisma.XOR<Prisma.SolfeggioFrequencyUpdateToOneWithWhereWithoutProclamationsInput, Prisma.SolfeggioFrequencyUpdateWithoutProclamationsInput>, Prisma.SolfeggioFrequencyUncheckedUpdateWithoutProclamationsInput>
} }
export type SolfeggioFrequencyCreateWithoutAffirmationsInput = { export type SolfeggioFrequencyCreateWithoutProclamationsInput = {
name: string name: string
value: number value: number
} }
export type SolfeggioFrequencyUncheckedCreateWithoutAffirmationsInput = { export type SolfeggioFrequencyUncheckedCreateWithoutProclamationsInput = {
id?: number id?: number
name: string name: string
value: number value: number
} }
export type SolfeggioFrequencyCreateOrConnectWithoutAffirmationsInput = { export type SolfeggioFrequencyCreateOrConnectWithoutProclamationsInput = {
where: Prisma.SolfeggioFrequencyWhereUniqueInput where: Prisma.SolfeggioFrequencyWhereUniqueInput
create: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutAffirmationsInput> create: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutProclamationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutProclamationsInput>
} }
export type SolfeggioFrequencyUpsertWithoutAffirmationsInput = { export type SolfeggioFrequencyUpsertWithoutProclamationsInput = {
update: Prisma.XOR<Prisma.SolfeggioFrequencyUpdateWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUncheckedUpdateWithoutAffirmationsInput> update: Prisma.XOR<Prisma.SolfeggioFrequencyUpdateWithoutProclamationsInput, Prisma.SolfeggioFrequencyUncheckedUpdateWithoutProclamationsInput>
create: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutAffirmationsInput> create: Prisma.XOR<Prisma.SolfeggioFrequencyCreateWithoutProclamationsInput, Prisma.SolfeggioFrequencyUncheckedCreateWithoutProclamationsInput>
where?: Prisma.SolfeggioFrequencyWhereInput where?: Prisma.SolfeggioFrequencyWhereInput
} }
export type SolfeggioFrequencyUpdateToOneWithWhereWithoutAffirmationsInput = { export type SolfeggioFrequencyUpdateToOneWithWhereWithoutProclamationsInput = {
where?: Prisma.SolfeggioFrequencyWhereInput where?: Prisma.SolfeggioFrequencyWhereInput
data: Prisma.XOR<Prisma.SolfeggioFrequencyUpdateWithoutAffirmationsInput, Prisma.SolfeggioFrequencyUncheckedUpdateWithoutAffirmationsInput> data: Prisma.XOR<Prisma.SolfeggioFrequencyUpdateWithoutProclamationsInput, Prisma.SolfeggioFrequencyUncheckedUpdateWithoutProclamationsInput>
} }
export type SolfeggioFrequencyUpdateWithoutAffirmationsInput = { export type SolfeggioFrequencyUpdateWithoutProclamationsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
value?: Prisma.FloatFieldUpdateOperationsInput | number value?: Prisma.FloatFieldUpdateOperationsInput | number
} }
export type SolfeggioFrequencyUncheckedUpdateWithoutAffirmationsInput = { export type SolfeggioFrequencyUncheckedUpdateWithoutProclamationsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
value?: Prisma.FloatFieldUpdateOperationsInput | number value?: Prisma.FloatFieldUpdateOperationsInput | number
@@ -378,11 +378,11 @@ export type SolfeggioFrequencyUncheckedUpdateWithoutAffirmationsInput = {
*/ */
export type SolfeggioFrequencyCountOutputType = { export type SolfeggioFrequencyCountOutputType = {
affirmations: number proclamations: number
} }
export type SolfeggioFrequencyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type SolfeggioFrequencyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
affirmations?: boolean | SolfeggioFrequencyCountOutputTypeCountAffirmationsArgs proclamations?: boolean | SolfeggioFrequencyCountOutputTypeCountProclamationsArgs
} }
/** /**
@@ -398,8 +398,8 @@ export type SolfeggioFrequencyCountOutputTypeDefaultArgs<ExtArgs extends runtime
/** /**
* SolfeggioFrequencyCountOutputType without action * SolfeggioFrequencyCountOutputType without action
*/ */
export type SolfeggioFrequencyCountOutputTypeCountAffirmationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type SolfeggioFrequencyCountOutputTypeCountProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.AffirmationWhereInput where?: Prisma.ProclamationWhereInput
} }
@@ -407,7 +407,7 @@ export type SolfeggioFrequencySelect<ExtArgs extends runtime.Types.Extensions.In
id?: boolean id?: boolean
name?: boolean name?: boolean
value?: boolean value?: boolean
affirmations?: boolean | Prisma.SolfeggioFrequency$affirmationsArgs<ExtArgs> proclamations?: boolean | Prisma.SolfeggioFrequency$proclamationsArgs<ExtArgs>
_count?: boolean | Prisma.SolfeggioFrequencyCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.SolfeggioFrequencyCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["solfeggioFrequency"]> }, ExtArgs["result"]["solfeggioFrequency"]>
@@ -431,7 +431,7 @@ export type SolfeggioFrequencySelectScalar = {
export type SolfeggioFrequencyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "value", ExtArgs["result"]["solfeggioFrequency"]> export type SolfeggioFrequencyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "value", ExtArgs["result"]["solfeggioFrequency"]>
export type SolfeggioFrequencyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type SolfeggioFrequencyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
affirmations?: boolean | Prisma.SolfeggioFrequency$affirmationsArgs<ExtArgs> proclamations?: boolean | Prisma.SolfeggioFrequency$proclamationsArgs<ExtArgs>
_count?: boolean | Prisma.SolfeggioFrequencyCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.SolfeggioFrequencyCountOutputTypeDefaultArgs<ExtArgs>
} }
export type SolfeggioFrequencyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {} export type SolfeggioFrequencyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@@ -440,7 +440,7 @@ export type SolfeggioFrequencyIncludeUpdateManyAndReturn<ExtArgs extends runtime
export type $SolfeggioFrequencyPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type $SolfeggioFrequencyPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "SolfeggioFrequency" name: "SolfeggioFrequency"
objects: { objects: {
affirmations: Prisma.$AffirmationPayload<ExtArgs>[] proclamations: Prisma.$ProclamationPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: number id: number
@@ -840,7 +840,7 @@ readonly fields: SolfeggioFrequencyFieldRefs;
*/ */
export interface Prisma__SolfeggioFrequencyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> { export interface Prisma__SolfeggioFrequencyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise" readonly [Symbol.toStringTag]: "PrismaPromise"
affirmations<T extends Prisma.SolfeggioFrequency$affirmationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.SolfeggioFrequency$affirmationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$AffirmationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> proclamations<T extends Prisma.SolfeggioFrequency$proclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.SolfeggioFrequency$proclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProclamationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1261,27 +1261,27 @@ export type SolfeggioFrequencyDeleteManyArgs<ExtArgs extends runtime.Types.Exten
} }
/** /**
* SolfeggioFrequency.affirmations * SolfeggioFrequency.proclamations
*/ */
export type SolfeggioFrequency$affirmationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type SolfeggioFrequency$proclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/** /**
* Select specific fields to fetch from the Affirmation * Select specific fields to fetch from the Proclamation
*/ */
select?: Prisma.AffirmationSelect<ExtArgs> | null select?: Prisma.ProclamationSelect<ExtArgs> | null
/** /**
* Omit specific fields from the Affirmation * Omit specific fields from the Proclamation
*/ */
omit?: Prisma.AffirmationOmit<ExtArgs> | null omit?: Prisma.ProclamationOmit<ExtArgs> | null
/** /**
* Choose, which related nodes to fetch as well * Choose, which related nodes to fetch as well
*/ */
include?: Prisma.AffirmationInclude<ExtArgs> | null include?: Prisma.ProclamationInclude<ExtArgs> | null
where?: Prisma.AffirmationWhereInput where?: Prisma.ProclamationWhereInput
orderBy?: Prisma.AffirmationOrderByWithRelationInput | Prisma.AffirmationOrderByWithRelationInput[] orderBy?: Prisma.ProclamationOrderByWithRelationInput | Prisma.ProclamationOrderByWithRelationInput[]
cursor?: Prisma.AffirmationWhereUniqueInput cursor?: Prisma.ProclamationWhereUniqueInput
take?: number take?: number
skip?: number skip?: number
distinct?: Prisma.AffirmationScalarFieldEnum | Prisma.AffirmationScalarFieldEnum[] distinct?: Prisma.ProclamationScalarFieldEnum | Prisma.ProclamationScalarFieldEnum[]
} }
/** /**
+318 -142
View File
@@ -1,194 +1,370 @@
# svelte-admin # svelte-admin
A lightweight, configuration-driven admin panel framework for SvelteKit + Prisma applications. Models are registered once in a central registry and automatically get list views, create modals, edit pages, and delete functionality — all wired to Prisma via dynamic delegates. A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
registry and get full CRUD UI with dynamic routing, role-based access control, searchable
dropdowns, and related-record panels — no per-model route files needed.
## Architecture ## Architecture
``` ```
src/lib/svelte-admin/ # Framework core src/lib/svelte-admin/ # Framework core
types.ts # AdminModelConfig type + supporting types ├── types.ts # AdminModelConfig and related types
registry.ts # Model configurations (the main config file) ├── registry.ts # Model configurations (the single source of truth)
roles.ts # Role constants + access helpers ├── roles.ts # Role hierarchy and access helpers
introspect.ts # Field label formatting + nested value resolution ├── prisma-delegate.ts # Dynamic Prisma delegate lookup
prisma-delegate.ts # Dynamic Prisma delegate lookup + ID parsing ├── introspect.ts # Field label generation and dot-notation resolution
form-data.ts # FormData → typed object extraction ├── form-data.ts # FormData → typed object parser
index.ts # Barrel re-exports └── index.ts # Barrel exports
src/lib/components/admin/ # UI components src/routes/(protected)/admin/ # Dynamic admin routes
AdminTable.svelte # Sortable, searchable list table ├── +layout.server.ts # Role-based access guard
AdminForm.svelte # Dynamic form (creates inputs from field config) ├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── [model]/
│ ├── +page.server.ts # List view + create action
│ ├── +page.svelte # List view UI
│ └── [id]/
│ ├── +page.server.ts # Edit view + update/delete actions
│ └── +page.svelte # Edit view UI
src/routes/(protected)/admin/ # SvelteKit routes src/lib/components/admin/ # Reusable admin UI components
+layout.server.ts # Auth guard (requires STAFF role or higher) ├── AdminTable.svelte # Sortable, searchable data table
+layout.svelte # Admin shell layout ├── AdminForm.svelte # Dynamic form with type-aware inputs
+page.svelte # Home grid (cards linking to each model) ├── ComboBox.svelte # Searchable dropdown for FK fields
+page.server.ts # Loads all model configs for the home grid ├── RelatedHasOne.svelte # Display panel for 1:1 related records
[model]/+page.svelte # List view + create modal for a model └── RelatedHasMany.svelte # Display table for 1:N related records
[model]/+page.server.ts # List loader + create action
[model]/[id]/+page.svelte # Edit view + delete for a single record
[model]/[id]/+page.server.ts # Record loader + update/delete actions
``` ```
## Adding a New Model ## Adding a New Model
1. Open `src/lib/svelte-admin/registry.ts`. 1. **Define the Prisma model** in `prisma/schema.prisma` and create a migration:
2. Add a new object to the `models` array:
```ts ```bash
{ npm run migrate -- --name add_my_model
slug: 'widgets', // URL segment: /admin/widgets ```
modelName: 'Widget', // Prisma model name (PascalCase)
displayName: 'Widgets', // Plural display name
displayNameSingular: 'Widget', // Singular display name
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
include: { category: true }, // Optional: Prisma include for relations
listFields: ['name', 'category.name', 'price'],
searchFields: ['name'],
formFields: ['name', 'categoryId', 'price'],
readonlyFields: ['id', 'createdAt'],
fieldTypes: {
name: 'String',
categoryId: 'Int',
price: 'Float',
createdAt: 'DateTime',
},
relationOptions: {
categoryId: {
model: 'Category',
labelField: 'name',
valueField: 'id',
},
},
}
```
3. That's it. The dynamic `[model]` routes handle everything else. 2. **Add a config object** to the `models` array in `registry.ts`:
```ts
{
slug: 'widgets', // URL segment: /admin/widgets
modelName: 'Widget', // Prisma model name (PascalCase, singular)
displayName: 'Widgets', // Plural display name
displayNameSingular: 'Widget', // Singular display name
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
include: { category: true }, // Optional: Prisma include for relations
listFields: ['name', 'category.name', 'price'],
searchFields: ['name'],
formFields: ['name', 'categoryId', 'price'],
readonlyFields: ['id', 'createdAt'],
fieldTypes: {
name: 'String',
categoryId: 'Int',
price: 'Float',
createdAt: 'DateTime'
},
relationOptions: {
categoryId: {
model: 'Category',
labelField: 'name',
valueField: 'id'
}
}
}
```
3. **Done.** Visit `/admin/widgets`. The dynamic `[model]` routes handle list, create, edit,
and delete automatically.
No new route files, no new components. The registry is the only thing you touch.
## Configuration Reference ## Configuration Reference
### `AdminModelConfig` ### AdminModelConfig
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |---|---|---|---|
| `slug` | `string` | Yes | URL path segment. Use kebab-case (e.g. `'license-keys'`). | | `slug` | `string` | Yes | URL segment (kebab-case). Used in `/admin/{slug}`. |
| `modelName` | `string` | Yes | Prisma model name in PascalCase (e.g. `'LicenseKey'`). Used to look up the Prisma delegate. | | `modelName` | `string` | Yes | Prisma model name (PascalCase, **singular**). Maps to `prisma[lcfirst(modelName)]`. |
| `displayName` | `string` | Yes | Plural name shown in headings and the admin home grid. | | `displayName` | `string` | Yes | Plural display name for headings and navigation. |
| `displayNameSingular` | `string` | Yes | Singular name for create/edit headings and button labels. | | `displayNameSingular` | `string` | Yes | Singular name for edit page headings. |
| `idType` | `'String' \| 'Int'` | Yes | Primary key type. Controls how the `id` URL param is parsed. | | `idType` | `'String' \| 'Int'` | Yes | Primary key type. Controls how the `id` URL param is parsed. |
| `include` | `Record<string, boolean>` | No | Prisma `include` clause for eager-loading relations in list and edit views. | | `include` | `Record<string, boolean \| { include: ... }>` | No | Prisma `include` clause for eager-loading relations. |
| `listFields` | `string[]` | Yes | Fields shown as columns in the list table. Supports dot-notation for relations (e.g. `'user.name'`). | | `listFields` | `string[]` | Yes | Table columns. Supports dot-notation for relations (e.g., `'role.name'`). |
| `searchFields` | `string[]` | Yes | Fields searched by the table's search box. Should be scalar fields on the model. | | `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
| `formFields` | `string[]` | Yes | Fields shown in the create/edit form. Order determines form field order. | | `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
| `readonlyFields` | `string[]` | No | Fields shown as read-only text on the edit page (not editable). Typically `['id', 'createdAt', 'updatedAt']`. | | `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
| `listOnly` | `boolean` | No | When `true`, suppresses edit links in the table. The create modal still works. Use for models where records are auto-generated or shouldn't be edited individually. | | `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table. |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to their Prisma scalar type. Controls form input type and form data parsing. | | `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | Configures dropdown selects for foreign key fields. | | `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as ComboBox dropdowns. |
| `accessFilter` | `(viewerRoleId: number) => Record<string, unknown> \| undefined` | No | Returns a Prisma `where` clause to filter list results based on the viewer's role. Return `undefined` for no filter. | | `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages. |
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
### `PrismaScalarType` ### PrismaScalarType
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'` `'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
Controls both the HTML input type in forms and how `FormData` values are parsed: Controls both form input rendering and FormData parsing:
| Type | Input | Parsing | | Type | HTML Input | Parse Logic |
|------|-------|---------| |---|---|---|
| `String` | `<input type="text">` | `.toString()` | | `String` | `<input type="text">` | `raw.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()` | | `Int` | `<input type="number" step="1">` | `parseInt()`, empty → `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()` | | `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty → `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` | | `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()` | | `DateTime` | `<input type="datetime-local">` | `new Date()`, empty → `null` |
### `RelationOptionConfig` ### RelationOptionConfig
```ts ```ts
{ {
model: 'UserRole', // Prisma model to query for options model: 'UserRole', // Prisma model to query for options
labelField: 'name', // Field to display in the dropdown labelField: 'name', // Field displayed in the dropdown
valueField: 'id', // Field value submitted with the form valueField: 'id' // Field value submitted with the form
} }
``` ```
The framework runs `findMany` on the related model, selecting only `labelField` and `valueField`, and renders a `<select>` dropdown in the form. ### RelatedRecordConfig
```ts
{
relationKey: 'profile', // Key on parent record (must match a key in `include`)
label: 'Profile', // Section heading on edit page
type: 'has-one', // 'has-one' or 'has-many'
displayFields: ['firstName', 'lastName'], // Fields shown in the panel/table
editSlug: 'profiles' // Optional: admin slug to link for editing
}
```
## Field Types & Form Rendering
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
When a field also has a `relationOptions` entry, the framework renders a `ComboBox` (searchable
dropdown) instead of the default input for that type.
## Relations ## Relations
To display a related field in the list table (e.g. show a user's role name): ### Foreign Key Dropdowns (relationOptions)
1. Add the relation to `include`: `{ role: true }` When a form field is a foreign key, add a `relationOptions` entry to render a searchable ComboBox:
2. Use dot-notation in `listFields`: `'role.name'`
To make a foreign key editable as a dropdown:
1. Add the FK field to `formFields`: `'roleId'`
2. Add its type to `fieldTypes`: `roleId: 'Int'`
3. Configure `relationOptions`:
```ts
relationOptions: {
roleId: { model: 'UserRole', labelField: 'name', valueField: 'id' }
}
```
## List-Only Models
Set `listOnly: true` on a model config to suppress edit links in the list table. Records still appear in the table and the create modal still works, but there's no way to click into an individual record to edit or delete it.
This is useful for models like license keys where records are auto-generated and shouldn't be manually edited.
## Read-Only Fields
Fields listed in `readonlyFields` are displayed as plain text on the edit page (above the form) but cannot be modified. Common use: `['id', 'createdAt', 'updatedAt']`.
If you want the edit page to show mostly read-only data with only one or two editable fields, move most fields from `formFields` to `readonlyFields`. For example, the Users config has `formFields: ['roleId']` with everything else in `readonlyFields`.
## Access Control
### Admin layout guard
The admin layout (`admin/+layout.server.ts`) checks `canAccessAdmin(user.roleId)` — which requires `roleId <= ROLES.STAFF` (i.e. Super, Admin, or Staff). Users with higher `roleId` values get a 403.
### Role constants
Defined in `roles.ts`:
```ts ```ts
ROLES.SUPER = 1 // Full access {
ROLES.ADMIN = 2 // Admin access formFields: ['name', 'categoryId'],
ROLES.STAFF = 3 // Staff access (minimum for admin panel) fieldTypes: { name: 'String', categoryId: 'Int' },
ROLES.USER = 4 // Regular user (no admin access) relationOptions: {
``` categoryId: {
model: 'ProclamationCategory',
Lower numbers = higher privilege. labelField: 'name',
valueField: 'id'
### Per-model filtering with `accessFilter` }
The `accessFilter` function receives the viewer's `roleId` and returns a Prisma `where` clause that's merged into the list query. This lets you restrict which records different roles can see.
Example from the Users config:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // Super sees all
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // Admin sees Staff+User
} }
return { roleId: { gt: ROLES.STAFF } }; // Staff sees only Users
} }
``` ```
## Customizing the UI The route loader automatically queries the related model and passes options to the ComboBox.
### Eager Loading (include)
Use `include` to load related data for display in list tables or edit pages:
```ts
{
include: { category: { include: { parent: true } } },
listFields: ['name', 'category.name'] // dot-notation resolves nested values
}
```
## Related Records
Display has-one or has-many related records as panels on the edit page using `relatedRecords`:
```ts
relatedRecords: [
{
relationKey: 'profile',
label: 'Profile',
type: 'has-one',
displayFields: ['firstName', 'lastName'],
editSlug: 'profiles' // links to /admin/profiles/{id}
},
{
relationKey: 'licenseKeys',
label: 'License Keys',
type: 'has-many',
displayFields: ['key', 'active', 'level']
}
]
```
The `relationKey` must match a key in `include` so the data is actually loaded. `has-one` renders
a key-value panel via `RelatedHasOne`; `has-many` renders a table via `RelatedHasMany`. Both
support an optional `editSlug` to link to the related record's edit page.
## Access Control
### Role Hierarchy
Defined in `roles.ts`. Lower numbers = higher privilege:
| Constant | Value | Access Level |
|---|---|---|
| `ROLES.SUPER` | 1 | Full access, sees all users |
| `ROLES.ADMIN` | 2 | Admin access, sees staff and below |
| `ROLES.STAFF` | 3 | Staff access (minimum for admin panel) |
| `ROLES.USER` | 4 | Regular user (no admin access) |
### Layout Guard
`+layout.server.ts` calls `canAccessAdmin(user.roleId)` which returns `true` when
`roleId <= ROLES.STAFF`. Users with `ROLES.USER` (4) or no role get a 403.
### Per-Model Access Filtering
Use `accessFilter` to restrict which records a viewer can see:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
}
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
}
```
## How Dynamic Routing Works
1. User visits `/admin/proclamations` (or `/admin/proclamations/abc123`)
2. SvelteKit matches the `[model]` (and `[id]`) route params
3. `+page.server.ts` calls `getModelConfig(params.model)` to look up the config
4. `getDelegate(config)` returns `prisma[lcfirst(config.modelName)]` (e.g., `prisma.proclamation`)
5. The delegate runs `findMany` (list), `findUnique` (edit), `create`, `update`, or `delete`
6. Data + config are passed to the Svelte page component
7. Components (`AdminTable`, `AdminForm`, etc.) render based on config
## Component API
### AdminTable ### AdminTable
Props: `records`, `listFields`, `searchFields`, `modelSlug`, `listOnly?` | Prop | Type | Description |
|---|---|---|
| `records` | `Record<string, unknown>[]` | Data rows |
| `listFields` | `string[]` | Column keys (dot-notation supported) |
| `searchFields` | `string[]` | Fields for client-side search |
| `modelSlug` | `string` | Used for building edit links |
| `listOnly` | `boolean?` | Suppress edit links |
Features: Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
- Client-side search across `searchFields` decimals), filtered count display.
- Click column headers to sort (ascending/descending toggle)
- Edit links per row (suppressed when `listOnly` is true)
### AdminForm ### AdminForm
Props: `fields`, `fieldTypes`, `relationOptions?`, `relationOptionsData?`, `record?`, `form?`, `action`, `submitLabel?` | Prop | Type | Description |
|---|---|---|
| `fields` | `string[]` | Field names to render |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for inputs |
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
| `form` | `ActionData?` | Carries validation errors |
| `action` | `string` | Form action (e.g., `'?/create'`) |
| `submitLabel` | `string?` | Button text |
Renders a `<form>` with inputs determined by `fieldTypes` and `relationOptions`. When `record` is provided, fields are pre-filled. The `form` prop carries SvelteKit action data for error display and field re-population after failed submissions. ### ComboBox
| Prop | Type | Description |
|---|---|---|
| `id` | `string` | Element ID |
| `name` | `string` | Form field name |
| `options` | `{ label; value }[]` | Dropdown items |
| `placeholder` | `string?` | Placeholder text |
| `value` | `string \| number?` | Pre-selected value |
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape),
clear button, scroll-to-highlight.
### RelatedHasOne
| Prop | Type | Description |
|---|---|---|
| `label` | `string` | Section heading |
| `record` | `Record<string, unknown> \| null` | Related record |
| `displayFields` | `string[]` | Fields to display as key-value pairs |
| `editSlug` | `string?` | Links to edit the related record |
### RelatedHasMany
| Prop | Type | Description |
|---|---|---|
| `label` | `string` | Section heading (shows count) |
| `records` | `Record<string, unknown>[]` | Related records |
| `displayFields` | `string[]` | Table columns |
| `editSlug` | `string?` | Per-row edit links |
## Utility Functions
| Function | Module | Description |
|---|---|---|
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` → `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
## Complete Example
A hypothetical `Product` model showing all configuration features:
```ts
{
slug: 'products',
modelName: 'Product',
displayName: 'Products',
displayNameSingular: 'Product',
idType: 'Int',
include: { category: true, variants: true, createdBy: true },
listFields: ['name', 'category.name', 'price', 'active', 'createdAt'],
searchFields: ['name', 'sku'],
formFields: ['name', 'sku', 'price', 'active', 'categoryId'],
readonlyFields: ['id', 'createdAt', 'updatedAt'],
fieldTypes: {
name: 'String',
sku: 'String',
price: 'Float',
active: 'Boolean',
categoryId: 'Int',
createdAt: 'DateTime',
updatedAt: 'DateTime'
},
relationOptions: {
categoryId: { model: 'Category', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'variants',
label: 'Variants',
type: 'has-many',
displayFields: ['size', 'color', 'stock'],
editSlug: 'variants'
},
{
relationKey: 'createdBy',
label: 'Created By',
type: 'has-one',
displayFields: ['name', 'email']
}
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= 2) return undefined;
return { active: true }; // staff only sees active products
}
}
```
+19 -19
View File
@@ -91,10 +91,10 @@ const models: AdminModelConfig[] = [
} }
}, },
{ {
slug: 'affirmations', slug: 'proclamations',
modelName: 'Affirmation', modelName: 'Proclamation',
displayName: 'Affirmations', displayName: 'Proclamations',
displayNameSingular: 'Affirmation', displayNameSingular: 'Proclamation',
idType: 'String', idType: 'String',
include: { solfeggioFrequency: true, category: { include: { parent: true } } }, include: { solfeggioFrequency: true, category: { include: { parent: true } } },
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'price'], listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'price'],
@@ -115,7 +115,7 @@ const models: AdminModelConfig[] = [
valueField: 'id' valueField: 'id'
}, },
categoryId: { categoryId: {
model: 'AffirmationCategory', model: 'ProclamationCategory',
labelField: 'name', labelField: 'name',
valueField: 'id' valueField: 'id'
} }
@@ -127,7 +127,7 @@ const models: AdminModelConfig[] = [
displayName: 'Solfeggio Frequencies', displayName: 'Solfeggio Frequencies',
displayNameSingular: 'Solfeggio Frequency', displayNameSingular: 'Solfeggio Frequency',
idType: 'Int', idType: 'Int',
include: { affirmations: true }, include: { proclamations: true },
listFields: ['name', 'value'], listFields: ['name', 'value'],
searchFields: ['name'], searchFields: ['name'],
formFields: ['name', 'value'], formFields: ['name', 'value'],
@@ -138,21 +138,21 @@ const models: AdminModelConfig[] = [
}, },
relatedRecords: [ relatedRecords: [
{ {
relationKey: 'affirmations', relationKey: 'proclamations',
label: 'Affirmations', label: 'Proclamations',
type: 'has-many', type: 'has-many',
displayFields: ['name', 'price'], displayFields: ['name', 'price'],
editSlug: 'affirmations' editSlug: 'proclamations'
} }
] ]
}, },
{ {
slug: 'affirmation-categories', slug: 'proclamation-categories',
modelName: 'AffirmationCategory', modelName: 'ProclamationCategory',
displayName: 'Affirmation Categories', displayName: 'Proclamation Categories',
displayNameSingular: 'Affirmation Category', displayNameSingular: 'Proclamation Category',
idType: 'Int', idType: 'Int',
include: { affirmations: true, children: true, parent: true }, include: { proclamations: true, children: true, parent: true },
listFields: ['name', 'parent.name'], listFields: ['name', 'parent.name'],
searchFields: ['name'], searchFields: ['name'],
formFields: ['name', 'parentId'], formFields: ['name', 'parentId'],
@@ -162,22 +162,22 @@ const models: AdminModelConfig[] = [
parentId: 'Int' parentId: 'Int'
}, },
relationOptions: { relationOptions: {
parentId: { model: 'AffirmationCategory', labelField: 'name', valueField: 'id' } parentId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' }
}, },
relatedRecords: [ relatedRecords: [
{ {
relationKey: 'affirmations', relationKey: 'proclamations',
label: 'Affirmations', label: 'Proclamations',
type: 'has-many', type: 'has-many',
displayFields: ['name', 'price'], displayFields: ['name', 'price'],
editSlug: 'affirmations' editSlug: 'proclamations'
}, },
{ {
relationKey: 'children', relationKey: 'children',
label: 'Sub-categories', label: 'Sub-categories',
type: 'has-many', type: 'has-many',
displayFields: ['name'], displayFields: ['name'],
editSlug: 'affirmation-categories' editSlug: 'proclamation-categories'
} }
] ]
}, },
+1 -1
View File
@@ -6,7 +6,7 @@ export const ROLES = {
} as const; } as const;
export function hasRole(userRoleId: number | null | undefined, requiredRoleId: number): boolean { export function hasRole(userRoleId: number | null | undefined, requiredRoleId: number): boolean {
if (userRoleId == null) return false; if (userRoleId == null || userRoleId < 1) return false;
return userRoleId <= requiredRoleId; return userRoleId <= requiredRoleId;
} }
+82
View File
@@ -54,3 +54,85 @@ export function mockUserWithProfile(overrides?: Record<string, unknown>) {
...overrides ...overrides
}); });
} }
/** User with profile that includes addresses. */
export function mockUserWithProfileAndAddresses(overrides?: Record<string, unknown>) {
return mockUser({
profile: {
id: 'profile-1',
userId: 'user-1',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '5550100',
addresses: [
{
id: 'addr-1',
profileId: 'profile-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
}
]
},
...overrides
});
}
/** Minimal license key object. */
export function mockLicenseKey(overrides?: Record<string, unknown>) {
return {
id: 'key-1',
key: 'AAAA-BBBB-CCCC-DDDD',
active: true,
level: 'standard',
userId: 'user-1',
...overrides
};
}
/** Minimal proclamation object with relations. */
export function mockProclamation(overrides?: Record<string, unknown>) {
return {
id: 'proc-1',
name: 'Test Proclamation',
content: '<p>Test content</p>',
solfeggioFrequencyId: 1,
categoryId: 1,
price: 0.99,
solfeggioFrequency: {
id: 1,
name: 'UT',
value: 396
},
category: {
id: 1,
name: 'Healing',
parentId: null,
parent: null
},
...overrides
};
}
/** Minimal proclamation category. */
export function mockCategory(overrides?: Record<string, unknown>) {
return {
id: 1,
name: 'Healing',
parentId: null,
...overrides
};
}
/** Build a URL object for use with PageServerLoad. */
export function createUrl(path: string, params?: Record<string, string>): URL {
const url = new URL(path, 'http://localhost');
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
}
return url;
}
+37 -19
View File
@@ -1,48 +1,66 @@
<!-- This layout is used for all pages where a user must be authenticated to visit.
Below the navigation bar, there should be two sections. A sidebar, containing links
to common "pages", and a window to the right of that sidebar, with it's contents expanded
to fill the entire area, to display the current "page".
This layout also acts as a sort of 'gatekeeper' in that it is also the authorization layer.
-->
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { canAccessAdmin } from '$lib/svelte-admin/roles'; import { canAccessAdmin } from '$lib/svelte-admin/roles';
import { getAllModelConfigs } from '$lib/svelte-admin/registry'; import { getAllModelConfigs } from '$lib/svelte-admin/registry';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
let { children, data } = $props<{ children: any; data: LayoutData }>(); let { children, data } = $props<{ children: any; data: LayoutData }>();
let adminExpanded = $state(true);
const isAdmin = $derived(canAccessAdmin(data.userRoleId)); const isAdmin = $derived(canAccessAdmin(data.userRoleId));
const adminModels = getAllModelConfigs(); const adminModels = getAllModelConfigs();
const navLinks = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/account', label: 'Account' },
{ href: '/proclamations', label: 'Proclamations' },
{ href: '/license-keys', label: 'License Keys' }
];
function isActive(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
</script> </script>
<!-- Main Area -->
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<!-- Sidebar --> <!-- Sidebar -->
<div class="mt-12 mr-12 flex flex-col gap-1"> <nav class="mt-12 mr-12 flex flex-col gap-1">
<!-- Sidebar links --> {#each navLinks as link}
<a href="/dashboard" class="text-blue-600">Dashboard</a> <a
href={link.href}
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
>
{link.label}
</a>
{/each}
{#if isAdmin} {#if isAdmin}
<div class="mt-2 flex flex-col"> <div class="mt-4 flex flex-col gap-1">
<a <a
href="/admin" href="/admin"
class="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900" class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}"
> >
Admin Panel Admin Panel
</a> </a>
<div class="ml-5 flex flex-col"> <div class="ml-3 flex flex-col gap-0.5">
{#each adminModels as model} {#each adminModels as model}
<a href="/admin/{model.slug}" class="text-sm text-blue-600 hover:text-blue-800"> <a
href="/admin/{model.slug}"
class="rounded px-3 py-1 text-sm transition-colors {isActive(
'/admin/' + model.slug
)
? 'bg-blue-50 text-blue-700'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800'}"
>
{model.displayName} {model.displayName}
</a> </a>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<!-- End Sidebar links --> </nav>
</div>
<!-- End Sidebar -->
<!-- Page content --> <!-- Page content -->
<div class="min-h-0 flex-1 overflow-auto"> <div class="min-h-0 flex-1 overflow-auto">
{@render children()} {@render children()}
+85 -3
View File
@@ -1,14 +1,96 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit'; import { error, fail, type Actions } from '@sveltejs/kit';
import { serverFormatPhoneNumber } from '$lib/utils';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: locals.user?.id }, where: { id: locals.user.id },
include: { profile: true } include: {
profile: {
include: { addresses: true }
}
}
}); });
if (!user) { if (!user) {
throw error(403, 'not authorized'); throw error(403, 'not authorized');
} }
return { user }; return { user };
}; };
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const firstName = data.get('firstName') as string;
const lastName = data.get('lastName') as string;
const phoneNumber = serverFormatPhoneNumber(data.get('phoneNumber') as string);
if (!firstName || !lastName || !phoneNumber) {
return fail(400, { error: 'All fields are required.', action: 'updateProfile' });
}
try {
await prisma.profile.update({
where: { userId: locals.user.id },
data: { firstName, lastName, phoneNumber }
});
return { success: true, action: 'updateProfile' };
} catch (err) {
console.error('Profile update error:', err);
return fail(500, { error: 'Failed to update profile.', action: 'updateProfile' });
}
},
addAddress: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const street = data.get('street') as string;
const city = data.get('city') as string;
const state = data.get('state') as string;
const postalCode = data.get('postalCode') as string;
const country = data.get('country') as string;
if (!street || !city || !state || !postalCode || !country) {
return fail(400, { error: 'All address fields are required.', action: 'addAddress' });
}
const profile = await prisma.profile.findUnique({
where: { userId: locals.user.id },
select: { id: true }
});
if (!profile) {
throw error(400, 'Profile not found');
}
try {
await prisma.address.create({
data: {
profileId: profile.id,
street,
city,
state,
postalCode,
country
}
});
return { success: true, action: 'addAddress' };
} catch (err) {
console.error('Address create error:', err);
return fail(500, { error: 'Failed to add address.', action: 'addAddress' });
}
}
};
+219 -4
View File
@@ -1,5 +1,220 @@
<script lang="ts"> <script lang="ts">
import type {PageProps} from "./$types"; import { clientFormatPhoneNumber } from '$lib/utils';
let {data}: PageProps = $props(); import type { PageProps } from './$types';
$inspect(data);
</script> let { data, form }: PageProps = $props();
let initialPhone = $derived(clientFormatPhoneNumber(data.user.profile?.phoneNumber ?? ''));
let phoneNumber = $state('');
let phoneInitialized = $state(false);
$effect(() => {
if (!phoneInitialized) {
phoneNumber = initialPhone;
phoneInitialized = true;
}
});
let showAddressForm = $state(false);
function handlePhoneInput(event: Event) {
const input = event.target as HTMLInputElement;
phoneNumber = clientFormatPhoneNumber(input.value);
}
</script>
<div class="flex h-full min-h-0 flex-col gap-8 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Account</h1>
<!-- Email (read-only) -->
<div>
<p class="text-sm font-medium text-gray-700">Email</p>
<p class="mt-1 text-gray-900">{data.user.email}</p>
</div>
<!-- Profile Form -->
<section>
<h2 class="mb-4 text-lg font-medium text-gray-900">Profile</h2>
{#if form?.action === 'updateProfile' && form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Profile updated successfully.
</div>
{/if}
{#if form?.action === 'updateProfile' && form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<form method="POST" action="?/updateProfile" class="flex max-w-lg flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-gray-700">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={data.user.profile?.firstName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-medium text-gray-700">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={data.user.profile?.lastName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">
Phone Number
</label>
<input
type="tel"
id="phoneNumber"
name="phoneNumber"
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Save Profile
</button>
</div>
</form>
</section>
<!-- Addresses -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">Addresses</h2>
<button
type="button"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showAddressForm = !showAddressForm)}
>
{showAddressForm ? 'Cancel' : 'Add Address'}
</button>
</div>
{#if form?.action === 'addAddress' && form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Address added successfully.
</div>
{/if}
{#if form?.action === 'addAddress' && form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
{#if showAddressForm}
<form
method="POST"
action="?/addAddress"
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div class="flex flex-col gap-3">
<div>
<label for="street" class="block text-sm font-medium text-gray-700">Street</label>
<input
type="text"
id="street"
name="street"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="city" class="block text-sm font-medium text-gray-700">City</label>
<input
type="text"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
<input
type="text"
id="state"
name="state"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="postalCode" class="block text-sm font-medium text-gray-700">
Postal Code
</label>
<input
type="text"
id="postalCode"
name="postalCode"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700">Country</label>
<input
type="text"
id="country"
name="country"
required
value="US"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Save Address
</button>
</div>
</div>
</form>
{/if}
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
<div class="flex flex-col gap-3">
{#each data.user.profile.addresses as address}
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-sm text-gray-900">{address.street}</p>
<p class="text-sm text-gray-600">
{address.city}, {address.state} {address.postalCode}
</p>
<p class="text-sm text-gray-600">{address.country}</p>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="text-sm text-gray-500">No addresses on file.</p>
{/if}
</section>
</div>
@@ -1,46 +1,255 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit'; import { isHttpError, isActionFailure } from '@sveltejs/kit';
import { createLocals, mockUser, mockUserWithProfile } from '$lib/test-utils'; import {
createLocals,
createFormRequest,
mockUser,
mockUserWithProfileAndAddresses
} from '$lib/test-utils';
vi.mock('$lib/db', () => ({ vi.mock('$lib/db', () => ({
prisma: { prisma: {
user: { user: { findUnique: vi.fn() },
findUnique: vi.fn() profile: { update: vi.fn(), findUnique: vi.fn() },
} address: { create: vi.fn() }
} }
})); }));
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { load } from './+page.server'; import { load, actions } from './+page.server';
const findUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>; const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const profileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
describe('(protected)/account page server load', () => { const invokeAction = (name: string, request: Request, locals: App.Locals) =>
(actions as any)[name]({ request, locals } as any);
describe('(protected)/account page server', () => {
beforeEach(() => { beforeEach(() => {
findUnique.mockReset(); userFindUnique.mockReset();
profileUpdate.mockReset();
profileFindUnique.mockReset();
addressCreate.mockReset();
}); });
it('throws 403 when locals.user is undefined', async () => { describe('load', () => {
findUnique.mockResolvedValue(null); it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch((e: unknown) => e); it('throws 403 when user is not found in the database', async () => {
expect(isHttpError(err, 403)).toBe(true); userFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns user with profile and addresses', async () => {
const user = mockUserWithProfileAndAddresses();
userFindUnique.mockResolvedValue(user);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.user).toEqual(user);
expect(result.user.profile.addresses).toHaveLength(1);
});
it('includes addresses in the query', async () => {
userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses());
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(userFindUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
include: { profile: { include: { addresses: true } } }
});
});
}); });
it('throws 403 when user is not found in the database', async () => { describe('updateProfile action', () => {
findUnique.mockResolvedValue(null); it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any); const err = await Promise.resolve(
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e); invokeAction('updateProfile', request, createLocals())
expect(isHttpError(err, 403)).toBe(true); ).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when firstName is missing', async () => {
const request = createFormRequest({
firstName: '',
lastName: 'Doe',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.error).toBe('All fields are required.');
});
it('returns fail(400) when lastName is missing', async () => {
const request = createFormRequest({
firstName: 'Jane',
lastName: '',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
});
it('returns fail(400) when phoneNumber is missing', async () => {
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: ''
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
});
it('updates profile with server-formatted phone number on success', async () => {
profileUpdate.mockResolvedValue({});
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Smith',
phoneNumber: '(817) 555-1234'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(result).toEqual({ success: true, action: 'updateProfile' });
expect(profileUpdate).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { firstName: 'Jane', lastName: 'Smith', phoneNumber: '8175551234' }
});
});
it('returns fail(500) when prisma update throws', async () => {
profileUpdate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
}); });
it('returns user with profile when found', async () => { describe('addAddress action', () => {
const user = mockUserWithProfile(); it('throws 403 when locals.user is undefined', async () => {
findUnique.mockResolvedValue(user); const request = createFormRequest({
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any); const err = await Promise.resolve(
const result = (await load({ locals } as any)) as any; invokeAction('addAddress', request, createLocals())
expect(result.user).toEqual(user); ).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when a required field is missing', async () => {
const request = createFormRequest({
street: '123 Main St',
city: '',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('addAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.error).toBe('All address fields are required.');
});
it('throws 400 when profile is not found', async () => {
profileFindUnique.mockResolvedValue(null);
const request = createFormRequest({
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(
invokeAction('addAddress', request, locals)
).catch((e: unknown) => e);
expect(isHttpError(err, 400)).toBe(true);
});
it('creates address linked to profile on success', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockResolvedValue({});
const request = createFormRequest({
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('addAddress', request, locals);
expect(result).toEqual({ success: true, action: 'addAddress' });
expect(addressCreate).toHaveBeenCalledWith({
data: {
profileId: 'profile-1',
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
}
});
});
it('returns fail(500) when prisma create throws', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('addAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
}); });
}); });
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
user: {
findUnique: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load } from './+layout.server';
const findUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
describe('admin layout server load', () => {
beforeEach(() => {
findUnique.mockReset();
});
it('throws 403 when user is not found', async () => {
findUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('throws 403 when roleId is null', async () => {
findUnique.mockResolvedValue({ id: 'user-1', name: 'Jane', roleId: null, role: null });
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('throws 403 when roleId is 0', async () => {
findUnique.mockResolvedValue({ id: 'user-1', name: 'Jane', roleId: 0, role: null });
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('throws 403 when roleId is USER (4)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 4,
role: { name: 'User' }
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('allows access for SUPER (roleId 1)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 1,
role: { name: 'Super' }
});
const locals = createLocals(mockUser() as any);
const result = await load({ locals } as any);
expect(result).toEqual({
adminUser: { id: 'user-1', name: 'Jane', roleId: 1, roleName: 'Super' }
});
});
it('allows access for ADMIN (roleId 2)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 2,
role: { name: 'Admin' }
});
const locals = createLocals(mockUser() as any);
const result = await load({ locals } as any);
expect(result).toEqual({
adminUser: { id: 'user-1', name: 'Jane', roleId: 2, roleName: 'Admin' }
});
});
it('allows access for STAFF (roleId 3)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 3,
role: { name: 'Staff' }
});
const locals = createLocals(mockUser() as any);
const result = await load({ locals } as any);
expect(result).toEqual({
adminUser: { id: 'user-1', name: 'Jane', roleId: 3, roleName: 'Staff' }
});
});
});
@@ -1,97 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { id: true, name: true, role: true }
});
const affirmations = await prisma.affirmation.findMany({
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
}
});
const affirmationCategories = await prisma.affirmationCategory.findMany({
include: { children: true }
});
const solfeggioFrequencies = await prisma.solfeggioFrequency.findMany();
return { user, affirmationCategories, affirmations, solfeggioFrequencies };
};
async function resolveCategory(categoryName: string, subCategoryName: string | null) {
let parent = await prisma.affirmationCategory.findFirst({
where: { name: categoryName, parentId: null }
});
if (!parent) {
parent = await prisma.affirmationCategory.create({
data: { name: categoryName }
});
}
if (!subCategoryName) return parent;
let child = await prisma.affirmationCategory.findFirst({
where: { name: subCategoryName, parentId: parent.id }
});
if (!child) {
child = await prisma.affirmationCategory.create({
data: { name: subCategoryName, parentId: parent.id }
});
}
return child;
}
export const actions: Actions = {
create: async ({ request }: RequestEvent) => {
const form = await request.formData();
const frequencyId = Number(form.get('solfeggioFrequencyId')?.toString()) || 0;
const price = Number(form.get('price')) || 0.0;
const categoryName = form.get('categoryName') as string;
const subCategoryName = (form.get('subCategoryName') as string) || null;
if (frequencyId === 0) {
return fail(400, {
error: 'Solfeggio Frequency not selected!',
...Object.fromEntries(form)
});
}
if (!categoryName) {
return fail(400, {
error: 'Category is required!',
...Object.fromEntries(form)
});
}
const category = await resolveCategory(categoryName, subCategoryName);
try {
const newAffirmation = await prisma.affirmation.create({
data: {
name: form.get('name') as string,
content: form.get('content') as string,
solfeggioFrequencyId: frequencyId,
categoryId: category.id,
price: price
},
include: {
category: true,
solfeggioFrequency: true
}
});
return { success: true, newAffirmation };
} catch (err) {
console.log(err);
return fail(500, {
error: 'An unknown error has occurred. Please try again later.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,72 +0,0 @@
<script lang="ts">
import AffirmationForm from '$lib/components/forms/affirmation/AffirmationForm.svelte';
import Center from '$lib/components/containers/Center.svelte';
import type { ActionData, PageData } from './$types';
import AffirmationList from '$lib/components/ui/AffirmationList/AffirmationList.svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let showModal = $state(false);
$effect(() => {
if (form?.success) {
showModal = false;
}
});
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 mb-4">
<div class="flex shrink-0 items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Affirmations</h1>
<button
type="button"
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showModal = true)}
>
Create +
</button>
</div>
{#if data.affirmations.length > 0}
<AffirmationList affirmations={data.affirmations} />
{:else}
<Center>
<div class="text-center">
<p class="text-lg text-gray-500">No affirmations yet</p>
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add your first one</p>
</div>
</Center>
{/if}
</div>
{#if showModal}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
<div class="flex min-h-full items-center justify-center p-4">
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Create New Affirmation</h2>
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onclick={() => (showModal = false)}
>
✕
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<AffirmationForm
categories={data.affirmationCategories}
frequencies={data.solfeggioFrequencies}
{form}
action="?/create"
submitLabel="Create Affirmation"
/>
</div>
</div>
</div>
{/if}
@@ -1,101 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error, fail, type Actions, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }: ServerLoadEvent) => {
const affirmation = await prisma.affirmation.findUnique({
where: { id: params.id },
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
});
const solfeggioFrequencies = await prisma.solfeggioFrequency.findMany();
const affirmationCategories = await prisma.affirmationCategory.findMany({
include: { children: true }
});
if (!affirmation) {
throw error(404, 'not found');
}
return { affirmation, solfeggioFrequencies, affirmationCategories };
};
async function resolveCategory(categoryName: string, subCategoryName: string | null) {
let parent = await prisma.affirmationCategory.findFirst({
where: { name: categoryName, parentId: null }
});
if (!parent) {
parent = await prisma.affirmationCategory.create({
data: { name: categoryName }
});
}
if (!subCategoryName) return parent;
let child = await prisma.affirmationCategory.findFirst({
where: { name: subCategoryName, parentId: parent.id }
});
if (!child) {
child = await prisma.affirmationCategory.create({
data: { name: subCategoryName, parentId: parent.id }
});
}
return child;
}
export const actions: Actions = {
update: async ({ request }: RequestEvent) => {
const form = await request.formData();
const frequencyId = Number(form.get('solfeggioFrequencyId')?.toString()) || 0;
const price = Number(form.get('price')) || 0.0;
const categoryName = form.get('categoryName') as string;
const subCategoryName = (form.get('subCategoryName') as string) || null;
if (!categoryName) {
return fail(400, {
error: 'Category is required!',
...Object.fromEntries(form)
});
}
if (frequencyId === 0) {
return fail(400, {
error: 'Solfeggio Frequency not selected!',
...Object.fromEntries(form)
});
}
const category = await resolveCategory(categoryName, subCategoryName);
try {
const updatedAffirmation = await prisma.affirmation.update({
where: {
id: form.get('id') as string
},
data: {
name: form.get('name') as string,
content: form.get('content') as string,
solfeggioFrequencyId: frequencyId,
categoryId: category.id,
price: price
},
include: {
category: true,
solfeggioFrequency: true
}
});
return { success: true, updatedAffirmation };
} catch (err) {
console.log(err);
return fail(500, {
error:
'An unknown error has occurred. Please try again later. Your changes have not been saved.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,49 +0,0 @@
<script lang="ts">
import AffirmationForm from '$lib/components/forms/affirmation/AffirmationForm.svelte';
import type { ActionData, PageData } from './$types';
import { onMount } from 'svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let seconds = $state(0);
let remainder = $derived(5 - seconds);
onMount(() => {
const interval = setInterval(() => {
if (form?.success) {
seconds += 1;
if (seconds === 6) {
window.location.href = '/affirmations';
}
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="mx-auto mb-auto mt-24 max-w-2xl px-4">
<a href="/affirmations" class="text-blue-600">Back to Affirmations</a>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Affirmation updated successfully! You will be redirected in {remainder} seconds.
</div>
{/if}
<div class="mt-6">
<AffirmationForm
affirmation={data.affirmation}
categories={data.affirmationCategories}
frequencies={data.solfeggioFrequencies}
{form}
action="?/update"
submitLabel="Save Affirmation"
/>
</div>
</div>
@@ -0,0 +1,33 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const profile = await prisma.profile.findUnique({
where: { userId: locals.user.id },
select: { firstName: true }
});
const [keyStats, proclamationCount] = await Promise.all([
prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
}),
prisma.proclamation.count()
]);
const activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
return {
firstName: profile?.firstName ?? 'User',
activeKeys,
totalKeys,
proclamationCount
};
};
+36 -3
View File
@@ -1,5 +1,38 @@
<script lang="ts"> <script lang="ts">
// This page should show a user's dashboard, not the admin. import type { PageProps } from './$types';
// There should be a dedicated 'admin/' page for admins
// There should be another dedicated 'support/' page for staff. let { data }: PageProps = $props();
</script> </script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Welcome, {data.firstName}!</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<a
href="/license-keys"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Active License Keys</p>
<p class="mt-2 text-3xl font-bold text-gray-900">{data.activeKeys}</p>
<p class="mt-1 text-sm text-gray-400">{data.totalKeys} total</p>
</a>
<a
href="/proclamations"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Proclamations</p>
<p class="mt-2 text-3xl font-bold text-gray-900">{data.proclamationCount}</p>
<p class="mt-1 text-sm text-gray-400">Browse all</p>
</a>
<a
href="/account"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Account</p>
<p class="mt-2 text-lg font-semibold text-gray-900">Manage Profile</p>
<p class="mt-1 text-sm text-gray-400">Edit your details</p>
</a>
</div>
</div>
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: { findUnique: vi.fn() },
licenseKey: { groupBy: vi.fn() },
proclamation: { count: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
const proclamationCount = prisma.proclamation.count as ReturnType<typeof vi.fn>;
describe('(protected)/dashboard page server load', () => {
beforeEach(() => {
profileFindUnique.mockReset();
licenseKeyGroupBy.mockReset();
proclamationCount.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns firstName from profile', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('Jane');
});
it('defaults firstName to "User" when profile is null', async () => {
profileFindUnique.mockResolvedValue(null);
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('User');
});
it('computes activeKeys and totalKeys from groupBy results', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([
{ active: true, _count: 3 },
{ active: false, _count: 2 }
]);
proclamationCount.mockResolvedValue(10);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(3);
expect(result.totalKeys).toBe(5);
});
it('returns 0 for activeKeys when none are active', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(0);
expect(result.totalKeys).toBe(4);
});
it('returns proclamationCount', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(42);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.proclamationCount).toBe(42);
});
});
@@ -1,47 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
const frequencies = await prisma.solfeggioFrequency.findMany({
include: {
_count: {
select: { affirmations: true }
}
}
});
return { frequencies };
};
export const actions: Actions = {
create: async ({ request }: RequestEvent) => {
const form = await request.formData();
const name = form.get('name') as string;
const value = Number(form.get('value'));
if (!name) {
return fail(400, { error: 'Name is required!', ...Object.fromEntries(form) });
}
if (!value || value <= 0) {
return fail(400, {
error: 'A valid frequency value is required!',
...Object.fromEntries(form)
});
}
try {
const newFrequency = await prisma.solfeggioFrequency.create({
data: { name, value }
});
return { success: true, newFrequency };
} catch (err) {
console.log(err);
return fail(500, {
error: 'An unknown error has occurred. Please try again later.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,66 +0,0 @@
<script lang="ts">
import FrequencyForm from '$lib/components/forms/frequency/FrequencyForm.svelte';
import Center from '$lib/components/containers/Center.svelte';
import type { ActionData, PageData } from './$types';
import FrequencyList from '$lib/components/ui/FrequencyList/FrequencyList.svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let showModal = $state(false);
$effect(() => {
if (form?.success) {
showModal = false;
}
});
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 mb-4">
<div class="flex shrink-0 items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Frequencies</h1>
<button
type="button"
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showModal = true)}
>
Create +
</button>
</div>
{#if data.frequencies.length > 0}
<FrequencyList frequencies={data.frequencies} />
{:else}
<Center>
<div class="text-center">
<p class="text-lg text-gray-500">No frequencies yet</p>
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add your first one</p>
</div>
</Center>
{/if}
</div>
{#if showModal}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
<div class="flex min-h-full items-center justify-center p-4">
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Create New Frequency</h2>
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onclick={() => (showModal = false)}
>
✕
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<FrequencyForm {form} action="?/create" submitLabel="Create Frequency" />
</div>
</div>
</div>
{/if}
@@ -1,50 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error, fail, type Actions, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }: ServerLoadEvent) => {
const frequency = await prisma.solfeggioFrequency.findUnique({
where: { id: Number(params.id) }
});
if (!frequency) {
throw error(404, 'not found');
}
return { frequency };
};
export const actions: Actions = {
update: async ({ request }: RequestEvent) => {
const form = await request.formData();
const id = Number(form.get('id'));
const name = form.get('name') as string;
const value = Number(form.get('value'));
if (!name) {
return fail(400, { error: 'Name is required!', ...Object.fromEntries(form) });
}
if (!value || value <= 0) {
return fail(400, {
error: 'A valid frequency value is required!',
...Object.fromEntries(form)
});
}
try {
const updatedFrequency = await prisma.solfeggioFrequency.update({
where: { id },
data: { name, value }
});
return { success: true, updatedFrequency };
} catch (err) {
console.log(err);
return fail(500, {
error: 'An unknown error has occurred. Please try again later. Your changes have not been saved.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,47 +0,0 @@
<script lang="ts">
import FrequencyForm from '$lib/components/forms/frequency/FrequencyForm.svelte';
import type { ActionData, PageData } from './$types';
import { onMount } from 'svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let seconds = $state(0);
let remainder = $derived(5 - seconds);
onMount(() => {
const interval = setInterval(() => {
if (form?.success) {
seconds += 1;
if (seconds === 6) {
window.location.href = '/frequencies';
}
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="mx-auto mb-auto mt-24 max-w-2xl px-4">
<a href="/frequencies" class="text-blue-600">Back to Frequencies</a>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Frequency updated successfully! You will be redirected in {remainder} seconds.
</div>
{/if}
<div class="mt-6">
<FrequencyForm
frequency={data.frequency}
{form}
action="?/update"
submitLabel="Save Frequency"
/>
</div>
</div>
@@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const licenseKeys = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
orderBy: { key: 'asc' }
});
return { licenseKeys };
};
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">License Keys</h1>
{#if data.licenseKeys.length > 0}
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Key
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Level
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.licenseKeys as key}
<tr>
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900">
{key.key}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700">
{key.level}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<span
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500">You don't have any license keys yet.</p>
</div>
{/if}
</div>
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/license-keys page server load', () => {
beforeEach(() => {
findMany.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns license keys for the authenticated user', async () => {
const keys = [
mockLicenseKey(),
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
];
findMany.mockResolvedValue(keys);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual(keys);
});
it('passes userId in the where clause', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(findMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
orderBy: { key: 'asc' }
});
});
it('returns empty array when user has no keys', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual([]);
});
});
@@ -0,0 +1,38 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
export const load: PageServerLoad = async ({ url }) => {
const search = url.searchParams.get('search') ?? '';
const categoryId = url.searchParams.get('category');
const where: Record<string, unknown> = {};
if (search) {
where.name = { contains: search, mode: 'insensitive' };
}
if (categoryId) {
where.categoryId = parseInt(categoryId, 10);
}
const [proclamations, categories] = await Promise.all([
prisma.proclamation.findMany({
where,
include: {
solfeggioFrequency: true,
category: true
},
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({
orderBy: { name: 'asc' }
})
]);
return {
proclamations,
categories,
search,
categoryId: categoryId ? parseInt(categoryId, 10) : null
};
};
@@ -0,0 +1,108 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let search = $state('');
let categoryId: number | null = $state(null);
let initialized = $state(false);
$effect(() => {
if (!initialized) {
search = data.search;
categoryId = data.categoryId;
initialized = true;
}
});
function applyFilters() {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (categoryId) params.set('category', String(categoryId));
goto(`/proclamations?${params.toString()}`, { replaceState: true });
}
function clearFilters() {
search = '';
categoryId = null;
goto('/proclamations', { replaceState: true });
}
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Proclamations</h1>
<!-- Filters -->
<div class="flex flex-wrap items-end gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
id="search"
bind:value={search}
placeholder="Search by name..."
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
/>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
<select
id="category"
bind:value={categoryId}
onchange={applyFilters}
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value={null}>All Categories</option>
{#each data.categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<button
type="button"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
onclick={applyFilters}
>
Search
</button>
{#if data.search || data.categoryId}
<button
type="button"
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
onclick={clearFilters}
>
Clear
</button>
{/if}
</div>
<!-- Results -->
{#if data.proclamations.length > 0}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.proclamations as proclamation}
<a
href="/proclamations/{proclamation.id}"
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md"
>
<h2 class="text-lg font-medium text-gray-900">{proclamation.name}</h2>
<p class="mt-1 text-sm text-gray-500">{proclamation.category.name}</p>
<div class="mt-auto flex items-center justify-between pt-4">
<span class="text-sm text-purple-600">
{proclamation.solfeggioFrequency.value} Hz &mdash;
{proclamation.solfeggioFrequency.name}
</span>
<span class="font-semibold text-gray-900">
${proclamation.price.toFixed(2)}
</span>
</div>
</a>
{/each}
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500">No proclamations found.</p>
</div>
{/if}
</div>
@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const proclamation = await prisma.proclamation.findUnique({
where: { id: params.id },
include: {
solfeggioFrequency: true,
category: {
include: { parent: true }
}
}
});
if (!proclamation) {
throw error(404, 'Proclamation not found');
}
return { proclamation };
};
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const categoryBreadcrumb = $derived(
data.proclamation.category.parent
? `${data.proclamation.category.parent.name} / ${data.proclamation.category.name}`
: data.proclamation.category.name
);
</script>
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6">
<a
href="/proclamations"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
&larr; Back to Proclamations
</a>
<div>
<h1 class="text-2xl font-semibold text-gray-900">{data.proclamation.name}</h1>
<p class="mt-1 text-sm text-gray-500">{categoryBreadcrumb}</p>
</div>
<div class="flex flex-wrap gap-4">
<div class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2">
<p class="text-xs font-medium text-purple-600">Frequency</p>
<p class="text-sm font-semibold text-purple-900">
{data.proclamation.solfeggioFrequency.value} Hz &mdash;
{data.proclamation.solfeggioFrequency.name}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2">
<p class="text-xs font-medium text-gray-500">Price</p>
<p class="text-sm font-semibold text-gray-900">
${data.proclamation.price.toFixed(2)}
</p>
</div>
</div>
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6">
{@html data.proclamation.content}
</div>
</div>
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { mockProclamation } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: { findUnique: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
describe('(protected)/proclamations/[id] page server load', () => {
beforeEach(() => {
findUnique.mockReset();
});
it('throws 404 when proclamation is not found', async () => {
findUnique.mockResolvedValue(null);
const err = await Promise.resolve(
load({ params: { id: 'nonexistent' } } as any)
).catch((e: unknown) => e);
expect(isHttpError(err, 404)).toBe(true);
});
it('returns proclamation when found', async () => {
const proclamation = mockProclamation();
findUnique.mockResolvedValue(proclamation);
const result = (await load({ params: { id: 'proc-1' } } as any)) as any;
expect(result.proclamation).toEqual(proclamation);
});
it('queries by params.id with frequency and category (including parent)', async () => {
findUnique.mockResolvedValue(mockProclamation());
await load({ params: { id: 'proc-1' } } as any);
expect(findUnique).toHaveBeenCalledWith({
where: { id: 'proc-1' },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
}
});
});
it('returns proclamation with parent category when present', async () => {
const proclamation = mockProclamation({
category: {
id: 2,
name: 'Inner Healing',
parentId: 1,
parent: { id: 1, name: 'Healing', parentId: null }
}
});
findUnique.mockResolvedValue(proclamation);
const result = (await load({ params: { id: 'proc-1' } } as any)) as any;
expect(result.proclamation.category.parent.name).toBe('Healing');
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockProclamation, mockCategory, createUrl } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: { findMany: vi.fn() },
proclamationCategory: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const proclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/proclamations page server load', () => {
beforeEach(() => {
proclamationFindMany.mockReset();
categoryFindMany.mockReset();
});
it('returns proclamations and categories with no filters', async () => {
const proclamations = [mockProclamation()];
const categories = [mockCategory()];
proclamationFindMany.mockResolvedValue(proclamations);
categoryFindMany.mockResolvedValue(categories);
const url = createUrl('/proclamations');
const result = (await load({ url } as any)) as any;
expect(result.proclamations).toEqual(proclamations);
expect(result.categories).toEqual(categories);
expect(result.search).toBe('');
expect(result.categoryId).toBeNull();
});
it('passes search filter to findMany when search param is present', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'healing' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { name: { contains: 'healing', mode: 'insensitive' } }
})
);
});
it('returns search string from URL params', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'peace' });
const result = (await load({ url } as any)) as any;
expect(result.search).toBe('peace');
});
it('passes categoryId filter when category param is present', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { category: '3' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { categoryId: 3 }
})
);
});
it('returns parsed categoryId from URL params', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { category: '5' });
const result = (await load({ url } as any)) as any;
expect(result.categoryId).toBe(5);
});
it('combines search and category filters', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'love', category: '2' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
name: { contains: 'love', mode: 'insensitive' },
categoryId: 2
}
})
);
});
it('includes solfeggioFrequency and category relations', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations');
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
include: { solfeggioFrequency: true, category: true }
})
);
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { LayoutServerLoad } from './$types';
import { prisma } from '$lib/db';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
return { userRoleId: null };
}
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { roleId: true }
});
return { userRoleId: user?.roleId ?? null };
};
+4 -4
View File
@@ -3,22 +3,22 @@
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import TopBar from '$lib/components/ui/topBar/TopBar.svelte'; import TopBar from '$lib/components/ui/topBar/TopBar.svelte';
let { children } = $props(); let { data, children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<title>Rhema Wave Technologies</title> <title>Rhema Wave Technologies</title>
<meta name="description" content="Rhema Wave Technologies User Portal — manage affirmations, audio content, and solfeggio frequencies." /> <meta name="description" content="Rhema Wave Technologies User Portal — manage proclamations, audio content, and solfeggio frequencies." />
<meta name="theme-color" content="#7B2D8E" /> <meta name="theme-color" content="#7B2D8E" />
<meta property="og:title" content="Rhema Wave Technologies" /> <meta property="og:title" content="Rhema Wave Technologies" />
<meta property="og:description" content="Manage affirmations, audio content, and solfeggio frequencies." /> <meta property="og:description" content="Manage proclamations, audio content, and solfeggio frequencies." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Rhema Wave Technologies" /> <meta property="og:site_name" content="Rhema Wave Technologies" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
</svelte:head> </svelte:head>
<TopBar /> <TopBar userRoleId={data.userRoleId} />
<div class="flex min-h-0 flex-1 px-8"> <div class="flex min-h-0 flex-1 px-8">
{@render children()} {@render children()}
</div> </div>
-15
View File
@@ -1,15 +0,0 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
export const GET: RequestHandler = async () => {
const affirmations = await prisma.affirmation.findMany();
if (!affirmations) {
throw error(500, 'unable to fetch affirmations');
}
return new Response(
JSON.stringify({
affirmations
})
);
};
@@ -0,0 +1,30 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
export const GET: RequestHandler = async ({ url }) => {
const key = url.searchParams.get('key') ?? '';
const email = url.searchParams.get('email') ?? '';
if (key === '' || email === '') {
const proclamations = await prisma.proclamation.findMany();
if (!proclamations) {
throw error(500, 'unable to fetch proclamations');
}
return new Response(
JSON.stringify({
proclamations
})
);
}
// Check that the key exists
// Check that the email exists
// Check that the email belongs to a user
// Check that the key belongs to the user
// Write a helper function for this
// Return all proclamations attached to the user
return new Response(JSON.stringify({ error: 'not implemented' }), { status: 501 });
};