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
+27
View File
@@ -1,5 +1,6 @@
'use server';
import type { DashboardBackground } from '@tessera/shared';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
@@ -15,6 +16,8 @@ export interface AuthUser {
isLocalUser?: boolean;
hasAvatar?: boolean;
accentColor?: string | null;
/** quick-260928-ujj: null = nie gewaehlt. */
dashboardBackground?: DashboardBackground | null;
}
export interface LoginResult {
@@ -236,6 +239,30 @@ export async function updateAccentColorAction(
}
}
/**
* Speichert den Dashboard-Hintergrund des angemeldeten Benutzers
* (quick-260928-ujj). Die API prueft die Wahl (parseDashboardBackground)
* und antwortet bei ungueltigem Wert mit 400.
*/
export async function updateDashboardBackgroundAction(
background: DashboardBackground,
): Promise<{ success: boolean; error?: string }> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) return { success: false, error: 'notAuthenticated' };
try {
const response = await fetch(`${API_URL}/users/me/dashboard-background`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: `session=${session}` },
body: JSON.stringify({ background }),
});
if (!response.ok) return { success: false, error: 'saveError' };
return { success: true };
} catch {
return { success: false, error: 'networkError' };
}
}
export type SessionState =
| { status: 'authenticated'; user: AuthUser }
| { status: 'unauthenticated' }
@@ -66,7 +66,9 @@ describe('dashboard-background (Design „Mosaik“)', () => {
});
it('die eingebauten Hintergruende sind deckungsgleich mit der Pruefregel in @tessera/shared', () => {
expect(BACKGROUND_PRESETS.map((p) => p.id).sort()).toEqual([...DASHBOARD_BACKGROUND_PRESET_IDS].sort());
expect(BACKGROUND_PRESETS.map((p) => p.id).sort()).toEqual(
[...DASHBOARD_BACKGROUND_PRESET_IDS].sort(),
);
});
it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {
+43 -35
View File
@@ -1,20 +1,28 @@
/**
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
* Dashboard-Hintergrund (Design „Mosaik“).
*
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
* drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen-
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
* (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im
* localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld
* kann folgen, wenn die Funktion bleibt.
* (`/dashboard/images`).
*
* Gespeichert wird seit quick-260928-ujj in der Datenbank
* (`User.dashboardBackground`, `PATCH /users/me/dashboard-background`) und
* mit der Sitzungsantwort gelesen — die Wahl folgt dem Benutzer auf jedes
* Geraet. Typ, Preset-Kennungen und Pruefregel kommen aus `@tessera/shared`
* (EINE Regel fuer API und Web). Der fruehere localStorage-Schluessel wird
* nur noch einmalig ausgelesen und entfernt ({@link takeLegacyDashboardBackground}).
*/
export type BackgroundPresetId = 'mist' | 'pebble' | 'bloom' | 'dunes' | 'mosaic';
import {
type DashboardBackground,
type DashboardBackgroundPresetId,
parseDashboardBackground,
} from '@tessera/shared';
export type DashboardBackground =
| { kind: 'none' }
| { kind: 'preset'; id: BackgroundPresetId }
| { kind: 'image'; imageId: string };
export type { DashboardBackground };
export type BackgroundPresetId = DashboardBackgroundPresetId;
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
@@ -22,7 +30,8 @@ function svgUrl(svg: string): string {
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
const VIEW = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"';
const VIEW =
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"';
function bloom(base: string, a: string, b: string, c: string): string {
return svgUrl(
@@ -121,35 +130,34 @@ export function presetBackground(id: BackgroundPresetId, dark: boolean): string
return BACKGROUND_PRESETS.find((p) => p.id === DARK_FALLBACK_ID)?.dark ?? null;
}
function storageKey(userId: string): string {
/** Schluessel der frueheren localStorage-Speicherung (Prototyp, vor quick-260928-ujj). */
function legacyStorageKey(userId: string): string {
return `tessera.dashboardBackground.${userId}`;
}
function isValid(value: unknown): value is DashboardBackground {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
if (v.kind === 'none') return true;
if (v.kind === 'preset') return BACKGROUND_PRESETS.some((p) => p.id === v.id);
if (v.kind === 'image') return typeof v.imageId === 'string' && v.imageId !== '';
return false;
}
/** Liest die Wahl; jeder Fehler (privates Fenster, kaputter Wert) ergibt „kein Hintergrund“. */
export function loadDashboardBackground(userId: string): DashboardBackground {
/**
* Liest eine im Browser gemerkte Wahl aus der Zeit vor der Datenbank-
* speicherung und entfernt den Schluessel — gueltig oder nicht, damit die
* Uebernahme genau einmal passiert. Geprueft mit derselben Regel wie die API
* (`parseDashboardBackground`); kaputte Werte, unbekannte Kennungen und ein
* gesperrter Speicher (privates Fenster) ergeben `null`, nie eine Ausnahme.
*/
export function takeLegacyDashboardBackground(userId: string): DashboardBackground | null {
let raw: string | null = null;
try {
const raw = window.localStorage.getItem(storageKey(userId));
if (!raw) return NO_BACKGROUND;
const parsed: unknown = JSON.parse(raw);
return isValid(parsed) ? parsed : NO_BACKGROUND;
raw = window.localStorage.getItem(legacyStorageKey(userId));
} catch {
return NO_BACKGROUND;
}
}
export function saveDashboardBackground(userId: string, value: DashboardBackground): void {
try {
window.localStorage.setItem(storageKey(userId), JSON.stringify(value));
} catch {
// Speicher gesperrt — die Wahl gilt dann nur fuer diese Sitzung.
return null;
}
if (raw === null) return null;
try {
window.localStorage.removeItem(legacyStorageKey(userId));
} catch {
// Entfernen gesperrt — die Wahl wird trotzdem hoechstens einmal je Sitzung uebernommen.
}
try {
return parseDashboardBackground(JSON.parse(raw));
} catch {
return null;
}
}
+3
View File
@@ -1,3 +1,4 @@
import type { DashboardBackground } from '@tessera/shared';
import { create } from 'zustand';
import { readableOnAccent } from '@/lib/color';
@@ -9,6 +10,8 @@ export interface AuthUser {
tenantId: string;
hasAvatar?: boolean;
accentColor?: string | null;
/** quick-260928-ujj: Dashboard-Hintergrund aus der Sitzungsantwort; null = nie gewaehlt. */
dashboardBackground?: DashboardBackground | null;
}
interface AuthState {