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:
2026-09-25 14:49:01 +02:00
parent 522cdfd368
commit 219038b3ee
17 changed files with 521 additions and 43 deletions
+10 -2
View File
@@ -5,6 +5,8 @@ import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
import { useAuthStore } from '@/lib/stores/auth-store';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { registerWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
@@ -45,6 +47,10 @@ interface ActiveModule {
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false);
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
const userId = useAuthStore((s) => s.user?.id);
const { background, choose: chooseBackground } = useDashboardBackground(userId);
const hasBackground = background.kind !== 'none';
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf — // quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst // der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed. // "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
@@ -115,7 +121,8 @@ export default function DashboardPage() {
} }
return ( return (
<div className="relative p-2"> <div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
<DashboardBackdrop background={background} />
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal {/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
@@ -151,11 +158,12 @@ export default function DashboardPage() {
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */} Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2"> <div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
{isEditMode && ( {isEditMode && (
<button <button
type="button" type="button"
onClick={() => setCatalogOpen(true)} onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="btn btn-primary shadow-lg"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -0,0 +1,276 @@
'use client';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import {
BACKGROUND_PRESETS,
type DashboardBackground,
loadDashboardBackground,
NO_BACKGROUND,
presetBackground,
saveDashboardBackground,
} from '@/lib/dashboard-background';
import {
type DashboardImageMeta,
dashboardImageSrc,
fetchDashboardImages,
uploadDashboardImage,
} from '@/lib/dashboard-images-api';
/** Wahl des Hintergrunds fuer den angemeldeten Benutzer (localStorage, Prototyp). */
export function useDashboardBackground(userId: string | null | undefined) {
const [background, setBackground] = useState<DashboardBackground>(NO_BACKGROUND);
useEffect(() => {
if (userId) setBackground(loadDashboardBackground(userId));
}, [userId]);
const choose = useCallback(
(value: DashboardBackground) => {
setBackground(value);
if (userId) saveDashboardBackground(userId, value);
},
[userId],
);
return { background, choose };
}
/** CSS fuer eine Wahl; Flaechenfarbe oder Bild, jeweils bildschirmfuellend. */
function backgroundStyle(value: DashboardBackground, dark: boolean): CSSProperties | null {
if (value.kind === 'none') return null;
if (value.kind === 'image') {
return {
backgroundImage: `url("${dashboardImageSrc(value.imageId)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
};
}
const css = presetBackground(value.id, dark);
if (!css) return null;
return css.startsWith('url(')
? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: css };
}
/**
* Bildschirmfuellende Hintergrundebene hinter dem Dashboard. Liegt fest
* unter Kopfzeile und Seitenleiste (beide haben eigene Flaechen). Im dunklen
* Erscheinungsbild werden eigene Bilder abgedunkelt — so bleibt der Text auf
* den durchscheinenden Kacheln auch bei einem sehr hellen Foto lesbar.
*/
export function DashboardBackdrop({ background }: { background: DashboardBackground }) {
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';
const style = backgroundStyle(background, dark);
if (!style) return null;
return (
<div
aria-hidden="true"
data-testid="dashboard-backdrop"
data-background={background.kind === 'preset' ? background.id : background.kind}
className={`pointer-events-none fixed inset-0 z-0 ${
background.kind === 'image' ? 'dark:brightness-[0.55]' : ''
}`}
style={style}
/>
);
}
interface BackgroundPickerProps {
value: DashboardBackground;
onChange: (value: DashboardBackground) => void;
}
function sameChoice(a: DashboardBackground, b: DashboardBackground): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'preset' && b.kind === 'preset') return a.id === b.id;
if (a.kind === 'image' && b.kind === 'image') return a.imageId === b.imageId;
return true;
}
/**
* Knopf „Hintergrund“ (nur im Bearbeitungsmodus) mit einem kleinen Fenster:
* Vorschaubilder fuer „Keiner“, die eingebauten Hintergruende, die eigenen
* Bilder und eine Kachel zum Hochladen eines neuen Bildes.
*/
export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const t = useTranslations('dashboard.background');
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';
const [open, setOpen] = useState(false);
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open || images !== null) return;
fetchDashboardImages()
.then(setImages)
.catch(() => setImages([]));
}, [open, images]);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [open]);
async function handleUpload(file: File | undefined) {
if (!file) return;
setError(null);
setUploading(true);
try {
const meta = await uploadDashboardImage(file);
setImages((prev) => [meta, ...(prev ?? [])]);
onChange({ kind: 'image', imageId: meta.id });
} catch (e) {
setError(e instanceof Error ? e.message : t('uploadError'));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
}
const option = (
key: string,
label: string,
choice: DashboardBackground,
preview: CSSProperties,
content?: React.ReactNode,
) => {
const selected = sameChoice(value, choice);
return (
<li key={key}>
<button
type="button"
onClick={() => onChange(choice)}
aria-pressed={selected}
title={label}
className="group flex w-full flex-col items-stretch gap-1 rounded-md text-left focus-visible:outline-none"
>
<span
className={`flex h-12 items-center justify-center overflow-hidden rounded-md border text-muted-foreground transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-ring ${
selected ? 'border-transparent ring-2 ring-foreground' : 'border-border group-hover:border-input-strong'
}`}
style={preview}
>
{content}
</span>
<span className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}>
{label}
</span>
</button>
</li>
);
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
className="btn btn-secondary shadow-lg"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</svg>
{t('button')}
</button>
{open && (
<div
role="dialog"
aria-label={t('title')}
className="absolute bottom-full right-0 z-30 mb-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
>
<h2 className="text-sm font-semibold">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground">{t('hint')}</p>
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
<ul className="mt-2 grid grid-cols-3 gap-3">
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
)}
{BACKGROUND_PRESETS.map((preset) => {
const css = dark ? preset.dark : preset.light;
return option(
preset.id,
t(`presets.${preset.labelKey}`),
{ kind: 'preset', id: preset.id },
css.startsWith('url(')
? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: css },
);
})}
</ul>
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('ownImages')}</h3>
<ul className="mt-2 grid max-h-40 grid-cols-3 gap-3 overflow-y-auto p-0.5">
{(images ?? []).map((image) =>
option(
image.id,
image.originalName,
{ kind: 'image', imageId: image.id },
{
backgroundImage: `url("${dashboardImageSrc(image.id)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
},
),
)}
<li>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="group flex w-full flex-col items-stretch gap-1 rounded-md text-left focus-visible:outline-none"
>
<span className="flex h-12 items-center justify-center rounded-md border border-dashed border-input-strong text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
{uploading ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
)}
</span>
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
</button>
</li>
</ul>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => handleUpload(e.target.files?.[0])}
/>
{images !== null && images.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground">{t('noImages')}</p>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-destructive">
{error}
</p>
)}
</div>
)}
</div>
);
}
@@ -165,7 +165,7 @@ describe('DashboardGrid', () => {
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
}); });
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
captured.props = null; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
render( render(
@@ -181,7 +181,7 @@ describe('DashboardGrid', () => {
expect(captured.props).not.toBeNull(); expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
expect(captured.props?.rowHeight).toBe(20); expect(captured.props?.rowHeight).toBe(20);
expect(captured.props?.margin).toEqual([8, 8]); expect(captured.props?.margin).toEqual([12, 12]);
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
expect(captured.props?.containerPadding).toBeUndefined(); expect(captured.props?.containerPadding).toBeUndefined();
}); });
@@ -229,7 +229,7 @@ export function DashboardGrid({
cols={COLS} cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts} layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20} rowHeight={20}
margin={[8, 8] as [number, number]} margin={[12, 12] as [number, number]}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{ dragConfig={{
@@ -507,7 +507,7 @@ export function DashboardTabs({
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')} aria-label={t('tabs.deleteDialogTitle')}
className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" className="relative z-[60] mx-4 max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-xl"
> >
<h3 className="mb-2 text-lg font-semibold text-foreground"> <h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')} {t('tabs.deleteDialogTitle')}
@@ -21,11 +21,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${ className={`btn btn-icon h-10 w-10 shadow-lg ${isEditMode ? 'btn-primary' : 'btn-secondary text-muted-foreground'}`}
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-pressed={isEditMode} aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')} aria-label={isEditMode ? t('saveChanges') : t('editMode')}
title={isEditMode ? t('saveChanges') : t('editMode')} title={isEditMode ? t('saveChanges') : t('editMode')}
@@ -313,7 +313,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
// Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet. // Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet.
const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95'; const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95';
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`; const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`; const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`; const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`;
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`; const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
// Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt. // Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
@@ -243,21 +243,21 @@ export function CalendarWidget({ config }: WidgetProps) {
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : []; const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
return ( return (
<div className="flex h-full flex-col gap-1 overflow-hidden p-1.5"> <div className="flex h-full flex-col gap-2 overflow-hidden p-3">
{showMonth && ( {showMonth && (
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1"> <div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
{/* Nav-Zeile */} {/* Nav-Zeile */}
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1"> <div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
<button <button
type="button" type="button"
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted" className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={showPrev} onClick={showPrev}
> >
{t('calendar.monthPrev')} {t('calendar.monthPrev')}
</button> </button>
<button <button
type="button" type="button"
className="widgetNoDrag truncate rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] font-semibold leading-none text-foreground hover:bg-muted" className="widgetNoDrag truncate rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(11px,2.8cqw,14px)] font-semibold leading-none text-foreground transition-colors hover:bg-muted"
title={t('calendar.monthToday')} title={t('calendar.monthToday')}
onClick={showToday} onClick={showToday}
> >
@@ -265,7 +265,7 @@ export function CalendarWidget({ config }: WidgetProps) {
</button> </button>
<button <button
type="button" type="button"
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted" className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={showNext} onClick={showNext}
> >
{t('calendar.monthNext')} {t('calendar.monthNext')}
@@ -286,9 +286,9 @@ export function CalendarWidget({ config }: WidgetProps) {
const badges = buildDayBadges(day.events); const badges = buildDayBadges(day.events);
const single = badges.length === 1; const single = badges.length === 1;
const cellClass = [ const cellClass = [
'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded bg-muted/50 px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none', 'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded-md px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/60', day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
day.isToday ? 'ring-1 ring-primary font-semibold text-primary' : '', day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
hasEvents ? 'cursor-default hover:bg-muted' : '', hasEvents ? 'cursor-default hover:bg-muted' : '',
] ]
.filter(Boolean) .filter(Boolean)
@@ -405,7 +405,7 @@ export function CalendarWidget({ config }: WidgetProps) {
) : ( ) : (
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto"> <ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{upcoming.map((event) => ( {upcoming.map((event) => (
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1"> <li key={event.id} className="flex items-start gap-2 rounded-md px-2 py-1 hover:bg-muted">
<span <span
data-testid="event-color-dot" data-testid="event-color-dot"
className="mt-1 h-2 w-2 shrink-0 rounded-full" className="mt-1 h-2 w-2 shrink-0 rounded-full"
@@ -416,7 +416,7 @@ export function CalendarWidget({ config }: WidgetProps) {
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground"> <p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
{formatEventDate(event)} {formatEventDate(event)}
</p> </p>
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground"> <p className="truncate text-[clamp(10px,2.5cqw,14px)] font-medium text-foreground">
{event.title} {event.title}
</p> </p>
{event.location && ( {event.location && (
@@ -438,7 +438,7 @@ export function CalendarWidget({ config }: WidgetProps) {
<div <div
data-testid="calendar-day-tooltip" data-testid="calendar-day-tooltip"
role="tooltip" role="tooltip"
className="pointer-events-none fixed z-50 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg" className="pointer-events-none fixed z-50 rounded bg-card dark:border dark:border-border p-2 text-xs text-foreground shadow-lg"
style={{ style={{
width: TOOLTIP_WIDTH_PX, width: TOOLTIP_WIDTH_PX,
top: hover.rect.bottom + TOOLTIP_EDGE_PX, top: hover.rect.bottom + TOOLTIP_EDGE_PX,
@@ -321,7 +321,7 @@ export function FavoritesWidget({
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */} {/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && ( {showHeader && (
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5"> <div className="widget-head">
{isEditMode ? ( {isEditMode ? (
<input <input
type="text" type="text"
@@ -341,7 +341,7 @@ export function FavoritesWidget({
</div> </div>
)} )}
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1"> <div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
{/* View mode toggle (edit mode only) */} {/* View mode toggle (edit mode only) */}
{isEditMode && ( {isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -359,8 +359,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('list')} onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
viewMode === 'list' viewMode === 'list'
? 'bg-primary text-primary-foreground' ? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t('favorites.listView')} {t('favorites.listView')}
@@ -370,8 +370,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('grid')} onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid' viewMode === 'grid'
? 'bg-primary text-primary-foreground' ? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t('favorites.gridView')} {t('favorites.gridView')}
@@ -675,7 +675,7 @@ function FavoriteTile({
href={fav.url} href={fav.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag" className="flex h-8 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag"
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
> >
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */} {/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
@@ -685,7 +685,7 @@ function FavoriteTile({
getFallbackLetter={getFallbackLetter} getFallbackLetter={getFallbackLetter}
/> />
<span className="text-xs truncate">{fav.title}</span> <span className="truncate text-sm">{fav.title}</span>
</a> </a>
{/* Action buttons (edit mode only, D-04) */} {/* Action buttons (edit mode only, D-04) */}
@@ -163,7 +163,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
return ( return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}> <div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */} {/* Header: title + pencil toggle */}
<div className="relative flex items-center border-b border-border px-1.5 py-1.5 gap-2"> <div className="widget-head relative">
<input <input
type="text" type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
@@ -180,7 +180,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
<button <button
type="button" type="button"
onClick={() => setIsEditing((v) => !v)} onClick={() => setIsEditing((v) => !v)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`} className={`widget-action flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-muted ${isEditing ? 'widget-action-pinned bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
title={isEditing ? t('note.viewMode') : t('note.editMode')} title={isEditing ? t('note.viewMode') : t('note.editMode')}
> >
<svg <svg
@@ -203,7 +203,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
{/* Content: preview or editor */} {/* Content: preview or editor */}
<div <div
className="flex-1 overflow-auto" className="widget-note-body flex-1 overflow-auto"
data-testid="note-preview" data-testid="note-preview"
ref={previewRef} ref={previewRef}
> >
@@ -162,7 +162,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
let header: ReactNode = null; let header: ReactNode = null;
if (isEditMode) { if (isEditMode) {
header = ( header = (
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2.5 py-1.5"> <div className="widget-head">
<input <input
type="text" type="text"
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
@@ -186,7 +186,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
); );
} else if (trimmedTitle !== '') { } else if (trimmedTitle !== '') {
header = ( header = (
<div className="shrink-0 border-b border-border px-2.5 py-1.5"> <div className="widget-head">
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2> <h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
</div> </div>
); );
@@ -113,7 +113,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
{/* Search button (right) */} {/* Search button (right) */}
<button <button
type="button" type="button"
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" className="btn btn-primary shrink-0"
onClick={handleSearch} onClick={handleSearch}
aria-label={t('search.searchButton')} aria-label={t('search.searchButton')}
> >
@@ -30,9 +30,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; const WidgetComponent = definition?.component;
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
const cardClassName = isEditMode const cardClassName = isEditMode
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg border border-primary/40 bg-card shadow-sm active:cursor-grabbing' ? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm'; : 'relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
return ( return (
<article <article
@@ -45,7 +47,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */} Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && ( {isEditMode && (
<div <div
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70" className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/80"
title={t('dragHint')} title={t('dragHint')}
data-testid="widget-drag-head" data-testid="widget-drag-head"
> >
@@ -84,12 +84,12 @@ describe('XframeWidget (quick-260921-qd3)', () => {
expect(link.className).toContain('absolute'); expect(link.className).toContain('absolute');
}); });
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => { it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre widget-head), nicht absolute', () => {
const { container } = renderWidget({ url: URL_A, title: 'Board' }); const { container } = renderWidget({ url: URL_A, title: 'Board' });
expect(container.querySelector('h2')).toHaveTextContent('Board'); expect(container.querySelector('h2')).toHaveTextContent('Board');
expect(frame().getAttribute('title')).toBe('Board'); expect(frame().getAttribute('title')).toBe('Board');
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' }); const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.closest('.border-b')).not.toBeNull(); expect(link.closest('.widget-head')).not.toBeNull();
expect(link.className).not.toContain('absolute'); expect(link.className).not.toContain('absolute');
}); });
@@ -119,12 +119,12 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im // Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den // Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen. // oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5"> <div className="widget-head">
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2> <h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
<NewTabLink <NewTabLink
url={url} url={url}
label={openLabel} label={openLabel}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground" className="widget-action shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
/> />
</div> </div>
)} )}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
BACKGROUND_PRESETS,
loadDashboardBackground,
presetBackground,
saveDashboardBackground,
} from './dashboard-background';
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
describe('dashboard-background (Design „Mosaik“, Prototyp)', () => {
it('ohne gespeicherte Wahl: kein Hintergrund', () => {
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
});
it('speichert je Benutzer getrennt', () => {
saveDashboardBackground('u1', { kind: 'preset', id: 'bloom' });
saveDashboardBackground('u2', { kind: 'image', imageId: 'img-1' });
expect(loadDashboardBackground('u1')).toEqual({ kind: 'preset', id: 'bloom' });
expect(loadDashboardBackground('u2')).toEqual({ kind: 'image', imageId: 'img-1' });
});
it('verwirft kaputte oder unbekannte Werte', () => {
window.localStorage.setItem('tessera.dashboardBackground.u1', '{kaputt');
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
window.localStorage.setItem(
'tessera.dashboardBackground.u1',
JSON.stringify({ kind: 'preset', id: 'regenbogen' }),
);
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
});
it('gesperrter Speicher wirft nicht', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(() => saveDashboardBackground('u1', { kind: 'preset', id: 'mist' })).not.toThrow();
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
});
it('jeder eingebaute Hintergrund hat eine helle und eine dunkle Fassung', () => {
for (const preset of BACKGROUND_PRESETS) {
expect(presetBackground(preset.id, false)).toBeTruthy();
expect(presetBackground(preset.id, true)).toBeTruthy();
expect(presetBackground(preset.id, false)).not.toBe(presetBackground(preset.id, true));
}
});
});
+142
View File
@@ -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.
}
}