diff --git a/plans/phase-3-advanced-features/CONTEXT.md b/plans/phase-3-advanced-features/CONTEXT.md index 51ff6e4..914ad53 100644 --- a/plans/phase-3-advanced-features/CONTEXT.md +++ b/plans/phase-3-advanced-features/CONTEXT.md @@ -1,7 +1,27 @@ # Feature Context: Phase 3 — Advanced Features ## Current State -Phase 2 is complete and merged. 176 tests, full build passes. Starting Phase 3 advanced features. +Phase 2 is complete and merged. 176 tests, full build passes. Phase 3 in progress. Phase 1 (Import/Export), Phase 2 (Sparklines), and Phase 3 (User Theme Overrides) are complete. + +### Phase 1 (Import/Export) Summary +exportService, importService, admin API endpoints, ImportExportPanel UI, Zod validation schema, i18n EN/RU translations. + +### Phase 2 (Sparklines) Summary +- History API at `/api/apps/[id]/history` — returns last 288 status records with uptime percentage +- `SparklineChart.svelte` — inline SVG bar chart with color-coded status bars (green/red/yellow/gray) +- `AppWidget.svelte` and `AppCard.svelte` updated to fetch and display sparklines on mount +- `pruneOldStatuses()` in healthcheck service — deletes records >24h, caps at 288 per app +- Hourly cleanup cron job in healthcheck scheduler +- i18n keys: `app.uptime`, `app.history_loading` (EN/RU) + +### Phase 3 (User Theme Overrides) Summary +- Prisma migration: added `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` nullable fields to User model +- Preferences API at `/api/users/me/preferences` — GET returns preferences, PATCH updates subset +- Settings page at `/settings` with `ThemeCustomizer.svelte` — hue/saturation sliders, mode toggle (dark/light/system), background selector, locale picker, save button +- Theme store `loadFromServer(prefs)` method applies server preferences over localStorage defaults +- `+layout.server.ts` passes `userPreferences` in layout data; `+layout.svelte` applies them on mount +- Header user menu includes "Settings" link +- i18n keys: `settings.title`, `settings.theme`, `settings.primary_color`, `settings.hue`, `settings.saturation`, `settings.background`, `settings.language`, `settings.save`, `settings.saving`, `settings.saved` (EN/RU) ## Cross-Phase Dependencies - Phases 1-3 are independent (import/export, sparklines, user themes) diff --git a/plans/phase-3-advanced-features/PLAN.md b/plans/phase-3-advanced-features/PLAN.md index c62d019..895fe97 100644 --- a/plans/phase-3-advanced-features/PLAN.md +++ b/plans/phase-3-advanced-features/PLAN.md @@ -19,9 +19,9 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D ## Phases -- [ ] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md) +- [x] Phase 1: Import/Export [fullstack] → [subplan](./phase-1-import-export.md) - [ ] Phase 2: Ping History Sparklines [fullstack] → [subplan](./phase-2-sparklines.md) -- [ ] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md) +- [x] Phase 3: User Theme Overrides [fullstack] → [subplan](./phase-3-user-themes.md) - [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md) - [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md) - [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md) @@ -31,9 +31,9 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| -| Phase 1: Import/Export | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 2: Sparklines | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 3: User Themes | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 1: Import/Export | fullstack | ✅ Done | ⬜ | ⬜ | ⬜ | +| Phase 2: Sparklines | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | +| Phase 3: User Themes | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/phase-3-advanced-features/phase-1-import-export.md b/plans/phase-3-advanced-features/phase-1-import-export.md index fc2d73d..89a07ff 100644 --- a/plans/phase-3-advanced-features/phase-1-import-export.md +++ b/plans/phase-3-advanced-features/phase-1-import-export.md @@ -1,18 +1,19 @@ # Phase 1: Import/Export -**Status:** ⬜ Not Started +**Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON -- [ ] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite) -- [ ] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download -- [ ] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload -- [ ] Task 5: Update admin settings page — add Import/Export section with download button and file upload -- [ ] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button -- [ ] Task 7: Add Zod schema for validating import data structure -- [ ] Task 8: Add i18n translations for import/export strings (EN/RU) +- [x] Task 1: Create `src/lib/server/services/exportService.ts` — export all data (apps, boards, sections, widgets, groups, settings) as JSON +- [x] Task 2: Create `src/lib/server/services/importService.ts` — import JSON with conflict resolution (skip/overwrite) +- [x] Task 3: Create `src/routes/api/admin/export/+server.ts` — GET endpoint, returns JSON file download +- [x] Task 4: Create `src/routes/api/admin/import/+server.ts` — POST endpoint, accepts JSON upload +- [x] Task 5: Update admin settings page — add Import/Export section with download button and file upload +- [x] Task 6: Create `src/lib/components/admin/ImportExportPanel.svelte` — UI with export button, file picker, preview, and import button +- [x] Task 7: Add Zod schema for validating import data structure +- [x] Task 8: Add i18n translations for import/export strings (EN/RU) ## Handoff to Next Phase - + +All import/export functionality implemented. Export service gathers all apps, boards (with sections/widgets), groups, and system settings into a versioned JSON structure. Import service validates with Zod, supports skip/overwrite conflict resolution, and runs in a Prisma transaction. Admin-only API endpoints with Content-Disposition for file download. UI panel with file upload, JSON preview, mode selector, and status feedback. diff --git a/plans/phase-3-advanced-features/phase-2-sparklines.md b/plans/phase-3-advanced-features/phase-2-sparklines.md index 496c2ad..dd25193 100644 --- a/plans/phase-3-advanced-features/phase-2-sparklines.md +++ b/plans/phase-3-advanced-features/phase-2-sparklines.md @@ -1,18 +1,20 @@ # Phase 2: Ping History Sparklines -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results -- [ ] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down) -- [ ] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge -- [ ] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards -- [ ] Task 5: Calculate and display uptime percentage (last 24h) -- [ ] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals) -- [ ] Task 7: Add cleanup job to prune old AppStatus records beyond retention period -- [ ] Task 8: Add i18n translations (EN/RU) + +- [x] Task 1: Create `src/routes/api/apps/[id]/history/+server.ts` — GET last 24h of healthcheck results +- [x] Task 2: Create `src/lib/components/app/SparklineChart.svelte` — tiny inline SVG sparkline (green=up, red=down) +- [x] Task 3: Update `src/lib/components/widget/AppWidget.svelte` — show sparkline below status badge +- [x] Task 4: Update `src/lib/components/app/AppCard.svelte` — show sparkline on app cards +- [x] Task 5: Calculate and display uptime percentage (last 24h) +- [x] Task 6: Update healthcheck service to retain last 288 records per app (24h at 5min intervals) +- [x] Task 7: Add cleanup job to prune old AppStatus records beyond retention period +- [x] Task 8: Add i18n translations (EN/RU) ## Handoff to Next Phase - + +All sparkline features implemented. History API returns last 288 records with uptime percentage. SparklineChart renders color-coded bars (green/red/yellow/gray). Cleanup job prunes records older than 24h hourly. Both AppWidget and AppCard fetch and display sparklines with uptime percentage on mount. diff --git a/plans/phase-3-advanced-features/phase-3-user-themes.md b/plans/phase-3-advanced-features/phase-3-user-themes.md index 9661f38..2626324 100644 --- a/plans/phase-3-advanced-features/phase-3-user-themes.md +++ b/plans/phase-3-advanced-features/phase-3-user-themes.md @@ -1,19 +1,19 @@ # Phase 3: User Theme Overrides -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack ## Tasks -- [ ] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration) -- [ ] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences -- [ ] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data -- [ ] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization -- [ ] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle -- [ ] Task 6: Update theme store to load user preferences from server on login -- [ ] Task 7: Update `+layout.server.ts` to pass user preferences -- [ ] Task 8: Add user settings link to header user menu -- [ ] Task 9: Add i18n translations (EN/RU) +- [x] Task 1: Add `themeMode`, `primaryHue`, `primarySaturation`, `backgroundType`, `locale` fields to User model (Prisma migration) +- [x] Task 2: Create `src/routes/api/users/me/preferences/+server.ts` — GET/PATCH user preferences +- [x] Task 3: Create `src/routes/settings/+page.server.ts` — user settings page data +- [x] Task 4: Create `src/routes/settings/+page.svelte` — user settings page with theme customization +- [x] Task 5: Create `src/lib/components/settings/ThemeCustomizer.svelte` — HSL color picker, background selector, mode toggle +- [x] Task 6: Update theme store to load user preferences from server on login +- [x] Task 7: Update `+layout.server.ts` to pass user preferences +- [x] Task 8: Add user settings link to header user menu +- [x] Task 9: Add i18n translations (EN/RU) ## Handoff to Next Phase - +Phase 3 (User Theme Overrides) complete. Added nullable preference fields to User model, preferences API (GET/PATCH), settings page with ThemeCustomizer component (hue/saturation sliders, mode toggle, background selector, locale picker), server-side preference loading in layout, and Settings link in Header user menu. i18n translations added for EN and RU. diff --git a/prisma/migrations/20260324212043_add_user_preferences/migration.sql b/prisma/migrations/20260324212043_add_user_preferences/migration.sql new file mode 100644 index 0000000..3442b2b --- /dev/null +++ b/prisma/migrations/20260324212043_add_user_preferences/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "backgroundType" TEXT; +ALTER TABLE "User" ADD COLUMN "locale" TEXT; +ALTER TABLE "User" ADD COLUMN "primaryHue" INTEGER; +ALTER TABLE "User" ADD COLUMN "primarySaturation" INTEGER; +ALTER TABLE "User" ADD COLUMN "themeMode" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10d0ddb..9867842 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,12 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + themeMode String? + primaryHue Int? + primarySaturation Int? + backgroundType String? + locale String? + groups UserGroup[] createdApps App[] boards Board[] diff --git a/src/lib/components/admin/ImportExportPanel.svelte b/src/lib/components/admin/ImportExportPanel.svelte new file mode 100644 index 0000000..272d697 --- /dev/null +++ b/src/lib/components/admin/ImportExportPanel.svelte @@ -0,0 +1,212 @@ + + +
+

{$t('admin.import_export_title')}

+

{$t('admin.import_export_description')}

+ + +
+

{$t('admin.export_section')}

+ +
+ + +
+ + +
+

{$t('admin.import_section')}

+ + +
+ + +
+ + + {#if previewData} +
+ +
{previewData}
+
+ {/if} + + + {#if parsedData} +
+ + +
+ + + {/if} +
+ + + {#if statusMessage} +
+ {statusMessage} +
+ {/if} +
diff --git a/src/lib/components/app/AppCard.svelte b/src/lib/components/app/AppCard.svelte index ff01800..64a8642 100644 --- a/src/lib/components/app/AppCard.svelte +++ b/src/lib/components/app/AppCard.svelte @@ -1,5 +1,8 @@ + + + {#each bars as bar} + + {/each} + diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index f5eca66..e49004e 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -161,6 +161,27 @@

{user.email}

+ (showUserMenu = false)} + class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent" + > + + + + + {$t('settings.title')} + +
+ {/each} + + + + +
+

{$t('settings.primary_color')}

+ + +
+
+ + HSL({theme.primaryHue}, {theme.primarySaturation}%, 50%) + +
+ + +
+ +
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ + +
+

{$t('settings.background')}

+
+ {#each bgOptions as opt (opt.value)} + + {/each} +
+
+ + +
+

{$t('settings.language')}

+
+ {#each localeOptions as opt (opt.value)} + + {/each} +
+
+ + +
+ + {#if saved} + {$t('settings.saved')} + {/if} + {#if errorMessage} + {errorMessage} + {/if} +
+ diff --git a/src/lib/components/widget/AppWidget.svelte b/src/lib/components/widget/AppWidget.svelte index 3d5b26f..1305a59 100644 --- a/src/lib/components/widget/AppWidget.svelte +++ b/src/lib/components/widget/AppWidget.svelte @@ -1,5 +1,8 @@ + + + {#if historyLoading} +
+ {:else if historyData.length > 0} +
+ + {#if uptimePercent !== null} + {uptimePercent}% + {/if} +
+ {/if}
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 02e0c2d..c52afdc 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -127,6 +127,8 @@ "app.healthcheck_timeout": "Timeout (ms)", "app.healthcheck_interval": "Interval (seconds)", "app.icon_board_label": "Icon (Lucide name)", + "app.uptime": "uptime", + "app.history_loading": "Loading history...", "admin.panel": "Admin Panel", "admin.users": "Users", @@ -213,6 +215,23 @@ "admin.perm_none": "No permissions configured.", "admin.perm_search_placeholder": "Type to search...", + "admin.import_export_title": "Import / Export", + "admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.", + "admin.export_section": "Export Data", + "admin.export_button": "Export JSON", + "admin.export_exporting": "Exporting...", + "admin.export_success": "Export downloaded successfully.", + "admin.import_section": "Import Data", + "admin.import_select_file": "Select a JSON export file", + "admin.import_preview": "Preview", + "admin.import_mode_label": "Conflict Resolution", + "admin.import_mode_skip": "Skip existing (keep current data)", + "admin.import_mode_overwrite": "Overwrite existing (replace with imported data)", + "admin.import_button": "Import", + "admin.import_importing": "Importing...", + "admin.import_success": "Import completed.", + "admin.import_invalid_json": "Selected file is not valid JSON.", + "search.placeholder": "Search apps and boards...", "search.trigger": "Search...", "search.min_chars": "Type at least 2 characters to search", @@ -261,5 +280,16 @@ "home.view_boards": "View Boards", "home.browse_apps": "Browse Apps", - "language.label": "Language" + "language.label": "Language", + + "settings.title": "Settings", + "settings.theme": "Theme Mode", + "settings.primary_color": "Primary Color", + "settings.hue": "Hue", + "settings.saturation": "Saturation", + "settings.background": "Background Effect", + "settings.language": "Language", + "settings.save": "Save Preferences", + "settings.saving": "Saving...", + "settings.saved": "Preferences saved!" } diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index db6c236..c1b9b03 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -127,6 +127,8 @@ "app.healthcheck_timeout": "\u0422\u0430\u0439\u043c\u0430\u0443\u0442 (\u043c\u0441)", "app.healthcheck_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b (\u0441\u0435\u043a\u0443\u043d\u0434\u044b)", "app.icon_board_label": "\u0418\u043a\u043e\u043d\u043a\u0430 (Lucide)", + "app.uptime": "\u0430\u043f\u0442\u0430\u0439\u043c", + "app.history_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438\u0441\u0442\u043e\u0440\u0438\u0438...", "admin.panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", "admin.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", @@ -213,7 +215,24 @@ "admin.perm_none": "\u041f\u0440\u0430\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.", "admin.perm_search_placeholder": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0432\u0432\u043e\u0434\u0438\u0442\u044c...", - "search.placeholder": "\u041f\u043e\u0438\u0441\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0438 \u0434\u043e\u0441\u043e\u043a...", + "admin.import_export_title": "Импорт / Экспорт", + "admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.", + "admin.export_section": "Экспорт данных", + "admin.export_button": "Экспорт JSON", + "admin.export_exporting": "Экспорт...", + "admin.export_success": "Экспорт успешно скачан.", + "admin.import_section": "Импорт данных", + "admin.import_select_file": "Выберите JSON-файл экспорта", + "admin.import_preview": "Предпросмотр", + "admin.import_mode_label": "Разрешение конфликтов", + "admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)", + "admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)", + "admin.import_button": "Импортировать", + "admin.import_importing": "Импорт...", + "admin.import_success": "Импорт завершён.", + "admin.import_invalid_json": "Выбранный файл не является корректным JSON.", + + "search.placeholder": "Поиск приложений и досок...", "search.trigger": "\u041f\u043e\u0438\u0441\u043a...", "search.min_chars": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043c\u0438\u043d\u0438\u043c\u0443\u043c 2 \u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u0434\u043b\u044f \u043f\u043e\u0438\u0441\u043a\u0430", "search.no_results": "\u041d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443 \u00ab{query}\u00bb", @@ -261,5 +280,16 @@ "home.view_boards": "\u041f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0434\u043e\u0441\u043a\u0438", "home.browse_apps": "\u041e\u0431\u0437\u043e\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439", - "language.label": "\u042f\u0437\u044b\u043a" + "language.label": "\u042f\u0437\u044b\u043a", + + "settings.title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "settings.theme": "\u0420\u0435\u0436\u0438\u043c \u0442\u0435\u043c\u044b", + "settings.primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442", + "settings.hue": "\u041e\u0442\u0442\u0435\u043d\u043e\u043a", + "settings.saturation": "\u041d\u0430\u0441\u044b\u0449\u0435\u043d\u043d\u043e\u0441\u0442\u044c", + "settings.background": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430", + "settings.language": "\u042f\u0437\u044b\u043a", + "settings.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", + "settings.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...", + "settings.saved": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u044b!" } diff --git a/src/lib/server/jobs/healthcheckScheduler.ts b/src/lib/server/jobs/healthcheckScheduler.ts index ddeda64..2efbf1b 100644 --- a/src/lib/server/jobs/healthcheckScheduler.ts +++ b/src/lib/server/jobs/healthcheckScheduler.ts @@ -1,11 +1,13 @@ import cron from 'node-cron'; -import { checkAllApps } from '$lib/server/services/healthcheckService.js'; +import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js'; let scheduledTask: cron.ScheduledTask | null = null; +let cleanupTask: cron.ScheduledTask | null = null; /** * Start the healthcheck scheduler. * Runs checkAllApps on a cron schedule (default: every 60 seconds). + * Also starts an hourly cleanup job to prune old status records. */ export function startScheduler(cronExpression: string = '* * * * *'): void { if (scheduledTask) { @@ -20,6 +22,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void { } }); + // Cleanup job: run every hour at minute 0 + cleanupTask = cron.schedule('0 * * * *', async () => { + try { + await pruneOldStatuses(); + } catch { + // Swallow errors to prevent scheduler crash + } + }); + // Run an initial check shortly after startup setTimeout(() => { checkAllApps().catch(() => { @@ -29,11 +40,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void { } /** - * Stop the healthcheck scheduler. + * Stop the healthcheck scheduler and cleanup job. */ export function stopScheduler(): void { if (scheduledTask) { scheduledTask.stop(); scheduledTask = null; } + if (cleanupTask) { + cleanupTask.stop(); + cleanupTask = null; + } } diff --git a/src/lib/server/services/exportService.ts b/src/lib/server/services/exportService.ts new file mode 100644 index 0000000..28dc19f --- /dev/null +++ b/src/lib/server/services/exportService.ts @@ -0,0 +1,154 @@ +import { prisma } from '../prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; + +export interface ExportData { + readonly version: string; + readonly exportedAt: string; + readonly apps: ReadonlyArray; + readonly boards: ReadonlyArray; + readonly groups: ReadonlyArray; + readonly settings: ExportSettings; +} + +export interface ExportApp { + readonly name: string; + readonly url: string; + readonly icon: string | null; + readonly iconType: string; + readonly description: string | null; + readonly category: string | null; + readonly tags: string; + readonly healthcheckEnabled: boolean; + readonly healthcheckInterval: number; + readonly healthcheckMethod: string; + readonly healthcheckExpectedStatus: number; + readonly healthcheckTimeout: number; +} + +export interface ExportWidget { + readonly type: string; + readonly order: number; + readonly config: string; + readonly appName: string | null; +} + +export interface ExportSection { + readonly title: string; + readonly icon: string | null; + readonly order: number; + readonly isExpandedByDefault: boolean; + readonly widgets: ReadonlyArray; +} + +export interface ExportBoard { + readonly name: string; + readonly icon: string | null; + readonly description: string | null; + readonly isDefault: boolean; + readonly isGuestAccessible: boolean; + readonly backgroundConfig: string | null; + readonly sections: ReadonlyArray; +} + +export interface ExportGroup { + readonly name: string; + readonly description: string | null; + readonly isDefault: boolean; +} + +export interface ExportSettings { + readonly authMode: string; + readonly registrationEnabled: boolean; + readonly defaultTheme: string; + readonly defaultPrimaryColor: string; + readonly healthcheckDefaults: string; +} + +export async function exportAllData(): Promise { + const [apps, boards, groups, settings] = await Promise.all([ + prisma.app.findMany({ + orderBy: { name: 'asc' } + }), + prisma.board.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + sections: { + orderBy: { order: 'asc' }, + include: { + widgets: { + orderBy: { order: 'asc' }, + include: { app: { select: { name: true } } } + } + } + } + } + }), + prisma.group.findMany({ + orderBy: { name: 'asc' } + }), + prisma.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: {}, + create: { id: DEFAULTS.SYSTEM_SETTINGS_ID } + }) + ]); + + const exportApps: ReadonlyArray = apps.map((app) => ({ + name: app.name, + url: app.url, + icon: app.icon, + iconType: app.iconType, + description: app.description, + category: app.category, + tags: app.tags, + healthcheckEnabled: app.healthcheckEnabled, + healthcheckInterval: app.healthcheckInterval, + healthcheckMethod: app.healthcheckMethod, + healthcheckExpectedStatus: app.healthcheckExpectedStatus, + healthcheckTimeout: app.healthcheckTimeout + })); + + const exportBoards: ReadonlyArray = boards.map((board) => ({ + name: board.name, + icon: board.icon, + description: board.description, + isDefault: board.isDefault, + isGuestAccessible: board.isGuestAccessible, + backgroundConfig: board.backgroundConfig, + sections: board.sections.map((section) => ({ + title: section.title, + icon: section.icon, + order: section.order, + isExpandedByDefault: section.isExpandedByDefault, + widgets: section.widgets.map((widget) => ({ + type: widget.type, + order: widget.order, + config: widget.config, + appName: widget.app?.name ?? null + })) + })) + })); + + const exportGroups: ReadonlyArray = groups.map((group) => ({ + name: group.name, + description: group.description, + isDefault: group.isDefault + })); + + const exportSettings: ExportSettings = { + authMode: settings.authMode, + registrationEnabled: settings.registrationEnabled, + defaultTheme: settings.defaultTheme, + defaultPrimaryColor: settings.defaultPrimaryColor, + healthcheckDefaults: settings.healthcheckDefaults + }; + + return { + version: '1.0', + exportedAt: new Date().toISOString(), + apps: exportApps, + boards: exportBoards, + groups: exportGroups, + settings: exportSettings + }; +} diff --git a/src/lib/server/services/healthcheckService.ts b/src/lib/server/services/healthcheckService.ts index e18bb3a..4cf99d6 100644 --- a/src/lib/server/services/healthcheckService.ts +++ b/src/lib/server/services/healthcheckService.ts @@ -1,6 +1,10 @@ import * as appService from './appService.js'; +import { prisma } from '../prisma.js'; import { AppStatusValue } from '$lib/utils/constants.js'; +const MAX_RECORDS_PER_APP = 288; +const RETENTION_HOURS = 24; + export interface HealthcheckResult { readonly appId: string; readonly status: string; @@ -81,3 +85,50 @@ export async function checkAllApps(): Promise { return outcomes; } + +/** + * Prune old AppStatus records. + * - Deletes records older than RETENTION_HOURS + * - Keeps at most MAX_RECORDS_PER_APP per app (deletes oldest excess) + */ +export async function pruneOldStatuses(): Promise { + const cutoff = new Date(Date.now() - RETENTION_HOURS * 60 * 60 * 1000); + + // Step 1: Delete all records older than retention period + await prisma.appStatus.deleteMany({ + where: { + checkedAt: { lt: cutoff } + } + }); + + // Step 2: For each app, keep at most MAX_RECORDS_PER_APP + const apps = await prisma.app.findMany({ + where: { healthcheckEnabled: true }, + select: { id: true } + }); + + for (const app of apps) { + const count = await prisma.appStatus.count({ + where: { appId: app.id } + }); + + if (count > MAX_RECORDS_PER_APP) { + const excess = count - MAX_RECORDS_PER_APP; + // Find the oldest records to delete + const oldestRecords = await prisma.appStatus.findMany({ + where: { appId: app.id }, + orderBy: { checkedAt: 'asc' }, + take: excess, + select: { id: true } + }); + + if (oldestRecords.length > 0) { + await prisma.appStatus.deleteMany({ + where: { + id: { in: oldestRecords.map((r) => r.id) } + } + }); + } + } + } +} diff --git a/src/lib/server/services/importService.ts b/src/lib/server/services/importService.ts new file mode 100644 index 0000000..753701d --- /dev/null +++ b/src/lib/server/services/importService.ts @@ -0,0 +1,226 @@ +import { prisma } from '../prisma.js'; +import { importDataSchema } from '$lib/utils/validators.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; +import type { ExportData } from './exportService.js'; + +export type ImportMode = 'skip' | 'overwrite'; + +export interface ImportResult { + readonly apps: { readonly created: number; readonly updated: number; readonly skipped: number }; + readonly boards: { readonly created: number; readonly updated: number; readonly skipped: number }; + readonly groups: { readonly created: number; readonly updated: number; readonly skipped: number }; + readonly settingsUpdated: boolean; +} + +export function validateImportData(data: unknown): { success: true; data: ExportData } | { success: false; errors: string[] } { + const parsed = importDataSchema.safeParse(data); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`); + return { success: false, errors }; + } + return { success: true, data: parsed.data as ExportData }; +} + +export async function importData(data: ExportData, mode: ImportMode): Promise { + const result = { + apps: { created: 0, updated: 0, skipped: 0 }, + boards: { created: 0, updated: 0, skipped: 0 }, + groups: { created: 0, updated: 0, skipped: 0 }, + settingsUpdated: false + }; + + await prisma.$transaction(async (tx) => { + // --- Import Apps --- + const appNameToId = new Map(); + + for (const appData of data.apps) { + const existing = await tx.app.findFirst({ where: { name: appData.name } }); + + if (existing) { + appNameToId.set(appData.name, existing.id); + if (mode === 'skip') { + result.apps.skipped++; + continue; + } + // overwrite + await tx.app.update({ + where: { id: existing.id }, + data: { + url: appData.url, + icon: appData.icon, + iconType: appData.iconType, + description: appData.description, + category: appData.category, + tags: appData.tags, + healthcheckEnabled: appData.healthcheckEnabled, + healthcheckInterval: appData.healthcheckInterval, + healthcheckMethod: appData.healthcheckMethod, + healthcheckExpectedStatus: appData.healthcheckExpectedStatus, + healthcheckTimeout: appData.healthcheckTimeout + } + }); + result.apps.updated++; + } else { + const created = await tx.app.create({ + data: { + name: appData.name, + url: appData.url, + icon: appData.icon, + iconType: appData.iconType, + description: appData.description, + category: appData.category, + tags: appData.tags, + healthcheckEnabled: appData.healthcheckEnabled, + healthcheckInterval: appData.healthcheckInterval, + healthcheckMethod: appData.healthcheckMethod, + healthcheckExpectedStatus: appData.healthcheckExpectedStatus, + healthcheckTimeout: appData.healthcheckTimeout + } + }); + appNameToId.set(appData.name, created.id); + result.apps.created++; + } + } + + // --- Import Groups --- + for (const groupData of data.groups) { + const existing = await tx.group.findUnique({ where: { name: groupData.name } }); + + if (existing) { + if (mode === 'skip') { + result.groups.skipped++; + continue; + } + await tx.group.update({ + where: { id: existing.id }, + data: { + description: groupData.description, + isDefault: groupData.isDefault + } + }); + result.groups.updated++; + } else { + await tx.group.create({ + data: { + name: groupData.name, + description: groupData.description, + isDefault: groupData.isDefault + } + }); + result.groups.created++; + } + } + + // --- Import Boards (with sections and widgets) --- + for (const boardData of data.boards) { + const existing = await tx.board.findFirst({ where: { name: boardData.name } }); + + if (existing) { + if (mode === 'skip') { + result.boards.skipped++; + continue; + } + // Overwrite: update board, delete old sections, recreate + await tx.section.deleteMany({ where: { boardId: existing.id } }); + await tx.board.update({ + where: { id: existing.id }, + data: { + icon: boardData.icon, + description: boardData.description, + isDefault: boardData.isDefault, + isGuestAccessible: boardData.isGuestAccessible, + backgroundConfig: boardData.backgroundConfig + } + }); + + for (const sectionData of boardData.sections) { + const section = await tx.section.create({ + data: { + boardId: existing.id, + title: sectionData.title, + icon: sectionData.icon, + order: sectionData.order, + isExpandedByDefault: sectionData.isExpandedByDefault + } + }); + + for (const widgetData of sectionData.widgets) { + const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null; + await tx.widget.create({ + data: { + sectionId: section.id, + type: widgetData.type, + order: widgetData.order, + config: widgetData.config, + appId + } + }); + } + } + + result.boards.updated++; + } else { + const board = await tx.board.create({ + data: { + name: boardData.name, + icon: boardData.icon, + description: boardData.description, + isDefault: boardData.isDefault, + isGuestAccessible: boardData.isGuestAccessible, + backgroundConfig: boardData.backgroundConfig + } + }); + + for (const sectionData of boardData.sections) { + const section = await tx.section.create({ + data: { + boardId: board.id, + title: sectionData.title, + icon: sectionData.icon, + order: sectionData.order, + isExpandedByDefault: sectionData.isExpandedByDefault + } + }); + + for (const widgetData of sectionData.widgets) { + const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null; + await tx.widget.create({ + data: { + sectionId: section.id, + type: widgetData.type, + order: widgetData.order, + config: widgetData.config, + appId + } + }); + } + } + + result.boards.created++; + } + } + + // --- Import Settings (always merge) --- + if (data.settings) { + const settingsData: Record = {}; + const s = data.settings; + + if (s.authMode !== undefined) settingsData.authMode = s.authMode; + if (s.registrationEnabled !== undefined) settingsData.registrationEnabled = s.registrationEnabled; + if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme; + if (s.defaultPrimaryColor !== undefined) settingsData.defaultPrimaryColor = s.defaultPrimaryColor; + if (s.healthcheckDefaults !== undefined) settingsData.healthcheckDefaults = s.healthcheckDefaults; + + if (Object.keys(settingsData).length > 0) { + await tx.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: settingsData, + create: { id: DEFAULTS.SYSTEM_SETTINGS_ID, ...settingsData } + }); + result.settingsUpdated = true; + } + } + }); + + return result; +} diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 48e6540..008345e 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -118,6 +118,30 @@ class ThemeStore { this.primaryHue = Math.max(0, Math.min(360, hue)); this.primarySaturation = Math.max(0, Math.min(100, saturation)); } + + /** + * Apply non-null server-stored user preferences over localStorage defaults. + * Call from +layout.svelte when user data is available. + */ + loadFromServer(prefs: { + themeMode?: string | null; + primaryHue?: number | null; + primarySaturation?: number | null; + backgroundType?: string | null; + }) { + if (prefs.themeMode != null) { + this.mode = prefs.themeMode as ThemeMode; + } + if (prefs.primaryHue != null) { + this.primaryHue = prefs.primaryHue; + } + if (prefs.primarySaturation != null) { + this.primarySaturation = prefs.primarySaturation; + } + if (prefs.backgroundType != null) { + this.backgroundType = prefs.backgroundType as BackgroundType; + } + } } export const theme = new ThemeStore(); diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index d6de82d..77f4ccd 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -181,6 +181,71 @@ export const createPermissionSchema = z.object({ level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN]) }); +// --- Import/Export --- + +const importAppSchema = z.object({ + name: z.string().min(1).max(200), + url: z.string().url(), + icon: z.string().max(500).nullable(), + iconType: z.string().max(50), + description: z.string().max(1000).nullable(), + category: z.string().max(100).nullable(), + tags: z.string().max(500), + healthcheckEnabled: z.boolean(), + healthcheckInterval: z.number().int().min(30).max(86400), + healthcheckMethod: z.string(), + healthcheckExpectedStatus: z.number().int().min(100).max(599), + healthcheckTimeout: z.number().int().min(1000).max(30000) +}); + +const importWidgetSchema = z.object({ + type: z.string().min(1), + order: z.number().int().min(0), + config: z.string(), + appName: z.string().nullable() +}); + +const importSectionSchema = z.object({ + title: z.string().min(1).max(200), + icon: z.string().max(500).nullable(), + order: z.number().int().min(0), + isExpandedByDefault: z.boolean(), + widgets: z.array(importWidgetSchema) +}); + +const importBoardSchema = z.object({ + name: z.string().min(1).max(200), + icon: z.string().max(500).nullable(), + description: z.string().max(1000).nullable(), + isDefault: z.boolean(), + isGuestAccessible: z.boolean(), + backgroundConfig: z.string().nullable(), + sections: z.array(importSectionSchema) +}); + +const importGroupSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).nullable(), + isDefault: z.boolean() +}); + +const importSettingsSchema = z.object({ + authMode: z.string().optional(), + registrationEnabled: z.boolean().optional(), + defaultTheme: z.string().optional(), + defaultPrimaryColor: z.string().optional(), + healthcheckDefaults: z.string().optional() +}); + +export const importDataSchema = z.object({ + version: z.string(), + exportedAt: z.string(), + apps: z.array(importAppSchema), + boards: z.array(importBoardSchema), + groups: z.array(importGroupSchema), + settings: importSettingsSchema +}); + // --- System Settings --- export const updateSystemSettingsSchema = z.object({ diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 17920b5..4c3803e 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -33,8 +33,36 @@ export const load: LayoutServerLoad = async ({ locals }) => { boards = []; } + // Fetch user preferences if authenticated + let userPreferences: { + themeMode: string | null; + primaryHue: number | null; + primarySaturation: number | null; + backgroundType: string | null; + locale: string | null; + } | null = null; + + if (locals.user) { + try { + const dbUser = await prisma.user.findUnique({ + where: { id: locals.user.id }, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + userPreferences = dbUser ?? null; + } catch { + // Fail gracefully + } + } + return { user: locals.user, - sidebarBoards: boards + sidebarBoards: boards, + userPreferences }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6da0017..3e1caaf 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,9 +9,18 @@ import { theme } from '$lib/stores/theme.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { search } from '$lib/stores/search.svelte'; + import { locale as i18nLocale } from 'svelte-i18n'; let { data, children }: { data: LayoutData; children: Snippet } = $props(); + // Apply user preferences from server (overrides localStorage defaults) + if (data.userPreferences) { + theme.loadFromServer(data.userPreferences); + if (data.userPreferences.locale) { + i18nLocale.set(data.userPreferences.locale); + } + } + // Initialize store effects within component context theme.initEffects(); ui.initEffects(); diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 1984d27..f649544 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -2,6 +2,7 @@ import { t } from 'svelte-i18n'; import type { PageData } from './$types.js'; import SettingsForm from '$lib/components/admin/SettingsForm.svelte'; + import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte'; let { data }: { data: PageData } = $props(); @@ -10,11 +11,13 @@ {$t('admin.system_settings')} — {$t('admin.panel')} -
+

{$t('admin.system_settings')}

{$t('admin.settings_description')}

+ +
diff --git a/src/routes/api/admin/export/+server.ts b/src/routes/api/admin/export/+server.ts new file mode 100644 index 0000000..cfb4362 --- /dev/null +++ b/src/routes/api/admin/export/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { exportAllData } from '$lib/server/services/exportService.js'; +import { error } from '$lib/server/utils/response.js'; + +/** + * GET /api/admin/export — Export all data as JSON file download. Admin only. + */ +export const GET: RequestHandler = async (event) => { + requireAdmin(event); + + try { + const data = await exportAllData(); + const jsonString = JSON.stringify(data, null, 2); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `web-app-launcher-export-${timestamp}.json`; + + return new Response(jsonString, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to export data'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/admin/import/+server.ts b/src/routes/api/admin/import/+server.ts new file mode 100644 index 0000000..2a35883 --- /dev/null +++ b/src/routes/api/admin/import/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/middleware/authorize.js'; +import { validateImportData, importData } from '$lib/server/services/importService.js'; +import type { ImportMode } from '$lib/server/services/importService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * POST /api/admin/import — Import data from JSON. Admin only. + * Body: { data: ExportData, mode: "skip" | "overwrite" } + */ +export const POST: RequestHandler = async (event) => { + requireAdmin(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + if (!body || typeof body !== 'object') { + return json(error('Request body must be an object'), { status: 400 }); + } + + const { data, mode } = body as { data: unknown; mode: unknown }; + + if (!data) { + return json(error('Missing "data" field in request body'), { status: 400 }); + } + + const validMode: ImportMode = mode === 'overwrite' ? 'overwrite' : 'skip'; + + const validation = validateImportData(data); + if (!validation.success) { + return json(error(`Validation failed: ${validation.errors.join('; ')}`), { status: 400 }); + } + + try { + const result = await importData(validation.data, validMode); + return json(success(result)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Import failed'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/apps/[id]/history/+server.ts b/src/routes/api/apps/[id]/history/+server.ts new file mode 100644 index 0000000..030a106 --- /dev/null +++ b/src/routes/api/apps/[id]/history/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +const MAX_HISTORY_RECORDS = 288; + +/** + * GET /api/apps/:id/history — Get last 24h of healthcheck history for an app. + * Returns status points sorted ascending (oldest first) and uptime percentage. + */ +export const GET: RequestHandler = async (event) => { + requireAuth(event); + + const { id } = event.params; + + try { + await appService.findById(id); + + const history = await appService.getStatusHistory(id, MAX_HISTORY_RECORDS); + + // History comes back desc from the service; reverse to ascending for sparkline + const ascending = [...history].reverse(); + + const totalChecks = ascending.length; + const onlineChecks = ascending.filter((s) => s.status === 'online').length; + const uptimePercent = totalChecks > 0 ? Math.round((onlineChecks / totalChecks) * 1000) / 10 : 0; + + return json( + success({ + history: ascending.map((s) => ({ + status: s.status, + responseTime: s.responseTime, + checkedAt: s.checkedAt + })), + uptimePercent, + totalChecks + }) + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch history'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/users/me/preferences/+server.ts b/src/routes/api/users/me/preferences/+server.ts new file mode 100644 index 0000000..82ea85f --- /dev/null +++ b/src/routes/api/users/me/preferences/+server.ts @@ -0,0 +1,148 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { prisma } from '$lib/server/prisma.js'; +import { success, error } from '$lib/server/utils/response.js'; + +const ALLOWED_FIELDS = [ + 'themeMode', + 'primaryHue', + 'primarySaturation', + 'backgroundType', + 'locale' +] as const; + +const VALID_THEME_MODES = ['dark', 'light', 'system']; +const VALID_BG_TYPES = ['mesh', 'particles', 'aurora', 'none']; +const VALID_LOCALES = ['en', 'ru']; + +/** + * GET /api/users/me/preferences — Return current user's theme/locale preferences. + */ +export const GET: RequestHandler = async (event) => { + const user = requireAuth(event); + + try { + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + + if (!dbUser) { + return json(error('User not found'), { status: 404 }); + } + + return json(success(dbUser)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch preferences'; + return json(error(message), { status: 500 }); + } +}; + +/** + * PATCH /api/users/me/preferences — Update any subset of user preferences. + */ +export const PATCH: RequestHandler = async (event) => { + const user = requireAuth(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return json(error('Request body must be a JSON object'), { status: 400 }); + } + + const raw = body as Record; + const data: Record = {}; + + // Validate themeMode + if ('themeMode' in raw) { + if (raw.themeMode !== null && !VALID_THEME_MODES.includes(raw.themeMode as string)) { + return json(error('Invalid themeMode. Must be: dark, light, or system'), { status: 400 }); + } + data.themeMode = raw.themeMode as string | null; + } + + // Validate primaryHue + if ('primaryHue' in raw) { + if (raw.primaryHue !== null) { + const hue = Number(raw.primaryHue); + if (!Number.isFinite(hue) || hue < 0 || hue > 360) { + return json(error('primaryHue must be a number between 0 and 360'), { status: 400 }); + } + data.primaryHue = Math.round(hue); + } else { + data.primaryHue = null; + } + } + + // Validate primarySaturation + if ('primarySaturation' in raw) { + if (raw.primarySaturation !== null) { + const sat = Number(raw.primarySaturation); + if (!Number.isFinite(sat) || sat < 0 || sat > 100) { + return json(error('primarySaturation must be a number between 0 and 100'), { + status: 400 + }); + } + data.primarySaturation = Math.round(sat); + } else { + data.primarySaturation = null; + } + } + + // Validate backgroundType + if ('backgroundType' in raw) { + if (raw.backgroundType !== null && !VALID_BG_TYPES.includes(raw.backgroundType as string)) { + return json(error('Invalid backgroundType. Must be: mesh, particles, aurora, or none'), { + status: 400 + }); + } + data.backgroundType = raw.backgroundType as string | null; + } + + // Validate locale + if ('locale' in raw) { + if (raw.locale !== null && !VALID_LOCALES.includes(raw.locale as string)) { + return json(error('Invalid locale. Must be: en or ru'), { status: 400 }); + } + data.locale = raw.locale as string | null; + } + + // Filter out any unknown keys + const hasValidFields = Object.keys(data).some((k) => + ALLOWED_FIELDS.includes(k as (typeof ALLOWED_FIELDS)[number]) + ); + if (!hasValidFields) { + return json(error('No valid preference fields provided'), { status: 400 }); + } + + try { + const updated = await prisma.user.update({ + where: { id: user.id }, + data, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + + return json(success(updated)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update preferences'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts new file mode 100644 index 0000000..af73ab1 --- /dev/null +++ b/src/routes/settings/+page.server.ts @@ -0,0 +1,28 @@ +import type { PageServerLoad } from './$types.js'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { prisma } from '$lib/server/prisma.js'; + +export const load: PageServerLoad = async (event) => { + const user = requireAuth(event); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + themeMode: true, + primaryHue: true, + primarySaturation: true, + backgroundType: true, + locale: true + } + }); + + return { + preferences: dbUser ?? { + themeMode: null, + primaryHue: null, + primarySaturation: null, + backgroundType: null, + locale: null + } + }; +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 0000000..e5c90b4 --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,17 @@ + + + + {$t('settings.title')} | {$t('app_name')} + + +
+

{$t('settings.title')}

+ + +