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:
@@ -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,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user