feat: Phases 4-7 — Full Feature Expansion (26 features)

Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
This commit is contained in:
2026-03-25 14:18:10 +03:00
parent 8d7847889e
commit 1c0a7cb850
212 changed files with 15642 additions and 981 deletions
+24 -1
View File
@@ -1,5 +1,6 @@
import type { LayoutServerLoad } from './$types.js';
import { prisma } from '$lib/server/prisma.js';
import { isOnboardingNeeded } from '$lib/server/services/onboardingService.js';
export const load: LayoutServerLoad = async ({ locals }) => {
// Fetch sidebar boards for the layout
@@ -60,9 +61,31 @@ export const load: LayoutServerLoad = async ({ locals }) => {
}
}
// Fetch system-level custom CSS
let systemCustomCss: string | null = null;
try {
const settings = await prisma.systemSettings.findUnique({
where: { id: 'singleton' },
select: { customCss: true }
});
systemCustomCss = settings?.customCss ?? null;
} catch {
// Fail gracefully
}
// Check if onboarding is needed
let onboardingNeeded = false;
try {
onboardingNeeded = await isOnboardingNeeded();
} catch {
// Fail gracefully — don't block the app
}
return {
user: locals.user,
sidebarBoards: boards,
userPreferences
userPreferences,
systemCustomCss,
onboardingNeeded
};
};
+30 -1
View File
@@ -9,10 +9,15 @@
import { theme } from '$lib/stores/theme.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { search } from '$lib/stores/search.svelte';
import { favorites } from '$lib/stores/favorites.svelte';
import { locale as i18nLocale } from 'svelte-i18n';
import { onSyncMessage } from '$lib/utils/broadcastSync.js';
import { invalidateAll } from '$app/navigation';
import { onDestroy } from 'svelte';
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
import OnboardingWizard from '$lib/components/onboarding/OnboardingWizard.svelte';
import KeyboardShortcutOverlay from '$lib/components/ui/KeyboardShortcutOverlay.svelte';
import { keyboard } from '$lib/stores/keyboard.svelte.js';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
@@ -29,6 +34,14 @@
ui.initEffects();
search.initEffects();
// Initialize keyboard shortcuts
keyboard.init();
// Load favorites for authenticated users
if (data.user) {
favorites.load();
}
// Listen for cross-tab sync messages (theme changes & data invalidation)
const cleanupSync = onSyncMessage((msg) => {
if (msg.type === 'theme-change') {
@@ -38,7 +51,10 @@
}
});
onDestroy(cleanupSync);
onDestroy(() => {
cleanupSync();
keyboard.destroy();
});
// Pages that should NOT have the main layout (login, register)
const noLayoutPaths = ['/login', '/register'];
@@ -47,6 +63,19 @@
const pageKey = $derived($page.url.pathname);
</script>
<!-- System-level Custom CSS -->
{#if data.systemCustomCss}
<CustomCssInjector css={data.systemCustomCss} />
{/if}
<!-- Onboarding Wizard (shown when no users exist or onboarding incomplete) -->
{#if data.onboardingNeeded}
<OnboardingWizard />
{/if}
<!-- Keyboard Shortcut Overlay -->
<KeyboardShortcutOverlay />
{#if showLayout}
<MainLayout
user={data.user ?? null}
+3 -1
View File
@@ -9,6 +9,8 @@
const navItems = $derived([
{ href: '/admin/users', labelKey: 'admin.users' },
{ href: '/admin/groups', labelKey: 'admin.groups' },
{ href: '/admin/tags', label: 'Tags' },
{ href: '/admin/audit-log', label: 'Audit Log' },
{ href: '/admin/settings', labelKey: 'admin.settings' }
]);
@@ -30,7 +32,7 @@
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{$t(item.labelKey)}
{item.labelKey ? $t(item.labelKey) : item.label}
</a>
{/each}
</div>
@@ -0,0 +1,46 @@
import type { PageServerLoad } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as auditLogService from '$lib/server/services/auditLogService.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const url = event.url;
const action = url.searchParams.get('action') || undefined;
const entityType = url.searchParams.get('entityType') || undefined;
const dateFrom = url.searchParams.get('dateFrom') || undefined;
const dateTo = url.searchParams.get('dateTo') || undefined;
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
const limit = 25;
try {
const result = await auditLogService.getAuditLogs({
action,
entityType,
startDate: dateFrom,
endDate: dateTo,
limit,
offset: (page - 1) * limit
});
return {
logs: result.logs,
total: result.total,
filters: {
action: action ?? '',
entityType: entityType ?? '',
dateFrom: dateFrom ?? '',
dateTo: dateTo ?? ''
},
page,
hasMore: result.logs.length === limit
};
} catch {
return {
logs: [],
filters: { action: '', entityType: '', dateFrom: '', dateTo: '' },
page: 1,
hasMore: false
};
}
};
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import AuditLogTable from '$lib/components/admin/AuditLogTable.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Audit Log — {$t('admin.panel')}</title>
</svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-card-foreground">Audit Log</h1>
<p class="mt-1 text-sm text-muted-foreground">
View all administrative actions performed on the system
</p>
</div>
<AuditLogTable
logs={data.logs}
filters={data.filters}
page={data.page}
hasMore={data.hasMore}
/>
</div>
+6 -3
View File
@@ -59,13 +59,16 @@ export const actions: Actions = {
const input = form.data;
if (input.authMode !== undefined) data.authMode = input.authMode;
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
if (input.registrationEnabled !== undefined)
data.registrationEnabled = input.registrationEnabled;
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
if (input.defaultPrimaryColor !== undefined)
data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined)
data.healthcheckDefaults = input.healthcheckDefaults;
await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import TagManager from '$lib/components/admin/TagManager.svelte';
</script>
<svelte:head>
<title>Tag Management — {$t('admin.panel')}</title>
</svelte:head>
<TagManager />
+53
View File
@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as auditLogService from '$lib/server/services/auditLogService.js';
import { auditLogQuerySchema } from '$lib/utils/validators.js';
import { error, paginated } from '$lib/server/utils/response.js';
/**
* GET /api/admin/audit-log — Query audit logs. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const queryParams: Record<string, unknown> = {};
const action = event.url.searchParams.get('action');
const entityType = event.url.searchParams.get('entityType');
const dateFrom = event.url.searchParams.get('dateFrom');
const dateTo = event.url.searchParams.get('dateTo');
const page = event.url.searchParams.get('page');
const limit = event.url.searchParams.get('limit');
if (action) queryParams.action = action;
if (entityType) queryParams.entityType = entityType;
if (dateFrom) queryParams.dateFrom = dateFrom;
if (dateTo) queryParams.dateTo = dateTo;
if (page) queryParams.page = parseInt(page, 10);
if (limit) queryParams.limit = parseInt(limit, 10);
const parsed = auditLogQuerySchema.safeParse(queryParams);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
const queryLimit = parsed.data.limit ?? 50;
const queryPage = parsed.data.page ?? 1;
const offset = (queryPage - 1) * queryLimit;
try {
const { logs, total } = await auditLogService.getAuditLogs({
action: parsed.data.action,
entityType: parsed.data.entityType,
startDate: parsed.data.dateFrom,
endDate: parsed.data.dateTo,
limit: queryLimit,
offset
});
return json(paginated(logs, total, queryPage, queryLimit));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch audit logs';
return json(error(message), { status: 500 });
}
};
+8 -2
View File
@@ -6,7 +6,10 @@ import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discover
import { success, error } from '$lib/server/utils/response.js';
const discoverConfigSchema = z.object({
dockerSocketPath: z.string().regex(/^[\w/.:-]+$/).optional(),
dockerSocketPath: z
.string()
.regex(/^[\w/.:-]+$/)
.optional(),
traefikApiUrl: z.string().url().optional()
});
@@ -27,7 +30,10 @@ export const POST: RequestHandler = async (event) => {
const parsed = discoverConfigSchema.safeParse(rawBody);
if (!parsed.success) {
return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
return json(
error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`),
{ status: 400 }
);
}
const config: DiscoveryConfig = {
@@ -6,13 +6,18 @@ 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)
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)
});
/**
@@ -32,7 +37,10 @@ export const POST: RequestHandler = async (event) => {
const parsed = approveSchema.safeParse(rawBody);
if (!parsed.success) {
return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
return json(
error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`),
{ status: 400 }
);
}
const body = parsed.data;
+4 -1
View File
@@ -3,14 +3,17 @@ 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';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/admin/export — Export all data as JSON file download. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const admin = requireAdmin(event);
try {
logAction(admin.id, AuditAction.EXPORT, 'system', 'export');
const data = await exportAllData();
const jsonString = JSON.stringify(data, null, 2);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+4 -1
View File
@@ -4,13 +4,15 @@ 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';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.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);
const admin = requireAdmin(event);
let body: unknown;
try {
@@ -38,6 +40,7 @@ export const POST: RequestHandler = async (event) => {
try {
const result = await importData(validation.data, validMode);
logAction(admin.id, AuditAction.IMPORT, 'system', 'import', { mode: validMode });
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Import failed';
+16 -5
View File
@@ -4,7 +4,8 @@ import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { prisma } from '$lib/server/prisma.js';
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import { DEFAULTS, AuditAction } from '$lib/utils/constants.js';
import { logAction } from '$lib/server/services/auditLogService.js';
/**
* GET /api/admin/settings — Get system settings. Admin only.
@@ -29,7 +30,7 @@ export const GET: RequestHandler = async (event) => {
* PATCH /api/admin/settings — Update system settings. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
const admin = requireAdmin(event);
let body: unknown;
try {
@@ -49,13 +50,16 @@ export const PATCH: RequestHandler = async (event) => {
const input = parsed.data;
if (input.authMode !== undefined) data.authMode = input.authMode;
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
if (input.registrationEnabled !== undefined)
data.registrationEnabled = input.registrationEnabled;
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
if (input.defaultPrimaryColor !== undefined)
data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined)
data.healthcheckDefaults = input.healthcheckDefaults;
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
@@ -66,6 +70,13 @@ export const PATCH: RequestHandler = async (event) => {
}
});
logAction(
admin.id,
AuditAction.SETTINGS_UPDATED,
'settings',
DEFAULTS.SYSTEM_SETTINGS_ID,
parsed.data
);
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update settings';
+3
View File
@@ -4,6 +4,8 @@ import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { createAppSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/apps — List all apps, optionally filtered by category or search.
@@ -47,6 +49,7 @@ export const POST: RequestHandler = async (event) => {
...parsed.data,
createdById: user.id
});
logAction(user.id, AuditAction.APP_CREATED, 'app', app.id, { name: app.name });
return json(success(app), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create app';
+4 -1
View File
@@ -4,6 +4,8 @@ import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { updateAppSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/apps/:id — Get a single app by ID.
@@ -57,12 +59,13 @@ export const PATCH: RequestHandler = async (event) => {
* DELETE /api/apps/:id — Delete an app.
*/
export const DELETE: RequestHandler = async (event) => {
requireAuth(event);
const user = requireAuth(event);
const { id } = event.params;
try {
await appService.remove(id);
logAction(user.id, AuditAction.APP_DELETED, 'app', id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete app';
+2 -1
View File
@@ -25,7 +25,8 @@ export const GET: RequestHandler = async (event) => {
const totalChecks = ascending.length;
const onlineChecks = ascending.filter((s) => s.status === 'online').length;
const uptimePercent = totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0;
const uptimePercent =
totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0;
return json(
success({
+137
View File
@@ -0,0 +1,137 @@
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 { createAppLinkSchema, updateAppLinkSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/apps/:id/links — Get all links for an app.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
const links = await appService.getAppLinks(id);
return json(success(links));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch links';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/apps/:id/links — Add a link to an app.
*/
export const POST: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
// Merge the appId from URL into the body for validation
const parsed = createAppLinkSchema.safeParse({ ...(body as object), appId: id });
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const link = await appService.addAppLink(id, {
label: parsed.data.label,
url: parsed.data.url,
icon: parsed.data.icon,
order: parsed.data.order
});
return json(success(link), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add link';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* PATCH /api/apps/:id/links — Update a link or reorder links.
* Body: { linkId: string, ...updateFields } or { linkIds: string[] } for reorder
*/
export const PATCH: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const bodyObj = body as Record<string, unknown>;
// Reorder mode
if (Array.isArray(bodyObj.linkIds)) {
try {
await appService.reorderAppLinks(id, bodyObj.linkIds as string[]);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to reorder links';
return json(error(message), { status: 500 });
}
}
// Update mode
const { linkId, ...updateFields } = bodyObj;
if (!linkId || typeof linkId !== 'string') {
return json(error('linkId is required'), { status: 400 });
}
const parsed = updateAppLinkSchema.safeParse(updateFields);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const link = await appService.updateAppLink(linkId, parsed.data);
return json(success(link));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update link';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/apps/:id/links — Remove a link from an app.
* Body: { linkId: string }
*/
export const DELETE: RequestHandler = async (event) => {
requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const { linkId } = body as { linkId: string };
if (!linkId || typeof linkId !== 'string') {
return json(error('linkId is required'), { status: 400 });
}
try {
await appService.removeAppLink(linkId);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove link';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+81
View File
@@ -0,0 +1,81 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as tagService from '$lib/server/services/tagService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/apps/:id/tags — Get tags for a specific app.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
const tags = await tagService.getTagsForApp(id);
return json(success(tags));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tags';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/apps/:id/tags — Add a tag to an app.
* Body: { tagId: string }
*/
export const POST: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const { tagId } = body as { tagId: string };
if (!tagId || typeof tagId !== 'string') {
return json(error('tagId is required'), { status: 400 });
}
try {
const appTag = await tagService.addTagToApp(id, tagId);
return json(success(appTag), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add tag';
const status = message.includes('already') ? 409 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/apps/:id/tags — Remove a tag from an app.
* Body: { tagId: string }
*/
export const DELETE: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const { tagId } = body as { tagId: string };
if (!tagId || typeof tagId !== 'string') {
return json(error('tagId is required'), { status: 400 });
}
try {
await tagService.removeTagFromApp(id, tagId);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove tag';
const status = message.includes('not assigned') ? 404 : 500;
return json(error(message), { status });
}
};
+178
View File
@@ -0,0 +1,178 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { success, error } from '$lib/server/utils/response.js';
import { z } from 'zod';
const previewSchema = z.object({
url: z.string().url('Invalid URL')
});
const PREVIEW_TIMEOUT_MS = 10_000;
/**
* Extract the page title from HTML content.
*/
function extractTitle(html: string): string | null {
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
return match ? match[1].trim() : null;
}
/**
* Extract favicon URL from HTML content.
* Checks for <link rel="icon"> and <link rel="shortcut icon"> tags.
*/
function extractFavicon(html: string, baseUrl: string): string | null {
// Try <link rel="icon"> or <link rel="shortcut icon">
const linkMatch = html.match(
/<link[^>]*rel=["'](?:shortcut\s+)?icon["'][^>]*href=["']([^"']+)["'][^>]*>/i
);
const hrefFirst = html.match(
/<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut\s+)?icon["'][^>]*>/i
);
const faviconPath = linkMatch?.[1] ?? hrefFirst?.[1];
if (faviconPath) {
try {
return new URL(faviconPath, baseUrl).href;
} catch {
return faviconPath;
}
}
// Default to /favicon.ico
try {
const url = new URL(baseUrl);
return `${url.origin}/favicon.ico`;
} catch {
return null;
}
}
/**
* POST /api/apps/preview — Test a URL and extract metadata.
*/
export const POST: RequestHandler = async (event) => {
requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = previewSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
const { url } = parsed.data;
try {
// HEAD request for status and timing
const startTime = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PREVIEW_TIMEOUT_MS);
let status: number;
let responseTime: number;
try {
const headRes = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow'
});
status = headRes.status;
responseTime = Date.now() - startTime;
} catch (headErr) {
// HEAD might be blocked, try GET
const getStartTime = Date.now();
try {
const getRes = await fetch(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
});
status = getRes.status;
responseTime = Date.now() - getStartTime;
} catch {
clearTimeout(timeout);
const isAbort = headErr instanceof DOMException && headErr.name === 'AbortError';
return json(
success({
status: 0,
responseTime: Date.now() - startTime,
favicon: null,
title: null,
error: isAbort ? 'Connection timed out' : 'Connection failed'
})
);
}
} finally {
clearTimeout(timeout);
}
// GET request for HTML parsing (title, favicon)
let title: string | null = null;
let favicon: string | null = null;
try {
const getController = new AbortController();
const getTimeout = setTimeout(() => getController.abort(), PREVIEW_TIMEOUT_MS);
const getRes = await fetch(url, {
method: 'GET',
signal: getController.signal,
redirect: 'follow',
headers: {
Accept: 'text/html'
}
});
clearTimeout(getTimeout);
const contentType = getRes.headers.get('content-type') ?? '';
if (contentType.includes('text/html')) {
// Only read first 64KB to avoid memory issues
const reader = getRes.body?.getReader();
if (reader) {
let html = '';
const decoder = new TextDecoder();
let bytesRead = 0;
const maxBytes = 65_536;
while (bytesRead < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
html += decoder.decode(value, { stream: true });
bytesRead += value.byteLength;
}
reader.cancel();
title = extractTitle(html);
favicon = extractFavicon(html, url);
}
}
} catch {
// Parsing failed — that's ok, we still have status/timing
}
return json(
success({
status,
responseTime,
favicon,
title,
error: null
})
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Preview failed';
return json(error(message), { status: 500 });
}
};
+3 -1
View File
@@ -4,8 +4,9 @@ import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { createBoardSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { EntityType, PermissionLevel, UserRole, AuditAction } from '$lib/utils/constants.js';
import { prisma } from '$lib/server/prisma.js';
import { logAction } from '$lib/server/services/auditLogService.js';
/**
* GET /api/boards — List boards filtered by permissions.
@@ -90,6 +91,7 @@ export const POST: RequestHandler = async (event) => {
...parsed.data,
createdById: user.id
});
logAction(user.id, AuditAction.BOARD_CREATED, 'board', board.id, { name: board.name });
return json(success(board), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create board';
+3 -1
View File
@@ -4,8 +4,9 @@ import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { updateBoardSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { EntityType, PermissionLevel, UserRole, AuditAction } from '$lib/utils/constants.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { logAction } from '$lib/server/services/auditLogService.js';
/**
* GET /api/boards/:id — Get a single board with sections and widgets.
@@ -118,6 +119,7 @@ export const DELETE: RequestHandler = async (event) => {
try {
await boardService.removeBoard(id);
logAction(user.id, AuditAction.BOARD_DELETED, 'board', id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete board';
@@ -40,7 +40,14 @@ describe('Board Permissions API', () => {
describe('GET /api/boards/:id/permissions', () => {
it('returns permissions for admin users', async () => {
const permissions = [
{ id: 'p1', entityType: 'board', entityId: 'b1', targetType: 'user', targetId: 'u2', level: 'view' }
{
id: 'p1',
entityType: 'board',
entityId: 'b1',
targetType: 'user',
targetId: 'u2',
level: 'view'
}
];
mockPermission.getPermissionsForEntity.mockResolvedValue(permissions);
@@ -61,12 +68,14 @@ describe('Board Permissions API', () => {
});
it('checks edit permission for non-admin users', async () => {
mockPermission.checkPermission.mockResolvedValue({ hasPermission: true, effectiveLevel: 'edit', source: 'user' });
mockPermission.checkPermission.mockResolvedValue({
hasPermission: true,
effectiveLevel: 'edit',
source: 'user'
});
mockPermission.getPermissionsForEntity.mockResolvedValue([]);
const response = await GET(
createMockEvent({ user: { id: 'u2', role: 'user' } })
);
const response = await GET(createMockEvent({ user: { id: 'u2', role: 'user' } }));
const data = await response.json();
expect(response.status).toBe(200);
@@ -77,9 +86,7 @@ describe('Board Permissions API', () => {
it('returns 403 for non-admin users without edit permission', async () => {
mockPermission.checkPermission.mockResolvedValue({ hasPermission: false });
const response = await GET(
createMockEvent({ user: { id: 'u2', role: 'user' } })
);
const response = await GET(createMockEvent({ user: { id: 'u2', role: 'user' } }));
const data = await response.json();
expect(response.status).toBe(403);
@@ -51,7 +51,7 @@ export const POST: RequestHandler = async (event) => {
}
// Inject the boardId from the URL param
const parsed = createSectionSchema.safeParse({ ...body as object, boardId: id });
const parsed = createSectionSchema.safeParse({ ...(body as object), boardId: id });
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
@@ -56,7 +56,7 @@ export const POST: RequestHandler = async (event) => {
}
// Inject sectionId from URL param
const parsed = createWidgetSchema.safeParse({ ...body as object, sectionId: sid });
const parsed = createWidgetSchema.safeParse({ ...(body as object), sectionId: sid });
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
+78
View File
@@ -0,0 +1,78 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as favoriteService from '$lib/server/services/favoriteService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/favorites — List user's favorite apps.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
try {
const favorites = await favoriteService.getUserFavorites(user.id);
return json(success(favorites));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch favorites';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/favorites — Add an app to favorites.
* Body: { appId: string }
*/
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 { appId } = body as { appId: string };
if (!appId || typeof appId !== 'string') {
return json(error('appId is required'), { status: 400 });
}
try {
const favorite = await favoriteService.addFavorite(user.id, appId);
return json(success(favorite), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add favorite';
const status = message.includes('already') ? 409 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/favorites — Remove an app from favorites.
* Body: { appId: string }
*/
export const DELETE: 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 { appId } = body as { appId: string };
if (!appId || typeof appId !== 'string') {
return json(error('appId is required'), { status: 400 });
}
try {
await favoriteService.removeFavorite(user.id, appId);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove favorite';
const status = message.includes('not in') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as favoriteService from '$lib/server/services/favoriteService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* PATCH /api/favorites/reorder — Reorder user's favorites.
* Body: { favoriteIds: string[] }
*/
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 });
}
const { favoriteIds } = body as { favoriteIds: string[] };
if (!Array.isArray(favoriteIds) || favoriteIds.length === 0) {
return json(error('favoriteIds must be a non-empty array'), { status: 400 });
}
try {
await favoriteService.reorderFavorites(user.id, favoriteIds);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to reorder favorites';
return json(error(message), { status: 500 });
}
};
+66
View File
@@ -0,0 +1,66 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as notificationService from '$lib/server/services/notificationService.js';
import { success, error, paginated } from '$lib/server/utils/response.js';
/**
* GET /api/notifications — List user's notifications with pagination.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
const unreadOnly = event.url.searchParams.get('unread') === 'true';
const limitParam = event.url.searchParams.get('limit');
const offsetParam = event.url.searchParams.get('offset');
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 50, 1), 100) : 50;
const offset = offsetParam ? Math.max(parseInt(offsetParam, 10) || 0, 0) : 0;
try {
const { notifications, total } = await notificationService.getNotifications(user.id, {
unreadOnly,
limit,
offset
});
const page = Math.floor(offset / limit) + 1;
return json(paginated(notifications, total, page, limit));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch notifications';
return json(error(message), { status: 500 });
}
};
/**
* PATCH /api/notifications — Mark notifications as read.
* Body: { notificationId: string } or { all: true }
*/
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 });
}
const { notificationId, all } = body as { notificationId?: string; all?: boolean };
try {
if (all) {
await notificationService.markAllAsRead(user.id);
return json(success(null));
}
if (!notificationId || typeof notificationId !== 'string') {
return json(error('notificationId or all:true is required'), { status: 400 });
}
const notification = await notificationService.markAsRead(notificationId, user.id);
return json(success(notification));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to mark as read';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as notificationService from '$lib/server/services/notificationService.js';
import { createNotificationChannelSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/notifications/channels — List user's notification channels.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
try {
const channels = await notificationService.listChannels(user.id);
return json(success(channels));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch channels';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/notifications/channels — Create a notification channel.
*/
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 = createNotificationChannelSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const channel = await notificationService.createChannel(
user.id,
parsed.data.type,
parsed.data.config,
parsed.data.enabled
);
return json(success(channel), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create channel';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as notificationService from '$lib/server/services/notificationService.js';
import { updateNotificationChannelSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/notifications/channels/:id — Get a single notification channel.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
const { id } = event.params;
try {
const channel = await notificationService.getChannelById(id, user.id);
return json(success(channel));
} catch (err) {
const message = err instanceof Error ? err.message : 'Channel not found';
return json(error(message), { status: 404 });
}
};
/**
* PATCH /api/notifications/channels/:id — Update a notification channel.
*/
export const PATCH: RequestHandler = async (event) => {
const user = requireAuth(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateNotificationChannelSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const channel = await notificationService.updateChannel(id, user.id, parsed.data);
return json(success(channel));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update channel';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/notifications/channels/:id — Delete a notification channel.
*/
export const DELETE: RequestHandler = async (event) => {
const user = requireAuth(event);
const { id } = event.params;
try {
await notificationService.deleteChannel(id, user.id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete channel';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as notificationService from '$lib/server/services/notificationService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* POST /api/notifications/channels/:id/test — Send a test notification to a channel.
*/
export const POST: RequestHandler = async (event) => {
const user = requireAuth(event);
const { id } = event.params;
try {
await notificationService.sendTestNotification(id, user.id);
return json(success({ message: 'Test notification sent' }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to send test notification';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+174
View File
@@ -0,0 +1,174 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { success, error } from '$lib/server/utils/response.js';
import * as onboardingService from '$lib/server/services/onboardingService.js';
import * as userService from '$lib/server/services/userService.js';
import * as boardService from '$lib/server/services/boardService.js';
import { prisma } from '$lib/server/prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import { z } from 'zod';
const completeStepSchema = z.object({
step: z.enum(['admin', 'authMode', 'theme', 'complete']),
data: z.record(z.unknown()).optional()
});
/**
* GET /api/onboarding — Check onboarding status.
* No auth required (onboarding runs before any user exists).
*/
export const GET: RequestHandler = async () => {
try {
const status = await onboardingService.getOnboardingStatus();
return json(success(status));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to check onboarding status';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/onboarding — Complete an onboarding step.
* No auth required (onboarding runs before any user exists).
*/
export const POST: RequestHandler = async ({ request }) => {
let body: unknown;
try {
body = await request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = completeStepSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
const { step, data } = parsed.data;
try {
switch (step) {
case 'admin': {
// Create admin user
const adminData = z
.object({
email: z.string().email(),
password: z.string().min(6),
displayName: z.string().min(1).max(100)
})
.safeParse(data);
if (!adminData.success) {
return json(error('Invalid admin account data'), { status: 400 });
}
const user = await userService.create({
email: adminData.data.email,
password: adminData.data.password,
displayName: adminData.data.displayName,
role: 'admin'
});
return json(success({ userId: user.id }), { status: 201 });
}
case 'authMode': {
// Update SystemSettings auth mode
const authData = z
.object({
authMode: z.enum(['local', 'oauth', 'both']),
oauthClientId: z.string().optional(),
oauthClientSecret: z.string().optional(),
oauthDiscoveryUrl: z.string().url().optional()
})
.safeParse(data);
if (!authData.success) {
return json(error('Invalid auth mode data'), { status: 400 });
}
await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {
authMode: authData.data.authMode,
...(authData.data.oauthClientId ? { oauthClientId: authData.data.oauthClientId } : {}),
...(authData.data.oauthClientSecret
? { oauthClientSecret: authData.data.oauthClientSecret }
: {}),
...(authData.data.oauthDiscoveryUrl
? { oauthDiscoveryUrl: authData.data.oauthDiscoveryUrl }
: {})
},
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
authMode: authData.data.authMode,
oauthClientId: authData.data.oauthClientId ?? null,
oauthClientSecret: authData.data.oauthClientSecret ?? null,
oauthDiscoveryUrl: authData.data.oauthDiscoveryUrl ?? null
}
});
return json(success({ authMode: authData.data.authMode }));
}
case 'theme': {
// Update system default theme settings
const themeData = z
.object({
defaultTheme: z.enum(['dark', 'light']).optional(),
defaultPrimaryColor: z.string().optional()
})
.safeParse(data);
if (!themeData.success) {
return json(error('Invalid theme data'), { status: 400 });
}
await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {
...(themeData.data.defaultTheme ? { defaultTheme: themeData.data.defaultTheme } : {}),
...(themeData.data.defaultPrimaryColor
? { defaultPrimaryColor: themeData.data.defaultPrimaryColor }
: {})
},
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
defaultTheme: themeData.data.defaultTheme ?? 'dark',
defaultPrimaryColor: themeData.data.defaultPrimaryColor ?? '#6366f1'
}
});
return json(success({ theme: themeData.data }));
}
case 'complete': {
// Mark onboarding as complete, optionally create first board
const completeData = z
.object({
boardName: z.string().min(1).optional(),
boardIcon: z.string().optional()
})
.safeParse(data);
if (completeData.success && completeData.data.boardName) {
await boardService.createBoard({
name: completeData.data.boardName,
icon: completeData.data.boardIcon ?? undefined,
isDefault: true
});
}
await onboardingService.completeOnboarding();
return json(success({ complete: true }));
}
default:
return json(error('Unknown step'), { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to complete onboarding step';
return json(error(message), { status: 500 });
}
};
+66
View File
@@ -0,0 +1,66 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as recentAppsService from '$lib/server/services/recentAppsService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/recent-apps — Get user's recent unique apps.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
const limitParam = event.url.searchParams.get('limit');
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 10, 1), 50) : 10;
try {
const recentApps = await recentAppsService.getRecentApps(user.id, limit);
return json(success(recentApps));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch recent apps';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/recent-apps — Record an app click.
* Body: { appId: string }
*/
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 { appId } = body as { appId: string };
if (!appId || typeof appId !== 'string') {
return json(error('appId is required'), { status: 400 });
}
try {
const click = await recentAppsService.recordClick(user.id, appId);
return json(success(click), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to record click';
return json(error(message), { status: 500 });
}
};
/**
* DELETE /api/recent-apps — Clear all click history for user.
*/
export const DELETE: RequestHandler = async (event) => {
const user = requireAuth(event);
try {
await recentAppsService.clearHistory(user.id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to clear history';
return json(error(message), { status: 500 });
}
};
+27 -8
View File
@@ -49,10 +49,7 @@ export const GET: RequestHandler = async (event) => {
// Search boards
const boards = await prisma.board.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } }
]
OR: [{ name: { contains: query } }, { description: { contains: query } }]
},
select: {
id: true,
@@ -70,7 +67,13 @@ export const GET: RequestHandler = async (event) => {
const filteredApps: SearchResult[] = [];
for (const app of apps) {
if (isAdmin) {
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
filteredApps.push({
type: 'app',
id: app.id,
name: app.name,
description: app.description,
category: app.category
});
continue;
}
@@ -81,7 +84,13 @@ export const GET: RequestHandler = async (event) => {
PermissionLevel.VIEW
);
if (check.hasPermission) {
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
filteredApps.push({
type: 'app',
id: app.id,
name: app.name,
description: app.description,
category: app.category
});
}
}
@@ -89,7 +98,12 @@ export const GET: RequestHandler = async (event) => {
const filteredBoards: SearchResult[] = [];
for (const board of boards) {
if (isAdmin || board.isGuestAccessible) {
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
filteredBoards.push({
type: 'board',
id: board.id,
name: board.name,
description: board.description
});
continue;
}
@@ -100,7 +114,12 @@ export const GET: RequestHandler = async (event) => {
PermissionLevel.VIEW
);
if (check.hasPermission) {
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
filteredBoards.push({
type: 'board',
id: board.id,
name: board.name,
description: board.description
});
}
}
+50
View File
@@ -0,0 +1,50 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as tagService from '$lib/server/services/tagService.js';
import { createTagSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/tags — List all tags.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
try {
const tags = await tagService.findAll();
return json(success(tags));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tags';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/tags — Create a new tag.
*/
export const POST: RequestHandler = async (event) => {
requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = createTagSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const tag = await tagService.create(parsed.data.name, parsed.data.color);
return json(success(tag), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create tag';
const status = message.includes('already exists') ? 409 : 500;
return json(error(message), { status });
}
};
+69
View File
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as tagService from '$lib/server/services/tagService.js';
import { updateTagSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/tags/:id — Get a single tag.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
const tag = await tagService.findById(id);
return json(success(tag));
} catch (err) {
const message = err instanceof Error ? err.message : 'Tag not found';
return json(error(message), { status: 404 });
}
};
/**
* PATCH /api/tags/:id — Update a tag.
*/
export const PATCH: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateTagSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const tag = await tagService.update(id, parsed.data);
return json(success(tag));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update tag';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/tags/:id — Delete a tag.
*/
export const DELETE: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
await tagService.remove(id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete tag';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+85
View File
@@ -0,0 +1,85 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { success, error } from '$lib/server/utils/response.js';
import * as templateService from '$lib/server/services/templateService.js';
import { z } from 'zod';
const createTemplateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
icon: z.string().max(50).optional(),
config: z.object({
sections: z.array(
z.object({
title: z.string().min(1),
icon: z.string().nullable().optional(),
order: z.number().int().min(0)
})
)
}),
boardId: z.string().optional() // If provided, export from board instead
});
/**
* GET /api/templates — List all templates (builtin + user-created).
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
try {
const templates = await templateService.getAllTemplates();
return json(success(templates));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch templates';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/templates — Create a template from config or export from a board.
*/
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 = createTemplateSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
if (parsed.data.boardId) {
// Export from existing board
const exported = await templateService.exportTemplate(parsed.data.boardId);
const template = await templateService.createTemplate({
name: parsed.data.name || exported.name,
description: parsed.data.description ?? exported.description,
icon: parsed.data.icon ?? null,
config: exported.config,
createdById: user.id
});
return json(success(template), { status: 201 });
}
const template = await templateService.createTemplate({
name: parsed.data.name,
description: parsed.data.description ?? null,
icon: parsed.data.icon ?? null,
config: parsed.data.config,
createdById: user.id
});
return json(success(template), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create template';
return json(error(message), { status: 500 });
}
};
+44
View File
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { success, error } from '$lib/server/utils/response.js';
import * as templateService from '$lib/server/services/templateService.js';
/**
* GET /api/templates/:id — Get a single template.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
const template = await templateService.getTemplateById(id);
if (!template) {
return json(error('Template not found'), { status: 404 });
}
return json(success(template));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch template';
return json(error(message), { status: 500 });
}
};
/**
* DELETE /api/templates/:id — Delete a user-created template.
*/
export const DELETE: RequestHandler = async (event) => {
requireAuth(event);
const { id } = event.params;
try {
await templateService.deleteTemplate(id);
return json(success({ deleted: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete template';
const status = message.includes('Cannot delete') || message.includes('not found') ? 400 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,49 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { success, error } from '$lib/server/utils/response.js';
import * as templateService from '$lib/server/services/templateService.js';
import { z } from 'zod';
const importSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional().nullable(),
icon: z.string().max(50).optional().nullable(),
config: z.object({
sections: z.array(
z.object({
title: z.string().min(1),
icon: z.string().nullable().optional(),
order: z.number().int().min(0).optional()
})
)
})
});
/**
* POST /api/templates/import — Import a template from JSON.
*/
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 = importSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(`Invalid template format: ${messages}`), { status: 400 });
}
try {
const template = await templateService.importTemplate(parsed.data, user.id);
return json(success(template), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import template';
return json(error(message), { status: 500 });
}
};
+55
View File
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { createApiTokenSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/tokens — List user's API tokens.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
try {
const tokens = await apiTokenService.listTokens(user.id);
return json(success(tokens));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tokens';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/tokens — Generate a new API token.
* Returns the plaintext token only once.
*/
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 = createApiTokenSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const result = await apiTokenService.generateToken(
user.id,
parsed.data.name,
parsed.data.scope,
parsed.data.expiresAt
);
return json(success(result), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate token';
return json(error(message), { status: 500 });
}
};
+22
View File
@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* DELETE /api/tokens/:id — Revoke an API token.
*/
export const DELETE: RequestHandler = async (event) => {
const user = requireAuth(event);
const { id } = event.params;
try {
await apiTokenService.revokeToken(id, user.id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke token';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+1 -6
View File
@@ -6,12 +6,7 @@ import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
const ALLOWED_TYPES = new Set([
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp'
]);
const ALLOWED_TYPES = new Set(['image/svg+xml', 'image/png', 'image/jpeg', 'image/webp']);
const EXTENSION_MAP: Record<string, string> = {
'image/svg+xml': '.svg',
+32
View File
@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as uptimeService from '$lib/server/services/uptimeService.js';
import { success, error } from '$lib/server/utils/response.js';
const VALID_TIME_RANGES = ['24h', '7d', '30d'] as const;
type TimeRange = (typeof VALID_TIME_RANGES)[number];
function parseTimeRange(value: string | null): TimeRange {
if (value && VALID_TIME_RANGES.includes(value as TimeRange)) {
return value as TimeRange;
}
return '24h';
}
/**
* GET /api/uptime — Get uptime summary for all apps.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const timeRange = parseTimeRange(event.url.searchParams.get('range'));
try {
const uptime = await uptimeService.getAllAppsUptime(timeRange);
return json(success(uptime));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch uptime data';
return json(error(message), { status: 500 });
}
};
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as uptimeService from '$lib/server/services/uptimeService.js';
import { success, error } from '$lib/server/utils/response.js';
const VALID_TIME_RANGES = ['24h', '7d', '30d'] as const;
type TimeRange = (typeof VALID_TIME_RANGES)[number];
function parseTimeRange(value: string | null): TimeRange {
if (value && VALID_TIME_RANGES.includes(value as TimeRange)) {
return value as TimeRange;
}
return '24h';
}
/**
* GET /api/uptime/:appId — Get uptime stats + timeline for a single app.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const { appId } = event.params;
const timeRange = parseTimeRange(event.url.searchParams.get('range'));
const includeIncidents = event.url.searchParams.get('incidents') === 'true';
try {
const [stats, timeline, incidents] = await Promise.all([
uptimeService.getUptimeStats(appId, timeRange),
uptimeService.getUptimeTimeline(appId, timeRange),
includeIncidents ? uptimeService.getIncidents(appId, timeRange) : Promise.resolve([])
]);
return json(
success({
stats,
timeline,
...(includeIncidents ? { incidents } : {})
})
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch uptime data';
return json(error(message), { status: 500 });
}
};
+4
View File
@@ -4,6 +4,8 @@ import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import { createUserSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/users — List all users. Admin only.
@@ -40,7 +42,9 @@ export const POST: RequestHandler = async (event) => {
}
try {
const admin = requireAdmin(event);
const user = await userService.create(parsed.data);
logAction(admin.id, AuditAction.USER_CREATED, 'user', user.id, { email: user.email });
return json(success(user), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user';
+5
View File
@@ -4,6 +4,8 @@ import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import { updateUserSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/users/:id — Get a single user by ID. Admin only.
@@ -44,7 +46,9 @@ export const PATCH: RequestHandler = async (event) => {
}
try {
const admin = requireAdmin(event);
const user = await userService.update(id, parsed.data);
logAction(admin.id, AuditAction.USER_UPDATED, 'user', id, parsed.data);
return json(success(user));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user';
@@ -67,6 +71,7 @@ export const DELETE: RequestHandler = async (event) => {
try {
await userService.remove(id);
logAction(admin.id, AuditAction.USER_DELETED, 'user', id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user';
@@ -108,9 +108,7 @@ describe('User Preferences API', () => {
});
it('rejects invalid themeMode', async () => {
const response = await PATCH(
createMockEvent({ body: { themeMode: 'invalid' } })
);
const response = await PATCH(createMockEvent({ body: { themeMode: 'invalid' } }));
const data = await response.json();
expect(response.status).toBe(400);
@@ -119,9 +117,7 @@ describe('User Preferences API', () => {
});
it('rejects primaryHue out of range', async () => {
const response = await PATCH(
createMockEvent({ body: { primaryHue: 500 } })
);
const response = await PATCH(createMockEvent({ body: { primaryHue: 500 } }));
const data = await response.json();
expect(response.status).toBe(400);
@@ -129,9 +125,7 @@ describe('User Preferences API', () => {
});
it('rejects primarySaturation out of range', async () => {
const response = await PATCH(
createMockEvent({ body: { primarySaturation: -10 } })
);
const response = await PATCH(createMockEvent({ body: { primarySaturation: -10 } }));
const data = await response.json();
expect(response.status).toBe(400);
@@ -139,9 +133,7 @@ describe('User Preferences API', () => {
});
it('rejects invalid backgroundType', async () => {
const response = await PATCH(
createMockEvent({ body: { backgroundType: 'invalid' } })
);
const response = await PATCH(createMockEvent({ body: { backgroundType: 'invalid' } }));
const data = await response.json();
expect(response.status).toBe(400);
@@ -149,9 +141,7 @@ describe('User Preferences API', () => {
});
it('rejects invalid locale', async () => {
const response = await PATCH(
createMockEvent({ body: { locale: 'fr' } })
);
const response = await PATCH(createMockEvent({ body: { locale: 'fr' } }));
const data = await response.json();
expect(response.status).toBe(400);
@@ -159,9 +149,7 @@ describe('User Preferences API', () => {
});
it('rejects request with no valid fields', async () => {
const response = await PATCH(
createMockEvent({ body: { unknownField: 'value' } })
);
const response = await PATCH(createMockEvent({ body: { unknownField: 'value' } }));
const data = await response.json();
expect(response.status).toBe(400);
+61
View File
@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { error, success } from '$lib/server/utils/response.js';
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const EXTENSION_MAP: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp'
};
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
/**
* POST /api/wallpaper — Upload a wallpaper image for board backgrounds.
* Accepts multipart form data with a single 'file' field.
* Validates type (PNG, JPG, WebP) and size (<5MB).
* Saves to static/uploads/wallpapers/ and returns the public path.
*/
export const POST: RequestHandler = async (event) => {
requireAuth(event);
let formData: FormData;
try {
formData = await event.request.formData();
} catch {
return json(error('Invalid form data'), { status: 400 });
}
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return json(error('No file provided'), { status: 400 });
}
if (!ALLOWED_TYPES.has(file.type)) {
return json(error('Invalid file type. Allowed: PNG, JPG, WebP'), { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return json(error('File too large. Maximum size: 5MB'), { status: 400 });
}
const extension = EXTENSION_MAP[file.type] ?? '.bin';
const filename = `${randomUUID()}${extension}`;
const wallpaperDir = join(process.cwd(), 'static', 'uploads', 'wallpapers');
await mkdir(wallpaperDir, { recursive: true });
const filePath = join(wallpaperDir, filename);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const publicPath = `/uploads/wallpapers/${filename}`;
return json(success({ path: publicPath, filename }), { status: 201 });
};
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as calendarService from '$lib/server/services/calendarService.js';
import { success, error } from '$lib/server/utils/response.js';
import { calendarWidgetConfigSchema } from '$lib/utils/validators.js';
/**
* POST /api/widgets/calendar — Fetch and parse events from iCal URLs.
* Uses POST because the body contains an array of calendar source objects.
*/
export const POST: RequestHandler = async (event) => {
requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = calendarWidgetConfigSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const events = await calendarService.fetchCalendarEvents(
parsed.data.icalUrls,
parsed.data.daysAhead
);
return json(success(events));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch calendar events';
return json(error(message), { status: 502 });
}
};
+41
View File
@@ -0,0 +1,41 @@
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as cameraService from '$lib/server/services/cameraService.js';
import { json } from '@sveltejs/kit';
import { error } from '$lib/server/utils/response.js';
import { z } from 'zod';
const querySchema = z.object({
streamUrl: z.string().url('Invalid stream URL')
});
/**
* GET /api/widgets/camera — Proxy a snapshot image from a camera URL.
* Returns the raw image binary with appropriate content-type header.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const parsed = querySchema.safeParse({
streamUrl: event.url.searchParams.get('streamUrl')
});
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const snapshot = await cameraService.fetchSnapshot(parsed.data.streamUrl);
return new Response(new Uint8Array(snapshot.buffer), {
headers: {
'Content-Type': snapshot.contentType,
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch camera snapshot';
return json(error(message), { status: 502 });
}
};
+54
View File
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as metricService from '$lib/server/services/metricService.js';
import { success, error } from '$lib/server/utils/response.js';
import { metricWidgetConfigSchema } from '$lib/utils/validators.js';
/**
* GET /api/widgets/metric — Fetch a single metric value from a configured source.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const source = event.url.searchParams.get('source');
const value = event.url.searchParams.get('value') ?? undefined;
const url = event.url.searchParams.get('url') ?? undefined;
const jsonPath = event.url.searchParams.get('jsonPath') ?? undefined;
const query = event.url.searchParams.get('query') ?? undefined;
const unit = event.url.searchParams.get('unit') ?? undefined;
const refreshIntervalParam = event.url.searchParams.get('refreshInterval');
const label = event.url.searchParams.get('label') ?? 'Metric';
const parsed = metricWidgetConfigSchema.safeParse({
label,
source,
value,
url,
jsonPath,
query,
unit,
refreshInterval: refreshIntervalParam ? parseInt(refreshIntervalParam, 10) : undefined
});
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const result = await metricService.fetchMetric({
source: parsed.data.source,
value: parsed.data.value,
url: parsed.data.url,
jsonPath: parsed.data.jsonPath,
query: parsed.data.query,
unit: parsed.data.unit,
refreshInterval: parsed.data.refreshInterval
});
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch metric';
return json(error(message), { status: 502 });
}
};
+34
View File
@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as rssFeedService from '$lib/server/services/rssFeedService.js';
import { success, error } from '$lib/server/utils/response.js';
import { rssWidgetConfigSchema } from '$lib/utils/validators.js';
/**
* GET /api/widgets/rss — Fetch and parse an RSS/Atom feed.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const feedUrl = event.url.searchParams.get('feedUrl');
const maxItemsParam = event.url.searchParams.get('maxItems');
const parsed = rssWidgetConfigSchema.safeParse({
feedUrl,
maxItems: maxItemsParam ? parseInt(maxItemsParam, 10) : undefined
});
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const items = await rssFeedService.fetchFeed(parsed.data.feedUrl, parsed.data.maxItems);
return json(success(items));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch RSS feed';
return json(error(message), { status: 502 });
}
};
@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as systemStatsService from '$lib/server/services/systemStatsService.js';
import { success, error } from '$lib/server/utils/response.js';
import { systemStatsWidgetConfigSchema } from '$lib/utils/validators.js';
/**
* GET /api/widgets/system-stats — Fetch system metrics from a source.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const sourceUrl = event.url.searchParams.get('sourceUrl');
const sourceType = event.url.searchParams.get('sourceType');
const metricsParam = event.url.searchParams.get('metrics');
const refreshInterval = event.url.searchParams.get('refreshInterval');
const parsed = systemStatsWidgetConfigSchema.safeParse({
sourceUrl,
sourceType,
metrics: metricsParam ? metricsParam.split(',') : [],
refreshInterval: refreshInterval ? parseInt(refreshInterval, 10) : undefined
});
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const stats = await systemStatsService.fetchSystemStats(
parsed.data.sourceUrl,
parsed.data.sourceType,
parsed.data.metrics,
parsed.data.refreshInterval
);
return json(success(stats));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch system stats';
return json(error(message), { status: 502 });
}
};
+36
View File
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as weatherService from '$lib/server/services/weatherService.js';
import { success, error } from '$lib/server/utils/response.js';
import { z } from 'zod';
const querySchema = z.object({
latitude: z.coerce.number().min(-90).max(90),
longitude: z.coerce.number().min(-180).max(180)
});
/**
* GET /api/widgets/weather — Fetch current weather for given coordinates.
*/
export const GET: RequestHandler = async (event) => {
requireAuth(event);
const parsed = querySchema.safeParse({
latitude: event.url.searchParams.get('latitude'),
longitude: event.url.searchParams.get('longitude')
});
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const weather = await weatherService.fetchWeather(parsed.data.latitude, parsed.data.longitude);
return json(success(weather));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch weather data';
return json(error(message), { status: 502 });
}
};
+6 -1
View File
@@ -35,7 +35,12 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
throw redirect(302, authUrl);
} catch (err) {
// Re-throw redirects
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
if (
err &&
typeof err === 'object' &&
'status' in err &&
(err as { status: number }).status === 302
) {
throw err;
}
+6 -1
View File
@@ -84,7 +84,12 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
throw redirect(302, '/');
} catch (err) {
// Re-throw redirects
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
if (
err &&
typeof err === 'object' &&
'status' in err &&
(err as { status: number }).status === 302
) {
throw err;
}
+35 -14
View File
@@ -5,10 +5,15 @@
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 BoardThemeProvider from '$lib/components/board/BoardThemeProvider.svelte';
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
import WallpaperBackground from '$lib/components/background/WallpaperBackground.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
const boardCardSize = $derived((data.board.cardSize as 'compact' | 'medium' | 'large') ?? 'medium');
let showShareDialog = $state(false);
let guestToggleError = $state('');
@@ -36,24 +41,40 @@
<title>{data.board.name}{$t('app_title')}</title>
</svelte:head>
<div class="p-6">
<div class="mx-auto max-w-7xl">
<BoardHeader
name={data.board.name}
description={data.board.description}
icon={data.board.icon}
boardId={data.board.id}
canEdit={data.canEdit}
onShare={() => { showShareDialog = true; }}
<BoardThemeProvider board={data.board}>
<!-- Board-level wallpaper background -->
{#if data.board.backgroundType === 'wallpaper' && data.board.wallpaperUrl}
<WallpaperBackground
url={data.board.wallpaperUrl}
blur={data.board.wallpaperBlur ?? 0}
overlayOpacity={data.board.wallpaperOverlay ?? 0.3}
/>
{/if}
{#if guestToggleError}
<p class="mb-2 text-sm text-destructive">{guestToggleError}</p>
{/if}
<!-- Board-level custom CSS -->
{#if data.board.customCss}
<CustomCssInjector css={data.board.customCss} />
{/if}
<Board sections={data.board.sections} allApps={data.allApps} />
<div class="p-6">
<div class="mx-auto max-w-7xl">
<BoardHeader
name={data.board.name}
description={data.board.description}
icon={data.board.icon}
boardId={data.board.id}
canEdit={data.canEdit}
onShare={() => { showShareDialog = true; }}
/>
{#if guestToggleError}
<p class="mb-2 text-sm text-destructive">{guestToggleError}</p>
{/if}
<Board sections={data.board.sections} allApps={data.allApps} {boardCardSize} />
</div>
</div>
</div>
</BoardThemeProvider>
{#if showShareDialog && data.canEdit}
<BoardShareDialog
@@ -90,7 +90,12 @@ export const actions: Actions = {
const { boardId } = event.params;
const formData = await event.request.formData();
const data = {
const themeHueRaw = formData.get('themeHue');
const themeSatRaw = formData.get('themeSaturation');
const wallpaperBlurRaw = formData.get('wallpaperBlur');
const wallpaperOverlayRaw = formData.get('wallpaperOverlay');
const data: Record<string, unknown> = {
name: formData.get('name') as string | undefined,
icon: formData.get('icon') as string | undefined,
description: formData.get('description') as string | undefined,
@@ -98,6 +103,20 @@ export const actions: Actions = {
isGuestAccessible: formData.get('isGuestAccessible') === 'on'
};
// Theme / visual fields — only include if present in the form submission
if (themeHueRaw != null) data.themeHue = Number(themeHueRaw);
if (themeSatRaw != null) data.themeSaturation = Number(themeSatRaw);
if (formData.has('backgroundType'))
data.backgroundType = formData.get('backgroundType') as string;
if (formData.has('cardSize')) data.cardSize = formData.get('cardSize') as string;
if (formData.has('wallpaperUrl')) {
const url = formData.get('wallpaperUrl') as string;
data.wallpaperUrl = url || null;
}
if (wallpaperBlurRaw != null) data.wallpaperBlur = Number(wallpaperBlurRaw);
if (wallpaperOverlayRaw != null) data.wallpaperOverlay = Number(wallpaperOverlayRaw);
if (formData.has('customCss')) data.customCss = (formData.get('customCss') as string) || null;
const parsed = updateBoardSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
@@ -147,7 +166,7 @@ export const actions: Actions = {
const formData = await event.request.formData();
const sectionId = formData.get('sectionId') as string;
const data = {
const data: Record<string, unknown> = {
title: (formData.get('title') as string) || undefined,
icon: formData.get('icon') as string | undefined,
order: formData.get('order') ? Number(formData.get('order')) : undefined,
@@ -157,6 +176,11 @@ export const actions: Actions = {
: undefined
};
if (formData.has('cardSize')) {
const cs = formData.get('cardSize') as string;
data.cardSize = cs || null;
}
const parsed = updateSectionSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
@@ -5,6 +5,7 @@
import { invalidateAll } from '$app/navigation';
import DraggableBoard from '$lib/components/board/DraggableBoard.svelte';
import BoardAccessControl from '$lib/components/board/BoardAccessControl.svelte';
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte';
let { data }: { data: PageData } = $props();
@@ -12,6 +13,55 @@
let showAddSection = $state(false);
let addWidgetSectionId = $state<string | null>(null);
let errorMessage = $state('');
let wallpaperUploading = $state(false);
let boardCustomCss = $state(data.board.customCss ?? '');
async function handleWallpaperUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
wallpaperUploading = true;
errorMessage = '';
try {
const formData = new FormData();
formData.set('file', file);
const res = await fetch('/api/wallpaper', {
method: 'POST',
body: formData
});
if (!res.ok) {
const json = await res.json();
errorMessage = json.error ?? 'Failed to upload wallpaper';
return;
}
const json = await res.json();
const wallpaperUrl = json.data?.path;
if (wallpaperUrl) {
// Save wallpaper URL to board
const updateRes = await fetch(`/api/boards/${data.board.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wallpaperUrl })
});
if (updateRes.ok) {
await invalidateAll();
} else {
errorMessage = 'Wallpaper uploaded but failed to save to board';
}
}
} catch {
errorMessage = 'Network error uploading wallpaper';
} finally {
wallpaperUploading = false;
}
}
function handleToggleAddWidget(sectionId: string) {
addWidgetSectionId = addWidgetSectionId === sectionId ? null : sectionId;
@@ -85,6 +135,26 @@
}
}
async function handleUpdateSection(sectionId: string, sectionData: Record<string, unknown>) {
const formData = new FormData();
formData.set('sectionId', sectionId);
for (const [key, value] of Object.entries(sectionData)) {
if (value != null) {
formData.set(key, String(value));
}
}
try {
await fetch(`?/updateSection`, {
method: 'POST',
body: formData
});
await invalidateAll();
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'Failed to update section';
}
}
async function handleDeleteWidget(widgetId: string) {
const formData = new FormData();
formData.set('widgetId', widgetId);
@@ -185,6 +255,226 @@
</form>
</section>
<!-- Board Theme -->
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('board.theme_settings') ?? 'Theme Settings'}</h2>
<form method="POST" action="?/updateBoard" use:enhance>
<!-- Pass required board name so the form is valid -->
<input type="hidden" name="name" value={data.board.name} />
<div class="grid gap-4">
<!-- Hue Slider -->
<div>
<label for="board-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('settings.hue') ?? 'Hue'}</label>
<div class="flex items-center gap-3">
<input
id="board-hue"
name="themeHue"
type="range"
min="0"
max="360"
step="1"
value={data.board.themeHue ?? 220}
class="h-3 w-full cursor-pointer appearance-none rounded-full bg-gradient-to-r from-red-500 via-green-500 via-blue-500 to-red-500 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-md"
/>
<span class="w-10 text-center text-sm text-muted-foreground">{data.board.themeHue ?? 220}</span>
</div>
</div>
<!-- Saturation Slider -->
<div>
<label for="board-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('settings.saturation') ?? 'Saturation'}</label>
<div class="flex items-center gap-3">
<input
id="board-sat"
name="themeSaturation"
type="range"
min="0"
max="100"
step="1"
value={data.board.themeSaturation ?? 70}
class="h-3 w-full cursor-pointer appearance-none rounded-full bg-gradient-to-r from-gray-500 to-primary [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-md"
/>
<span class="w-10 text-center text-sm text-muted-foreground">{data.board.themeSaturation ?? 70}%</span>
</div>
</div>
<!-- Background Type -->
<div>
<label class="mb-1 block text-sm font-medium text-foreground">{$t('settings.background') ?? 'Background'}</label>
<div class="flex flex-wrap gap-1 rounded-lg border border-border bg-muted/50 p-1">
{#each ['mesh', 'particles', 'aurora', 'wallpaper', 'none'] as bg (bg)}
<label class="flex-1">
<input type="radio" name="backgroundType" value={bg} checked={data.board.backgroundType === bg} class="peer sr-only" />
<span class="block cursor-pointer rounded-md px-3 py-2 text-center text-sm font-medium text-muted-foreground transition-colors peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-sm hover:text-foreground">
{bg}
</span>
</label>
{/each}
</div>
</div>
<!-- Card Size -->
<div>
<label class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label>
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
{#each ['compact', 'medium', 'large'] as size (size)}
<label class="flex-1">
<input type="radio" name="cardSize" value={size} checked={(data.board.cardSize ?? 'medium') === size} class="peer sr-only" />
<span class="block cursor-pointer rounded-md px-3 py-2 text-center text-sm font-medium text-muted-foreground transition-colors peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-sm hover:text-foreground">
{size}
</span>
</label>
{/each}
</div>
</div>
</div>
<div class="mt-4">
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('board.save')}
</button>
</div>
</form>
</section>
<!-- Wallpaper Settings -->
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('board.wallpaper') ?? 'Wallpaper'}</h2>
<div class="grid gap-4">
<!-- Current wallpaper preview -->
{#if data.board.wallpaperUrl}
<div class="relative overflow-hidden rounded-lg border border-border">
<img
src={data.board.wallpaperUrl}
alt="Board wallpaper"
class="h-32 w-full object-cover"
/>
<div class="absolute inset-0" style="background: rgba(0,0,0,{data.board.wallpaperOverlay ?? 0.3}); backdrop-filter: blur({data.board.wallpaperBlur ?? 0}px);"></div>
</div>
{/if}
<!-- Upload -->
<div>
<label for="wallpaper-upload" class="mb-1 block text-sm font-medium text-foreground">
{$t('board.upload_wallpaper') ?? 'Upload wallpaper'}
</label>
<input
id="wallpaper-upload"
type="file"
accept="image/png,image/jpeg,image/webp"
onchange={handleWallpaperUpload}
disabled={wallpaperUploading}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground file:mr-4 file:rounded file:border-0 file:bg-primary file:px-4 file:py-1 file:text-sm file:font-medium file:text-primary-foreground"
/>
<p class="mt-1 text-xs text-muted-foreground">{$t('board.wallpaper_hint') ?? 'PNG, JPG, or WebP. Max 5MB.'}</p>
{#if wallpaperUploading}
<p class="mt-1 text-xs text-primary">{$t('common.uploading') ?? 'Uploading...'}</p>
{/if}
</div>
<!-- Or URL input -->
<form method="POST" action="?/updateBoard" use:enhance>
<input type="hidden" name="name" value={data.board.name} />
<div>
<label for="wallpaper-url" class="mb-1 block text-sm font-medium text-foreground">
{$t('board.wallpaper_url') ?? 'Or enter URL'}
</label>
<input
id="wallpaper-url"
name="wallpaperUrl"
type="url"
value={data.board.wallpaperUrl ?? ''}
placeholder="https://images.unsplash.com/..."
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
/>
</div>
<!-- Blur slider -->
<div class="mt-3">
<label for="wallpaper-blur" class="mb-1 block text-sm font-medium text-foreground">
{$t('board.blur') ?? 'Blur'}: {data.board.wallpaperBlur ?? 0}px
</label>
<input
id="wallpaper-blur"
name="wallpaperBlur"
type="range"
min="0"
max="20"
step="1"
value={data.board.wallpaperBlur ?? 0}
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-muted [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<!-- Overlay opacity slider -->
<div class="mt-3">
<label for="wallpaper-overlay" class="mb-1 block text-sm font-medium text-foreground">
{$t('board.overlay_opacity') ?? 'Overlay opacity'}: {Math.round((data.board.wallpaperOverlay ?? 0.3) * 100)}%
</label>
<input
id="wallpaper-overlay"
name="wallpaperOverlay"
type="range"
min="0"
max="1"
step="0.05"
value={data.board.wallpaperOverlay ?? 0.3}
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-muted [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<!-- Parallax toggle -->
<div class="mt-3">
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
name="wallpaperParallax"
checked={false}
class="h-4 w-4 rounded border-input accent-primary"
/>
{$t('board.parallax') ?? 'Parallax effect'}
</label>
<p class="mt-1 text-xs text-muted-foreground">{$t('board.parallax_hint') ?? 'Adds subtle depth movement to the wallpaper background'}</p>
</div>
<div class="mt-4">
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('board.save')}
</button>
</div>
</form>
</div>
</section>
<!-- Board Custom CSS -->
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('board.custom_css') ?? 'Custom CSS'}</h2>
<form method="POST" action="?/updateBoard" use:enhance>
<input type="hidden" name="name" value={data.board.name} />
<input type="hidden" name="customCss" value={boardCustomCss} />
<CustomCssEditor
value={boardCustomCss}
onchange={(css) => { boardCustomCss = css; }}
label={$t('board.custom_css_label') ?? 'Board-scoped CSS'}
/>
<div class="mt-4">
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('board.save')}
</button>
</div>
</form>
</section>
<!-- Guest Access -->
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('board.guest_access_title')}</h2>
@@ -315,6 +605,7 @@
onDeleteSection={handleDeleteSection}
onAddWidget={handleAddWidget}
onDeleteWidget={handleDeleteWidget}
onUpdateSection={handleUpdateSection}
/>
</section>
</div>
+13
View File
@@ -4,6 +4,7 @@ import { superValidate, message } from 'sveltekit-superforms';
import { zod } from '$lib/utils/zod-adapter.js';
import { createBoardSchema } from '$lib/utils/validators.js';
import * as boardService from '$lib/server/services/boardService.js';
import * as templateService from '$lib/server/services/templateService.js';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
@@ -30,6 +31,18 @@ export const actions: Actions = {
...form.data,
createdById: locals.user.id
});
// Apply template if one was selected
const formData = await request.clone().formData();
const templateId = formData.get('templateId');
if (templateId && typeof templateId === 'string' && templateId.length > 0) {
try {
await templateService.applyTemplate(templateId, board.id);
} catch {
// Template application failed — board was still created, continue
}
}
throw redirect(302, `/boards/${board.id}`);
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 302) {
+11
View File
@@ -2,9 +2,16 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import { superForm } from 'sveltekit-superforms';
import TemplatePicker from '$lib/components/board/TemplatePicker.svelte';
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form);
let selectedTemplateId = $state<string | null>(null);
function handleTemplateSelect(templateId: string | null) {
selectedTemplateId = templateId;
}
</script>
<svelte:head>
@@ -60,6 +67,10 @@
/>
</div>
<!-- Template Picker -->
<TemplatePicker onSelect={handleTemplateSelect} />
<input type="hidden" name="templateId" value={selectedTemplateId ?? ''} />
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" name="isDefault" bind:checked={$form.isDefault} class="rounded" />
+43
View File
@@ -16,5 +16,48 @@
<ThemeCustomizer preferences={data.preferences} />
<!-- Quick links to other settings -->
<div class="rounded-xl border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">More Settings</h2>
<div class="space-y-2">
<a
href="/settings/notifications"
class="flex items-center justify-between rounded-lg border border-border px-4 py-3 text-sm text-foreground transition-colors hover:bg-accent"
>
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<div>
<p class="font-medium">Notifications</p>
<p class="text-xs text-muted-foreground">Manage notification channels and history</p>
</div>
</div>
<svg class="h-4 w-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</a>
<a
href="/settings/api-tokens"
class="flex items-center justify-between rounded-lg border border-border px-4 py-3 text-sm text-foreground transition-colors hover:bg-accent"
>
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<div>
<p class="font-medium">API Tokens</p>
<p class="text-xs text-muted-foreground">Generate and manage API access tokens</p>
</div>
</div>
<svg class="h-4 w-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</a>
</div>
</div>
<BookmarkletGenerator />
</div>
@@ -0,0 +1,70 @@
import type { PageServerLoad, Actions } from './$types.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { createApiTokenSchema } from '$lib/utils/validators.js';
import { fail } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const user = requireAuth(event);
const tokens = await apiTokenService.listTokens(user.id);
return { tokens };
};
export const actions: Actions = {
create: async (event) => {
const user = requireAuth(event);
const formData = await event.request.formData();
const name = formData.get('name') as string;
const scope = formData.get('scope') as string;
const expiresAt = formData.get('expiresAt') as string;
const parsed = createApiTokenSchema.safeParse({
name,
scope,
expiresAt: expiresAt || undefined
});
if (!parsed.success) {
return fail(400, {
error: parsed.error.issues.map((i) => i.message).join(', ')
});
}
try {
const result = await apiTokenService.generateToken(
user.id,
parsed.data.name,
parsed.data.scope,
parsed.data.expiresAt ?? undefined
);
return { token: result.token, tokenId: result.id };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create token';
return fail(500, { error: message });
}
},
revoke: async (event) => {
const user = requireAuth(event);
const formData = await event.request.formData();
const tokenId = formData.get('tokenId') as string;
if (!tokenId) {
return fail(400, { error: 'Token ID is required' });
}
try {
await apiTokenService.revokeToken(tokenId, user.id);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke token';
return fail(500, { error: message });
}
return { success: true };
}
};
@@ -0,0 +1,96 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData, ActionData } from './$types.js';
import ApiTokenList from '$lib/components/settings/ApiTokenList.svelte';
import ApiTokenCreateForm from '$lib/components/settings/ApiTokenCreateForm.svelte';
let { data, form: actionData }: { data: PageData; form: ActionData } = $props();
let showCreateForm = $state(false);
let createdToken = $state<string | null>(null);
// After form submission, check if a token was returned
$effect(() => {
if (actionData && 'token' in actionData && actionData.token) {
createdToken = actionData.token;
showCreateForm = false;
}
});
function dismissToken() {
createdToken = null;
}
async function copyToken() {
if (createdToken) {
try {
await navigator.clipboard.writeText(createdToken);
} catch {
// Fallback: select text
}
}
}
</script>
<svelte:head>
<title>API Tokens | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">API Tokens</h1>
{#if !showCreateForm && !createdToken}
<button
type="button"
onclick={() => (showCreateForm = true)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate Token
</button>
{/if}
</div>
<!-- Token Created Alert -->
{#if createdToken}
<div class="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4">
<h3 class="mb-2 text-sm font-semibold text-foreground">Token Created</h3>
<p class="mb-3 text-xs text-muted-foreground">
Copy this token now. It will not be shown again.
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-xs font-mono text-foreground">
{createdToken}
</code>
<button
type="button"
onclick={copyToken}
class="rounded-md bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Copy
</button>
</div>
<button
type="button"
onclick={dismissToken}
class="mt-3 text-xs text-muted-foreground hover:text-foreground"
>
I have copied the token
</button>
</div>
{/if}
<!-- Error -->
{#if actionData && 'error' in actionData && actionData.error}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{actionData.error}
</div>
{/if}
<!-- Create Form -->
{#if showCreateForm}
<ApiTokenCreateForm onCancel={() => (showCreateForm = false)} />
{/if}
<!-- Token List -->
<ApiTokenList tokens={data.tokens} />
</div>
@@ -0,0 +1,204 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import NotificationChannelForm from '$lib/components/notifications/NotificationChannelForm.svelte';
import NotificationHistory from '$lib/components/notifications/NotificationHistory.svelte';
interface Channel {
id: string;
type: string;
config: string;
enabled: boolean;
createdAt: string;
}
let channels = $state<Channel[]>([]);
let loading = $state(true);
let showForm = $state(false);
let editingChannel = $state<Channel | null>(null);
let activeTab = $state<'channels' | 'history'>('channels');
let error = $state<string | null>(null);
onMount(async () => {
await loadChannels();
});
async function loadChannels() {
loading = true;
try {
const res = await fetch('/api/notifications/channels');
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
channels = json.data;
}
}
} catch {
error = 'Failed to load notification channels';
} finally {
loading = false;
}
}
async function handleSave(data: { type: string; config: string; enabled: boolean }) {
error = null;
try {
if (editingChannel) {
const res = await fetch(`/api/notifications/channels/${editingChannel.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
error = 'Failed to update channel';
return;
}
} else {
const res = await fetch('/api/notifications/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
error = 'Failed to create channel';
return;
}
}
showForm = false;
editingChannel = null;
await loadChannels();
} catch {
error = 'Network error saving channel';
}
}
async function deleteChannel(channelId: string) {
try {
const res = await fetch(`/api/notifications/channels/${channelId}`, {
method: 'DELETE'
});
if (res.ok) {
await loadChannels();
}
} catch {
error = 'Failed to delete channel';
}
}
function channelTypeLabel(type: string): string {
switch (type) {
case 'discord': return 'Discord';
case 'slack': return 'Slack';
case 'telegram': return 'Telegram';
case 'http': return 'HTTP Webhook';
default: return type;
}
}
</script>
<svelte:head>
<title>Notification Settings | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">Notifications</h1>
</div>
<!-- Tabs -->
<div class="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
<button
type="button"
onclick={() => (activeTab = 'channels')}
class="rounded-md px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'channels'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
Channels
</button>
<button
type="button"
onclick={() => (activeTab = 'history')}
class="rounded-md px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'history'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
History
</button>
</div>
{#if error}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
{#if activeTab === 'channels'}
<!-- Channel Form -->
{#if showForm || editingChannel}
<NotificationChannelForm
channel={editingChannel}
onSave={handleSave}
onCancel={() => { showForm = false; editingChannel = null; }}
/>
{:else}
<button
type="button"
onclick={() => (showForm = true)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Add Channel
</button>
{/if}
<!-- Channel List -->
{#if loading}
<div class="py-8 text-center text-muted-foreground">Loading channels...</div>
{:else if channels.length === 0 && !showForm}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">No notification channels configured</p>
<p class="mt-1 text-xs text-muted-foreground">
Add a channel to receive alerts when your services go up or down
</p>
</div>
{:else}
<div class="space-y-3">
{#each channels as channel (channel.id)}
<div class="flex items-center justify-between rounded-lg border border-border bg-card p-4">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-md bg-muted text-xs font-bold text-muted-foreground">
{channelTypeLabel(channel.type).charAt(0)}
</span>
<div>
<p class="text-sm font-medium text-foreground">{channelTypeLabel(channel.type)}</p>
<p class="text-xs text-muted-foreground">
{channel.enabled ? 'Enabled' : 'Disabled'}
&middot; Created {new Date(channel.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => { editingChannel = channel; showForm = false; }}
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Edit
</button>
<button
type="button"
onclick={() => deleteChannel(channel.id)}
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<NotificationHistory />
{/if}
</div>
+52
View File
@@ -0,0 +1,52 @@
import type { PageServerLoad } from './$types.js';
import * as uptimeService from '$lib/server/services/uptimeService.js';
export const load: PageServerLoad = async ({ url }) => {
const timeRange = (url.searchParams.get('range') as '24h' | '7d' | '30d') ?? '24h';
const validRanges = ['24h', '7d', '30d'];
const range = validRanges.includes(timeRange) ? timeRange : '24h';
try {
const [allAppsUptime, incidents] = await Promise.all([
uptimeService.getAllAppsUptime(range as '24h' | '7d' | '30d'),
uptimeService.getIncidents(undefined, range as '24h' | '7d' | '30d')
]);
// Enrich each app with timeline data for sparkline charts
const appsWithTimelines = await Promise.all(
allAppsUptime.map(async (app) => {
const timeline = await uptimeService.getUptimeTimeline(
app.appId,
range as '24h' | '7d' | '30d'
);
return { ...app, timeline };
})
);
// Compute summary stats
const totalApps = appsWithTimelines.length;
const appsOnline = appsWithTimelines.filter((a) => a.currentStatus === 'online').length;
const uptimeValues = appsWithTimelines
.map((a) => a.uptimePercentage)
.filter((v): v is number => v !== null);
const overallUptime =
uptimeValues.length > 0
? Math.round((uptimeValues.reduce((sum, v) => sum + v, 0) / uptimeValues.length) * 100) /
100
: null;
return {
apps: appsWithTimelines,
incidents,
summary: { totalApps, appsOnline, overallUptime },
range
};
} catch {
return {
apps: [],
incidents: [],
summary: { totalApps: 0, appsOnline: 0, overallUptime: null },
range
};
}
};
+185
View File
@@ -0,0 +1,185 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import type { PageData } from './$types.js';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
let { data }: { data: PageData } = $props();
const ranges = [
{ value: '24h', label: '24 Hours' },
{ value: '7d', label: '7 Days' },
{ value: '30d', label: '30 Days' }
] as const;
function selectRange(range: string) {
goto(`/status?range=${range}`, { replaceState: true });
}
function statusColor(status: string): string {
switch (status) {
case 'online':
return 'text-green-500';
case 'offline':
return 'text-red-500';
case 'degraded':
return 'text-yellow-500';
default:
return 'text-gray-400';
}
}
function statusDotColor(status: string): string {
switch (status) {
case 'online':
return 'bg-green-500';
case 'offline':
return 'bg-red-500';
case 'degraded':
return 'bg-yellow-500';
default:
return 'bg-gray-400';
}
}
function formatUptime(value: number | null): string {
if (value === null) return 'N/A';
return `${value.toFixed(2)}%`;
}
function formatResponseTime(value: number | null): string {
if (value === null) return 'N/A';
return `${Math.round(value)}ms`;
}
</script>
<svelte:head>
<title>Status Page | {$t('app_name')}</title>
</svelte:head>
<div class="p-6">
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-foreground">Status Page</h1>
<p class="mt-1 text-sm text-muted-foreground">
Real-time status and uptime monitoring for all services
</p>
</div>
<!-- Summary Cards -->
<div class="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-border bg-card p-5">
<p class="text-sm text-muted-foreground">Total Services</p>
<p class="mt-1 text-3xl font-bold text-foreground">{data.summary.totalApps}</p>
</div>
<div class="rounded-xl border border-border bg-card p-5">
<p class="text-sm text-muted-foreground">Services Online</p>
<p class="mt-1 text-3xl font-bold text-green-500">{data.summary.appsOnline}</p>
</div>
<div class="rounded-xl border border-border bg-card p-5">
<p class="text-sm text-muted-foreground">Overall Uptime</p>
<p class="mt-1 text-3xl font-bold text-foreground">
{formatUptime(data.summary.overallUptime)}
</p>
</div>
</div>
<!-- Time Range Selector -->
<div class="mb-6 flex items-center gap-2">
{#each ranges as r (r.value)}
<button
type="button"
onclick={() => selectRange(r.value)}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {data.range === r.value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{r.label}
</button>
{/each}
</div>
<!-- Apps List -->
<div class="space-y-3">
{#if data.apps.length === 0}
<div class="rounded-xl border border-border bg-card p-12 text-center">
<p class="text-muted-foreground">No monitored services found</p>
</div>
{:else}
{#each data.apps as app (app.appId)}
<div class="rounded-xl border border-border bg-card p-4 transition-colors hover:bg-card/80">
<div class="flex items-center gap-4">
<!-- Status Dot -->
<div class="flex-shrink-0">
<span class="inline-block h-3 w-3 rounded-full {statusDotColor(app.currentStatus ?? 'unknown')}"></span>
</div>
<!-- App Name & Status -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate text-sm font-semibold text-foreground">{app.appName}</h3>
<span class="text-xs capitalize {statusColor(app.currentStatus ?? 'unknown')}">
{app.currentStatus ?? 'unknown'}
</span>
</div>
</div>
<!-- Uptime % -->
<div class="text-right">
<p class="text-sm font-medium text-foreground">
{formatUptime(app.uptimePercentage)}
</p>
<p class="text-xs text-muted-foreground">
{formatResponseTime(app.avgResponseTime)}
</p>
</div>
<!-- Sparkline -->
{#if app.timeline && app.timeline.length > 0}
<div class="hidden sm:block">
<SparklineChart
data={app.timeline.map((t: { status: string; checkedAt: Date | string }) => ({
status: t.status,
checkedAt: String(t.checkedAt)
}))}
width={120}
height={24}
/>
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Incidents Section -->
{#if data.incidents.length > 0}
<div class="mt-8">
<h2 class="mb-4 text-lg font-semibold text-foreground">Recent Incidents</h2>
<div class="space-y-2">
{#each data.incidents as incident (`${incident.appId}-${incident.startedAt}`)}
<div class="rounded-lg border border-border bg-card/50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>
<span class="text-sm font-medium text-foreground">{incident.appName ?? 'Unknown'}</span>
</div>
<span class="text-xs text-muted-foreground">
{incident.durationMs ? `${Math.round(incident.durationMs / 60_000)}min` : 'ongoing'}
</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{new Date(incident.startedAt).toLocaleString()}
{#if incident.endedAt}
&mdash; {new Date(incident.endedAt).toLocaleString()}
{/if}
</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>