Customize Proclamations Admin page
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
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
|
||||
|
||||
- **Framework:** SvelteKit 2.x with Svelte 5 (runes API: `$props()`, `$state()`, `$derived()`, `$effect()`)
|
||||
- **Language:** TypeScript (strict mode)
|
||||
- **Styling:** Tailwind CSS 4.x (Vite plugin, not PostCSS)
|
||||
- **Database:** PostgreSQL via Prisma 7.x (driver adapter with `pg` pool)
|
||||
- **Auth:** `better-auth` with email/password + Google OAuth
|
||||
- **Rich Text:** TipTap 3.x
|
||||
- **Runtime:** Node.js 22, deployed via `@sveltejs/adapter-node`
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
npm run build # Production build
|
||||
npm run check # Type-check (svelte-kit sync + svelte-check)
|
||||
npm run lint # Prettier check + ESLint
|
||||
npm run format # Auto-format with Prettier
|
||||
npm run test # Run all tests once (vitest --run)
|
||||
npm run test:unit # Run tests in watch mode (vitest)
|
||||
npx vitest run src/path/to/file.spec.ts # Run a single test file
|
||||
npm run migrate -- --name <name> # Create a new Prisma migration
|
||||
npm run deploy # Apply pending Prisma migrations
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Routing & Auth Guard
|
||||
|
||||
- `src/routes/(protected)/` — route group requiring authentication. The `+layout.server.ts` in this group checks `locals.user` against the DB and throws 403 if missing.
|
||||
- `src/routes/account/setup/` — intentionally outside `(protected)` so logged-in users without a profile can complete setup.
|
||||
- `src/hooks.server.ts` — runs `auth.api.getSession()` on every request, injecting `session` and `user` into `event.locals`.
|
||||
|
||||
### Server/Client Split
|
||||
|
||||
- Auth config: `src/lib/auth.ts` (server) / `src/lib/auth-client.ts` (client)
|
||||
- DB access: `src/lib/db.ts` (server-only) — creates a `pg` pool with optional SSL via `DO_CA_CERTIFICATE` env var for DigitalOcean managed DB.
|
||||
- Prisma generated client outputs to `src/lib/generated/prisma/` (gitignored).
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Svelte 5 runes throughout** — use `$props()`, `$state()`, `$derived()`, `$effect()` (not Svelte 4 reactivity).
|
||||
- **SvelteKit form actions** for mutations (create/edit proclamations, registration, account setup).
|
||||
- **API routes** at `src/routes/api/v1/` (proclamations, license keys).
|
||||
|
||||
### Database Schema
|
||||
|
||||
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
|
||||
|
||||
Vitest 4.x with two project configurations in `vite.config.ts`:
|
||||
- **`client`** — browser tests (Playwright/Chromium) matching `*.svelte.{test,spec}.{js,ts}`
|
||||
- **`server`** — Node environment tests matching `*.{test,spec}.{js,ts}` (excludes `.svelte.*`)
|
||||
|
||||
Global config: `expect: { requireAssertions: true }`.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Prettier:** tabs, single quotes, no trailing commas, semicolons, 100 char width
|
||||
- **ESLint:** flat config with typescript-eslint + eslint-plugin-svelte
|
||||
- Svelte files parsed with `prettier-plugin-svelte`; class sorting via `prettier-plugin-tailwindcss`
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Server infrastructure:** NixOS, configured declaratively in the `Webserver-Infra` repo
|
||||
- **Secrets:** Managed via `sops-nix` (no plaintext `.env` files on the server)
|
||||
- **CI/CD:** Forgejo Actions on push to `main` → `npm ci` → migrate → build → rsync to `/var/lib/portal/current/` → restart
|
||||
- **Hosting:** DigitalOcean droplet (NixOS), managed PostgreSQL with SSL, self-hosted Forgejo runner
|
||||
- **Portal path:** `/var/lib/portal/current` (deployed by CI, owned by `portal` user)
|
||||
- **Reverse proxy:** Caddy with automatic HTTPS at `rhemawavetech.com`
|
||||
- **Logs:** `journalctl -u portal -f`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. Optional: `DO_CA_CERTIFICATE` (path to CA cert for managed DB SSL).
|
||||
@@ -0,0 +1,26 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIETTCCArWgAwIBAgIUSiKrBdf9KHXL2om54yA9sQTxeawwDQYJKoZIhvcNAQEM
|
||||
BQAwQDE+MDwGA1UEAww1MTdmNmMxNDktZmQ1Yy00MTRmLTlkNjctYjIxYTc2MWE3
|
||||
ZDlhIEdFTiAxIFByb2plY3QgQ0EwHhcNMjUwNDI5MTc0NDA5WhcNMzUwNDI3MTc0
|
||||
NDA5WjBAMT4wPAYDVQQDDDUxN2Y2YzE0OS1mZDVjLTQxNGYtOWQ2Ny1iMjFhNzYx
|
||||
YTdkOWEgR0VOIDEgUHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCC
|
||||
AYoCggGBANMf/9zhlInHTm5CrsnxHZQcqwy2sr4i4w2aWbpCa9j3rWcSyS7PAd+i
|
||||
p2u1xTEoboJcbUghZYdmS92epVgejAHsHn4yx8LUsmdQvVF8imNlJfZA5mV09Voj
|
||||
MpRwmXhQWCxyNqn/pmjMEKtpTm7TCr2GjEPDJC4gHSmPZF1EqJ3wWK+leoeZvRuE
|
||||
omamd2oP60CxtHxt+8zcI6V80sC4QDze2qqRQ3rv1u2MkymUTAQT8kkb1pGmC/E+
|
||||
wanXI1gI5nLqhMcoY55WujA/BOgGLWM1fNCIUWkAoQXUV6G0rM8aYDbTUvuTR0Fk
|
||||
6LP96/bhlxuhrkTSbezO7kwGngiD0rKL4zKFjdwqBbbL1ZjAgfncmzb9zE6NW6Ad
|
||||
Z8zcLgGCC0Z74GhXNsqNuxY2Mmqo0B/YUa1ahiFjNeXAk14mMXVGG7pbdu+YUUpU
|
||||
I3duyxwefZsr3KHvz31HhX2jtcaVFrbOF/Svu23MqRhvj9cCUhiF34h7BqEqMFJP
|
||||
AeIJ/O8xjwIDAQABoz8wPTAdBgNVHQ4EFgQUQyY3hBxq86mcYi8vRRIkCcDsmkMw
|
||||
DwYDVR0TBAgwBgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGB
|
||||
AJqSrBQ8Qi6nIT1N0jo4WXQ5J9p9cyxYcaoRKkdOnvtnB9IXYwX6Ea4w7btZI3O5
|
||||
eYamItTqNAC9ZZmwc2i16v1Nfpm50vGJjZZUMXYkxx1e9/kzOsUn4h6Q6H6+490Y
|
||||
PAl7kn4Ngi7xU3bwxt0K80dU5p3a16r0A93y8GYW/WDuGRuGgrmRz6+psic4U8j4
|
||||
nzHPFeJqtg9I00XYtzsFIIYrbAZT+jMuViVWCNAHJPyQYip8LkEzAfnlrszo9P9G
|
||||
ieFN/wZbaPaVdju8wsSXN35yxy/eOsJ1p6gA4vzNdTtOmbMAlTQJ65QLu+HJrPpA
|
||||
M5gjIf6QDSJyNpvWcjVROn6DiUnhsbe6EhRIPf6e8R0xwTe6y59XiU3sdsSDC0xp
|
||||
3R2v7khln6IUimCsgs9lFvhOSZp4DmGuMs+CVCbqr8rtPTj5xWkeitkjlmiVSkg/
|
||||
z2IItxzG4pGmlPc17TCgKzI6bkNoCZvJ92Wvq85q5LcQegJFQjASZKPFXNOm3SDq
|
||||
wQ==
|
||||
-----END CERTIFICATE-----
|
||||
Generated
-27
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772956932,
|
||||
"narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "608d0cadfed240589a7eea422407a547ad626a14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -2,9 +2,8 @@
|
||||
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
|
||||
import type { PrismaScalarType, RelationOptionConfig } from '$lib/svelte-admin/types';
|
||||
import ComboBox from './ComboBox.svelte';
|
||||
|
||||
import TipTap from '$lib/components/tiptap/TipTap.svelte';
|
||||
type RelationOptionsData = Record<string, { label: string; value: string | number }[]>;
|
||||
|
||||
type Props = {
|
||||
fields: string[];
|
||||
fieldTypes: Record<string, PrismaScalarType>;
|
||||
@@ -56,6 +55,11 @@
|
||||
placeholder="Select {label}"
|
||||
value={getValue(field) as string | number | undefined}
|
||||
/>
|
||||
{:else if type === 'Tiptap'}
|
||||
<TipTap
|
||||
name={field}
|
||||
initialContent={String(getValue(field) ?? '')}
|
||||
/>
|
||||
{:else if type === 'Boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related?.closest(`[data-combobox="${id}"]`)) return;
|
||||
open = false;
|
||||
search = '';
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
@@ -97,6 +96,7 @@
|
||||
|
||||
<div class="relative" data-combobox={id}>
|
||||
<input type="hidden" {name} value={selectedValue ?? ''} />
|
||||
<input type="hidden" name={name + 'Label'} value={selectedValue != null && selectedValue !== '' ? selectedLabel() : search} />
|
||||
|
||||
{#if selectedValue != null && selectedValue !== ''}
|
||||
<div
|
||||
|
||||
@@ -97,13 +97,13 @@ const models: AdminModelConfig[] = [
|
||||
displayNameSingular: 'Proclamation',
|
||||
idType: 'String',
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'price'],
|
||||
listFields: ['name', 'category.displayName', 'solfeggioFrequency.name', 'price'],
|
||||
searchFields: ['name', 'content'],
|
||||
formFields: ['name', 'content', 'solfeggioFrequencyId', 'categoryId', 'price'],
|
||||
readonlyFields: ['id'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
content: 'String',
|
||||
content: 'Tiptap',
|
||||
solfeggioFrequencyId: 'Int',
|
||||
categoryId: 'Int',
|
||||
price: 'Float'
|
||||
@@ -153,7 +153,7 @@ const models: AdminModelConfig[] = [
|
||||
displayNameSingular: 'Proclamation Category',
|
||||
idType: 'Int',
|
||||
include: { proclamations: true, children: true, parent: true },
|
||||
listFields: ['name', 'parent.name'],
|
||||
listFields: ['displayName', 'parent.name'],
|
||||
searchFields: ['name'],
|
||||
formFields: ['name', 'parentId'],
|
||||
readonlyFields: ['id'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type PrismaScalarType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime';
|
||||
export type PrismaScalarType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap';
|
||||
|
||||
export type RelationOptionConfig = {
|
||||
model: string;
|
||||
|
||||
@@ -7,6 +7,69 @@ import { prisma } from '$lib/db';
|
||||
import type { RelationOptionConfig } from '$lib/svelte-admin/types';
|
||||
import { generateLicenseKey } from '$lib/utils';
|
||||
|
||||
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
|
||||
if (!category) return '';
|
||||
const name = String(category.name ?? '');
|
||||
const parent = category.parent as Record<string, unknown> | undefined;
|
||||
if (parent?.name) return `${String(parent.name)}/${name}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
function parseIntId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) || parsed <= 0 ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findOrCreateCategoryByLabel(label: string): Promise<number> {
|
||||
const normalized = label.trim();
|
||||
if (!normalized) throw new Error('Invalid category label');
|
||||
const [parentName, childName] = normalized.split('/').map((v) => v.trim());
|
||||
|
||||
if (childName) {
|
||||
let parent = await prisma.proclamationCategory.findFirst({
|
||||
where: { name: parentName, parentId: null }
|
||||
});
|
||||
if (!parent) {
|
||||
parent = await prisma.proclamationCategory.create({ data: { name: parentName } });
|
||||
}
|
||||
|
||||
let child = await prisma.proclamationCategory.findFirst({
|
||||
where: { name: childName, parentId: parent.id }
|
||||
});
|
||||
if (!child) {
|
||||
child = await prisma.proclamationCategory.create({
|
||||
data: { name: childName, parentId: parent.id }
|
||||
});
|
||||
}
|
||||
return child.id;
|
||||
}
|
||||
|
||||
let category = await prisma.proclamationCategory.findFirst({
|
||||
where: { name: normalized, parentId: null }
|
||||
});
|
||||
if (!category) {
|
||||
category = await prisma.proclamationCategory.create({ data: { name: normalized } });
|
||||
}
|
||||
return category.id;
|
||||
}
|
||||
|
||||
async function resolveCategoryIdFromFormData(formData: FormData, categoryField: string): Promise<number | null> {
|
||||
const categoryValue = formData.get(categoryField);
|
||||
const categoryLabelValue = formData.get(`${categoryField}Label`);
|
||||
|
||||
const parsedId = parseIntId(categoryValue);
|
||||
if (parsedId) return parsedId;
|
||||
|
||||
const label = String(categoryLabelValue ?? categoryValue ?? '').trim();
|
||||
if (!label) return null;
|
||||
|
||||
return await findOrCreateCategoryByLabel(label);
|
||||
}
|
||||
|
||||
async function loadRelationOptions(
|
||||
relationOptions: Record<string, RelationOptionConfig> | undefined
|
||||
) {
|
||||
@@ -19,14 +82,30 @@ async function loadRelationOptions(
|
||||
const delegate = (prisma as any)[key];
|
||||
if (!delegate) continue;
|
||||
|
||||
const shouldIncludeParent = config.model === 'ProclamationCategory';
|
||||
const select: Record<string, unknown> = {
|
||||
[config.valueField]: true,
|
||||
[config.labelField]: true
|
||||
};
|
||||
if (shouldIncludeParent) {
|
||||
(select as Record<string, unknown>).parent = { select: { name: true } };
|
||||
}
|
||||
|
||||
const records = await delegate.findMany({
|
||||
select: { [config.valueField]: true, [config.labelField]: true }
|
||||
select
|
||||
});
|
||||
|
||||
result[field] = records.map((r: Record<string, unknown>) => ({
|
||||
label: String(r[config.labelField]),
|
||||
value: r[config.valueField] as string | number
|
||||
}));
|
||||
result[field] = records.map((r: Record<string, unknown>) => {
|
||||
const value = r[config.valueField] as string | number;
|
||||
const label =
|
||||
config.model === 'ProclamationCategory'
|
||||
? getCategoryDisplayName(r)
|
||||
: String(r[config.labelField]);
|
||||
return {
|
||||
label,
|
||||
value
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -41,11 +120,28 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
|
||||
const where = config.accessFilter ? config.accessFilter(adminUser.roleId) : undefined;
|
||||
|
||||
const records = await delegate.findMany({
|
||||
let records = await delegate.findMany({
|
||||
...(config.include ? { include: config.include } : {}),
|
||||
...(where ? { where } : {})
|
||||
});
|
||||
|
||||
if (config.modelName === 'Proclamation') {
|
||||
records = records.map((r: Record<string, unknown>) => {
|
||||
const category = r.category as Record<string, unknown> | undefined;
|
||||
if (category) {
|
||||
(category as Record<string, unknown>).displayName = getCategoryDisplayName(category);
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
if (config.modelName === 'ProclamationCategory') {
|
||||
records = records.map((r: Record<string, unknown>) => {
|
||||
(r as Record<string, unknown>).displayName = getCategoryDisplayName(r as Record<string, unknown>);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
const relationOptionsData = await loadRelationOptions(config.relationOptions);
|
||||
|
||||
return {
|
||||
@@ -77,6 +173,13 @@ export const actions: Actions = {
|
||||
(data as Record<string, unknown>).key = generateLicenseKey();
|
||||
}
|
||||
|
||||
if (config.modelName === 'Proclamation') {
|
||||
const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId');
|
||||
if (resolvedCategoryId != null) {
|
||||
(data as Record<string, unknown>).categoryId = resolvedCategoryId;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const delegate = getDelegate(config);
|
||||
await delegate.create({ data });
|
||||
|
||||
@@ -6,6 +6,69 @@ import { extractFormData } from '$lib/svelte-admin/form-data';
|
||||
import { prisma } from '$lib/db';
|
||||
import type { RelationOptionConfig } from '$lib/svelte-admin/types';
|
||||
|
||||
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
|
||||
if (!category) return '';
|
||||
const name = String(category.name ?? '');
|
||||
const parent = category.parent as Record<string, unknown> | undefined;
|
||||
if (parent?.name) return `${String(parent.name)}/${name}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
function parseIntId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) || parsed <= 0 ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findOrCreateCategoryByLabel(label: string): Promise<number> {
|
||||
const normalized = label.trim();
|
||||
if (!normalized) throw new Error('Invalid category label');
|
||||
const [parentName, childName] = normalized.split('/').map((v) => v.trim());
|
||||
|
||||
if (childName) {
|
||||
let parent = await prisma.proclamationCategory.findFirst({
|
||||
where: { name: parentName, parentId: null }
|
||||
});
|
||||
if (!parent) {
|
||||
parent = await prisma.proclamationCategory.create({ data: { name: parentName } });
|
||||
}
|
||||
|
||||
let child = await prisma.proclamationCategory.findFirst({
|
||||
where: { name: childName, parentId: parent.id }
|
||||
});
|
||||
if (!child) {
|
||||
child = await prisma.proclamationCategory.create({
|
||||
data: { name: childName, parentId: parent.id }
|
||||
});
|
||||
}
|
||||
return child.id;
|
||||
}
|
||||
|
||||
let category = await prisma.proclamationCategory.findFirst({
|
||||
where: { name: normalized, parentId: null }
|
||||
});
|
||||
if (!category) {
|
||||
category = await prisma.proclamationCategory.create({ data: { name: normalized } });
|
||||
}
|
||||
return category.id;
|
||||
}
|
||||
|
||||
async function resolveCategoryIdFromFormData(formData: FormData, categoryField: string): Promise<number | null> {
|
||||
const categoryValue = formData.get(categoryField);
|
||||
const categoryLabelValue = formData.get(`${categoryField}Label`);
|
||||
|
||||
const parsedId = parseIntId(categoryValue);
|
||||
if (parsedId) return parsedId;
|
||||
|
||||
const label = String(categoryLabelValue ?? categoryValue ?? '').trim();
|
||||
if (!label) return null;
|
||||
|
||||
return await findOrCreateCategoryByLabel(label);
|
||||
}
|
||||
|
||||
async function loadRelationOptions(
|
||||
relationOptions: Record<string, RelationOptionConfig> | undefined
|
||||
) {
|
||||
@@ -18,14 +81,30 @@ async function loadRelationOptions(
|
||||
const delegate = (prisma as any)[key];
|
||||
if (!delegate) continue;
|
||||
|
||||
const shouldIncludeParent = config.model === 'ProclamationCategory';
|
||||
const select: Record<string, unknown> = {
|
||||
[config.valueField]: true,
|
||||
[config.labelField]: true
|
||||
};
|
||||
if (shouldIncludeParent) {
|
||||
(select as Record<string, unknown>).parent = { select: { name: true } };
|
||||
}
|
||||
|
||||
const records = await delegate.findMany({
|
||||
select: { [config.valueField]: true, [config.labelField]: true }
|
||||
select
|
||||
});
|
||||
|
||||
result[field] = records.map((r: Record<string, unknown>) => ({
|
||||
label: String(r[config.labelField]),
|
||||
value: r[config.valueField] as string | number
|
||||
}));
|
||||
result[field] = records.map((r: Record<string, unknown>) => {
|
||||
const value = r[config.valueField] as string | number;
|
||||
const label =
|
||||
config.model === 'ProclamationCategory'
|
||||
? getCategoryDisplayName(r)
|
||||
: String(r[config.labelField]);
|
||||
return {
|
||||
label,
|
||||
value
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -73,6 +152,13 @@ export const actions: Actions = {
|
||||
const id = parseId(config, params.id!);
|
||||
const data = extractFormData(formData, config.formFields, config.fieldTypes);
|
||||
|
||||
if (config.modelName === 'Proclamation') {
|
||||
const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId');
|
||||
if (resolvedCategoryId != null) {
|
||||
(data as Record<string, unknown>).categoryId = resolvedCategoryId;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const delegate = getDelegate(config);
|
||||
await delegate.update({ where: { id }, data });
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/proclamations"
|
||||
href="/admin/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>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
|
||||
if (!category) return '';
|
||||
const name = String(category.name ?? '');
|
||||
const parent = category.parent as Record<string, unknown> | undefined;
|
||||
if (parent?.name) return `${String(parent.name)}/${name}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -18,20 +23,29 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
const [proclamations, categories] = await Promise.all([
|
||||
prisma.proclamation.findMany({
|
||||
where,
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: true
|
||||
},
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
prisma.proclamationCategory.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
|
||||
]);
|
||||
|
||||
const normalizedProclamations = proclamations.map((p) => {
|
||||
if (p.category) {
|
||||
(p.category as Record<string, unknown>).displayName = getCategoryDisplayName(
|
||||
p.category as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const normalizedCategories = categories.map((c) => ({
|
||||
...c,
|
||||
displayName: getCategoryDisplayName(c)
|
||||
}));
|
||||
|
||||
return {
|
||||
proclamations,
|
||||
categories,
|
||||
proclamations: normalizedProclamations,
|
||||
categories: normalizedCategories,
|
||||
search,
|
||||
categoryId: categoryId ? parseInt(categoryId, 10) : null
|
||||
};
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
function displayCategoryName(category: { name: string; displayName?: string }) {
|
||||
return category.displayName ?? category.name;
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let categoryId: number | null = $state(null);
|
||||
let initialized = $state(false);
|
||||
@@ -56,7 +60,7 @@
|
||||
>
|
||||
<option value={null}>All Categories</option>
|
||||
{#each data.categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
<option value={cat.id}>{displayCategoryName(cat)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -87,7 +91,7 @@
|
||||
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>
|
||||
<p class="mt-1 text-sm text-gray-500">{displayCategoryName(proclamation.category)}</p>
|
||||
<div class="mt-auto flex items-center justify-between pt-4">
|
||||
<span class="text-sm text-purple-600">
|
||||
{proclamation.solfeggioFrequency.value} Hz —
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { canAccessAdmin } from '$lib/svelte-admin/roles';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const parentData = typeof parent === 'function' ? await parent() : {};
|
||||
const userRoleId = (parentData as { userRoleId?: number }).userRoleId ?? null;
|
||||
if (userRoleId != null && !canAccessAdmin(userRoleId)) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const proclamation = await prisma.proclamation.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: {
|
||||
include: { parent: true }
|
||||
}
|
||||
category: { include: { parent: true } }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { load } from './+page.server';
|
||||
|
||||
const findUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/proclamations/[id] page server load', () => {
|
||||
describe('(protected)/admin/proclamations/[id] page server load', () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ 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', () => {
|
||||
describe('(protected)/admin/proclamations page server load', () => {
|
||||
beforeEach(() => {
|
||||
proclamationFindMany.mockReset();
|
||||
categoryFindMany.mockReset();
|
||||
@@ -26,10 +26,13 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue(proclamations);
|
||||
categoryFindMany.mockResolvedValue(categories);
|
||||
|
||||
const url = createUrl('/proclamations');
|
||||
const url = createUrl('/admin/proclamations');
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.proclamations).toEqual(proclamations);
|
||||
expect(result.categories).toEqual(categories);
|
||||
expect(result.categories[0]).toEqual(expect.objectContaining({
|
||||
...categories[0],
|
||||
displayName: categories[0].name
|
||||
}));
|
||||
expect(result.search).toBe('');
|
||||
expect(result.categoryId).toBeNull();
|
||||
});
|
||||
@@ -38,7 +41,7 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/proclamations', { search: 'healing' });
|
||||
const url = createUrl('/admin/proclamations', { search: 'healing' });
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
@@ -52,7 +55,7 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/proclamations', { search: 'peace' });
|
||||
const url = createUrl('/admin/proclamations', { search: 'peace' });
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.search).toBe('peace');
|
||||
});
|
||||
@@ -61,7 +64,7 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/proclamations', { category: '3' });
|
||||
const url = createUrl('/admin/proclamations', { category: '3' });
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
@@ -75,7 +78,7 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/proclamations', { category: '5' });
|
||||
const url = createUrl('/admin/proclamations', { category: '5' });
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.categoryId).toBe(5);
|
||||
});
|
||||
@@ -84,7 +87,7 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/proclamations', { search: 'love', category: '2' });
|
||||
const url = createUrl('/admin/proclamations', { search: 'love', category: '2' });
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
@@ -101,12 +104,12 @@ describe('(protected)/proclamations page server load', () => {
|
||||
proclamationFindMany.mockResolvedValue([]);
|
||||
categoryFindMany.mockResolvedValue([]);
|
||||
|
||||
const url = createUrl('/proclamations');
|
||||
const url = createUrl('/admin/proclamations');
|
||||
await load({ url } as any);
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: { solfeggioFrequency: true, category: true }
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user