fix: resolve runtime errors and missing routes
- Fix $effect orphan error: move $effect calls from store constructors to initEffects() methods called from component context - Fix icon rendering: create DynamicIcon component to render Lucide icons from name strings instead of displaying raw text - Add /boards/new route for board creation - Fix seed emails (admin@launcher.local / user@launcher.local) to pass Zod email validation
This commit is contained in:
+4
-4
@@ -29,10 +29,10 @@ async function main() {
|
|||||||
// --- Admin User ---
|
// --- Admin User ---
|
||||||
const adminPassword = await bcrypt.hash('admin123', 12);
|
const adminPassword = await bcrypt.hash('admin123', 12);
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { email: 'admin@localhost' },
|
where: { email: 'admin@launcher.local' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
email: 'admin@localhost',
|
email: 'admin@launcher.local',
|
||||||
password: adminPassword,
|
password: adminPassword,
|
||||||
displayName: 'Administrator',
|
displayName: 'Administrator',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
@@ -44,10 +44,10 @@ async function main() {
|
|||||||
// --- Regular User ---
|
// --- Regular User ---
|
||||||
const userPassword = await bcrypt.hash('user123', 12);
|
const userPassword = await bcrypt.hash('user123', 12);
|
||||||
const regularUser = await prisma.user.upsert({
|
const regularUser = await prisma.user.upsert({
|
||||||
where: { email: 'user@localhost' },
|
where: { email: 'user@launcher.local' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
email: 'user@localhost',
|
email: 'user@launcher.local',
|
||||||
password: userPassword,
|
password: userPassword,
|
||||||
displayName: 'Demo User',
|
displayName: 'Demo User',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
|
||||||
interface BoardSummary {
|
interface BoardSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
{#if board.icon}
|
{#if board.icon}
|
||||||
<span class="text-xl">{board.icon}</span>
|
<DynamicIcon name={board.icon} size={22} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
|
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
|
||||||
B
|
B
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
<div class="mb-6 flex items-start justify-between">
|
<div class="mb-6 flex items-start justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<span class="text-2xl">{icon}</span>
|
<DynamicIcon name={icon} size={28} />
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
|
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ui } from '$lib/stores/ui.svelte.js';
|
import { ui } from '$lib/stores/ui.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
|
||||||
interface BoardLink {
|
interface BoardLink {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -150,7 +151,7 @@
|
|||||||
onclick={() => ui.closeMobileSidebar()}
|
onclick={() => ui.closeMobileSidebar()}
|
||||||
>
|
>
|
||||||
{#if board.icon}
|
{#if board.icon}
|
||||||
<span class="shrink-0 text-base">{board.icon}</span>
|
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<span class="text-base">{icon}</span>
|
<DynamicIcon name={icon} size={18} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="font-medium text-foreground">{title}</span>
|
<span class="font-medium text-foreground">{title}</span>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as icons from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string | null;
|
||||||
|
size?: number;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, size = 16, class: className = '' }: Props = $props();
|
||||||
|
|
||||||
|
// Convert kebab-case to PascalCase: "layout-dashboard" → "LayoutDashboard"
|
||||||
|
function toPascalCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.split('-')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconComponent = $derived(
|
||||||
|
name ? (icons as Record<string, unknown>)[toPascalCase(name)] ?? null : null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if iconComponent}
|
||||||
|
<svelte:component this={iconComponent} {size} class={className} />
|
||||||
|
{/if}
|
||||||
@@ -31,7 +31,10 @@ class SearchStore {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be called from within a component to set up reactive search effect */
|
||||||
|
initEffects() {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const q = this.query;
|
const q = this.query;
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ class ThemeStore {
|
|||||||
this.#systemPreference = e.matches ? 'dark' : 'light';
|
this.#systemPreference = e.matches ? 'dark' : 'light';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be called from within a component to set up persistence and DOM effects */
|
||||||
|
initEffects() {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, this.mode);
|
localStorage.setItem(THEME_STORAGE_KEY, this.mode);
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ class UiStore {
|
|||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be called from within a component to set up persistence effects */
|
||||||
|
initEffects() {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(this.sidebarCollapsed));
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(this.sidebarCollapsed));
|
||||||
|
|||||||
@@ -5,9 +5,17 @@
|
|||||||
import MainLayout from '$lib/components/layout/MainLayout.svelte';
|
import MainLayout from '$lib/components/layout/MainLayout.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { theme } from '$lib/stores/theme.svelte';
|
||||||
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
import { search } from '$lib/stores/search.svelte';
|
||||||
|
|
||||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||||
|
|
||||||
|
// Initialize store effects within component context
|
||||||
|
theme.initEffects();
|
||||||
|
ui.initEffects();
|
||||||
|
search.initEffects();
|
||||||
|
|
||||||
// Pages that should NOT have the main layout (login, register)
|
// Pages that should NOT have the main layout (login, register)
|
||||||
const noLayoutPaths = ['/login', '/register'];
|
const noLayoutPaths = ['/login', '/register'];
|
||||||
const showLayout = $derived(!noLayoutPaths.includes($page.url.pathname));
|
const showLayout = $derived(!noLayoutPaths.includes($page.url.pathname));
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { PageServerLoad, Actions } from './$types.js';
|
||||||
|
import { redirect, fail } from '@sveltejs/kit';
|
||||||
|
import { superValidate, message } from 'sveltekit-superforms';
|
||||||
|
import { zod } from '$lib/utils/zod-adapter.js';
|
||||||
|
import { createBoardSchema } from '$lib/utils/validators.js';
|
||||||
|
import * as boardService from '$lib/server/services/boardService.js';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user || locals.user.role !== 'admin') {
|
||||||
|
throw redirect(302, '/boards');
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await superValidate(zod(createBoardSchema));
|
||||||
|
return { form };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, locals }) => {
|
||||||
|
if (!locals.user || locals.user.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await superValidate(request, zod(createBoardSchema));
|
||||||
|
if (!form.valid) {
|
||||||
|
return fail(400, { form });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const board = await boardService.createBoard({
|
||||||
|
...form.data,
|
||||||
|
createdById: locals.user.id
|
||||||
|
});
|
||||||
|
throw redirect(302, `/boards/${board.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && 'status' in err && err.status === 302) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return message(form, 'Failed to create board', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types.js';
|
||||||
|
import { superForm } from 'sveltekit-superforms';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
const { form, errors, enhance, submitting } = superForm(data.form);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>New Board — Web App Launcher</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/boards" class="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← Back to Boards
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-6 text-3xl font-bold text-foreground">New Board</h1>
|
||||||
|
|
||||||
|
<form method="POST" use:enhance class="space-y-4 rounded-xl border border-border bg-card p-6">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={$form.name}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="My Dashboard"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if $errors.name}<p class="mt-1 text-xs text-destructive">{$errors.name}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="description" class="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||||
|
<input
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
bind:value={$form.description}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="icon" class="mb-1 block text-sm font-medium text-foreground">Icon (Lucide name)</label>
|
||||||
|
<input
|
||||||
|
id="icon"
|
||||||
|
name="icon"
|
||||||
|
type="text"
|
||||||
|
bind:value={$form.icon}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="layout-dashboard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" name="isDefault" bind:checked={$form.isDefault} class="rounded" />
|
||||||
|
Default board
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" name="isGuestAccessible" bind:checked={$form.isGuestAccessible} class="rounded" />
|
||||||
|
Guest accessible
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<a href="/boards" class="rounded-lg border border-border px-4 py-2 text-sm text-foreground hover:bg-accent">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={$submitting}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{$submitting ? 'Creating...' : 'Create Board'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,10 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
port: 5181,
|
||||||
|
host: '0.0.0.0'
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
|||||||
Reference in New Issue
Block a user