feat(web): Dashboard-Kacheln im Fluent-Stil und waehlbarer Hintergrund
Einheitliche Kachel-Kopfzeile, ruhigere Notiz-/Kalender-/Favoriten- Darstellung, 12 px Raster-Abstand, gestrichelte Kontur und Griff im Bearbeitungsmodus. Neuer Knopf Hintergrund: Keiner, fuenf eingebaute (Nebel, Salbei, Bluete, Duenen, Mosaik) oder eigenes Bild aus den Bilderrahmen-Bildern; Prototyp speichert im localStorage je Benutzer. Mit Hintergrund werden Kacheln Mica-artig durchscheinend (Kontrast >= 4,5:1 auch im schlechtesten Fall). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
|
||||
*
|
||||
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
||||
* drei abstrakte Motive in gedaempften Modulfarben — 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' | 'sage' | '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, alpha: number, yellowAlpha: number): string {
|
||||
const tiles: [number, number, string, number?][] = [
|
||||
[1060, 120, '#2a8f8f'],
|
||||
[1260, 120, '#7a5cc2'],
|
||||
[1260, 320, '#3a78c9'],
|
||||
[1060, 320, '#b37a1f'],
|
||||
[1460, 320, '#c65436'],
|
||||
[860, 520, '#3f8f5a'],
|
||||
[1260, 520, '#3a78c9'],
|
||||
[1460, 720, '#7a5cc2'],
|
||||
[1060, 720, '#2a8f8f'],
|
||||
[140, 760, '#c65436'],
|
||||
];
|
||||
const rects = tiles
|
||||
.map(
|
||||
([x, y, fill]) =>
|
||||
`<rect x="${x}" y="${y}" width="170" height="170" rx="34" fill="${fill}" fill-opacity="${alpha}"/>`,
|
||||
)
|
||||
.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;
|
||||
}
|
||||
|
||||
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
||||
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
|
||||
{ id: 'sage', labelKey: 'sage', light: '#e3ebe6', dark: '#1c2521' },
|
||||
{
|
||||
id: 'bloom',
|
||||
labelKey: 'bloom',
|
||||
light: bloom('#e8ecf3', '#b7c7e6', '#bcdcd7', '#d4cbe9'),
|
||||
dark: bloom('#1b1f28', '#2a3a58', '#21403c', '#352e4d'),
|
||||
},
|
||||
{
|
||||
id: 'dunes',
|
||||
labelKey: 'dunes',
|
||||
light: dunes('#e6ebef', '#d3dde3', '#c1cfd8', '#aebfca'),
|
||||
dark: dunes('#1a1f25', '#212930', '#28323a', '#303c45'),
|
||||
},
|
||||
{
|
||||
id: 'mosaic',
|
||||
labelKey: 'mosaic',
|
||||
light: mosaic('#eceff3', 0.16, 0.35),
|
||||
dark: mosaic('#1b1f26', 0.24, 0.3),
|
||||
},
|
||||
];
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user