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