feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank

- Spalte User.dashboardBackground (JSONB) samt Migration
- PATCH /users/me/dashboard-background, geprueft mit parseDashboardBackground aus @tessera/shared (Allowlist, UUID-Bildkennung)
- getMe liefert dashboardBackground normalisiert neben accentColor
- Web liest die Wahl aus dem Auth-Store, speichert ueber die Server-Aktion, alte localStorage-Wahl wird einmalig uebernommen
- Hinweistext: gilt auf jedem Geraet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:13:49 +02:00
parent 76f6d87973
commit 0aaa15240d
16 changed files with 333 additions and 66 deletions
+62
View File
@@ -194,3 +194,65 @@ export interface ReleaseNoticeResponse {
currentRelease: string | null;
lastSeenReleaseVersion: string | null;
}
/**
* Dashboard-Hintergrund pro Benutzer (quick-260928-ujj) — EINE Pruefregel
* fuer API und Web.
*
* Gespeichert in `User.dashboardBackground` (JSONB), geschrieben nur ueber
* `PATCH /users/me/dashboard-background`, gelesen mit der Sitzungsantwort
* (`GET /auth/me`, neben `accentColor`). Das Web rendert die Wahl als
* CSS-Hintergrund (`url("...")`) — deshalb ist die Pruefung streng
* (T-ujj-01): `kind` und Preset-Kennung aus einer festen Liste, `imageId`
* nur als UUID (Kennung eines Bilderrahmen-Bildes, `DashboardImage.id`).
*
* Laufzeit-Import aus `@tessera/shared` (siehe Warnkommentar ueber
* `WIDGET_TYPES`): nur loeschbare Syntax, keine relativen Importe.
*/
export const DASHBOARD_BACKGROUND_PRESET_IDS = [
'mist',
'pebble',
'bloom',
'dunes',
'mosaic',
] as const;
export type DashboardBackgroundPresetId = (typeof DASHBOARD_BACKGROUND_PRESET_IDS)[number];
export type DashboardBackground =
| { kind: 'none' }
| { kind: 'preset'; id: DashboardBackgroundPresetId }
| { kind: 'image'; imageId: string };
const DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Liefert eine gueltige Wahl als FRISCH aufgebautes Objekt (nur die
* erlaubten Felder, Zusatzschluessel fallen weg) oder `null`, wenn der Wert
* keine gueltige Wahl ist. `null` bedeutet beim Lesen „nie gewaehlt“ und
* beim Schreiben „abweisen“.
*/
export function parseDashboardBackground(value: unknown): DashboardBackground | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null;
}
const v = value as Record<string, unknown>;
if (v.kind === 'none') {
return { kind: 'none' };
}
if (v.kind === 'preset') {
const id = v.id;
if (typeof id !== 'string') return null;
const known = DASHBOARD_BACKGROUND_PRESET_IDS.find((p) => p === id);
return known ? { kind: 'preset', id: known } : null;
}
if (v.kind === 'image') {
const imageId = v.imageId;
if (typeof imageId !== 'string' || !DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN.test(imageId)) {
return null;
}
return { kind: 'image', imageId };
}
return null;
}