Files
tessera-ctl/apps/web/src/components/dashboard/dashboard-background.tsx
T
schalli 12214a948e fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00

472 lines
17 KiB
TypeScript

'use client';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { updateDashboardBackgroundAction } from '@/lib/auth-actions';
import {
BACKGROUND_PRESETS,
type DashboardBackground,
NO_BACKGROUND,
presetBackground,
takeLegacyDashboardBackground,
} from '@/lib/dashboard-background';
import {
type DashboardImageMeta,
dashboardImageSrc,
fetchDashboardImages,
uploadDashboardImage,
} from '@/lib/dashboard-images-api';
import { useAuthStore } from '@/lib/stores/auth-store';
/** Schreibt die Wahl in den Auth-Store, sofern noch derselbe Benutzer angemeldet ist. */
function setStoredBackground(userId: string, value: DashboardBackground | null) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
}
/**
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
* Klicks sonst die zweite Wahl ueberschreiben.
*/
function revertStoredBackground(
userId: string,
optimistic: DashboardBackground,
previous: DashboardBackground | null,
) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
if (current.dashboardBackground !== optimistic) return;
setStoredBackground(userId, previous);
}
/**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
*
* Gelesen aus dem Auth-Store (Sitzungsantwort, `null` = nie gewaehlt =
* kein Hintergrund). `choose()` setzt den Store sofort und speichert ueber
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
* wird sie genau einmal je Benutzerkennung uebernommen.
*
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
* ist nur Anzeige; gespeichert wird dabei nichts.
*/
export function useDashboardBackground() {
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null;
const stored = user?.dashboardBackground ?? null;
const imageId = stored?.kind === 'image' ? stored.imageId : null;
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
const background =
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
const migratedFor = useRef<string | null>(null);
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
// Wahl als „kein Hintergrund“ angezeigt.
useEffect(() => {
if (imageId === null || typeof Image === 'undefined') return;
let active = true;
const probe = new Image();
probe.onerror = () => {
if (active) setBrokenImageId(imageId);
};
probe.src = dashboardImageSrc(imageId);
return () => {
active = false;
probe.onerror = null;
};
}, [imageId]);
const save = useCallback(
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
setStoredBackground(id, value);
const revert = () => revertStoredBackground(id, value, previous);
updateDashboardBackgroundAction(value)
.then((result) => {
if (!result.success) revert();
})
.catch(revert);
},
[],
);
const choose = useCallback(
(value: DashboardBackground) => {
if (!userId) return;
save(userId, value, stored);
},
[userId, stored, save],
);
useEffect(() => {
if (!userId || migratedFor.current === userId) return;
migratedFor.current = userId;
// Auch bei gesetztem Server-Wert auslesen: der alte Schluessel wird so
// aufgeraeumt, uebernommen wird aber nur, wenn noch nie gewaehlt wurde.
const legacy = takeLegacyDashboardBackground(userId);
if (legacy && stored === null) save(userId, legacy, null);
}, [userId, stored, save]);
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);
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
const [imagesListed, setImagesListed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
// (quick-260930).
const shown: DashboardBackground =
value.kind === 'image' &&
imagesListed &&
images !== null &&
!images.some((image) => image.id === value.imageId)
? NO_BACKGROUND
: value;
useEffect(() => {
if (!open || images !== null) return;
fetchDashboardImages()
.then((list) => {
setImages(list);
setImagesListed(true);
})
.catch(() => setImages([]));
}, [open, images]);
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
useEffect(() => {
if (open) {
wasOpen.current = true;
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
const timer = window.setTimeout(() => {
const active = document.activeElement;
if (!active || active === document.body || rootRef.current?.contains(active)) {
triggerRef.current?.focus();
}
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
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);
return;
}
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
if (e.key === 'Tab' && dialogRef.current) {
const items = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!active || !dialogRef.current.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
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(shown, 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
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={t('button')}
title={t('button')}
className="btn btn-appbar"
>
<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>
{/* In der App-Leiste ist der Platz knapp: Beschriftung erst ab lg. */}
<span className="hidden lg:inline">{t('button')}</span>
</button>
{open && (
<div
ref={dialogRef}
role="dialog"
aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-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.filter((preset) => !dark || preset.dark !== null).map((preset) => {
const css = (dark ? preset.dark : preset.light) ?? 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>
);
}