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:
@@ -0,0 +1,263 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
/**
|
||||
* picture-frame-config-form.test — NEU (quick-260921-pi9). Texte aus der
|
||||
* echten de.json (Muster widget-settings-panel.test.tsx): Auswahlfelder,
|
||||
* Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette,
|
||||
* https-Pruefung, Grenze 30.
|
||||
*/
|
||||
vi.mock('next-intl', async () => {
|
||||
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
|
||||
const lookup = (path: string): string | undefined =>
|
||||
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[k] : undefined), messages) as
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
useTranslations:
|
||||
(ns?: string) =>
|
||||
(key: string, values?: Record<string, unknown>) => {
|
||||
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
|
||||
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { uploadMock, deleteMock } = vi.hoisted(() => ({
|
||||
uploadMock: vi.fn(),
|
||||
deleteMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/dashboard-images-api', () => ({
|
||||
uploadDashboardImage: uploadMock,
|
||||
deleteDashboardImage: deleteMock,
|
||||
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${encodeURIComponent(id)}`,
|
||||
}));
|
||||
|
||||
import { PictureFrameConfigForm } from './picture-frame-config-form';
|
||||
|
||||
const texts = (de as { widgets: { pictureFrame: Record<string, string> } }).widgets.pictureFrame;
|
||||
const URL_A = 'https://bilder.example/a.jpg';
|
||||
|
||||
function renderForm(config: Record<string, unknown>) {
|
||||
const onChange = vi.fn();
|
||||
render(<PictureFrameConfigForm config={config} onChange={onChange} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
uploadMock.mockReset();
|
||||
deleteMock.mockReset();
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('PictureFrameConfigForm (quick-260921-pi9)', () => {
|
||||
it('Test 1: drei Auswahlfelder mit Vorgaben; jede Aenderung ruft onChange mit genau dem einen Feld', () => {
|
||||
const { onChange } = renderForm({});
|
||||
const fit = screen.getByLabelText(texts.fitLabel) as HTMLSelectElement;
|
||||
const interval = screen.getByLabelText(texts.intervalLabel) as HTMLSelectElement;
|
||||
const order = screen.getByLabelText(texts.orderLabel) as HTMLSelectElement;
|
||||
expect(fit.value).toBe('contain');
|
||||
expect(interval.value).toBe('30');
|
||||
expect(order.value).toBe('sequence');
|
||||
expect(Array.from(interval.options).map((o) => o.value)).toEqual([
|
||||
'0', '5', '10', '15', '30', '60', '120', '300', '600', '1800', '3600',
|
||||
]);
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('Kein Wechsel');
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden');
|
||||
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten');
|
||||
|
||||
fireEvent.change(fit, { target: { value: 'cover' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' });
|
||||
fireEvent.change(interval, { target: { value: '120' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ intervalSeconds: 120 });
|
||||
fireEvent.change(order, { target: { value: 'random' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ order: 'random' });
|
||||
expect(onChange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Test 2: Liste zeigt je Eintrag Vorschau (no-referrer, Upload ueber Proxy-Pfad); kaputte Vorschau wird zu "Bild nicht verfügbar"', () => {
|
||||
renderForm({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
const previews = screen.getAllByTestId('picture-frame-preview') as HTMLImageElement[];
|
||||
expect(previews).toHaveLength(2);
|
||||
expect(previews[0].getAttribute('src')).toBe(URL_A);
|
||||
expect(previews[0].getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(previews[1].getAttribute('src')).toBe('/api-proxy/dashboard/images/img-1');
|
||||
|
||||
fireEvent.error(previews[0]);
|
||||
expect(screen.getAllByTestId('picture-frame-preview')).toHaveLength(1);
|
||||
expect(screen.getByText(texts.unavailable)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: Unterschrift — Entwurf im Feld, Uebernahme bei Blur/Enter mit dem GANZEN images-Array; unveraendert -> kein Aufruf', () => {
|
||||
const { onChange } = renderForm({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A, caption: 'Alt' },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
const inputs = screen.getAllByLabelText(texts.captionPlaceholder) as HTMLInputElement[];
|
||||
expect(inputs[0].value).toBe('Alt');
|
||||
expect(inputs[1].value).toBe('');
|
||||
|
||||
fireEvent.change(inputs[1], { target: { value: 'Neu' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(inputs[1], { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A, caption: 'Alt' },
|
||||
{ kind: 'upload', imageId: 'img-1', caption: 'Neu' },
|
||||
],
|
||||
});
|
||||
|
||||
// Leer machen entfernt die Unterschrift, Blur uebernimmt
|
||||
fireEvent.change(inputs[0], { target: { value: ' ' } });
|
||||
fireEvent.blur(inputs[0]);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
|
||||
// Unveraendert: kein weiterer Aufruf
|
||||
const calls = onChange.mock.calls.length;
|
||||
fireEvent.blur(inputs[1]);
|
||||
expect(onChange).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it('Test 4: Pfeile — oberster ohne "hoch", unterster ohne "runter"; Bewegen sendet das ganze Array in neuer Reihenfolge', () => {
|
||||
const { onChange } = renderForm({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'upload', imageId: 'img-2' },
|
||||
],
|
||||
});
|
||||
const ups = screen.getAllByRole('button', { name: texts.moveUpButton }) as HTMLButtonElement[];
|
||||
const downs = screen.getAllByRole('button', { name: texts.moveDownButton }) as HTMLButtonElement[];
|
||||
expect(ups[0].disabled).toBe(true);
|
||||
expect(ups[1].disabled).toBe(false);
|
||||
expect(downs[2].disabled).toBe(true);
|
||||
expect(downs[1].disabled).toBe(false);
|
||||
|
||||
fireEvent.click(downs[0]);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-2' },
|
||||
],
|
||||
});
|
||||
fireEvent.click(ups[2]);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'img-2' },
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 5: Entfernen eines Upload-Eintrags ruft deleteDashboardImage (Fehler verschluckt) UND onChange mit dem verkuerzten Array; URL-Eintrag ohne Serveraufruf', async () => {
|
||||
deleteMock.mockRejectedValue(new Error('weg'));
|
||||
const { onChange } = renderForm({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'url', url: URL_A },
|
||||
],
|
||||
});
|
||||
const removes = screen.getAllByRole('button', { name: texts.removeButton });
|
||||
|
||||
fireEvent.click(removes[0]);
|
||||
expect(deleteMock).toHaveBeenCalledWith('img-1');
|
||||
expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'url', url: URL_A }] });
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
fireEvent.click(removes[1]);
|
||||
expect(deleteMock).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ images: [{ kind: 'upload', imageId: 'img-1' }] });
|
||||
});
|
||||
|
||||
it('Test 6: Webadresse — http:// zeigt role=alert und ruft onChange nicht; https ruft onChange mit angehaengtem URL-Eintrag', () => {
|
||||
const { onChange } = renderForm({ images: [{ kind: 'upload', imageId: 'img-1' }] });
|
||||
const input = screen.getByLabelText(texts.urlAddButton) as HTMLInputElement;
|
||||
const button = screen.getByRole('button', { name: texts.urlAddButton });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'http://bilder.example/a.jpg' } });
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(texts.urlInvalid);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(input, { target: { value: URL_A } });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
images: [
|
||||
{ kind: 'upload', imageId: 'img-1' },
|
||||
{ kind: 'url', url: URL_A },
|
||||
],
|
||||
});
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('Test 7: Datei waehlen ruft uploadDashboardImage(file) und danach onChange mit neuem Upload-Eintrag (Kennung aus der Antwort)', async () => {
|
||||
uploadMock.mockResolvedValue({ id: 'neu-42', originalName: 'a.png', mimeType: 'image/png', size: 3 });
|
||||
const { onChange } = renderForm({ images: [{ kind: 'url', url: URL_A }] });
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' });
|
||||
const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
expect(uploadMock).toHaveBeenCalledWith(file);
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
images: [
|
||||
{ kind: 'url', url: URL_A },
|
||||
{ kind: 'upload', imageId: 'neu-42' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 8: wirft der Upload, erscheint seine Meldung als role=alert und onChange bleibt aus', async () => {
|
||||
uploadMock.mockRejectedValue(new Error('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'));
|
||||
const { onChange } = renderForm({});
|
||||
const input = screen.getByTestId('picture-frame-file-input') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [new File([1], 'x.png')] } });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.',
|
||||
),
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 9: bei 30 Eintraegen ist "Bild hochladen" deaktiviert, der Hinweis limitReached steht, Webadresse ebenfalls gesperrt', () => {
|
||||
const images = Array.from({ length: 30 }, (_, i) => ({ kind: 'upload', imageId: `img-${i}` }));
|
||||
renderForm({ images });
|
||||
const upload = screen.getByRole('button', { name: texts.uploadButton }) as HTMLButtonElement;
|
||||
expect(upload.disabled).toBe(true);
|
||||
expect(screen.getByTestId('picture-frame-limit')).toHaveTextContent(texts.limitReached);
|
||||
expect((screen.getByRole('button', { name: texts.urlAddButton }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getAllByTestId('picture-frame-entry')).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
'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 });
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
|
||||
import { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
@@ -196,6 +197,16 @@ export function WidgetSettingsPanel({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bilderrahmen (quick-260921-pi9) */}
|
||||
{widget.widgetType === 'picture-frame' && (
|
||||
<PictureFrameConfigForm
|
||||
config={widget.config}
|
||||
onChange={(cfg) =>
|
||||
handleConfigChange(widget.id, cfg)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user