feat(quick-260921-pi9): Bilderrahmen-Widget - Diashow mit Grossansicht, Bildverwaltung in den Einstellungen

- picture-frame-config.ts: Eintragstyp als Vereinigung (upload | url) in EINER
  geordneten Liste, resolvePictureFrameConfig laesst alles ausser https weg
  (T-PI9-07), Intervall 0 oder 5..3600 s, pickNextIndex (Zufall nie dasselbe)
- dashboard-images-api.ts: Upload als FormData-Feld image ohne eigenen
  Content-Type, 413 -> deutsche Meldung, Proxy-Pfad fuer <img src>
- PictureFrameWidget: Leerzustand, <img referrerPolicy=no-referrer> (Browser
  laedt Fremdbilder, Server nie), object-contain/cover, Unterschrift-Streifen,
  Wechsel per Timer mit Raeumung, kaputte Bilder verlassen den Umlauf,
  Grossansicht nur ausserhalb des Bearbeitungsmodus mit Fokus-Rueckgabe und
  Pause des Wechsels; im Bearbeitungsmodus kein Knopf (Karte bleibt Griff)
- PictureFrameConfigForm im WidgetSettingsPanel: Ausschnitt, Intervall,
  Reihenfolge, Liste mit Vorschau/Unterschrift/Pfeilen/Entfernen (Upload wird
  auch serverseitig geloescht), Datei hochladen, https-Adresse hinzufuegen
- Registry (4x4 min, 8x8 Vorgabe), Katalog, Seite, Uebersetzungen de/en
  (widgets.pictureFrame, 28 Schluessel), bestehende Tests auf acht Typen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:56:02 +02:00
parent 737974b653
commit c080580459
18 changed files with 1688 additions and 4 deletions
@@ -0,0 +1,154 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { dashboardImageSrc } from '@/lib/dashboard-images-api';
import {
type PictureFrameEntry,
entryKey,
pickNextIndex,
resolvePictureFrameConfig,
} from './picture-frame-config';
import { PictureFrameLightbox } from './picture-frame-lightbox';
/**
* PictureFrameWidget — Diashow aus hochgeladenen Bildern und https-Adressen
* (quick-260921-pi9).
*
* Woher die Bytes kommen: hochgeladene Bilder ueber den Next-Rewrite
* `/api-proxy/dashboard/images/:id` (Cookie laeuft mit, eigener Origin);
* Webadressen laedt AUSSCHLIESSLICH der Browser des Benutzers direkt per
* `<img>` — der Server ruft nie eine Adresse ab (T-PI9-05: keine
* SSRF-Flaeche, kein Proxy-Endpunkt, der eine URL entgegennimmt; interne
* Hosts sieht damit nur, wer sie ohnehin erreicht). `referrerPolicy=
* "no-referrer"` an jedem Bild, damit der Fremdhost die Tessera-Adresse
* nicht erfaehrt (T-PI9-09).
*
* Was gerendert wird, entscheidet `resolvePictureFrameConfig` (T-PI9-07):
* nur https-Adressen, nur Upload-Eintraege mit Kennung. Ein Bild, das nicht
* laedt (`onError`), verlaesst den Umlauf; sind alle kaputt, steht
* „Bild nicht verfuegbar“ (Entscheidung 7: leerer Zustand als zentrierter
* grauer Text wie die anderen Widgets).
*
* Wechsel per Timer nur bei > 1 sichtbarem Bild, Intervall > 0 und
* geschlossener Grossansicht; Zufall waehlt nie das aktuelle erneut
* (`pickNextIndex`); der Timer wird beim Aushaengen und bei jeder Aenderung
* der Eingaben geraeumt (Entscheidung 6).
*
* Klick auf das Bild NUR ausserhalb des Bearbeitungsmodus (Entscheidung 5):
* dort liegt Bild + Streifen in einem `<button>` (Grossansicht, Fokus kehrt
* nach dem Schliessen zum Knopf zurueck). Im Bearbeitungsmodus ist es ein
* `<div>` ohne Handler — die ganze Karte bleibt der Ziehgriff
* (widget-wrapper.tsx), ein Knopf wuerde ueber den cancel-Selektor des
* Rasters das Ziehen an dieser Stelle verhindern.
*/
export function PictureFrameWidget({ config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const resolved = useMemo(() => resolvePictureFrameConfig(config), [config]);
const { images, fit, intervalSeconds, order } = resolved;
const [index, setIndex] = useState(0);
const [brokenKeys, setBrokenKeys] = useState<string[]>([]);
const [lightboxOpen, setLightboxOpen] = useState(false);
const openButtonRef = useRef<HTMLButtonElement>(null);
const visible = useMemo(
() =>
images
.map((entry, i) => ({ entry, key: entryKey(entry, i) }))
.filter(({ key }) => !brokenKeys.includes(key)),
[images, brokenKeys],
);
const count = visible.length;
const safeIndex = count === 0 ? 0 : index % count;
useEffect(() => {
if (intervalSeconds <= 0 || count <= 1 || lightboxOpen) return;
const timer = setInterval(() => {
setIndex((current) => pickNextIndex(current % count, count, order, Math.random));
}, intervalSeconds * 1000);
return () => clearInterval(timer);
}, [intervalSeconds, count, order, lightboxOpen]);
const markBroken = useCallback((key: string) => {
setBrokenKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
}, []);
const closeLightbox = useCallback(() => {
setLightboxOpen(false);
// Fokus-Rueckgabe an den Ausloeser (Muster: Dialog fokussiert beim
// Oeffnen, der Ausloeser bekommt ihn nach dem Schliessen zurueck).
openButtonRef.current?.focus();
}, []);
if (images.length === 0) {
return (
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
{t('pictureFrame.empty')}
</div>
);
}
if (count === 0) {
return (
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
{t('pictureFrame.unavailable')}
</div>
);
}
const current = visible[safeIndex];
const src = entrySrc(current.entry);
const caption = current.entry.caption;
const imgClassName = fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain';
const picture = (
<>
<img
key={current.key}
src={src}
alt={caption ?? ''}
referrerPolicy="no-referrer"
loading="lazy"
data-testid="picture-frame-image"
className={imgClassName}
onError={() => markBroken(current.key)}
/>
{caption && (
<div
className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-left text-xs text-white"
data-testid="picture-frame-caption"
>
{caption}
</div>
)}
</>
);
return (
<div className="relative h-full w-full overflow-hidden">
{isEditMode ? (
<div className="relative h-full w-full">{picture}</div>
) : (
<button
ref={openButtonRef}
type="button"
aria-label={t('pictureFrame.open')}
className="relative block h-full w-full cursor-zoom-in"
onClick={() => setLightboxOpen(true)}
>
{picture}
</button>
)}
{lightboxOpen && !isEditMode && (
<PictureFrameLightbox src={src} caption={caption} onClose={closeLightbox} />
)}
</div>
);
}
function entrySrc(entry: PictureFrameEntry): string {
return entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url;
}