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>
This commit is contained in:
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
|
||||
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).
|
||||
*
|
||||
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
|
||||
* 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 background = stored ?? NO_BACKGROUND;
|
||||
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 = () => setStoredBackground(id, previous);
|
||||
const revert = () => revertStoredBackground(id, value, previous);
|
||||
updateDashboardBackgroundAction(value)
|
||||
.then((result) => {
|
||||
if (!result.success) revert();
|
||||
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
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(setImages)
|
||||
.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);
|
||||
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);
|
||||
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
preview: CSSProperties,
|
||||
content?: React.ReactNode,
|
||||
) => {
|
||||
const selected = sameChoice(value, choice);
|
||||
const selected = sameChoice(shown, choice);
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
|
||||
{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"
|
||||
|
||||
Reference in New Issue
Block a user