feat(phase3): import/export, sparklines, user theme overrides

- JSON import/export with conflict resolution (skip/overwrite) + admin UI
- Ping history sparklines on AppWidget and AppCard (24h, 288 points)
- Hourly cleanup job for old AppStatus records
- User theme preferences (hue, saturation, mode, background, locale)
- Settings page with ThemeCustomizer (sliders, toggles, live preview)
- Prisma migration for user preference fields
- i18n translations for all new strings (EN/RU)
This commit is contained in:
2026-03-25 00:51:01 +03:00
parent d155b3ce4a
commit c6a7de895d
30 changed files with 1633 additions and 44 deletions
+29 -1
View File
@@ -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
};
};
+9
View File
@@ -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();
+4 -1
View File
@@ -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();
</script>
@@ -10,11 +11,13 @@
<title>{$t('admin.system_settings')}{$t('admin.panel')}</title>
</svelte:head>
<div>
<div class="space-y-8">
<div class="mb-6">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.system_settings')}</h1>
<p class="mt-1 text-sm text-muted-foreground">{$t('admin.settings_description')}</p>
</div>
<SettingsForm form={data.form} />
<ImportExportPanel />
</div>
+30
View File
@@ -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 });
}
};
+46
View File
@@ -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 });
}
};
@@ -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 });
}
};
@@ -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<string, unknown>;
const data: Record<string, unknown> = {};
// 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 });
}
};
+28
View File
@@ -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
}
};
};
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import ThemeCustomizer from '$lib/components/settings/ThemeCustomizer.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{$t('settings.title')} | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="mb-6 text-2xl font-bold text-foreground">{$t('settings.title')}</h1>
<ThemeCustomizer preferences={data.preferences} />
</div>