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 ---
|
||||
const adminPassword = await bcrypt.hash('admin123', 12);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@localhost' },
|
||||
where: { email: 'admin@launcher.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@localhost',
|
||||
email: 'admin@launcher.local',
|
||||
password: adminPassword,
|
||||
displayName: 'Administrator',
|
||||
role: 'admin',
|
||||
@@ -44,10 +44,10 @@ async function main() {
|
||||
// --- Regular User ---
|
||||
const userPassword = await bcrypt.hash('user123', 12);
|
||||
const regularUser = await prisma.user.upsert({
|
||||
where: { email: 'user@localhost' },
|
||||
where: { email: 'user@launcher.local' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'user@localhost',
|
||||
email: 'user@launcher.local',
|
||||
password: userPassword,
|
||||
displayName: 'Demo User',
|
||||
role: 'user',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface BoardSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,7 +26,7 @@
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
{#if board.icon}
|
||||
<span class="text-xl">{board.icon}</span>
|
||||
<DynamicIcon name={board.icon} size={22} />
|
||||
{:else}
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
|
||||
B
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description: string | null;
|
||||
@@ -13,7 +15,7 @@
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if icon}
|
||||
<span class="text-2xl">{icon}</span>
|
||||
<DynamicIcon name={icon} size={28} />
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface BoardLink {
|
||||
id: string;
|
||||
@@ -150,7 +151,7 @@
|
||||
onclick={() => ui.closeMobileSidebar()}
|
||||
>
|
||||
{#if board.icon}
|
||||
<span class="shrink-0 text-base">{board.icon}</span>
|
||||
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
|
||||
{:else}
|
||||
<span
|
||||
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">
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon: string | null;
|
||||
@@ -29,7 +31,7 @@
|
||||
</svg>
|
||||
|
||||
{#if icon}
|
||||
<span class="text-base">{icon}</span>
|
||||
<DynamicIcon name={icon} size={18} />
|
||||
{/if}
|
||||
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Must be called from within a component to set up reactive search effect */
|
||||
initEffects() {
|
||||
$effect(() => {
|
||||
const q = this.query;
|
||||
if (q.length < 2) {
|
||||
|
||||
@@ -56,7 +56,10 @@ class ThemeStore {
|
||||
this.#systemPreference = e.matches ? 'dark' : 'light';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Must be called from within a component to set up persistence and DOM effects */
|
||||
initEffects() {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(THEME_STORAGE_KEY, this.mode);
|
||||
|
||||
@@ -40,7 +40,10 @@ class UiStore {
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
}
|
||||
}
|
||||
|
||||
/** Must be called from within a component to set up persistence effects */
|
||||
initEffects() {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(this.sidebarCollapsed));
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
import MainLayout from '$lib/components/layout/MainLayout.svelte';
|
||||
import { page } from '$app/stores';
|
||||
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();
|
||||
|
||||
// Initialize store effects within component context
|
||||
theme.initEffects();
|
||||
ui.initEffects();
|
||||
search.initEffects();
|
||||
|
||||
// Pages that should NOT have the main layout (login, register)
|
||||
const noLayoutPaths = ['/login', '/register'];
|
||||
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({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5181,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
environment: 'node',
|
||||
|
||||
Reference in New Issue
Block a user