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:
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 />
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'}
|
||||
· 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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
— {new Date(incident.endedAt).toLocaleString()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user