'use client'; import { type ChangeEvent, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { PICTURE_FRAME_INTERVAL_OPTIONS, PICTURE_FRAME_MAX_IMAGES, type PictureFrameEntry, entryKey, isHttpsUrl, resolvePictureFrameConfig, } from '@/components/dashboard/widgets/picture-frame-config'; import { dashboardImageSrc, deleteDashboardImage, uploadDashboardImage, } from '@/lib/dashboard-images-api'; interface PictureFrameConfigFormProps { config: Record; onChange: (partial: Record) => void; } /** * Bildverwaltung des Bilderrahmen-Widgets im WidgetSettingsPanel * (quick-260921-pi9, Muster ClockConfig/FavoritesConfig). * * `onChange` bekommt bei Listenaenderungen IMMER das vollstaendige * `images`-Array: die API fuehrt Widget-Konfigurationen flach zusammen * (`{ ...alt, ...neu }`), ein Teil-Array wuerde die uebrigen Eintraege * ueberschreiben. Die drei Auswahlfelder senden dagegen nur ihr eines Feld. * * Kette Datei -> Server -> Config: Datei waehlen -> `uploadDashboardImage` * (POST /dashboard/images, Magic-Byte-Pruefung serverseitig) -> Antwort * `{ id }` -> `onChange({ images: [...alt, { kind: 'upload', imageId }] })`. * Entfernen eines Upload-Eintrags loescht das Bild auch auf dem Server * (best effort, Fehler verschluckt — der Eintrag verschwindet in jedem Fall * aus der Config). * * Webadressen: nur https (`isHttpsUrl`, echter URL-Parser) — dieselbe * Pruefung, die das Widget beim Rendern erneut anwendet (T-PI9-07). Die * Vorschau laedt der Browser direkt mit `referrerPolicy="no-referrer"` * (T-PI9-09); der Server ruft nie eine Adresse ab (T-PI9-05). */ export function PictureFrameConfigForm({ config, onChange }: PictureFrameConfigFormProps) { const t = useTranslations('widgets'); const { images, fit, intervalSeconds, order } = resolvePictureFrameConfig(config); const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [urlDraft, setUrlDraft] = useState(''); const [urlError, setUrlError] = useState(false); // Entwuerfe der Unterschriften je Eintragsschluessel; Uebernahme bei // Blur/Enter (Muster commitFontSize in ClockConfig). const [captionDrafts, setCaptionDrafts] = useState>({}); const [brokenPreviews, setBrokenPreviews] = useState([]); const limitReached = images.length >= PICTURE_FRAME_MAX_IMAGES; function setImages(next: PictureFrameEntry[]) { // Entwuerfe verwerfen: nach einer Listenaenderung gelten die Schluessel // (Index-Anteil bei URL-Eintraegen) nicht mehr zwingend fuer dieselbe Zeile. setCaptionDrafts({}); onChange({ images: next }); } async function handleFileChange(e: ChangeEvent) { const file = e.target.files?.[0]; // Auswahl zuruecksetzen, damit dieselbe Datei erneut waehlbar ist. e.target.value = ''; if (!file || limitReached) return; setUploading(true); setUploadError(null); try { const meta = await uploadDashboardImage(file); setImages([...images, { kind: 'upload', imageId: meta.id }]); } catch (error) { setUploadError(error instanceof Error ? error.message : t('pictureFrame.uploadFailed')); } finally { setUploading(false); } } function handleAddUrl() { const url = urlDraft.trim(); if (!isHttpsUrl(url)) { setUrlError(true); return; } setUrlError(false); setUrlDraft(''); setImages([...images, { kind: 'url', url }]); } function handleRemove(index: number) { const entry = images[index]; if (entry.kind === 'upload') { // Best effort: das Bild gehoert dem Benutzer, ein Fehler hier aendert // nichts daran, dass der Eintrag aus der Config verschwindet. deleteDashboardImage(entry.imageId).catch(() => undefined); } setImages(images.filter((_, i) => i !== index)); } function handleMove(index: number, direction: 'up' | 'down') { const target = direction === 'up' ? index - 1 : index + 1; if (target < 0 || target >= images.length) return; const next = [...images]; [next[index], next[target]] = [next[target], next[index]]; setImages(next); } function commitCaption(index: number, key: string) { const draft = captionDrafts[key]; if (draft === undefined) return; const trimmed = draft.trim(); const current = images[index].caption ?? ''; if (trimmed === current) return; const next = images.map((entry, i) => { if (i !== index) return entry; const { caption: _dropped, ...rest } = entry; return trimmed === '' ? rest : { ...rest, caption: trimmed }; }); setImages(next); } function intervalLabel(seconds: number): string { if (seconds === 0) return t('pictureFrame.intervalOff'); if (seconds < 60) return t('pictureFrame.intervalSeconds', { n: seconds }); return t('pictureFrame.intervalMinutes', { n: seconds / 60 }); } const fieldClassName = 'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground'; const iconButtonClassName = 'rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40'; return (
{/* Bildausschnitt */}
{/* Wechselintervall */}
{/* Reihenfolge */}
{/* Bilder */}

{t('pictureFrame.imagesLabel')}

{images.length > 0 && (
    {images.map((entry, index) => { const key = entryKey(entry, index); const src = entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url; const draft = captionDrafts[key] ?? entry.caption ?? ''; return (
  • {brokenPreviews.includes(key) ? ( {t('pictureFrame.unavailable')} ) : ( setBrokenPreviews((prev) => (prev.includes(key) ? prev : [...prev, key])) } /> )} setCaptionDrafts((prev) => ({ ...prev, [key]: e.target.value }))} onBlur={() => commitCaption(index, key)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitCaption(index, key); } }} className="h-9 min-w-0 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground" />
  • ); })}
)} {/* Hochladen */}

{t('pictureFrame.uploadHint')}

{limitReached && (

{t('pictureFrame.limitReached')}

)} {uploadError && (

{uploadError}

)}
{/* Webadresse */}
{ setUrlDraft(e.target.value); setUrlError(false); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddUrl(); } }} disabled={limitReached} aria-invalid={urlError || undefined} className={fieldClassName} />
{urlError && (

{t('pictureFrame.urlInvalid')}

)}
); } function ArrowIcon({ direction }: { direction: 'up' | 'down' }) { return ( ); }