12214a948e
- 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>
472 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|