+
{$t('admin.system_settings')}
{$t('admin.settings_description')}
-
+
+
+
+
+
diff --git a/src/routes/api/admin/discover/+server.ts b/src/routes/api/admin/discover/+server.ts
new file mode 100644
index 0000000..bb1f303
--- /dev/null
+++ b/src/routes/api/admin/discover/+server.ts
@@ -0,0 +1,52 @@
+import { json } from '@sveltejs/kit';
+import { z } from 'zod';
+import type { RequestHandler } from './$types';
+import { requireAdmin } from '$lib/server/middleware/authorize.js';
+import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discoveryService.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+const discoverConfigSchema = z.object({
+ dockerSocketPath: z.string().regex(/^[\w/.:-]+$/).optional(),
+ traefikApiUrl: z.string().url().optional()
+});
+
+/**
+ * POST /api/admin/discover — Scan Docker and Traefik for services. Admin only.
+ *
+ * Body: { dockerSocketPath?: string, traefikApiUrl?: string }
+ */
+export const POST: RequestHandler = async (event) => {
+ requireAdmin(event);
+
+ let rawBody: unknown;
+ try {
+ rawBody = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ const parsed = discoverConfigSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
+ }
+
+ const config: DiscoveryConfig = {
+ dockerSocketPath: parsed.data.dockerSocketPath || undefined,
+ traefikApiUrl: parsed.data.traefikApiUrl || undefined
+ };
+
+ if (!config.dockerSocketPath && !config.traefikApiUrl) {
+ return json(
+ error('At least one discovery source must be configured (dockerSocketPath or traefikApiUrl)'),
+ { status: 400 }
+ );
+ }
+
+ try {
+ const result = await discoverAll(config);
+ return json(success(result));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Discovery scan failed';
+ return json(error(message), { status: 500 });
+ }
+};
diff --git a/src/routes/api/admin/discover/approve/+server.ts b/src/routes/api/admin/discover/approve/+server.ts
new file mode 100644
index 0000000..4593899
--- /dev/null
+++ b/src/routes/api/admin/discover/approve/+server.ts
@@ -0,0 +1,67 @@
+import { json } from '@sveltejs/kit';
+import { z } from 'zod';
+import type { RequestHandler } from './$types';
+import { requireAdmin } from '$lib/server/middleware/authorize.js';
+import { create } from '$lib/server/services/appService.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+const approveSchema = z.object({
+ services: z.array(z.object({
+ name: z.string().min(1),
+ url: z.string().url(),
+ source: z.enum(['docker', 'traefik']),
+ icon: z.string().optional(),
+ description: z.string().optional()
+ })).min(1).max(100)
+});
+
+/**
+ * POST /api/admin/discover/approve — Approve discovered services and create app entries. Admin only.
+ *
+ * Body: { services: DiscoveredService[] }
+ */
+export const POST: RequestHandler = async (event) => {
+ const user = requireAdmin(event);
+
+ let rawBody: unknown;
+ try {
+ rawBody = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ const parsed = approveSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
+ }
+
+ const body = parsed.data;
+
+ const created: string[] = [];
+ const errors: string[] = [];
+
+ for (const service of body.services) {
+ try {
+ const app = await create({
+ name: service.name,
+ url: service.url,
+ icon: service.icon,
+ description: service.description ?? `Discovered via ${service.source}`,
+ category: 'Discovered',
+ healthcheckEnabled: true,
+ createdById: user.id
+ });
+ created.push(app.id);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ errors.push(`Failed to create "${service.name}": ${message}`);
+ }
+ }
+
+ return json(
+ success({
+ created: created.length,
+ errors
+ })
+ );
+};
diff --git a/src/routes/api/admin/export/+server.ts b/src/routes/api/admin/export/+server.ts
new file mode 100644
index 0000000..cfb4362
--- /dev/null
+++ b/src/routes/api/admin/export/+server.ts
@@ -0,0 +1,30 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAdmin } from '$lib/server/middleware/authorize.js';
+import { exportAllData } from '$lib/server/services/exportService.js';
+import { error } from '$lib/server/utils/response.js';
+
+/**
+ * GET /api/admin/export — Export all data as JSON file download. Admin only.
+ */
+export const GET: RequestHandler = async (event) => {
+ requireAdmin(event);
+
+ try {
+ const data = await exportAllData();
+ const jsonString = JSON.stringify(data, null, 2);
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const filename = `web-app-launcher-export-${timestamp}.json`;
+
+ return new Response(jsonString, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Disposition': `attachment; filename="${filename}"`
+ }
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to export data';
+ return json(error(message), { status: 500 });
+ }
+};
diff --git a/src/routes/api/admin/import/+server.ts b/src/routes/api/admin/import/+server.ts
new file mode 100644
index 0000000..2a35883
--- /dev/null
+++ b/src/routes/api/admin/import/+server.ts
@@ -0,0 +1,46 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAdmin } from '$lib/server/middleware/authorize.js';
+import { validateImportData, importData } from '$lib/server/services/importService.js';
+import type { ImportMode } from '$lib/server/services/importService.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+/**
+ * POST /api/admin/import — Import data from JSON. Admin only.
+ * Body: { data: ExportData, mode: "skip" | "overwrite" }
+ */
+export const POST: RequestHandler = async (event) => {
+ requireAdmin(event);
+
+ let body: unknown;
+ try {
+ body = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ if (!body || typeof body !== 'object') {
+ return json(error('Request body must be an object'), { status: 400 });
+ }
+
+ const { data, mode } = body as { data: unknown; mode: unknown };
+
+ if (!data) {
+ return json(error('Missing "data" field in request body'), { status: 400 });
+ }
+
+ const validMode: ImportMode = mode === 'overwrite' ? 'overwrite' : 'skip';
+
+ const validation = validateImportData(data);
+ if (!validation.success) {
+ return json(error(`Validation failed: ${validation.errors.join('; ')}`), { status: 400 });
+ }
+
+ try {
+ const result = await importData(validation.data, validMode);
+ return json(success(result));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Import failed';
+ return json(error(message), { status: 500 });
+ }
+};
diff --git a/src/routes/api/apps/[id]/history/+server.ts b/src/routes/api/apps/[id]/history/+server.ts
new file mode 100644
index 0000000..030a106
--- /dev/null
+++ b/src/routes/api/apps/[id]/history/+server.ts
@@ -0,0 +1,46 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+const MAX_HISTORY_RECORDS = 288;
+
+/**
+ * GET /api/apps/:id/history — Get last 24h of healthcheck history for an app.
+ * Returns status points sorted ascending (oldest first) and uptime percentage.
+ */
+export const GET: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ const { id } = event.params;
+
+ try {
+ await appService.findById(id);
+
+ const history = await appService.getStatusHistory(id, MAX_HISTORY_RECORDS);
+
+ // History comes back desc from the service; reverse to ascending for sparkline
+ const ascending = [...history].reverse();
+
+ const totalChecks = ascending.length;
+ const onlineChecks = ascending.filter((s) => s.status === 'online').length;
+ const uptimePercent = totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0;
+
+ return json(
+ success({
+ history: ascending.map((s) => ({
+ status: s.status,
+ responseTime: s.responseTime,
+ checkedAt: s.checkedAt
+ })),
+ uptimePercent,
+ totalChecks
+ })
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to fetch history';
+ const status = message.includes('not found') ? 404 : 500;
+ return json(error(message), { status });
+ }
+};
diff --git a/src/routes/api/apps/quick-add/+server.ts b/src/routes/api/apps/quick-add/+server.ts
new file mode 100644
index 0000000..297b63c
--- /dev/null
+++ b/src/routes/api/apps/quick-add/+server.ts
@@ -0,0 +1,71 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { success, error } from '$lib/server/utils/response.js';
+import { z } from 'zod';
+
+const quickAddSchema = z.object({
+ url: z
+ .string()
+ .url('Invalid URL')
+ .refine(
+ (u) => u.startsWith('http://') || u.startsWith('https://'),
+ 'URL must use http or https protocol'
+ ),
+ name: z.string().min(1, 'Name is required').max(200),
+ description: z.string().max(1000).optional()
+});
+
+/**
+ * POST /api/apps/quick-add — Quick-add an app with sensible defaults.
+ * Accepts { url, name, description? }, creates app with healthcheck enabled
+ * and attempts to auto-detect a favicon icon from the URL's domain.
+ */
+export const POST: RequestHandler = async (event) => {
+ const user = requireAuth(event);
+
+ let body: unknown;
+ try {
+ body = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ const parsed = quickAddSchema.safeParse(body);
+ if (!parsed.success) {
+ const messages = parsed.error.errors.map((e) => e.message).join(', ');
+ return json(error(messages), { status: 400 });
+ }
+
+ const { url, name, description } = parsed.data;
+
+ // Attempt to derive a favicon URL from the domain
+ let faviconUrl: string | undefined;
+ try {
+ const parsedUrl = new URL(url);
+ faviconUrl = `${parsedUrl.origin}/favicon.ico`;
+ } catch {
+ // URL parsing failed — skip icon detection
+ }
+
+ try {
+ const app = await appService.create({
+ name,
+ url,
+ description,
+ icon: faviconUrl,
+ iconType: faviconUrl ? 'url' : 'lucide',
+ healthcheckEnabled: true,
+ healthcheckInterval: 300,
+ healthcheckMethod: 'GET',
+ healthcheckExpectedStatus: 200,
+ healthcheckTimeout: 5000,
+ createdById: user.id
+ });
+ return json(success(app), { status: 201 });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to create app';
+ return json(error(message), { status: 500 });
+ }
+};
diff --git a/src/routes/api/apps/quick-add/__tests__/quickAdd.test.ts b/src/routes/api/apps/quick-add/__tests__/quickAdd.test.ts
new file mode 100644
index 0000000..2442251
--- /dev/null
+++ b/src/routes/api/apps/quick-add/__tests__/quickAdd.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$lib/server/services/appService.js', () => ({
+ create: vi.fn()
+}));
+
+import * as appService from '$lib/server/services/appService.js';
+import { POST } from '../+server.js';
+
+const mockCreate = appService.create as ReturnType
;
+
+function createMockEvent(
+ overrides: {
+ user?: { id: string; role: string } | null;
+ body?: unknown;
+ jsonThrows?: boolean;
+ } = {}
+) {
+ const { user = { id: 'u1', role: 'user' }, body = {}, jsonThrows = false } = overrides;
+
+ return {
+ locals: { user },
+ request: {
+ json: jsonThrows
+ ? vi.fn().mockRejectedValue(new Error('Invalid JSON'))
+ : vi.fn().mockResolvedValue(body)
+ }
+ } as unknown as Parameters[0];
+}
+
+describe('Quick-Add API', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('POST /api/apps/quick-add', () => {
+ it('creates app with valid URL and name', async () => {
+ const createdApp = {
+ id: 'app1',
+ name: 'My App',
+ url: 'https://myapp.example.com',
+ icon: 'https://myapp.example.com/favicon.ico',
+ iconType: 'url'
+ };
+ mockCreate.mockResolvedValue(createdApp);
+
+ const response = await POST(
+ createMockEvent({
+ body: { url: 'https://myapp.example.com', name: 'My App' }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(201);
+ expect(data.success).toBe(true);
+ expect(data.data).toEqual(createdApp);
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'My App',
+ url: 'https://myapp.example.com',
+ icon: 'https://myapp.example.com/favicon.ico',
+ iconType: 'url',
+ healthcheckEnabled: true,
+ createdById: 'u1'
+ })
+ );
+ });
+
+ it('derives favicon URL from app URL', async () => {
+ mockCreate.mockResolvedValue({ id: 'app2' });
+
+ await POST(
+ createMockEvent({
+ body: { url: 'https://git.example.com/repos', name: 'Gitea' }
+ })
+ );
+
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ icon: 'https://git.example.com/favicon.ico'
+ })
+ );
+ });
+
+ it('rejects invalid URL', async () => {
+ const response = await POST(
+ createMockEvent({
+ body: { url: 'not-a-url', name: 'Bad App' }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('rejects missing name', async () => {
+ const response = await POST(
+ createMockEvent({
+ body: { url: 'https://example.com' }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('rejects non-http URLs', async () => {
+ const response = await POST(
+ createMockEvent({
+ body: { url: 'ftp://files.example.com', name: 'FTP Server' }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('returns 400 for invalid JSON body', async () => {
+ const response = await POST(createMockEvent({ jsonThrows: true }));
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('returns 500 when service throws', async () => {
+ mockCreate.mockRejectedValue(new Error('DB error'));
+
+ const response = await POST(
+ createMockEvent({
+ body: { url: 'https://example.com', name: 'Failing App' }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(data.success).toBe(false);
+ });
+
+ it('redirects when not authenticated', async () => {
+ try {
+ await POST(createMockEvent({ user: null }));
+ expect.unreachable('Should have thrown redirect');
+ } catch (e) {
+ expect(e).toBeDefined();
+ }
+ });
+ });
+});
diff --git a/src/routes/api/users/me/preferences/+server.ts b/src/routes/api/users/me/preferences/+server.ts
new file mode 100644
index 0000000..82ea85f
--- /dev/null
+++ b/src/routes/api/users/me/preferences/+server.ts
@@ -0,0 +1,148 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import { prisma } from '$lib/server/prisma.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+const ALLOWED_FIELDS = [
+ 'themeMode',
+ 'primaryHue',
+ 'primarySaturation',
+ 'backgroundType',
+ 'locale'
+] as const;
+
+const VALID_THEME_MODES = ['dark', 'light', 'system'];
+const VALID_BG_TYPES = ['mesh', 'particles', 'aurora', 'none'];
+const VALID_LOCALES = ['en', 'ru'];
+
+/**
+ * GET /api/users/me/preferences — Return current user's theme/locale preferences.
+ */
+export const GET: RequestHandler = async (event) => {
+ const user = requireAuth(event);
+
+ try {
+ const dbUser = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: {
+ themeMode: true,
+ primaryHue: true,
+ primarySaturation: true,
+ backgroundType: true,
+ locale: true
+ }
+ });
+
+ if (!dbUser) {
+ return json(error('User not found'), { status: 404 });
+ }
+
+ return json(success(dbUser));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to fetch preferences';
+ return json(error(message), { status: 500 });
+ }
+};
+
+/**
+ * PATCH /api/users/me/preferences — Update any subset of user preferences.
+ */
+export const PATCH: RequestHandler = async (event) => {
+ const user = requireAuth(event);
+
+ let body: unknown;
+ try {
+ body = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
+ return json(error('Request body must be a JSON object'), { status: 400 });
+ }
+
+ const raw = body as Record;
+ const data: Record = {};
+
+ // Validate themeMode
+ if ('themeMode' in raw) {
+ if (raw.themeMode !== null && !VALID_THEME_MODES.includes(raw.themeMode as string)) {
+ return json(error('Invalid themeMode. Must be: dark, light, or system'), { status: 400 });
+ }
+ data.themeMode = raw.themeMode as string | null;
+ }
+
+ // Validate primaryHue
+ if ('primaryHue' in raw) {
+ if (raw.primaryHue !== null) {
+ const hue = Number(raw.primaryHue);
+ if (!Number.isFinite(hue) || hue < 0 || hue > 360) {
+ return json(error('primaryHue must be a number between 0 and 360'), { status: 400 });
+ }
+ data.primaryHue = Math.round(hue);
+ } else {
+ data.primaryHue = null;
+ }
+ }
+
+ // Validate primarySaturation
+ if ('primarySaturation' in raw) {
+ if (raw.primarySaturation !== null) {
+ const sat = Number(raw.primarySaturation);
+ if (!Number.isFinite(sat) || sat < 0 || sat > 100) {
+ return json(error('primarySaturation must be a number between 0 and 100'), {
+ status: 400
+ });
+ }
+ data.primarySaturation = Math.round(sat);
+ } else {
+ data.primarySaturation = null;
+ }
+ }
+
+ // Validate backgroundType
+ if ('backgroundType' in raw) {
+ if (raw.backgroundType !== null && !VALID_BG_TYPES.includes(raw.backgroundType as string)) {
+ return json(error('Invalid backgroundType. Must be: mesh, particles, aurora, or none'), {
+ status: 400
+ });
+ }
+ data.backgroundType = raw.backgroundType as string | null;
+ }
+
+ // Validate locale
+ if ('locale' in raw) {
+ if (raw.locale !== null && !VALID_LOCALES.includes(raw.locale as string)) {
+ return json(error('Invalid locale. Must be: en or ru'), { status: 400 });
+ }
+ data.locale = raw.locale as string | null;
+ }
+
+ // Filter out any unknown keys
+ const hasValidFields = Object.keys(data).some((k) =>
+ ALLOWED_FIELDS.includes(k as (typeof ALLOWED_FIELDS)[number])
+ );
+ if (!hasValidFields) {
+ return json(error('No valid preference fields provided'), { status: 400 });
+ }
+
+ try {
+ const updated = await prisma.user.update({
+ where: { id: user.id },
+ data,
+ select: {
+ themeMode: true,
+ primaryHue: true,
+ primarySaturation: true,
+ backgroundType: true,
+ locale: true
+ }
+ });
+
+ return json(success(updated));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to update preferences';
+ return json(error(message), { status: 500 });
+ }
+};
diff --git a/src/routes/api/users/me/preferences/__tests__/preferences.test.ts b/src/routes/api/users/me/preferences/__tests__/preferences.test.ts
new file mode 100644
index 0000000..a436a43
--- /dev/null
+++ b/src/routes/api/users/me/preferences/__tests__/preferences.test.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$lib/server/prisma.js', () => ({
+ prisma: {
+ user: {
+ findUnique: vi.fn(),
+ update: vi.fn()
+ }
+ }
+}));
+
+import { prisma } from '$lib/server/prisma.js';
+import { GET, PATCH } from '../+server.js';
+
+const mockUser = prisma.user as unknown as {
+ findUnique: ReturnType;
+ update: ReturnType;
+};
+
+function createMockEvent(
+ overrides: {
+ user?: { id: string; role: string } | null;
+ body?: unknown;
+ } = {}
+) {
+ const { user = { id: 'u1', role: 'user' }, body = {} } = overrides;
+
+ return {
+ locals: { user },
+ request: {
+ json: vi.fn().mockResolvedValue(body)
+ }
+ } as unknown as Parameters[0];
+}
+
+describe('User Preferences API', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('GET /api/users/me/preferences', () => {
+ it('returns preferences for authenticated user', async () => {
+ const prefs = {
+ themeMode: 'dark',
+ primaryHue: 240,
+ primarySaturation: 80,
+ backgroundType: 'none',
+ locale: 'en'
+ };
+ mockUser.findUnique.mockResolvedValue(prefs);
+
+ const response = await GET(createMockEvent());
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data.success).toBe(true);
+ expect(data.data).toEqual(prefs);
+ });
+
+ it('returns 404 when user not found', async () => {
+ mockUser.findUnique.mockResolvedValue(null);
+
+ const response = await GET(createMockEvent());
+ const data = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(data.success).toBe(false);
+ });
+
+ it('redirects when not authenticated', async () => {
+ try {
+ await GET(createMockEvent({ user: null }));
+ expect.unreachable('Should have thrown redirect');
+ } catch (e) {
+ // SvelteKit redirect is thrown as an object with status and location
+ expect(e).toBeDefined();
+ }
+ });
+ });
+
+ describe('PATCH /api/users/me/preferences', () => {
+ it('updates theme preferences', async () => {
+ const updatedPrefs = {
+ themeMode: 'light',
+ primaryHue: 120,
+ primarySaturation: 60,
+ backgroundType: 'mesh',
+ locale: 'ru'
+ };
+ mockUser.update.mockResolvedValue(updatedPrefs);
+
+ const response = await PATCH(
+ createMockEvent({
+ body: {
+ themeMode: 'light',
+ primaryHue: 120,
+ primarySaturation: 60,
+ backgroundType: 'mesh',
+ locale: 'ru'
+ }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data.success).toBe(true);
+ expect(data.data).toEqual(updatedPrefs);
+ });
+
+ it('rejects invalid themeMode', async () => {
+ const response = await PATCH(
+ createMockEvent({ body: { themeMode: 'invalid' } })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ expect(data.error).toContain('themeMode');
+ });
+
+ it('rejects primaryHue out of range', async () => {
+ const response = await PATCH(
+ createMockEvent({ body: { primaryHue: 500 } })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('rejects primarySaturation out of range', async () => {
+ const response = await PATCH(
+ createMockEvent({ body: { primarySaturation: -10 } })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('rejects invalid backgroundType', async () => {
+ const response = await PATCH(
+ createMockEvent({ body: { backgroundType: 'invalid' } })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('rejects invalid locale', async () => {
+ const response = await PATCH(
+ createMockEvent({ body: { locale: 'fr' } })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('rejects request with no valid fields', async () => {
+ const response = await PATCH(
+ createMockEvent({ body: { unknownField: 'value' } })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(data.success).toBe(false);
+ });
+
+ it('allows null values to reset preferences', async () => {
+ mockUser.update.mockResolvedValue({
+ themeMode: null,
+ primaryHue: null,
+ primarySaturation: null,
+ backgroundType: null,
+ locale: null
+ });
+
+ const response = await PATCH(
+ createMockEvent({
+ body: { themeMode: null, primaryHue: null }
+ })
+ );
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data.success).toBe(true);
+ });
+ });
+});
diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte
index b9315df..5491f1b 100644
--- a/src/routes/apps/+page.svelte
+++ b/src/routes/apps/+page.svelte
@@ -3,10 +3,21 @@
import type { PageData } from './$types.js';
import AppCard from '$lib/components/app/AppCard.svelte';
import AppForm from '$lib/components/app/AppForm.svelte';
+ import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
let showForm = $state(false);
+
+ // Track app count to detect CRUD changes and broadcast to other tabs
+ let previousAppCount = $state(data.apps.length);
+ $effect(() => {
+ const currentCount = data.apps.length;
+ if (currentCount !== previousAppCount) {
+ broadcastDataChange('app');
+ previousAppCount = currentCount;
+ }
+ });
diff --git a/src/routes/apps/quick-add/+page.server.ts b/src/routes/apps/quick-add/+page.server.ts
new file mode 100644
index 0000000..2f4cc18
--- /dev/null
+++ b/src/routes/apps/quick-add/+page.server.ts
@@ -0,0 +1,64 @@
+import type { Actions, PageServerLoad } from './$types.js';
+import { superValidate, setError } from 'sveltekit-superforms';
+import { zod } from '$lib/utils/zod-adapter.js';
+import { fail } from '@sveltejs/kit';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { createAppSchema } from '$lib/utils/validators.js';
+
+export const load: PageServerLoad = async (event) => {
+ requireAuth(event);
+
+ const url = event.url.searchParams.get('url') ?? '';
+ const name = event.url.searchParams.get('name') ?? '';
+
+ const form = await superValidate(zod(createAppSchema));
+
+ // Pre-fill from query params
+ if (url) form.data.url = url;
+ if (name) form.data.name = name;
+
+ // Set quick-add defaults
+ form.data.healthcheckEnabled = true;
+ form.data.healthcheckInterval = 300;
+ form.data.healthcheckMethod = 'GET';
+ form.data.healthcheckExpectedStatus = 200;
+ form.data.healthcheckTimeout = 5000;
+
+ // Attempt to auto-detect favicon
+ if (url) {
+ try {
+ const parsedUrl = new URL(url);
+ form.data.icon = `${parsedUrl.origin}/favicon.ico`;
+ form.data.iconType = 'url';
+ } catch {
+ // Invalid URL — skip icon detection
+ }
+ }
+
+ return { form };
+};
+
+export const actions: Actions = {
+ create: async (event) => {
+ const user = requireAuth(event);
+
+ const form = await superValidate(event.request, zod(createAppSchema));
+
+ if (!form.valid) {
+ return fail(400, { form });
+ }
+
+ try {
+ await appService.create({
+ ...form.data,
+ createdById: user.id
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to create app';
+ return setError(form, '', message);
+ }
+
+ return { form, created: true };
+ }
+};
diff --git a/src/routes/apps/quick-add/+page.svelte b/src/routes/apps/quick-add/+page.svelte
new file mode 100644
index 0000000..f579c05
--- /dev/null
+++ b/src/routes/apps/quick-add/+page.svelte
@@ -0,0 +1,58 @@
+
+
+
+ {$t('app.quick_add_title')} | {$t('app_name')}
+
+
+
+
{$t('app.quick_add_title')}
+
{$t('app.quick_add_description')}
+
+ {#if created}
+
+
+ {$t('app.quick_add_success')}
+
+
+
+ {:else}
+
+ {/if}
+
diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte
index 3f7a62e..c52f4e3 100644
--- a/src/routes/boards/[boardId]/+page.svelte
+++ b/src/routes/boards/[boardId]/+page.svelte
@@ -5,6 +5,7 @@
import Board from '$lib/components/board/Board.svelte';
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte';
+ import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
@@ -20,6 +21,7 @@
body: JSON.stringify({ isGuestAccessible: value })
});
if (res.ok) {
+ broadcastDataChange('board');
await invalidateAll();
} else {
guestToggleError = 'Failed to update guest access';
diff --git a/src/routes/offline/+page.svelte b/src/routes/offline/+page.svelte
new file mode 100644
index 0000000..cd1a406
--- /dev/null
+++ b/src/routes/offline/+page.svelte
@@ -0,0 +1,38 @@
+
+
+
+ {$t('offline.title')}
+
+
+
+
+
+
+
+
+
+ {$t('offline.title')}
+
+
+ {$t('offline.description')}
+
+
+
+
+
diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts
new file mode 100644
index 0000000..af73ab1
--- /dev/null
+++ b/src/routes/settings/+page.server.ts
@@ -0,0 +1,28 @@
+import type { PageServerLoad } from './$types.js';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import { prisma } from '$lib/server/prisma.js';
+
+export const load: PageServerLoad = async (event) => {
+ const user = requireAuth(event);
+
+ const dbUser = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: {
+ themeMode: true,
+ primaryHue: true,
+ primarySaturation: true,
+ backgroundType: true,
+ locale: true
+ }
+ });
+
+ return {
+ preferences: dbUser ?? {
+ themeMode: null,
+ primaryHue: null,
+ primarySaturation: null,
+ backgroundType: null,
+ locale: null
+ }
+ };
+};
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte
new file mode 100644
index 0000000..93a54bc
--- /dev/null
+++ b/src/routes/settings/+page.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {$t('settings.title')} | {$t('app_name')}
+
+
+
+
{$t('settings.title')}
+
+
+
+
+
diff --git a/src/service-worker.ts b/src/service-worker.ts
new file mode 100644
index 0000000..8144eab
--- /dev/null
+++ b/src/service-worker.ts
@@ -0,0 +1,138 @@
+///
+///
+///
+///
+
+declare const self: ServiceWorkerGlobalScope;
+
+import { build, files, version } from '$service-worker';
+
+const CACHE_NAME = `cache-${version}`;
+const ASSETS = [...build, ...files];
+
+const OFFLINE_URL = '/offline';
+
+// Install: pre-cache all static assets and the offline fallback page
+self.addEventListener('install', (event: ExtendableEvent) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await caches.open(CACHE_NAME);
+ await cache.addAll(ASSETS);
+ // Cache offline fallback page
+ await cache.add(OFFLINE_URL);
+ await self.skipWaiting();
+ })()
+ );
+});
+
+// Activate: clean up old caches
+self.addEventListener('activate', (event: ExtendableEvent) => {
+ event.waitUntil(
+ (async () => {
+ const keys = await caches.keys();
+ const deletions = keys
+ .filter((key) => key !== CACHE_NAME)
+ .map((key) => caches.delete(key));
+ await Promise.all(deletions);
+ await self.clients.claim();
+ })()
+ );
+});
+
+// Fetch: cache-first for static assets, network-first for API/pages
+self.addEventListener('fetch', (event: FetchEvent) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Skip non-GET requests
+ if (request.method !== 'GET') return;
+
+ // Skip cross-origin requests
+ if (url.origin !== self.location.origin) return;
+
+ // Sensitive API paths: never cache, always go to network
+ const sensitiveApiPrefixes = ['/api/users/', '/api/admin/', '/api/auth/'];
+ if (sensitiveApiPrefixes.some((prefix) => url.pathname.startsWith(prefix))) {
+ event.respondWith(fetch(request));
+ return;
+ }
+
+ // API calls: network-first with cache fallback
+ if (url.pathname.startsWith('/api/')) {
+ event.respondWith(networkFirst(request));
+ return;
+ }
+
+ // Static assets (build artifacts + static files): cache-first
+ if (ASSETS.includes(url.pathname)) {
+ event.respondWith(cacheFirst(request));
+ return;
+ }
+
+ // Navigation requests (HTML pages): network-first with offline fallback
+ if (request.mode === 'navigate') {
+ event.respondWith(navigationHandler(request));
+ return;
+ }
+
+ // Everything else: network-first
+ event.respondWith(networkFirst(request));
+});
+
+/**
+ * Cache-first strategy: serve from cache, fall back to network.
+ */
+async function cacheFirst(request: Request): Promise {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
+ }
+}
+
+/**
+ * Network-first strategy: try network, fall back to cache.
+ */
+async function networkFirst(request: Request): Promise {
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
+ }
+}
+
+/**
+ * Navigation handler: network-first with offline fallback page.
+ */
+async function navigationHandler(request: Request): Promise {
+ try {
+ return await fetch(request);
+ } catch {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+
+ const offlinePage = await caches.match(OFFLINE_URL);
+ if (offlinePage) return offlinePage;
+
+ return new Response('Offline', {
+ status: 503,
+ statusText: 'Service Unavailable',
+ headers: { 'Content-Type': 'text/html' }
+ });
+ }
+}
diff --git a/static/icon.svg b/static/icon.svg
new file mode 100644
index 0000000..2d0e427
--- /dev/null
+++ b/static/icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/static/manifest.json b/static/manifest.json
new file mode 100644
index 0000000..262373c
--- /dev/null
+++ b/static/manifest.json
@@ -0,0 +1,25 @@
+{
+ "name": "Web App Launcher",
+ "short_name": "Launcher",
+ "start_url": "/",
+ "display": "standalone",
+ "theme_color": "#6366f1",
+ "background_color": "#0a0a0a",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ },
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}