[0]) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ redirect(302, '/');
+ }
+
+ const users = await db
+ .select({
+ id: userTable.id,
+ username: userTable.username,
+ displayname: userTable.displayname,
+ role: userTable.role,
+ createdAt: userTable.createdAt
+ })
+ .from(userTable)
+ .orderBy(userTable.createdAt);
+
+ return { users };
+};
+
+export const actions = {
+ create: async ({ request, locals }: import('./$types').RequestEvent) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const displayname = formData.get('displayname') || '';
+ const password = formData.get('password');
+ const confirmPassword = formData.get('confirmPassword');
+ const role = formData.get('role');
+
+ if (
+ typeof username !== 'string' ||
+ typeof displayname !== 'string' ||
+ typeof password !== 'string' ||
+ typeof confirmPassword !== 'string' ||
+ typeof role !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ if (username.length < 3 || username.length > 255) {
+ return fail(400, { message: 'Username must be 3-255 characters' });
+ }
+
+ if (password !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(password);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ if (role !== 'admin' && role !== 'user') {
+ return fail(400, { message: 'Invalid role' });
+ }
+
+ // Check if username already exists
+ const [existing] = await db
+ .select({ id: userTable.id })
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (existing) {
+ return fail(400, { message: 'Username already exists' });
+ }
+
+ const passwordHash = await hashPassword(password);
+ const userId = generateIdFromEntropySize(10);
+
+ await db.insert(userTable).values({
+ id: userId,
+ username,
+ displayname,
+ passwordHash,
+ role: role as 'admin' | 'user'
+ });
+
+ return { success: true };
+ },
+
+ updateDisplayname: async ({ request, locals }: import('./$types').RequestEvent) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+ const displayname = formData.get('displayname');
+
+ if (typeof userId !== 'string' || typeof displayname !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ await db.update(userTable).set({ displayname }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ resetPassword: async ({ request, locals }: import('./$types').RequestEvent) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId === 'string' && userId === locals.user.id) {
+ return fail(400, { message: 'Cannot reset your own password from here' });
+ }
+ const newPassword = formData.get('newPassword');
+ const confirmPassword = formData.get('confirmPassword');
+
+ if (
+ typeof userId !== 'string' ||
+ typeof newPassword !== 'string' ||
+ typeof confirmPassword !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (newPassword !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(newPassword);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ const passwordHash = await hashPassword(newPassword);
+
+ await db.update(userTable).set({ passwordHash }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ delete: async ({ request, locals }: import('./$types').RequestEvent) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (userId === locals.user.id) {
+ return fail(400, { message: 'Cannot delete your own account' });
+ }
+
+ // Prevent deleting admin users
+ const [targetUser] = await db
+ .select({ role: userTable.role })
+ .from(userTable)
+ .where(eq(userTable.id, userId))
+ .limit(1);
+
+ if (targetUser?.role === 'admin') {
+ return fail(400, { message: 'Cannot delete an admin user' });
+ }
+
+ await db.delete(userTable).where(eq(userTable.id, userId));
+
+ return { success: true };
+ }
+};
+;null as any as Actions;
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1e67551
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM oven/bun:alpine
+WORKDIR /app
+COPY package.json bun.lock* ./
+RUN bun install --frozen-lockfile || bun install
+COPY . .
+RUN bun run build
+ENV NODE_ENV=production
+EXPOSE 3000
+CMD ["bun", "build/index.js"]
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..e44a4eb
--- /dev/null
+++ b/components.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://shadcn-svelte.com/schema.json",
+ "tailwind": {
+ "css": "src\\app.css",
+ "baseColor": "neutral"
+ },
+ "aliases": {
+ "components": "$lib/components",
+ "utils": "$lib/utils",
+ "ui": "$lib/components/ui",
+ "hooks": "$lib/hooks",
+ "lib": "$lib"
+ },
+ "typescript": true,
+ "registry": "https://shadcn-svelte.com/registry"
+}
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000..2c0ea05
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ schema: './src/lib/schema.ts',
+ dialect: 'mysql',
+ dbCredentials: {
+ host: process.env.MYSQL_HOST || 'localhost',
+ port: Number(process.env.MYSQL_PORT) || 3306,
+ user: process.env.MYSQL_USER || 'emly',
+ password: process.env.MYSQL_PASSWORD || '',
+ database: process.env.MYSQL_DATABASE || 'emly_bugreports'
+ }
+});
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f043c37
--- /dev/null
+++ b/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "emly-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev --port 3001",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@internationalized/date": "^3.11.0",
+ "@lucide/svelte": "^0.561.0",
+ "@sveltejs/adapter-node": "^5.5.3",
+ "@sveltejs/kit": "^2.52.0",
+ "@sveltejs/vite-plugin-svelte": "^5.1.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/node": "^25.2.3",
+ "drizzle-kit": "^0.31.9",
+ "svelte": "^5.51.2",
+ "svelte-check": "^4.4.0",
+ "tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.4.1"
+ },
+ "dependencies": {
+ "@lucia-auth/adapter-drizzle": "^1.1.0",
+ "@node-rs/argon2": "^2.0.2",
+ "drizzle-orm": "^0.38.4",
+ "lucia": "^3.2.2",
+ "mysql2": "^3.17.1",
+ "bits-ui": "^2.15.5",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^3.4.1",
+ "tailwind-variants": "^3.2.2",
+ "jszip": "^3.10.1",
+ "lucide-svelte": "^0.469.0"
+ },
+ "type": "module"
+}
diff --git a/src/app.css b/src/app.css
new file mode 100644
index 0000000..ac256d3
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1,121 @@
+@import "tailwindcss";
+
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..c0087e0
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,10 @@
+declare global {
+ namespace App {
+ interface Locals {
+ user: import('lucia').User | null;
+ session: import('lucia').Session | null;
+ }
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..a2e03b2
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
new file mode 100644
index 0000000..0909941
--- /dev/null
+++ b/src/hooks.server.ts
@@ -0,0 +1,60 @@
+import type { Handle } from '@sveltejs/kit';
+import { lucia } from '$lib/server/auth';
+import { initLogger, Log } from '$lib/server/logger';
+
+// Initialize dashboard logger
+initLogger();
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const ip =
+ event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ event.request.headers.get('x-real-ip') ||
+ event.getClientAddress?.() ||
+ 'unknown';
+ Log('HTTP', `${event.request.method} ${event.url.pathname} from ${ip}`);
+
+ const sessionId = event.cookies.get(lucia.sessionCookieName);
+
+ if (!sessionId) {
+ event.locals.user = null;
+ event.locals.session = null;
+ return resolve(event);
+ }
+
+ const { session, user } = await lucia.validateSession(sessionId);
+
+ if (session && session.fresh) {
+ const sessionCookie = lucia.createSessionCookie(session.id);
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ }
+
+ if (!session) {
+ Log('AUTH', `Invalid session from ip=${ip}`);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ }
+
+ // If user is disabled, invalidate their session and clear cookie
+ if (session && user && !user.enabled) {
+ Log('AUTH', `Disabled user rejected: username=${user.username} ip=${ip}`);
+ await lucia.invalidateSession(session.id);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ event.locals.user = null;
+ event.locals.session = null;
+ return resolve(event);
+ }
+
+ event.locals.user = user;
+ event.locals.session = session;
+ return resolve(event);
+};
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 0000000..a005691
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 0000000..a7b0cf7
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 0000000..236bcad
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 0000000..2ec67dc
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 0000000..f78b97a
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 0000000..1835d91
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 0000000..a64ee76
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
new file mode 100644
index 0000000..f0a19a8
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 0000000..7ef2b5f
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
new file mode 100644
index 0000000..b22d1d5
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog.svelte b/src/lib/components/ui/alert-dialog/alert-dialog.svelte
new file mode 100644
index 0000000..7ea78bb
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/alert-dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/alert-dialog/index.ts b/src/lib/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..269538e
--- /dev/null
+++ b/src/lib/components/ui/alert-dialog/index.ts
@@ -0,0 +1,37 @@
+import Root from "./alert-dialog.svelte";
+import Portal from "./alert-dialog-portal.svelte";
+import Trigger from "./alert-dialog-trigger.svelte";
+import Title from "./alert-dialog-title.svelte";
+import Action from "./alert-dialog-action.svelte";
+import Cancel from "./alert-dialog-cancel.svelte";
+import Footer from "./alert-dialog-footer.svelte";
+import Header from "./alert-dialog-header.svelte";
+import Overlay from "./alert-dialog-overlay.svelte";
+import Content from "./alert-dialog-content.svelte";
+import Description from "./alert-dialog-description.svelte";
+
+export {
+ Root,
+ Title,
+ Action,
+ Cancel,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ //
+ Root as AlertDialog,
+ Title as AlertDialogTitle,
+ Action as AlertDialogAction,
+ Cancel as AlertDialogCancel,
+ Portal as AlertDialogPortal,
+ Footer as AlertDialogFooter,
+ Header as AlertDialogHeader,
+ Trigger as AlertDialogTrigger,
+ Overlay as AlertDialogOverlay,
+ Content as AlertDialogContent,
+ Description as AlertDialogDescription,
+};
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..a8296ae
--- /dev/null
+++ b/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+{#if href}
+
+ {@render children?.()}
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..fb585d7
--- /dev/null
+++ b/src/lib/components/ui/button/index.ts
@@ -0,0 +1,17 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+} from "./button.svelte";
+
+export {
+ Root,
+ type ButtonProps as Props,
+ //
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+};
diff --git a/src/lib/components/ui/card/card-action.svelte b/src/lib/components/ui/card/card-action.svelte
new file mode 100644
index 0000000..cc36c56
--- /dev/null
+++ b/src/lib/components/ui/card/card-action.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..bc90b83
--- /dev/null
+++ b/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..9b20ac7
--- /dev/null
+++ b/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..2d4d0f2
--- /dev/null
+++ b/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..2501788
--- /dev/null
+++ b/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..7447231
--- /dev/null
+++ b/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..99448cc
--- /dev/null
+++ b/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..4d3fce4
--- /dev/null
+++ b/src/lib/components/ui/card/index.ts
@@ -0,0 +1,25 @@
+import Root from "./card.svelte";
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+import Action from "./card-action.svelte";
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Action,
+ //
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+ Action as CardAction,
+};
diff --git a/src/lib/components/ui/dialog/dialog-close.svelte b/src/lib/components/ui/dialog/dialog-close.svelte
new file mode 100644
index 0000000..840b2f6
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte
new file mode 100644
index 0000000..5c6ee6d
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-content.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+ {@render children?.()}
+ {#if showCloseButton}
+
+
+ Close
+
+ {/if}
+
+
diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte
new file mode 100644
index 0000000..3845023
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte
new file mode 100644
index 0000000..e7ff446
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte
new file mode 100644
index 0000000..4e5c447
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte
new file mode 100644
index 0000000..f81ad83
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-portal.svelte b/src/lib/components/ui/dialog/dialog-portal.svelte
new file mode 100644
index 0000000..ccfa79c
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte
new file mode 100644
index 0000000..e4d4b34
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-trigger.svelte b/src/lib/components/ui/dialog/dialog-trigger.svelte
new file mode 100644
index 0000000..9d1e801
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog.svelte b/src/lib/components/ui/dialog/dialog.svelte
new file mode 100644
index 0000000..211672c
--- /dev/null
+++ b/src/lib/components/ui/dialog/dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts
new file mode 100644
index 0000000..076cef5
--- /dev/null
+++ b/src/lib/components/ui/dialog/index.ts
@@ -0,0 +1,34 @@
+import Root from "./dialog.svelte";
+import Portal from "./dialog-portal.svelte";
+import Title from "./dialog-title.svelte";
+import Footer from "./dialog-footer.svelte";
+import Header from "./dialog-header.svelte";
+import Overlay from "./dialog-overlay.svelte";
+import Content from "./dialog-content.svelte";
+import Description from "./dialog-description.svelte";
+import Trigger from "./dialog-trigger.svelte";
+import Close from "./dialog-close.svelte";
+
+export {
+ Root,
+ Title,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Close,
+ //
+ Root as Dialog,
+ Title as DialogTitle,
+ Portal as DialogPortal,
+ Footer as DialogFooter,
+ Header as DialogHeader,
+ Trigger as DialogTrigger,
+ Overlay as DialogOverlay,
+ Content as DialogContent,
+ Description as DialogDescription,
+ Close as DialogClose,
+};
diff --git a/src/lib/components/ui/empty/empty-content.svelte b/src/lib/components/ui/empty/empty-content.svelte
new file mode 100644
index 0000000..f5a9c68
--- /dev/null
+++ b/src/lib/components/ui/empty/empty-content.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/empty/empty-description.svelte b/src/lib/components/ui/empty/empty-description.svelte
new file mode 100644
index 0000000..85a866c
--- /dev/null
+++ b/src/lib/components/ui/empty/empty-description.svelte
@@ -0,0 +1,23 @@
+
+
+a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/empty/empty-header.svelte b/src/lib/components/ui/empty/empty-header.svelte
new file mode 100644
index 0000000..296eaf8
--- /dev/null
+++ b/src/lib/components/ui/empty/empty-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/empty/empty-media.svelte b/src/lib/components/ui/empty/empty-media.svelte
new file mode 100644
index 0000000..0b4e45d
--- /dev/null
+++ b/src/lib/components/ui/empty/empty-media.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/empty/empty-title.svelte b/src/lib/components/ui/empty/empty-title.svelte
new file mode 100644
index 0000000..8c237aa
--- /dev/null
+++ b/src/lib/components/ui/empty/empty-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/empty/empty.svelte b/src/lib/components/ui/empty/empty.svelte
new file mode 100644
index 0000000..4ccf060
--- /dev/null
+++ b/src/lib/components/ui/empty/empty.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/empty/index.ts b/src/lib/components/ui/empty/index.ts
new file mode 100644
index 0000000..ae4c106
--- /dev/null
+++ b/src/lib/components/ui/empty/index.ts
@@ -0,0 +1,22 @@
+import Root from "./empty.svelte";
+import Header from "./empty-header.svelte";
+import Media from "./empty-media.svelte";
+import Title from "./empty-title.svelte";
+import Description from "./empty-description.svelte";
+import Content from "./empty-content.svelte";
+
+export {
+ Root,
+ Header,
+ Media,
+ Title,
+ Description,
+ Content,
+ //
+ Root as Empty,
+ Header as EmptyHeader,
+ Media as EmptyMedia,
+ Title as EmptyTitle,
+ Description as EmptyDescription,
+ Content as EmptyContent,
+};
diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..f47b6d3
--- /dev/null
+++ b/src/lib/components/ui/input/index.ts
@@ -0,0 +1,7 @@
+import Root from "./input.svelte";
+
+export {
+ Root,
+ //
+ Root as Input,
+};
diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..ff1a4c8
--- /dev/null
+++ b/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,52 @@
+
+
+{#if type === "file"}
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..8bfca0b
--- /dev/null
+++ b/src/lib/components/ui/label/index.ts
@@ -0,0 +1,7 @@
+import Root from "./label.svelte";
+
+export {
+ Root,
+ //
+ Root as Label,
+};
diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..d71afbc
--- /dev/null
+++ b/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/select/index.ts b/src/lib/components/ui/select/index.ts
new file mode 100644
index 0000000..4dec358
--- /dev/null
+++ b/src/lib/components/ui/select/index.ts
@@ -0,0 +1,37 @@
+import Root from "./select.svelte";
+import Group from "./select-group.svelte";
+import Label from "./select-label.svelte";
+import Item from "./select-item.svelte";
+import Content from "./select-content.svelte";
+import Trigger from "./select-trigger.svelte";
+import Separator from "./select-separator.svelte";
+import ScrollDownButton from "./select-scroll-down-button.svelte";
+import ScrollUpButton from "./select-scroll-up-button.svelte";
+import GroupHeading from "./select-group-heading.svelte";
+import Portal from "./select-portal.svelte";
+
+export {
+ Root,
+ Group,
+ Label,
+ Item,
+ Content,
+ Trigger,
+ Separator,
+ ScrollDownButton,
+ ScrollUpButton,
+ GroupHeading,
+ Portal,
+ //
+ Root as Select,
+ Group as SelectGroup,
+ Label as SelectLabel,
+ Item as SelectItem,
+ Content as SelectContent,
+ Trigger as SelectTrigger,
+ Separator as SelectSeparator,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton as SelectScrollUpButton,
+ GroupHeading as SelectGroupHeading,
+ Portal as SelectPortal,
+};
diff --git a/src/lib/components/ui/select/select-content.svelte b/src/lib/components/ui/select/select-content.svelte
new file mode 100644
index 0000000..4b9ca43
--- /dev/null
+++ b/src/lib/components/ui/select/select-content.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/src/lib/components/ui/select/select-group-heading.svelte b/src/lib/components/ui/select/select-group-heading.svelte
new file mode 100644
index 0000000..1fab5f0
--- /dev/null
+++ b/src/lib/components/ui/select/select-group-heading.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/select/select-group.svelte b/src/lib/components/ui/select/select-group.svelte
new file mode 100644
index 0000000..a1f43bf
--- /dev/null
+++ b/src/lib/components/ui/select/select-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/select/select-item.svelte b/src/lib/components/ui/select/select-item.svelte
new file mode 100644
index 0000000..b85eef6
--- /dev/null
+++ b/src/lib/components/ui/select/select-item.svelte
@@ -0,0 +1,38 @@
+
+
+
+ {#snippet children({ selected, highlighted })}
+
+ {#if selected}
+
+ {/if}
+
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ {/snippet}
+
diff --git a/src/lib/components/ui/select/select-label.svelte b/src/lib/components/ui/select/select-label.svelte
new file mode 100644
index 0000000..4696025
--- /dev/null
+++ b/src/lib/components/ui/select/select-label.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/select/select-portal.svelte b/src/lib/components/ui/select/select-portal.svelte
new file mode 100644
index 0000000..424bcdd
--- /dev/null
+++ b/src/lib/components/ui/select/select-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/select/select-scroll-down-button.svelte b/src/lib/components/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 0000000..3629205
--- /dev/null
+++ b/src/lib/components/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/select/select-scroll-up-button.svelte b/src/lib/components/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 0000000..1aa2300
--- /dev/null
+++ b/src/lib/components/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/select/select-separator.svelte b/src/lib/components/ui/select/select-separator.svelte
new file mode 100644
index 0000000..0eac3eb
--- /dev/null
+++ b/src/lib/components/ui/select/select-separator.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/lib/components/ui/select/select-trigger.svelte b/src/lib/components/ui/select/select-trigger.svelte
new file mode 100644
index 0000000..dbb81df
--- /dev/null
+++ b/src/lib/components/ui/select/select-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/select/select.svelte b/src/lib/components/ui/select/select.svelte
new file mode 100644
index 0000000..05eb663
--- /dev/null
+++ b/src/lib/components/ui/select/select.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts
new file mode 100644
index 0000000..82442d2
--- /dev/null
+++ b/src/lib/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
+import Root from "./separator.svelte";
+
+export {
+ Root,
+ //
+ Root as Separator,
+};
diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte
new file mode 100644
index 0000000..f40999f
--- /dev/null
+++ b/src/lib/components/ui/separator/separator.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/index.ts b/src/lib/components/ui/sheet/index.ts
new file mode 100644
index 0000000..28d7da1
--- /dev/null
+++ b/src/lib/components/ui/sheet/index.ts
@@ -0,0 +1,34 @@
+import Root from "./sheet.svelte";
+import Portal from "./sheet-portal.svelte";
+import Trigger from "./sheet-trigger.svelte";
+import Close from "./sheet-close.svelte";
+import Overlay from "./sheet-overlay.svelte";
+import Content from "./sheet-content.svelte";
+import Header from "./sheet-header.svelte";
+import Footer from "./sheet-footer.svelte";
+import Title from "./sheet-title.svelte";
+import Description from "./sheet-description.svelte";
+
+export {
+ Root,
+ Close,
+ Trigger,
+ Portal,
+ Overlay,
+ Content,
+ Header,
+ Footer,
+ Title,
+ Description,
+ //
+ Root as Sheet,
+ Close as SheetClose,
+ Trigger as SheetTrigger,
+ Portal as SheetPortal,
+ Overlay as SheetOverlay,
+ Content as SheetContent,
+ Header as SheetHeader,
+ Footer as SheetFooter,
+ Title as SheetTitle,
+ Description as SheetDescription,
+};
diff --git a/src/lib/components/ui/sheet/sheet-close.svelte b/src/lib/components/ui/sheet/sheet-close.svelte
new file mode 100644
index 0000000..ae382c1
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte
new file mode 100644
index 0000000..065fe04
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-content.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-description.svelte b/src/lib/components/ui/sheet/sheet-description.svelte
new file mode 100644
index 0000000..333b17a
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-footer.svelte b/src/lib/components/ui/sheet/sheet-footer.svelte
new file mode 100644
index 0000000..dd9ed84
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte
new file mode 100644
index 0000000..757a6a5
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte
new file mode 100644
index 0000000..345e197
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-portal.svelte b/src/lib/components/ui/sheet/sheet-portal.svelte
new file mode 100644
index 0000000..f3085a3
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte
new file mode 100644
index 0000000..9fda327
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-trigger.svelte b/src/lib/components/ui/sheet/sheet-trigger.svelte
new file mode 100644
index 0000000..e266975
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet.svelte b/src/lib/components/ui/sheet/sheet.svelte
new file mode 100644
index 0000000..5bf9783
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/constants.ts b/src/lib/components/ui/sidebar/constants.ts
new file mode 100644
index 0000000..4de4435
--- /dev/null
+++ b/src/lib/components/ui/sidebar/constants.ts
@@ -0,0 +1,6 @@
+export const SIDEBAR_COOKIE_NAME = "sidebar:state";
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = "16rem";
+export const SIDEBAR_WIDTH_MOBILE = "18rem";
+export const SIDEBAR_WIDTH_ICON = "3rem";
+export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
diff --git a/src/lib/components/ui/sidebar/context.svelte.ts b/src/lib/components/ui/sidebar/context.svelte.ts
new file mode 100644
index 0000000..15248ad
--- /dev/null
+++ b/src/lib/components/ui/sidebar/context.svelte.ts
@@ -0,0 +1,81 @@
+import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
+import { getContext, setContext } from "svelte";
+import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
+
+type Getter = () => T;
+
+export type SidebarStateProps = {
+ /**
+ * A getter function that returns the current open state of the sidebar.
+ * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
+ * component.
+ */
+ open: Getter;
+
+ /**
+ * A function that sets the open state of the sidebar. To support `bind:open`, we need
+ * a source of truth for changing the open state to ensure it will be synced throughout
+ * the sub-components and any `bind:` references.
+ */
+ setOpen: (open: boolean) => void;
+};
+
+class SidebarState {
+ readonly props: SidebarStateProps;
+ open = $derived.by(() => this.props.open());
+ openMobile = $state(false);
+ setOpen: SidebarStateProps["setOpen"];
+ #isMobile: IsMobile;
+ state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
+
+ constructor(props: SidebarStateProps) {
+ this.setOpen = props.setOpen;
+ this.#isMobile = new IsMobile();
+ this.props = props;
+ }
+
+ // Convenience getter for checking if the sidebar is mobile
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
+ get isMobile() {
+ return this.#isMobile.current;
+ }
+
+ // Event handler to apply to the ``
+ handleShortcutKeydown = (e: KeyboardEvent) => {
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ this.toggle();
+ }
+ };
+
+ setOpenMobile = (value: boolean) => {
+ this.openMobile = value;
+ };
+
+ toggle = () => {
+ return this.#isMobile.current
+ ? (this.openMobile = !this.openMobile)
+ : this.setOpen(!this.open);
+ };
+}
+
+const SYMBOL_KEY = "scn-sidebar";
+
+/**
+ * Instantiates a new `SidebarState` instance and sets it in the context.
+ *
+ * @param props The constructor props for the `SidebarState` class.
+ * @returns The `SidebarState` instance.
+ */
+export function setSidebar(props: SidebarStateProps): SidebarState {
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
+}
+
+/**
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
+ * so you cannot destructure it.
+ * @returns The `SidebarState` instance.
+ */
+export function useSidebar(): SidebarState {
+ return getContext(Symbol.for(SYMBOL_KEY));
+}
diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts
new file mode 100644
index 0000000..318a341
--- /dev/null
+++ b/src/lib/components/ui/sidebar/index.ts
@@ -0,0 +1,75 @@
+import { useSidebar } from "./context.svelte.js";
+import Content from "./sidebar-content.svelte";
+import Footer from "./sidebar-footer.svelte";
+import GroupAction from "./sidebar-group-action.svelte";
+import GroupContent from "./sidebar-group-content.svelte";
+import GroupLabel from "./sidebar-group-label.svelte";
+import Group from "./sidebar-group.svelte";
+import Header from "./sidebar-header.svelte";
+import Input from "./sidebar-input.svelte";
+import Inset from "./sidebar-inset.svelte";
+import MenuAction from "./sidebar-menu-action.svelte";
+import MenuBadge from "./sidebar-menu-badge.svelte";
+import MenuButton from "./sidebar-menu-button.svelte";
+import MenuItem from "./sidebar-menu-item.svelte";
+import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
+import MenuSubButton from "./sidebar-menu-sub-button.svelte";
+import MenuSubItem from "./sidebar-menu-sub-item.svelte";
+import MenuSub from "./sidebar-menu-sub.svelte";
+import Menu from "./sidebar-menu.svelte";
+import Provider from "./sidebar-provider.svelte";
+import Rail from "./sidebar-rail.svelte";
+import Separator from "./sidebar-separator.svelte";
+import Trigger from "./sidebar-trigger.svelte";
+import Root from "./sidebar.svelte";
+
+export {
+ Content,
+ Footer,
+ Group,
+ GroupAction,
+ GroupContent,
+ GroupLabel,
+ Header,
+ Input,
+ Inset,
+ Menu,
+ MenuAction,
+ MenuBadge,
+ MenuButton,
+ MenuItem,
+ MenuSkeleton,
+ MenuSub,
+ MenuSubButton,
+ MenuSubItem,
+ Provider,
+ Rail,
+ Root,
+ Separator,
+ //
+ Root as Sidebar,
+ Content as SidebarContent,
+ Footer as SidebarFooter,
+ Group as SidebarGroup,
+ GroupAction as SidebarGroupAction,
+ GroupContent as SidebarGroupContent,
+ GroupLabel as SidebarGroupLabel,
+ Header as SidebarHeader,
+ Input as SidebarInput,
+ Inset as SidebarInset,
+ Menu as SidebarMenu,
+ MenuAction as SidebarMenuAction,
+ MenuBadge as SidebarMenuBadge,
+ MenuButton as SidebarMenuButton,
+ MenuItem as SidebarMenuItem,
+ MenuSkeleton as SidebarMenuSkeleton,
+ MenuSub as SidebarMenuSub,
+ MenuSubButton as SidebarMenuSubButton,
+ MenuSubItem as SidebarMenuSubItem,
+ Provider as SidebarProvider,
+ Rail as SidebarRail,
+ Separator as SidebarSeparator,
+ Trigger as SidebarTrigger,
+ Trigger,
+ useSidebar,
+};
diff --git a/src/lib/components/ui/sidebar/sidebar-content.svelte b/src/lib/components/ui/sidebar/sidebar-content.svelte
new file mode 100644
index 0000000..f121800
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-footer.svelte b/src/lib/components/ui/sidebar/sidebar-footer.svelte
new file mode 100644
index 0000000..6259cb9
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-footer.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/src/lib/components/ui/sidebar/sidebar-group-action.svelte
new file mode 100644
index 0000000..a76dfe1
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group-action.svelte
@@ -0,0 +1,36 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/src/lib/components/ui/sidebar/sidebar-group-content.svelte
new file mode 100644
index 0000000..415255f
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group-content.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/src/lib/components/ui/sidebar/sidebar-group-label.svelte
new file mode 100644
index 0000000..b2e72b6
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group-label.svelte
@@ -0,0 +1,34 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-group.svelte b/src/lib/components/ui/sidebar/sidebar-group.svelte
new file mode 100644
index 0000000..ec18a69
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-header.svelte b/src/lib/components/ui/sidebar/sidebar-header.svelte
new file mode 100644
index 0000000..a1b2db1
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-header.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-input.svelte b/src/lib/components/ui/sidebar/sidebar-input.svelte
new file mode 100644
index 0000000..19b3666
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-input.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte
new file mode 100644
index 0000000..7d6d459
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
new file mode 100644
index 0000000..d3fe295
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
new file mode 100644
index 0000000..e8ecdb4
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
new file mode 100644
index 0000000..0acd1ec
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+{#snippet Button({ props }: { props?: Record })}
+ {@const mergedProps = mergeProps(buttonProps, props)}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#if !tooltipContent}
+ {@render Button({})}
+{:else}
+
+
+ {#snippet child({ props })}
+ {@render Button({ props })}
+ {/snippet}
+
+
+ {#if typeof tooltipContent === "string"}
+ {tooltipContent}
+ {:else if tooltipContent}
+ {@render tooltipContent()}
+ {/if}
+
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
new file mode 100644
index 0000000..4db4453
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
new file mode 100644
index 0000000..68604e2
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {#if showIcon}
+
+ {/if}
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
new file mode 100644
index 0000000..c8cd4ff
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
new file mode 100644
index 0000000..681d0f1
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
new file mode 100644
index 0000000..76bd1d9
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu.svelte b/src/lib/components/ui/sidebar/sidebar-menu.svelte
new file mode 100644
index 0000000..946ccce
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-provider.svelte b/src/lib/components/ui/sidebar/sidebar-provider.svelte
new file mode 100644
index 0000000..5b0d0aa
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-provider.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-rail.svelte b/src/lib/components/ui/sidebar/sidebar-rail.svelte
new file mode 100644
index 0000000..704d54f
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-rail.svelte
@@ -0,0 +1,36 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-separator.svelte b/src/lib/components/ui/sidebar/sidebar-separator.svelte
new file mode 100644
index 0000000..5a7deda
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-separator.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte
new file mode 100644
index 0000000..1825182
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte
@@ -0,0 +1,35 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte
new file mode 100644
index 0000000..bac55d8
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar.svelte
@@ -0,0 +1,104 @@
+
+
+{#if collapsible === "none"}
+
+ {@render children?.()}
+
+{:else if sidebar.isMobile}
+ sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
+ {...restProps}
+ >
+
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..186db21
--- /dev/null
+++ b/src/lib/components/ui/skeleton/index.ts
@@ -0,0 +1,7 @@
+import Root from "./skeleton.svelte";
+
+export {
+ Root,
+ //
+ Root as Skeleton,
+};
diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte
new file mode 100644
index 0000000..c7e3d26
--- /dev/null
+++ b/src/lib/components/ui/skeleton/skeleton.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/table/index.ts b/src/lib/components/ui/table/index.ts
new file mode 100644
index 0000000..14695c8
--- /dev/null
+++ b/src/lib/components/ui/table/index.ts
@@ -0,0 +1,28 @@
+import Root from "./table.svelte";
+import Body from "./table-body.svelte";
+import Caption from "./table-caption.svelte";
+import Cell from "./table-cell.svelte";
+import Footer from "./table-footer.svelte";
+import Head from "./table-head.svelte";
+import Header from "./table-header.svelte";
+import Row from "./table-row.svelte";
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow,
+};
diff --git a/src/lib/components/ui/table/table-body.svelte b/src/lib/components/ui/table/table-body.svelte
new file mode 100644
index 0000000..29e9687
--- /dev/null
+++ b/src/lib/components/ui/table/table-body.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/table/table-caption.svelte b/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000..4696cff
--- /dev/null
+++ b/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/table/table-cell.svelte b/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000..2c0c26a
--- /dev/null
+++ b/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+ |
diff --git a/src/lib/components/ui/table/table-footer.svelte b/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000..b9b14eb
--- /dev/null
+++ b/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
+
+
+tr]:last:border-b-0", className)}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/table/table-head.svelte b/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000..b67a6f9
--- /dev/null
+++ b/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+ |
diff --git a/src/lib/components/ui/table/table-header.svelte b/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000..f47d259
--- /dev/null
+++ b/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/table/table-row.svelte b/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000..0df769e
--- /dev/null
+++ b/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
+
+
+svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/table/table.svelte b/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000..a334956
--- /dev/null
+++ b/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/textarea/index.ts b/src/lib/components/ui/textarea/index.ts
new file mode 100644
index 0000000..ace797a
--- /dev/null
+++ b/src/lib/components/ui/textarea/index.ts
@@ -0,0 +1,7 @@
+import Root from "./textarea.svelte";
+
+export {
+ Root,
+ //
+ Root as Textarea,
+};
diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte
new file mode 100644
index 0000000..124e9d0
--- /dev/null
+++ b/src/lib/components/ui/textarea/textarea.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts
new file mode 100644
index 0000000..1718604
--- /dev/null
+++ b/src/lib/components/ui/tooltip/index.ts
@@ -0,0 +1,19 @@
+import Root from "./tooltip.svelte";
+import Trigger from "./tooltip-trigger.svelte";
+import Content from "./tooltip-content.svelte";
+import Provider from "./tooltip-provider.svelte";
+import Portal from "./tooltip-portal.svelte";
+
+export {
+ Root,
+ Trigger,
+ Content,
+ Provider,
+ Portal,
+ //
+ Root as Tooltip,
+ Content as TooltipContent,
+ Trigger as TooltipTrigger,
+ Provider as TooltipProvider,
+ Portal as TooltipPortal,
+};
diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte
new file mode 100644
index 0000000..2662522
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip-content.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+ {@render children?.()}
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
diff --git a/src/lib/components/ui/tooltip/tooltip-portal.svelte b/src/lib/components/ui/tooltip/tooltip-portal.svelte
new file mode 100644
index 0000000..d234f7d
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/tooltip/tooltip-provider.svelte b/src/lib/components/ui/tooltip/tooltip-provider.svelte
new file mode 100644
index 0000000..8150bef
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip-provider.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte
new file mode 100644
index 0000000..1acdaa4
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/tooltip/tooltip.svelte b/src/lib/components/ui/tooltip/tooltip.svelte
new file mode 100644
index 0000000..0b0f9ce
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts
new file mode 100644
index 0000000..4829c00
--- /dev/null
+++ b/src/lib/hooks/is-mobile.svelte.ts
@@ -0,0 +1,9 @@
+import { MediaQuery } from "svelte/reactivity";
+
+const DEFAULT_MOBILE_BREAKPOINT = 768;
+
+export class IsMobile extends MediaQuery {
+ constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
+ super(`max-width: ${breakpoint - 1}px`);
+ }
+}
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
new file mode 100644
index 0000000..df3e117
--- /dev/null
+++ b/src/lib/schema.ts
@@ -0,0 +1,74 @@
+import {
+ mysqlTable,
+ int,
+ varchar,
+ text,
+ json,
+ mysqlEnum,
+ timestamp,
+ datetime,
+ boolean,
+ customType
+} from 'drizzle-orm/mysql-core';
+
+const longblob = customType<{ data: Buffer }>({
+ dataType() {
+ return 'longblob';
+ }
+});
+
+export const bugReports = mysqlTable('bug_reports', {
+ id: int('id').autoincrement().primaryKey(),
+ name: varchar('name', { length: 255 }).notNull(),
+ email: varchar('email', { length: 255 }).notNull(),
+ description: text('description').notNull(),
+ hwid: varchar('hwid', { length: 255 }).notNull().default(''),
+ hostname: varchar('hostname', { length: 255 }).notNull().default(''),
+ os_user: varchar('os_user', { length: 255 }).notNull().default(''),
+ submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''),
+ system_info: json('system_info'),
+ status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'),
+ created_at: timestamp('created_at').notNull().defaultNow(),
+ updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
+});
+
+export const bugReportFiles = mysqlTable('bug_report_files', {
+ id: int('id').autoincrement().primaryKey(),
+ report_id: int('report_id')
+ .notNull()
+ .references(() => bugReports.id, { onDelete: 'cascade' }),
+ file_role: mysqlEnum('file_role', [
+ 'screenshot',
+ 'mail_file',
+ 'localstorage',
+ 'config',
+ 'system_info'
+ ]).notNull(),
+ filename: varchar('filename', { length: 255 }).notNull(),
+ mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'),
+ file_size: int('file_size').notNull().default(0),
+ data: longblob('data').notNull(),
+ created_at: timestamp('created_at').notNull().defaultNow()
+});
+
+export const userTable = mysqlTable('user', {
+ id: varchar('id', { length: 255 }).primaryKey(),
+ username: varchar('username', { length: 255 }).notNull().unique(),
+ displayname: varchar('displayname', { length: 255 }).notNull().default(''),
+ passwordHash: varchar('password_hash', { length: 255 }).notNull(),
+ role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'),
+ enabled: boolean('enabled').notNull().default(true),
+ createdAt: timestamp('created_at').notNull().defaultNow()
+});
+
+export const sessionTable = mysqlTable('session', {
+ id: varchar('id', { length: 255 }).primaryKey(),
+ userId: varchar('user_id', { length: 255 })
+ .notNull()
+ .references(() => userTable.id),
+ expiresAt: datetime('expires_at').notNull()
+});
+
+export type BugReport = typeof bugReports.$inferSelect;
+export type BugReportFile = typeof bugReportFiles.$inferSelect;
+export type BugReportStatus = BugReport['status'];
diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts
new file mode 100644
index 0000000..cf3b188
--- /dev/null
+++ b/src/lib/server/auth.ts
@@ -0,0 +1,38 @@
+import { Lucia } from 'lucia';
+import { DrizzleMySQLAdapter } from '@lucia-auth/adapter-drizzle';
+import { db } from './db';
+import { sessionTable, userTable } from '$lib/schema';
+import { dev } from '$app/environment';
+
+const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable);
+
+export const lucia = new Lucia(adapter, {
+ sessionCookie: {
+ attributes: {
+ secure: !dev
+ }
+ },
+ getUserAttributes: (attributes) => {
+ return {
+ username: attributes.username,
+ role: attributes.role,
+ displayname: attributes.displayname,
+ enabled: attributes.enabled
+ };
+ }
+});
+
+declare module 'lucia' {
+ interface Register {
+ Lucia: typeof lucia;
+ DatabaseUserAttributes: DatabaseUserAttributes;
+ }
+}
+
+interface DatabaseUserAttributes {
+ username: string;
+ role: 'admin' | 'user';
+ displayname: string;
+ enabled: boolean;
+}
+// End of file
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts
new file mode 100644
index 0000000..d93f0ea
--- /dev/null
+++ b/src/lib/server/db.ts
@@ -0,0 +1,16 @@
+import { drizzle } from 'drizzle-orm/mysql2';
+import mysql from 'mysql2/promise';
+import * as schema from '$lib/schema';
+import { env } from '$env/dynamic/private';
+
+const pool = mysql.createPool({
+ host: env.MYSQL_HOST || 'localhost',
+ port: Number(env.MYSQL_PORT) || 3306,
+ user: env.MYSQL_USER || 'emly',
+ password: env.MYSQL_PASSWORD,
+ database: env.MYSQL_DATABASE || 'emly_bugreports',
+ connectionLimit: 10,
+ idleTimeout: 60000
+});
+
+export const db = drizzle(pool, { schema, mode: 'default' });
diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts
new file mode 100644
index 0000000..d2769d1
--- /dev/null
+++ b/src/lib/server/logger.ts
@@ -0,0 +1,42 @@
+import { mkdirSync, appendFileSync, existsSync } from "fs";
+import { join } from "path";
+
+let logFilePath: string | null = null;
+
+/**
+ * Initialize the logger. Creates the logs/ directory if needed
+ * and opens the log file in append mode.
+ */
+export function initLogger(filename = "dashboard.log"): void {
+ const logsDir = join(process.cwd(), "logs");
+ if (!existsSync(logsDir)) {
+ mkdirSync(logsDir, { recursive: true });
+ }
+ logFilePath = join(logsDir, filename);
+ Log("LOGGER", "Logger initialized. Writing to:", logFilePath);
+}
+
+/**
+ * Log a timestamped, source-tagged message to stdout and the log file.
+ * Format: [YYYY-MM-DD] - [HH:MM:SS] - [source] - message
+ */
+export function Log(source: string, ...args: unknown[]): void {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10);
+ const time = now.toTimeString().slice(0, 8);
+ const msg = args
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
+ .join(" ");
+
+ const line = `[${date}] - [${time}] - [${source}] - ${msg}`;
+
+ console.log(line);
+
+ if (logFilePath) {
+ try {
+ appendFileSync(logFilePath, line + "\n");
+ } catch {
+ // If file write fails, stdout logging still works
+ }
+ }
+}
diff --git a/src/lib/stores/presence.svelte.ts b/src/lib/stores/presence.svelte.ts
new file mode 100644
index 0000000..87250fa
--- /dev/null
+++ b/src/lib/stores/presence.svelte.ts
@@ -0,0 +1,95 @@
+import { browser } from '$app/environment';
+import { page } from '$app/stores';
+
+export interface ActiveUser {
+ userId: string;
+ username: string;
+ displayname: string;
+ currentPath: string;
+ reportId: number | null;
+ lastSeen: number;
+}
+
+class PresenceStore {
+ activeUsers = $state([]);
+ connected = $state(false);
+
+ private eventSource: EventSource | null = null;
+ private heartbeatInterval: ReturnType | null = null;
+ private currentPath = '/';
+ private unsubscribePage: (() => void) | null = null;
+
+ connect() {
+ if (!browser || this.eventSource) return;
+
+ this.eventSource = new EventSource('/api/presence');
+
+ this.eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ this.activeUsers = data;
+ } catch {
+ // ignore parse errors
+ }
+ };
+
+ this.eventSource.onopen = () => {
+ this.connected = true;
+ };
+
+ this.eventSource.onerror = () => {
+ this.connected = false;
+ // EventSource auto-reconnects
+ };
+
+ // Track current page and send heartbeats
+ this.unsubscribePage = page.subscribe((p) => {
+ this.currentPath = p.url.pathname;
+ });
+
+ // Send heartbeat every 15 seconds
+ this.sendHeartbeat();
+ this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), 15000);
+ }
+
+ disconnect() {
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ }
+ if (this.unsubscribePage) {
+ this.unsubscribePage();
+ this.unsubscribePage = null;
+ }
+ this.connected = false;
+ this.activeUsers = [];
+ }
+
+ private async sendHeartbeat() {
+ try {
+ const reportMatch = this.currentPath.match(/^\/reports\/(\d+)/);
+ const reportId = reportMatch ? Number(reportMatch[1]) : null;
+
+ await fetch('/api/presence/heartbeat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ currentPath: this.currentPath,
+ reportId
+ })
+ });
+ } catch {
+ // ignore heartbeat failures
+ }
+ }
+
+ getViewersForReport(reportId: number): ActiveUser[] {
+ return this.activeUsers.filter((u) => u.reportId === reportId);
+ }
+}
+
+export const presence = new PresenceStore();
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..3cebf9a
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,46 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+export function formatDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+}
+
+export const statusColors: Record = {
+ new: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+ in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+ resolved: 'bg-green-500/20 text-green-400 border-green-500/30',
+ closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
+};
+
+export const statusLabels: Record = {
+ new: 'New',
+ in_review: 'In Review',
+ resolved: 'Resolved',
+ closed: 'Closed'
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChild = T extends { child?: any } ? Omit : T;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChildren = T extends { children?: any } ? Omit : T;
+export type WithoutChildrenOrChild = WithoutChildren>;
+export type WithElementRef = T & { ref?: U | null };
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..024d2f9
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,14 @@
+
+
+
+
{$page.status}
+
{$page.error?.message || 'Something went wrong'}
+
+ Back to Reports
+
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
new file mode 100644
index 0000000..d95b760
--- /dev/null
+++ b/src/routes/+layout.server.ts
@@ -0,0 +1,25 @@
+import type { LayoutServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { eq, count } from 'drizzle-orm';
+
+export const load: LayoutServerLoad = async ({ locals, url }) => {
+ if (url.pathname === '/login') {
+ return { newCount: 0, user: null };
+ }
+
+ if (!locals.user) {
+ redirect(302, '/login');
+ }
+
+ const [result] = await db
+ .select({ count: count() })
+ .from(bugReports)
+ .where(eq(bugReports.status, 'new'));
+
+ return {
+ newCount: result.count,
+ user: locals.user
+ };
+};
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..7c0c80c
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,169 @@
+
+
+{#if !data.user}
+ {@render children()}
+{:else}
+
+
+
+
+
+ EMLy Dashboard
+
+
+
+
+ Menu
+
+
+
+
+ {#snippet child({ props })}
+
+
+ Reports
+
+ {/snippet}
+
+
+ {#if data.user.role === 'admin'}
+
+
+ {#snippet child({ props })}
+
+
+ Users
+
+ {/snippet}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+{/if}
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
new file mode 100644
index 0000000..2f4c72f
--- /dev/null
+++ b/src/routes/+page.server.ts
@@ -0,0 +1,70 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq, like, or, count, sql, desc, and } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ url }) => {
+ const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
+ const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20));
+ const status = url.searchParams.get('status') || '';
+ const search = url.searchParams.get('search') || '';
+
+ const conditions = [];
+
+ if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) {
+ conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed'));
+ }
+
+ if (search) {
+ conditions.push(
+ or(
+ like(bugReports.hostname, `%${search}%`),
+ like(bugReports.os_user, `%${search}%`),
+ like(bugReports.name, `%${search}%`),
+ like(bugReports.email, `%${search}%`)
+ )
+ );
+ }
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ // Get total count
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(bugReports)
+ .where(where);
+
+ // Get paginated reports with file count
+ const reports = await db
+ .select({
+ id: bugReports.id,
+ name: bugReports.name,
+ email: bugReports.email,
+ hostname: bugReports.hostname,
+ os_user: bugReports.os_user,
+ status: bugReports.status,
+ created_at: bugReports.created_at,
+ file_count: count(bugReportFiles.id)
+ })
+ .from(bugReports)
+ .leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id))
+ .where(where)
+ .groupBy(bugReports.id)
+ .orderBy(desc(bugReports.created_at))
+ .limit(pageSize)
+ .offset((page - 1) * pageSize);
+
+ return {
+ reports,
+ pagination: {
+ page,
+ pageSize,
+ total,
+ totalPages: Math.ceil(total / pageSize)
+ },
+ filters: {
+ status,
+ search
+ }
+ };
+};
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..ab285dd
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+ e.key === 'Enter' && applyFilters()}
+ class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
applyFilters()}>
+
+ {statusOptions.find((o) => o.value === statusFilter)?.label || 'All statuses'}
+
+
+
+ {#each statusOptions as option}
+
+ {/each}
+
+
+
+
+ {#if data.filters.search || data.filters.status}
+
+ {/if}
+
+
+
+ {#if data.reports.length === 0}
+
+
+
+
+
+
+ No reports found
+
+ There are no bug reports matching your current filters.
+
+
+ {#if data.filters.search || data.filters.status}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {:else}
+
+
+
+
+ ID
+ Hostname
+ User
+ Reporter
+ Status
+ Files
+ Created
+
+
+
+ {#each data.reports as report (report.id)}
+ goto(`/reports/${report.id}`)}
+ >
+ #{report.id}
+ {report.hostname || '—'}
+ {report.os_user || '—'}
+
+ {report.name}
+ {report.email}
+
+
+
+ {statusLabels[report.status]}
+
+
+
+ {#if report.file_count > 0}
+
+
+ {report.file_count}
+
+ {:else}
+ —
+ {/if}
+
+
+ {formatDate(report.created_at)}
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {#if data.pagination.totalPages > 1}
+
+
+ Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min(
+ data.pagination.page * data.pagination.pageSize,
+ data.pagination.total
+ )} of {data.pagination.total} reports
+
+
+
+ {#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
+ {#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
+
+ {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
+ ...
+ {/if}
+ {/each}
+
+
+
+ {/if}
+
+
diff --git a/src/routes/api/presence/+server.ts b/src/routes/api/presence/+server.ts
new file mode 100644
index 0000000..ff88606
--- /dev/null
+++ b/src/routes/api/presence/+server.ts
@@ -0,0 +1,49 @@
+import type { RequestHandler } from './$types';
+import { presenceMap, sseClients, broadcastPresence } from './state';
+
+export const GET: RequestHandler = async ({ locals }) => {
+ if (!locals.user) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const userId = locals.user.id;
+
+ const stream = new ReadableStream({
+ start(controller) {
+ const clientId = `${userId}-${Date.now()}`;
+
+ sseClients.set(clientId, controller);
+
+ // Send current state immediately
+ const users = Array.from(presenceMap.values()).filter(
+ (u) => Date.now() - u.lastSeen < 60000
+ );
+ controller.enqueue(`data: ${JSON.stringify(users)}\n\n`);
+
+ // Cleanup on close
+ const cleanup = () => {
+ sseClients.delete(clientId);
+ presenceMap.delete(userId);
+ broadcastPresence();
+ };
+
+ // Use a heartbeat to detect disconnection
+ const keepAlive = setInterval(() => {
+ try {
+ controller.enqueue(': keepalive\n\n');
+ } catch {
+ cleanup();
+ clearInterval(keepAlive);
+ }
+ }, 30000);
+ }
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive'
+ }
+ });
+};
diff --git a/src/routes/api/presence/heartbeat/+server.ts b/src/routes/api/presence/heartbeat/+server.ts
new file mode 100644
index 0000000..6b12a07
--- /dev/null
+++ b/src/routes/api/presence/heartbeat/+server.ts
@@ -0,0 +1,25 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { presenceMap, broadcastPresence } from '../state';
+
+export const POST: RequestHandler = async ({ request, locals }) => {
+ if (!locals.user) {
+ error(401, 'Unauthorized');
+ }
+
+ const body = await request.json();
+ const { currentPath, reportId } = body;
+
+ presenceMap.set(locals.user.id, {
+ userId: locals.user.id,
+ username: locals.user.username,
+ displayname: locals.user.displayname,
+ currentPath: currentPath || '/',
+ reportId: reportId || null,
+ lastSeen: Date.now()
+ });
+
+ broadcastPresence();
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/presence/state.ts b/src/routes/api/presence/state.ts
new file mode 100644
index 0000000..5b274bb
--- /dev/null
+++ b/src/routes/api/presence/state.ts
@@ -0,0 +1,20 @@
+import type { ActiveUser } from '$lib/stores/presence.svelte';
+
+// In-memory presence tracking - shared between SSE and heartbeat endpoints
+export const presenceMap = new Map();
+
+// SSE client connections
+export const sseClients = new Map();
+
+export function broadcastPresence() {
+ const users = Array.from(presenceMap.values()).filter((u) => Date.now() - u.lastSeen < 60000);
+ const data = `data: ${JSON.stringify(users)}\n\n`;
+
+ for (const [clientId, controller] of sseClients.entries()) {
+ try {
+ controller.enqueue(data);
+ } catch {
+ sseClients.delete(clientId);
+ }
+ }
+}
diff --git a/src/routes/api/reports/[id]/download/+server.ts b/src/routes/api/reports/[id]/download/+server.ts
new file mode 100644
index 0000000..51595c2
--- /dev/null
+++ b/src/routes/api/reports/[id]/download/+server.ts
@@ -0,0 +1,68 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+import JSZip from 'jszip';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [report] = await db
+ .select()
+ .from(bugReports)
+ .where(eq(bugReports.id, id))
+ .limit(1);
+
+ if (!report) throw error(404, 'Report not found');
+
+ const files = await db
+ .select()
+ .from(bugReportFiles)
+ .where(eq(bugReportFiles.report_id, id));
+
+ const zip = new JSZip();
+
+ // Add report metadata as text file
+ const reportText = [
+ `Bug Report #${report.id}`,
+ `========================`,
+ ``,
+ `Name: ${report.name}`,
+ `Email: ${report.email}`,
+ `Hostname: ${report.hostname}`,
+ `OS User: ${report.os_user}`,
+ `HWID: ${report.hwid}`,
+ `IP: ${report.submitter_ip}`,
+ `Status: ${report.status}`,
+ `Created: ${report.created_at.toISOString()}`,
+ `Updated: ${report.updated_at.toISOString()}`,
+ ``,
+ `Description:`,
+ `------------`,
+ report.description,
+ ``,
+ ...(report.system_info
+ ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)]
+ : [])
+ ].join('\n');
+
+ zip.file('report.txt', reportText);
+
+ // Add all files
+ for (const file of files) {
+ const folder = file.file_role;
+ zip.file(`${folder}/${file.filename}`, file.data);
+ }
+
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
+
+ return new Response(new Uint8Array(zipBuffer), {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': `attachment; filename="report-${id}.zip"`,
+ 'Content-Length': String(zipBuffer.length)
+ }
+ });
+};
diff --git a/src/routes/api/reports/[id]/files/[fileId]/+server.ts b/src/routes/api/reports/[id]/files/[fileId]/+server.ts
new file mode 100644
index 0000000..980bdfd
--- /dev/null
+++ b/src/routes/api/reports/[id]/files/[fileId]/+server.ts
@@ -0,0 +1,28 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReportFiles } from '$lib/schema';
+import { eq, and } from 'drizzle-orm';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const reportId = Number(params.id);
+ const fileId = Number(params.fileId);
+
+ if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID');
+
+ const [file] = await db
+ .select()
+ .from(bugReportFiles)
+ .where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId)))
+ .limit(1);
+
+ if (!file) throw error(404, 'File not found');
+
+ return new Response(new Uint8Array(file.data), {
+ headers: {
+ 'Content-Type': file.mime_type,
+ 'Content-Disposition': `inline; filename="${file.filename}"`,
+ 'Content-Length': String(file.file_size)
+ }
+ });
+};
diff --git a/src/routes/api/reports/refresh/+server.ts b/src/routes/api/reports/refresh/+server.ts
new file mode 100644
index 0000000..5506987
--- /dev/null
+++ b/src/routes/api/reports/refresh/+server.ts
@@ -0,0 +1,13 @@
+import type { RequestHandler } from './$types';
+import { json } from '@sveltejs/kit';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { count } from 'drizzle-orm';
+
+export const GET: RequestHandler = async () => {
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(bugReports);
+
+ return json({ total });
+};
\ No newline at end of file
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts
new file mode 100644
index 0000000..c1ca5af
--- /dev/null
+++ b/src/routes/login/+page.server.ts
@@ -0,0 +1,63 @@
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { verify } from '@node-rs/argon2';
+import { lucia } from '$lib/server/auth';
+import { db } from '$lib/server/db';
+import { userTable } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (locals.user) {
+ redirect(302, '/');
+ }
+};
+
+export const actions: Actions = {
+ default: async ({ request, cookies }) => {
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const password = formData.get('password');
+
+ if (typeof username !== 'string' || typeof password !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ const [user] = await db
+ .select()
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (!user) {
+ return fail(400, { message: 'Invalid username or password' });
+ }
+
+ const validPassword = await verify(user.passwordHash, password, {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+
+ if (!validPassword) {
+ return fail(400, { message: 'Invalid username or password' });
+ }
+
+ if (!user.enabled) {
+ return fail(403, { message: 'Account is disabled' });
+ }
+
+ const session = await lucia.createSession(user.id, {});
+ const sessionCookie = lucia.createSessionCookie(session.id);
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+
+ redirect(302, '/');
+ }
+};
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
new file mode 100644
index 0000000..2c74fee
--- /dev/null
+++ b/src/routes/login/+page.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
EMLy Dashboard
+
Sign in to continue
+
+
+ {#if form?.message}
+
+ {form.message}
+
+ {/if}
+
+
+
+
diff --git a/src/routes/logout/+page.server.ts b/src/routes/logout/+page.server.ts
new file mode 100644
index 0000000..67b748e
--- /dev/null
+++ b/src/routes/logout/+page.server.ts
@@ -0,0 +1,24 @@
+import type { Actions, PageServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { lucia } from '$lib/server/auth';
+
+export const load: PageServerLoad = async () => {
+ redirect(302, '/');
+};
+
+export const actions: Actions = {
+ default: async ({ locals, cookies }) => {
+ if (!locals.session) {
+ redirect(302, '/login');
+ }
+
+ await lucia.invalidateSession(locals.session.id);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+
+ redirect(302, '/login');
+ }
+};
diff --git a/src/routes/reports/[id]/+page.server.ts b/src/routes/reports/[id]/+page.server.ts
new file mode 100644
index 0000000..50cfe11
--- /dev/null
+++ b/src/routes/reports/[id]/+page.server.ts
@@ -0,0 +1,45 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [report] = await db
+ .select()
+ .from(bugReports)
+ .where(eq(bugReports.id, id))
+ .limit(1);
+
+ if (!report) throw error(404, 'Report not found');
+
+ const files = await db
+ .select({
+ id: bugReportFiles.id,
+ report_id: bugReportFiles.report_id,
+ file_role: bugReportFiles.file_role,
+ filename: bugReportFiles.filename,
+ mime_type: bugReportFiles.mime_type,
+ file_size: bugReportFiles.file_size,
+ created_at: bugReportFiles.created_at
+ })
+ .from(bugReportFiles)
+ .where(eq(bugReportFiles.report_id, id));
+
+ return {
+ report: {
+ ...report,
+ system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null,
+ created_at: report.created_at.toISOString(),
+ updated_at: report.updated_at.toISOString()
+ },
+ files: files.map((f) => ({
+ ...f,
+ created_at: f.created_at.toISOString()
+ })),
+ currentUserId: locals.user?.id ?? ''
+ };
+};
diff --git a/src/routes/reports/[id]/+page.svelte b/src/routes/reports/[id]/+page.svelte
new file mode 100644
index 0000000..25a4e2e
--- /dev/null
+++ b/src/routes/reports/[id]/+page.svelte
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Report #{data.report.id}
+
+ {statusLabels[data.report.status]}
+
+
+
+ Submitted by {data.report.name} ({data.report.email})
+
+
+
+ {#if otherViewers.length > 0}
+
+
+ {#each otherViewers as viewer}
+
+
+
+ {(viewer.displayname || viewer.username).charAt(0).toUpperCase()}
+
+
+
+
+ {viewer.displayname || viewer.username} is viewing this report
+
+
+ {/each}
+
+ {/if}
+
+
updateStatus(val)}
+ >
+
+ {statusLabels[data.report.status]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hostname
+
{data.report.hostname || '—'}
+
+
+
OS User
+
{data.report.os_user || '—'}
+
+
+
HWID
+
{data.report.hwid || '—'}
+
+
+
IP Address
+
{data.report.submitter_ip || '—'}
+
+
+
Created
+
{formatDate(data.report.created_at)}
+
+
+
Updated
+
{formatDate(data.report.updated_at)}
+
+
+
+
+
+
+
+
+ Description
+
+
+ {data.report.description}
+
+
+
+
+ {#if data.report.system_info}
+
+
+ System Information
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+ Attached Files ({data.files.length})
+
+
+ {#if data.files.length > 0}
+
+ {@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
+ {#if screenshots.length > 0}
+
+ {#each screenshots as file}
+
+

+
{file.filename}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+ Role
+ Filename
+ Size
+ Action
+
+
+
+ {#each data.files as file}
+ {@const Icon = roleIcons[file.file_role] || FileText}
+
+
+
+
+ {roleLabels[file.file_role] || file.file_role}
+
+
+ {file.filename}
+ {formatBytes(file.file_size)}
+
+
+
+
+ {/each}
+
+
+
+ {:else}
+ No files attached.
+ {/if}
+
+
+
+
+
+
+
+
+ Delete Report
+
+ Are you sure you want to delete report #{data.report.id}? This will permanently remove the
+ report and all attached files. This action cannot be undone.
+
+
+
+ (showDeleteDialog = false)}>Cancel
+
+
+
+
diff --git a/src/routes/reports/[id]/+server.ts b/src/routes/reports/[id]/+server.ts
new file mode 100644
index 0000000..1b67138
--- /dev/null
+++ b/src/routes/reports/[id]/+server.ts
@@ -0,0 +1,39 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+export const PATCH: RequestHandler = async ({ params, request }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const body = await request.json();
+ const { status } = body;
+
+ if (!['new', 'in_review', 'resolved', 'closed'].includes(status)) {
+ throw error(400, 'Invalid status');
+ }
+
+ const [result] = await db
+ .update(bugReports)
+ .set({ status })
+ .where(eq(bugReports.id, id));
+
+ if (result.affectedRows === 0) throw error(404, 'Report not found');
+
+ return json({ success: true });
+};
+
+export const DELETE: RequestHandler = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [result] = await db
+ .delete(bugReports)
+ .where(eq(bugReports.id, id));
+
+ if (result.affectedRows === 0) throw error(404, 'Report not found');
+
+ return json({ success: true });
+};
diff --git a/src/routes/users/+page.server.ts b/src/routes/users/+page.server.ts
new file mode 100644
index 0000000..018e496
--- /dev/null
+++ b/src/routes/users/+page.server.ts
@@ -0,0 +1,256 @@
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { hash } from '@node-rs/argon2';
+import { generateIdFromEntropySize } from 'lucia';
+import { db } from '$lib/server/db';
+import { userTable } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+const PASSWORD_MIN_LENGTH = 8;
+const PASSWORD_MAX_LENGTH = 255;
+
+function validatePassword(password: string): string | null {
+ if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) {
+ return `Password must be ${PASSWORD_MIN_LENGTH}-${PASSWORD_MAX_LENGTH} characters`;
+ }
+ if (!/[A-Z]/.test(password)) {
+ return 'Password must contain at least one uppercase letter';
+ }
+ if (!/[a-z]/.test(password)) {
+ return 'Password must contain at least one lowercase letter';
+ }
+ if (!/[0-9]/.test(password)) {
+ return 'Password must contain at least one number';
+ }
+ if (!/[^A-Za-z0-9]/.test(password)) {
+ return 'Password must contain at least one special character';
+ }
+ return null;
+}
+
+async function hashPassword(password: string): Promise {
+ return hash(password, {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+}
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ redirect(302, '/');
+ }
+
+ const users = await db
+ .select({
+ id: userTable.id,
+ username: userTable.username,
+ displayname: userTable.displayname,
+ role: userTable.role,
+ enabled: userTable.enabled,
+ createdAt: userTable.createdAt
+ })
+ .from(userTable)
+ .orderBy(userTable.createdAt);
+
+ return { users };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const displayname = formData.get('displayname') || '';
+ const password = formData.get('password');
+ const confirmPassword = formData.get('confirmPassword');
+ const role = formData.get('role');
+
+ if (
+ typeof username !== 'string' ||
+ typeof displayname !== 'string' ||
+ typeof password !== 'string' ||
+ typeof confirmPassword !== 'string' ||
+ typeof role !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ if (username.length < 3 || username.length > 255) {
+ return fail(400, { message: 'Username must be 3-255 characters' });
+ }
+
+ if (password !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(password);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ if (role !== 'admin' && role !== 'user') {
+ return fail(400, { message: 'Invalid role' });
+ }
+
+ // Check if username already exists
+ const [existing] = await db
+ .select({ id: userTable.id })
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (existing) {
+ return fail(400, { message: 'Username already exists' });
+ }
+
+ const passwordHash = await hashPassword(password);
+ const userId = generateIdFromEntropySize(10);
+
+ await db.insert(userTable).values({
+ id: userId,
+ username,
+ displayname,
+ passwordHash,
+ role: role as 'admin' | 'user'
+ });
+
+ return { success: true };
+ },
+
+ updateDisplayname: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+ const displayname = formData.get('displayname');
+
+ if (typeof userId !== 'string' || typeof displayname !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ await db.update(userTable).set({ displayname }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ resetPassword: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId === 'string' && userId === locals.user.id) {
+ return fail(400, { message: 'Cannot reset your own password from here' });
+ }
+ const newPassword = formData.get('newPassword');
+ const confirmPassword = formData.get('confirmPassword');
+
+ if (
+ typeof userId !== 'string' ||
+ typeof newPassword !== 'string' ||
+ typeof confirmPassword !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (newPassword !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(newPassword);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ const passwordHash = await hashPassword(newPassword);
+
+ await db.update(userTable).set({ passwordHash }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ toggleEnabled: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ // Cannot disable yourself
+ if (userId === locals.user.id) {
+ return fail(400, { message: 'Cannot disable your own account' });
+ }
+
+ // Cannot disable other admins
+ const [targetUser] = await db
+ .select({ role: userTable.role, enabled: userTable.enabled })
+ .from(userTable)
+ .where(eq(userTable.id, userId))
+ .limit(1);
+
+ if (!targetUser) {
+ return fail(404, { message: 'User not found' });
+ }
+
+ if (targetUser.role === 'admin') {
+ return fail(400, { message: 'Cannot disable an admin user' });
+ }
+
+ await db
+ .update(userTable)
+ .set({ enabled: !targetUser.enabled })
+ .where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ delete: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (userId === locals.user.id) {
+ return fail(400, { message: 'Cannot delete your own account' });
+ }
+
+ // Prevent deleting admin users
+ const [targetUser] = await db
+ .select({ role: userTable.role })
+ .from(userTable)
+ .where(eq(userTable.id, userId))
+ .limit(1);
+
+ if (targetUser?.role === 'admin') {
+ return fail(400, { message: 'Cannot delete an admin user' });
+ }
+
+ await db.delete(userTable).where(eq(userTable.id, userId));
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte
new file mode 100644
index 0000000..d7172c9
--- /dev/null
+++ b/src/routes/users/+page.svelte
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+
+
+
+
+ | Username |
+ Display Name |
+ Role |
+ Status |
+ Created |
+ Actions |
+
+
+
+ {#each data.users as user (user.id)}
+
+ | {user.username} |
+ {user.displayname || '—'} |
+
+
+ {user.role}
+
+ |
+
+
+ {user.enabled ? 'Enabled' : 'Disabled'}
+
+ |
+
+ {user.createdAt ? formatDate(user.createdAt) : '—'}
+ |
+
+ {#if user.id === data.user?.id}
+ Current user
+ {:else}
+
+ {#if user.role !== 'admin'}
+
+ {/if}
+
+
+ {#if user.id !== data.user?.id && data.user?.role === 'admin'}
+
+ {/if}
+
+ {/if}
+ |
+
+ {:else}
+
+ |
+ No users found.
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ Create User
+ Add a new user account with role assignment
+
+
+
+
+
+
+
+
+
+
+ Reset Password
+
+ Set a new password for {resetUsername}
+
+
+
+
+
+
+
+
+
+
+
+ Update Display Name
+
+ Update display name for {selectedUser?.username}
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the user account
+ {selectedUser?.username} and remove their
+ data from our servers.
+
+
+
+ (deleteDialogOpen = false)}>Cancel
+
+
+
+
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..b4b7de8
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..bf699a8
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,7 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()]
+});