Files
tessera-ctl/apps/web/src/lib/dashboard-background.ts
T
schalli 228112014c style(web): Modul-Kacheln einfarbig, Akzent nur fuer das Modul im Fokus
Statt bunter Modulfarben neutrale graue Kacheln; gelb (persoenliche
Akzentfarbe) nur fuer den gewaehlten Eintrag der Seitenleiste und den
Seitenkopf des Moduls. Schrift auf Akzentflaechen wird jetzt je nach
Akzentfarbe hell oder dunkel gewaehlt (readableOnAccent). Anmelde-
Mosaik und Dashboard-Hintergruende nur noch in Gelb, Grau, Graphit;
Salbei ersetzt durch Kiesel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:09:05 +02:00

143 lines
5.4 KiB
TypeScript

/**
* Dashboard-Hintergrund (Prototyp, 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.
*/
export type BackgroundPresetId = 'mist' | 'pebble' | 'bloom' | 'dunes' | 'mosaic';
export type DashboardBackground =
| { kind: 'none' }
| { kind: 'preset'; id: BackgroundPresetId }
| { kind: 'image'; imageId: string };
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
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"';
function bloom(base: string, a: string, b: string, c: string): string {
return svgUrl(
`<svg ${VIEW}><defs><filter id="f" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="90"/></filter></defs>` +
`<rect width="1600" height="1000" fill="${base}"/>` +
`<g filter="url(#f)"><ellipse cx="1180" cy="240" rx="520" ry="360" fill="${a}"/>` +
`<ellipse cx="300" cy="820" rx="600" ry="340" fill="${b}"/>` +
`<ellipse cx="920" cy="930" rx="380" ry="240" fill="${c}"/></g>` +
// Feines Korn gegen Farbstufen im weichgezeichneten Verlauf.
`<filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>` +
`<rect width="1600" height="1000" filter="url(#n)" opacity="0.06"/></svg>`,
);
}
function dunes(base: string, a: string, b: string, c: string): string {
return svgUrl(
`<svg ${VIEW}><rect width="1600" height="1000" fill="${base}"/>` +
`<path d="M0 600 C300 520 620 690 920 630 S1400 500 1600 580 L1600 1000 L0 1000Z" fill="${a}"/>` +
`<path d="M0 750 C360 680 660 820 1010 755 S1450 670 1600 730 L1600 1000 L0 1000Z" fill="${b}"/>` +
`<path d="M0 885 C400 835 800 930 1200 875 S1500 845 1600 865 L1600 1000 L0 1000Z" fill="${c}"/></svg>`,
);
}
function mosaic(base: string, gray: string, alphas: number[], yellowAlpha: number): string {
const tiles: [number, number][] = [
[1060, 120],
[1260, 120],
[1260, 320],
[1060, 320],
[1460, 320],
[860, 520],
[1260, 520],
[1460, 720],
[1060, 720],
];
const rects = tiles
.map(
([x, y], i) =>
`<rect x="${x}" y="${y}" width="170" height="170" rx="34" fill="${gray}" fill-opacity="${alphas[i % alphas.length]}"/>`,
)
.join('');
return svgUrl(
`<svg ${VIEW}><rect width="1600" height="1000" fill="${base}"/>${rects}` +
`<rect x="1060" y="520" width="170" height="170" rx="34" fill="#ffed00" fill-opacity="${yellowAlpha}" transform="rotate(12 1145 605)"/></svg>`,
);
}
export interface BackgroundPreset {
id: BackgroundPresetId;
/** Uebersetzungsschluessel unter `dashboard.background.presets`. */
labelKey: string;
light: string;
dark: string;
}
// Runde 2: nur Gelb-, Grau- und Graphittoene — keine bunten Modulfarben.
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
{ id: 'pebble', labelKey: 'pebble', light: '#e9e7e3', dark: '#23221f' },
{
id: 'bloom',
labelKey: 'bloom',
light: bloom('#eceef1', '#f0e9c6', '#d3d7de', '#dddbd5'),
dark: bloom('#1b1d22', '#37342a', '#2a2e35', '#2f2e2a'),
},
{
id: 'dunes',
labelKey: 'dunes',
light: dunes('#eceef1', '#dcdfe4', '#caced5', '#b8bdc6'),
dark: dunes('#1a1c20', '#23262b', '#2c3036', '#363a41'),
},
{
id: 'mosaic',
labelKey: 'mosaic',
light: mosaic('#eceef1', '#5b6270', [0.1, 0.16, 0.07, 0.13], 0.4),
dark: mosaic('#1b1d22', '#c9ced8', [0.06, 0.1, 0.04, 0.08], 0.78),
},
];
export function presetBackground(id: BackgroundPresetId, dark: boolean): string | null {
const preset = BACKGROUND_PRESETS.find((p) => p.id === id);
if (!preset) return null;
return dark ? preset.dark : preset.light;
}
function storageKey(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 {
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;
} 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.
}
}