c080580459
- 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>
155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
'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;
|
|
}
|