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:
2026-03-24 22:39:23 +03:00
parent e6b50fb4f1
commit bb3b1a5db7
13 changed files with 192 additions and 8 deletions
+4 -4
View File
@@ -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',
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+27
View File
@@ -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}
+3
View File
@@ -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) {
+3
View File
@@ -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);
+3
View File
@@ -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));
+8
View File
@@ -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));
+41
View File
@@ -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 });
}
}
};
+88
View File
@@ -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">
&larr; 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
View File
@@ -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',