8b45a281be
Zwei Kosmetik-Punkte nach dem Browser-Rundgang vom 21.09.2026: - Das laengste Wechselintervall (3600 s) hiess "60 Minuten", beim XFrame heisst dieselbe Stufe "Jede Stunde". Neuer Schluessel `pictureFrame.intervalHours` (ICU-Plural, de + en). - Unter Einstellungen -> Dashboard zeigte "XFrame #1 - Board" seinen Titel, "Bilderrahmen #1" nichts. Die Kopfzeile nennt jetzt "- 1 Bild" bzw. "- N Bilder" (Schluessel `imageCountOne`/`imageCountMany`, zwei Schluessel statt ICU, weil die Panel-Tests eine einfache Uebersetzungs-Attrappe nutzen). Web-Tests 603 -> 604, type-check und lint unveraendert gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
'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<string, unknown>;
|
|
onChange: (partial: Record<string, unknown>) => 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<HTMLInputElement>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(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<Record<string, string>>({});
|
|
const [brokenPreviews, setBrokenPreviews] = useState<string[]>([]);
|
|
|
|
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<HTMLInputElement>) {
|
|
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 });
|
|
// 3600 s heisst wie beim XFrame „1 Stunde“, nicht „60 Minuten“.
|
|
if (seconds >= 3600) return t('pictureFrame.intervalHours', { n: seconds / 3600 });
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Bildausschnitt */}
|
|
<div>
|
|
<label htmlFor="picture-frame-fit" className="mb-1 block text-sm text-foreground">
|
|
{t('pictureFrame.fitLabel')}
|
|
</label>
|
|
<select
|
|
id="picture-frame-fit"
|
|
className={fieldClassName}
|
|
value={fit}
|
|
onChange={(e) => onChange({ fit: e.target.value })}
|
|
>
|
|
<option value="contain">{t('pictureFrame.fitContain')}</option>
|
|
<option value="cover">{t('pictureFrame.fitCover')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Wechselintervall */}
|
|
<div>
|
|
<label htmlFor="picture-frame-interval" className="mb-1 block text-sm text-foreground">
|
|
{t('pictureFrame.intervalLabel')}
|
|
</label>
|
|
<select
|
|
id="picture-frame-interval"
|
|
className={fieldClassName}
|
|
value={String(intervalSeconds)}
|
|
onChange={(e) => onChange({ intervalSeconds: Number(e.target.value) })}
|
|
>
|
|
{PICTURE_FRAME_INTERVAL_OPTIONS.map((seconds) => (
|
|
<option key={seconds} value={String(seconds)}>
|
|
{intervalLabel(seconds)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Reihenfolge */}
|
|
<div>
|
|
<label htmlFor="picture-frame-order" className="mb-1 block text-sm text-foreground">
|
|
{t('pictureFrame.orderLabel')}
|
|
</label>
|
|
<select
|
|
id="picture-frame-order"
|
|
className={fieldClassName}
|
|
value={order}
|
|
onChange={(e) => onChange({ order: e.target.value })}
|
|
>
|
|
<option value="sequence">{t('pictureFrame.orderSequence')}</option>
|
|
<option value="random">{t('pictureFrame.orderRandom')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Bilder */}
|
|
<div>
|
|
<p className="mb-1 block text-sm text-foreground">{t('pictureFrame.imagesLabel')}</p>
|
|
{images.length > 0 && (
|
|
<ul className="space-y-2" data-testid="picture-frame-list">
|
|
{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 (
|
|
<li key={key} className="flex items-center gap-2" data-testid="picture-frame-entry">
|
|
{brokenPreviews.includes(key) ? (
|
|
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded bg-muted text-center text-[10px] leading-tight text-muted-foreground">
|
|
{t('pictureFrame.unavailable')}
|
|
</span>
|
|
) : (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={48}
|
|
height={48}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
className="h-12 w-12 flex-shrink-0 rounded bg-muted object-cover"
|
|
data-testid="picture-frame-preview"
|
|
onError={() =>
|
|
setBrokenPreviews((prev) => (prev.includes(key) ? prev : [...prev, key]))
|
|
}
|
|
/>
|
|
)}
|
|
<input
|
|
type="text"
|
|
aria-label={t('pictureFrame.captionPlaceholder')}
|
|
placeholder={t('pictureFrame.captionPlaceholder')}
|
|
value={draft}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={t('pictureFrame.moveUpButton')}
|
|
disabled={index === 0}
|
|
onClick={() => handleMove(index, 'up')}
|
|
className={iconButtonClassName}
|
|
>
|
|
<ArrowIcon direction="up" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={t('pictureFrame.moveDownButton')}
|
|
disabled={index === images.length - 1}
|
|
onClick={() => handleMove(index, 'down')}
|
|
className={iconButtonClassName}
|
|
>
|
|
<ArrowIcon direction="down" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={t('pictureFrame.removeButton')}
|
|
onClick={() => handleRemove(index)}
|
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
|
>
|
|
<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"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Hochladen */}
|
|
<div className="mt-3 space-y-1">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
className="hidden"
|
|
aria-label={t('pictureFrame.uploadButton')}
|
|
data-testid="picture-frame-file-input"
|
|
onChange={handleFileChange}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={limitReached || uploading}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="h-9 rounded border border-border bg-background px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
|
|
>
|
|
{t('pictureFrame.uploadButton')}
|
|
</button>
|
|
<p className="text-xs text-muted-foreground">{t('pictureFrame.uploadHint')}</p>
|
|
{limitReached && (
|
|
<p className="text-xs text-muted-foreground" data-testid="picture-frame-limit">
|
|
{t('pictureFrame.limitReached')}
|
|
</p>
|
|
)}
|
|
{uploadError && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{uploadError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Webadresse */}
|
|
<div className="mt-3 space-y-1">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="url"
|
|
aria-label={t('pictureFrame.urlAddButton')}
|
|
placeholder={t('pictureFrame.urlPlaceholder')}
|
|
value={urlDraft}
|
|
onChange={(e) => {
|
|
setUrlDraft(e.target.value);
|
|
setUrlError(false);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleAddUrl();
|
|
}
|
|
}}
|
|
disabled={limitReached}
|
|
aria-invalid={urlError || undefined}
|
|
className={fieldClassName}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={limitReached}
|
|
onClick={handleAddUrl}
|
|
className="h-9 whitespace-nowrap rounded border border-border bg-background px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
|
|
>
|
|
{t('pictureFrame.urlAddButton')}
|
|
</button>
|
|
</div>
|
|
{urlError && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{t('pictureFrame.urlInvalid')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ArrowIcon({ direction }: { direction: 'up' | 'down' }) {
|
|
return (
|
|
<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"
|
|
>
|
|
{direction === 'up' ? <polyline points="18 15 12 9 6 15" /> : <polyline points="6 9 12 15 18 9" />}
|
|
</svg>
|
|
);
|
|
}
|