diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index 7eec2ad..9202772 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -58,6 +58,7 @@ vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () => vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null })); vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null })); vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null })); +vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null })); beforeEach(() => { mockStore.isEditMode = false; diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 25b72ce..b75e329 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; @@ -13,6 +13,7 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; +import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -24,6 +25,7 @@ wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); wireStopwatchWidget(StopwatchWidget); wireFavoritesWidget(FavoritesWidget); +wirePictureFrameWidget(PictureFrameWidget); export default function DashboardPage() { const t = useTranslations('widgets'); diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx index d3d33ad..44d9b86 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx @@ -23,6 +23,8 @@ vi.mock('next-intl', () => ({ 'favorites.description': 'Schnellzugriff auf Favoriten', 'stopwatch.name': 'Stoppuhr', 'stopwatch.description': 'Zeit messen', + 'pictureFrame.name': 'Bilderrahmen', + 'pictureFrame.description': 'Bilder als Diashow', }, common: { close: 'Schließen', diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.tsx index 5060c02..2e6e02d 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.tsx @@ -18,6 +18,7 @@ const WIDGET_TYPES: WidgetType[] = [ 'calculator', 'favorites', 'stopwatch', + 'picture-frame', ]; /** diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index b74e757..1d5a03a 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -16,6 +16,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [ 'calculator', 'favorites', 'stopwatch', + // Bilderrahmen (quick-260921-pi9) + 'picture-frame', ]; describe('WIDGET_CONSTRAINTS (DASH-11)', () => { @@ -51,7 +53,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); }); - it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt, sieben Typen)', () => { + it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu, acht Typen)', () => { // Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit // quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben // (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte. @@ -64,6 +66,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 }, stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, + 'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 8 }, }); let counted = 0; @@ -73,6 +76,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { counted++; } } - expect(counted).toBe(28); + expect(counted).toBe(32); }); }); diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index da019ef..41bbf56 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -6,6 +6,7 @@ import type { ComponentType } from 'react'; * calculator/favorites/stopwatch: Phase 8 additions (der fruehere * Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten * decken den Fall ab). + * picture-frame: Bilderrahmen (quick-260921-pi9). */ export type WidgetType = | 'clock' @@ -14,7 +15,8 @@ export type WidgetType = | 'note' | 'calculator' | 'favorites' - | 'stopwatch'; + | 'stopwatch' + | 'picture-frame'; /** * Props contract that every widget component must accept. @@ -53,6 +55,10 @@ export const WIDGET_CONSTRAINTS: Record< calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 }, stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, + // quick-260921-pi9: ein Bild braucht Flaeche — 8x8 ist ein Quadrat von der + // Groesse einer halben Notiz (6x8 -> 8x8), 4x4 die kleinste Kachel, in der + // Bild und Unterschrift-Streifen noch erkennbar sind. + 'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 8 }, }; /** @@ -248,6 +254,28 @@ function StopwatchIcon({ className }: { className?: string }) { ); } +function PictureFrameIcon({ className }: { className?: string }) { + return ( + + ); +} + /** * Registry of all widget types. Used by the widget catalog modal * and the grid renderer to look up components and constraints. @@ -309,6 +337,14 @@ export const WIDGET_REGISTRY: Record = { ...WIDGET_CONSTRAINTS.stopwatch, component: PlaceholderWidget, // Replaced via wireStopwatchWidget() }, + 'picture-frame': { + type: 'picture-frame', + nameKey: 'pictureFrame.name', + descriptionKey: 'pictureFrame.description', + icon: PictureFrameIcon, + ...WIDGET_CONSTRAINTS['picture-frame'], + component: PlaceholderWidget, // Replaced via wirePictureFrameWidget() + }, }; // Wire actual widget components lazily to avoid circular deps @@ -370,3 +406,11 @@ export function wireStopwatchWidget(component: ComponentType) { stopwatchWired = true; } } + +let pictureFrameWired = false; +export function wirePictureFrameWidget(component: ComponentType) { + if (!pictureFrameWired) { + WIDGET_REGISTRY['picture-frame'].component = component; + pictureFrameWired = true; + } +} diff --git a/apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts b/apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts new file mode 100644 index 0000000..092e51c --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { + PICTURE_FRAME_CAPTION_MAX, + PICTURE_FRAME_INTERVAL_DEFAULT, + PICTURE_FRAME_INTERVAL_MAX, + PICTURE_FRAME_INTERVAL_MIN, + PICTURE_FRAME_INTERVAL_OPTIONS, + PICTURE_FRAME_MAX_IMAGES, + entryKey, + isHttpsUrl, + pickNextIndex, + resolvePictureFrameConfig, +} from './picture-frame-config'; + +/** + * picture-frame-config.test — NEU (quick-260921-pi9, Bilderrahmen). + * + * Reine Helfer ohne React: die Aufloesung der Widget-Konfiguration (die API + * prueft Config-Felder nicht — allein diese Funktion entscheidet, was zum + * `` wird, T-PI9-07), die https-Pruefung, die Wahl des naechsten + * Bildes (Zufall nie dasselbe zweimal) und die React-Schluessel. + */ +describe('resolvePictureFrameConfig (quick-260921-pi9)', () => { + it('Test 1: leere Konfiguration -> Vorgaben (keine Bilder, contain, 30 s, Reihenfolge)', () => { + expect(resolvePictureFrameConfig({})).toEqual({ + images: [], + fit: 'contain', + intervalSeconds: PICTURE_FRAME_INTERVAL_DEFAULT, + order: 'sequence', + }); + expect(PICTURE_FRAME_INTERVAL_DEFAULT).toBe(30); + }); + + it('Test 2: Intervall wird geklemmt — 3 -> 5, 9999 -> 3600, 0 bleibt 0, "abc" -> 30', () => { + expect(resolvePictureFrameConfig({ intervalSeconds: 3 }).intervalSeconds).toBe(PICTURE_FRAME_INTERVAL_MIN); + expect(resolvePictureFrameConfig({ intervalSeconds: 9999 }).intervalSeconds).toBe(PICTURE_FRAME_INTERVAL_MAX); + expect(resolvePictureFrameConfig({ intervalSeconds: 0 }).intervalSeconds).toBe(0); + expect(resolvePictureFrameConfig({ intervalSeconds: 'abc' }).intervalSeconds).toBe(30); + expect(resolvePictureFrameConfig({ intervalSeconds: Number.NaN }).intervalSeconds).toBe(30); + expect(resolvePictureFrameConfig({ intervalSeconds: 7.6 }).intervalSeconds).toBe(8); + expect(PICTURE_FRAME_INTERVAL_MIN).toBe(5); + expect(PICTURE_FRAME_INTERVAL_MAX).toBe(3600); + expect(PICTURE_FRAME_INTERVAL_OPTIONS).toEqual([0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]); + }); + + it('Test 3: unbekannte fit/order-Werte fallen auf die Vorgabe zurueck, gueltige bleiben', () => { + expect(resolvePictureFrameConfig({ fit: 'x' }).fit).toBe('contain'); + expect(resolvePictureFrameConfig({ fit: 'cover' }).fit).toBe('cover'); + expect(resolvePictureFrameConfig({ order: 'x' }).order).toBe('sequence'); + expect(resolvePictureFrameConfig({ order: 'random' }).order).toBe('random'); + }); + + it('Test 4: Eintraege ohne gueltiges kind, Upload ohne imageId-String und Nicht-Objekte werden weggelassen', () => { + const { images } = resolvePictureFrameConfig({ + images: [ + { kind: 'video', url: 'https://a.de/x.mp4' }, + { kind: 'upload' }, + { kind: 'upload', imageId: 42 }, + 'text', + null, + { kind: 'upload', imageId: 'ok-1' }, + ], + }); + expect(images).toEqual([{ kind: 'upload', imageId: 'ok-1' }]); + expect(resolvePictureFrameConfig({ images: 'nicht-array' }).images).toEqual([]); + }); + + it('Test 5: URL-Eintraege mit http://, javascript:, data: oder ohne Parser-Erfolg werden weggelassen (T-PI9-07)', () => { + const { images } = resolvePictureFrameConfig({ + images: [ + { kind: 'url', url: 'http://a.de/b.jpg' }, + { kind: 'url', url: 'javascript:alert(1)' }, + { kind: 'url', url: 'data:image/png;base64,AAAA' }, + { kind: 'url', url: 'nicht-url' }, + { kind: 'url', url: 'file:///etc/passwd' }, + { kind: 'url', url: 42 }, + { kind: 'url', url: 'https://a.de/b.jpg' }, + ], + }); + expect(images).toEqual([{ kind: 'url', url: 'https://a.de/b.jpg' }]); + }); + + it('Test 6: beide Varianten in EINER Liste behalten ihre Reihenfolge (Invariante der Vereinigung)', () => { + const { images } = resolvePictureFrameConfig({ + images: [ + { kind: 'url', url: 'https://a.de/1.jpg' }, + { kind: 'upload', imageId: 'u-2' }, + { kind: 'url', url: 'https://a.de/3.jpg' }, + ], + }); + expect(images.map((e) => (e.kind === 'url' ? e.url : e.imageId))).toEqual([ + 'https://a.de/1.jpg', + 'u-2', + 'https://a.de/3.jpg', + ]); + }); + + it('Test 7: caption nur als String, auf 200 Zeichen gekuerzt; leere Unterschrift wird weggelassen', () => { + const { images } = resolvePictureFrameConfig({ + images: [ + { kind: 'upload', imageId: 'a', caption: 'x'.repeat(300) }, + { kind: 'upload', imageId: 'b', caption: 12 }, + { kind: 'upload', imageId: 'c', caption: '' }, + { kind: 'url', url: 'https://a.de/1.jpg', caption: 'Urlaub' }, + ], + }); + expect(images[0]).toEqual({ kind: 'upload', imageId: 'a', caption: 'x'.repeat(PICTURE_FRAME_CAPTION_MAX) }); + expect(images[1]).toEqual({ kind: 'upload', imageId: 'b' }); + expect(images[2]).toEqual({ kind: 'upload', imageId: 'c' }); + expect(images[3]).toEqual({ kind: 'url', url: 'https://a.de/1.jpg', caption: 'Urlaub' }); + expect(PICTURE_FRAME_CAPTION_MAX).toBe(200); + expect(PICTURE_FRAME_MAX_IMAGES).toBe(30); + }); +}); + +describe('isHttpsUrl (quick-260921-pi9)', () => { + it('Test 8: https ja (auch grossgeschrieben), http/ftp/javascript/kein-URL nein', () => { + expect(isHttpsUrl('https://a.de/b.jpg')).toBe(true); + expect(isHttpsUrl('HTTPS://a.de/b.jpg')).toBe(true); + expect(isHttpsUrl('http://a.de/b.jpg')).toBe(false); + expect(isHttpsUrl('ftp://a.de/b.jpg')).toBe(false); + expect(isHttpsUrl('javascript:alert(1)')).toBe(false); + expect(isHttpsUrl('nicht-url')).toBe(false); + expect(isHttpsUrl('')).toBe(false); + }); +}); + +describe('pickNextIndex (quick-260921-pi9)', () => { + it('Test 9: count <= 1 -> 0; sequence -> (current + 1) % count', () => { + expect(pickNextIndex(0, 0, 'sequence', Math.random)).toBe(0); + expect(pickNextIndex(5, 1, 'random', Math.random)).toBe(0); + expect(pickNextIndex(0, 3, 'sequence', Math.random)).toBe(1); + expect(pickNextIndex(2, 3, 'sequence', Math.random)).toBe(0); + }); + + it('Test 10: random liefert bei count = 3 ueber 200 Ziehungen nie den aktuellen Index, auch mit gestelltem Zufall', () => { + let seed = 1; + const random = () => { + seed = (seed * 16807) % 2147483647; + return (seed - 1) / 2147483646; + }; + for (let i = 0; i < 200; i++) { + const current = i % 3; + const next = pickNextIndex(current, 3, 'random', random); + expect(next).not.toBe(current); + expect(next).toBeGreaterThanOrEqual(0); + expect(next).toBeLessThan(3); + } + // Randfaelle des Zufallsgenerators: 0 und knapp unter 1 + expect(pickNextIndex(0, 3, 'random', () => 0)).not.toBe(0); + expect(pickNextIndex(2, 3, 'random', () => 0.999999)).not.toBe(2); + expect(pickNextIndex(1, 2, 'random', () => 0.5)).toBe(0); + }); +}); + +describe('entryKey (quick-260921-pi9)', () => { + it('Test 11: stabiler Schluessel je Variante', () => { + expect(entryKey({ kind: 'upload', imageId: 'u-1' }, 3)).toBe('upload:u-1'); + expect(entryKey({ kind: 'url', url: 'https://a.de/1.jpg' }, 3)).toBe('url:https://a.de/1.jpg:3'); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/picture-frame-config.ts b/apps/web/src/components/dashboard/widgets/picture-frame-config.ts new file mode 100644 index 0000000..f1f5514 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/picture-frame-config.ts @@ -0,0 +1,119 @@ +/** + * picture-frame-config — Typen, Grenzen und reine Helfer des Bilderrahmen- + * Widgets (quick-260921-pi9). Ohne React-Import, damit der Test schlank + * bleibt (Muster clock-font-size.ts, calendar-month.ts). + * + * Die Bildquelle ist EINE geordnete Liste `images` mit zwei Varianten + * (`kind: 'upload'` mit Kennung eines hochgeladenen Bildes, `kind: 'url'` + * mit https-Webadresse) — keine parallelen Listen, damit Reihenfolge, + * Unterschrift und Wechsel fuer beide dieselbe Logik durchlaufen. + * + * T-PI9-07: Die API prueft Widget-Konfigurationen nicht inhaltlich + * (`@IsObject()`, flache Zusammenfuehrung). Deshalb entscheidet ALLEIN + * `resolvePictureFrameConfig`, was zum `` wird: nur eine Adresse, + * die der echte `URL`-Parser als `https:` erkennt. `http:`, `data:`, + * `javascript:`, `file:` und Unparsbares fallen still weg — ein von Hand + * manipulierter Config-Wert wird auf dem eigenen Dashboard nicht einmal + * gerendert. Das Formular prueft mit derselben Funktion, bevor es speichert. + */ + +export const PICTURE_FRAME_INTERVAL_MIN = 5; +export const PICTURE_FRAME_INTERVAL_MAX = 3600; +export const PICTURE_FRAME_INTERVAL_DEFAULT = 30; +/** Auswahlwerte des Formulars in Sekunden; 0 = kein Wechsel. */ +export const PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]; +/** Gleich der Servergrenze DASHBOARD_IMAGE_MAX_COUNT. */ +export const PICTURE_FRAME_MAX_IMAGES = 30; +export const PICTURE_FRAME_CAPTION_MAX = 200; + +export type PictureFrameEntry = + | { kind: 'upload'; imageId: string; caption?: string } + | { kind: 'url'; url: string; caption?: string }; + +export type PictureFrameFit = 'contain' | 'cover'; +export type PictureFrameOrder = 'sequence' | 'random'; + +export interface PictureFrameConfig { + images: PictureFrameEntry[]; + fit: PictureFrameFit; + /** 0 = kein Wechsel, sonst MIN..MAX. */ + intervalSeconds: number; + order: PictureFrameOrder; +} + +/** Echter URL-Parser, Protokoll kleingeschrieben verglichen. */ +export function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +function resolveCaption(raw: unknown): string | undefined { + if (typeof raw !== 'string' || raw === '') return undefined; + return raw.slice(0, PICTURE_FRAME_CAPTION_MAX); +} + +function resolveEntry(raw: unknown): PictureFrameEntry | null { + if (typeof raw !== 'object' || raw === null) return null; + const record = raw as Record; + const caption = resolveCaption(record.caption); + if (record.kind === 'upload') { + if (typeof record.imageId !== 'string' || record.imageId === '') return null; + return caption === undefined + ? { kind: 'upload', imageId: record.imageId } + : { kind: 'upload', imageId: record.imageId, caption }; + } + if (record.kind === 'url') { + if (typeof record.url !== 'string' || !isHttpsUrl(record.url)) return null; + return caption === undefined + ? { kind: 'url', url: record.url } + : { kind: 'url', url: record.url, caption }; + } + return null; +} + +function resolveInterval(raw: unknown): number { + if (typeof raw !== 'number' || !Number.isFinite(raw)) return PICTURE_FRAME_INTERVAL_DEFAULT; + if (raw <= 0) return 0; + const rounded = Math.round(raw); + return Math.min(PICTURE_FRAME_INTERVAL_MAX, Math.max(PICTURE_FRAME_INTERVAL_MIN, rounded)); +} + +export function resolvePictureFrameConfig(config: Record): PictureFrameConfig { + const rawImages = Array.isArray(config.images) ? config.images : []; + const images: PictureFrameEntry[] = []; + for (const raw of rawImages) { + const entry = resolveEntry(raw); + if (entry) images.push(entry); + } + return { + images, + fit: config.fit === 'cover' ? 'cover' : 'contain', + intervalSeconds: resolveInterval(config.intervalSeconds), + order: config.order === 'random' ? 'random' : 'sequence', + }; +} + +/** + * Naechster Index: Reihenfolge zyklisch; Zufall waehlt bei mehr als einem + * Bild nie das aktuelle erneut (Ziehung aus count-1 Kandidaten, Verschiebung + * ueber das aktuelle hinweg — kein Wiederholen bis es passt). + */ +export function pickNextIndex( + current: number, + count: number, + order: PictureFrameOrder, + random: () => number, +): number { + if (count <= 1) return 0; + if (order === 'sequence') return (current + 1) % count; + const draw = Math.min(count - 2, Math.max(0, Math.floor(random() * (count - 1)))); + return draw >= current ? draw + 1 : draw; +} + +/** Stabiler React-Schluessel je Eintrag. */ +export function entryKey(entry: PictureFrameEntry, index: number): string { + return entry.kind === 'upload' ? `upload:${entry.imageId}` : `url:${entry.url}:${index}`; +} diff --git a/apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx b/apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx new file mode 100644 index 0000000..f3d337b --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; + +interface PictureFrameLightboxProps { + src: string; + caption?: string; + onClose: () => void; +} + +/** + * Grossansicht des Bilderrahmen-Widgets (quick-260921-pi9). + * + * Aufbau wie widget-catalog-modal.tsx: Hintergrund als echte, benannte + * Schaltflaeche (schliesst), Dialog `role="dialog" aria-modal` mit + * `tabIndex={-1}` und Fokus beim Einhaengen, Escape-Listener mit Aufraeumung, + * Schliessen-Knopf oben rechts. Die Fokus-RUECKGABE an den Bild-Knopf macht + * das Widget selbst (es kennt den Ausloeser), nicht diese Komponente. + * + * `referrerPolicy="no-referrer"` auch hier (T-PI9-09): ein Fremdhost erfaehrt + * die Tessera-Adresse nicht. Die Unterschrift ist ein React-Textknoten, + * kein HTML (T-PI9-06). + */ +export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLightboxProps) { + const t = useTranslations('widgets'); + const tCommon = useTranslations('common'); + const dialogRef = useRef(null); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + onClose(); + } + } + document.addEventListener('keydown', handleKeyDown); + dialogRef.current?.focus(); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( +
+ + + {caption + {caption && ( +

+ {caption} +

+ )} +
+ + ); +} diff --git a/apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx b/apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx new file mode 100644 index 0000000..a28bc56 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx @@ -0,0 +1,222 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// next-intl — Durchreiche t(key) => key (Muster stopwatch-widget.test.tsx) +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +import { PictureFrameWidget } from './picture-frame-widget'; + +/** + * picture-frame-widget.test — NEU (quick-260921-pi9). + * + * Kachel: Leerzustand, je Variante (URL direkt mit no-referrer, + * Upload ueber den Proxy-Pfad), Ausschnitt-Klassen, Unterschrift-Streifen, + * Wechsel per Timer inkl. Raeumung beim Aushaengen, kein Wechsel bei 0, + * kaputte Bilder verlassen den Umlauf, Grossansicht (Escape / Hintergrund / + * Schliessen-Knopf, Fokus-Rueckgabe, Pause des Wechsels), kein Knopf im + * Bearbeitungsmodus. + */ +const URL_A = 'https://bilder.example/a.jpg'; +const URL_B = 'https://bilder.example/b.jpg'; + +function renderWidget(config: Record, isEditMode = false) { + return render(); +} + +function currentImg(): HTMLImageElement { + return screen.getByTestId('picture-frame-image') as HTMLImageElement; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('PictureFrameWidget (quick-260921-pi9)', () => { + it('Test 1: leere Konfiguration -> Leerhinweis, kein Bild', () => { + renderWidget({}); + expect(screen.getByText('pictureFrame.empty')).toBeInTheDocument(); + expect(screen.queryByTestId('picture-frame-image')).not.toBeInTheDocument(); + }); + + it('Test 2: URL-Eintrag -> mit der Adresse, referrerPolicy no-referrer, object-contain, alt = Unterschrift', () => { + renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] }); + const img = currentImg(); + expect(img.getAttribute('src')).toBe(URL_A); + expect(img.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(img.className).toContain('object-contain'); + expect(img.getAttribute('alt')).toBe('Urlaub'); + expect(screen.getByTestId('picture-frame-caption')).toHaveTextContent('Urlaub'); + }); + + it('Test 3: fit cover -> object-cover; ohne Unterschrift alt="" und kein Streifen', () => { + renderWidget({ images: [{ kind: 'url', url: URL_A }], fit: 'cover' }); + const img = currentImg(); + expect(img.className).toContain('object-cover'); + expect(img.getAttribute('alt')).toBe(''); + expect(screen.queryByTestId('picture-frame-caption')).not.toBeInTheDocument(); + }); + + it('Test 4: Upload-Eintrag -> src ueber den Proxy-Pfad /api-proxy/dashboard/images/', () => { + renderWidget({ images: [{ kind: 'upload', imageId: 'img 1' }] }); + expect(currentImg().getAttribute('src')).toBe('/api-proxy/dashboard/images/img%201'); + }); + + it('Test 5: Intervall 5 s mit zwei Bildern -> nach 5000 ms das zweite, nach 10000 ms wieder das erste; unmount raeumt den Timer', () => { + const { unmount } = renderWidget({ + images: [ + { kind: 'url', url: URL_A }, + { kind: 'url', url: URL_B }, + ], + intervalSeconds: 5, + }); + expect(currentImg().getAttribute('src')).toBe(URL_A); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(currentImg().getAttribute('src')).toBe(URL_B); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(currentImg().getAttribute('src')).toBe(URL_A); + + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('Test 6: intervalSeconds 0 -> nie ein Wechsel, kein Timer; ein einzelnes Bild -> ebenfalls kein Timer', () => { + renderWidget({ + images: [ + { kind: 'url', url: URL_A }, + { kind: 'url', url: URL_B }, + ], + intervalSeconds: 0, + }); + expect(vi.getTimerCount()).toBe(0); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(currentImg().getAttribute('src')).toBe(URL_A); + cleanup(); + + renderWidget({ images: [{ kind: 'url', url: URL_A }], intervalSeconds: 5 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('Test 7: Zufall mit drei Bildern -> ueber 30 Wechsel nie dasselbe Bild zweimal hintereinander', () => { + renderWidget({ + images: [ + { kind: 'url', url: URL_A }, + { kind: 'url', url: URL_B }, + { kind: 'url', url: 'https://bilder.example/c.jpg' }, + ], + intervalSeconds: 5, + order: 'random', + }); + let previous = currentImg().getAttribute('src'); + for (let i = 0; i < 30; i++) { + act(() => { + vi.advanceTimersByTime(5000); + }); + const next = currentImg().getAttribute('src'); + expect(next).not.toBe(previous); + previous = next; + } + }); + + it('Test 8: onError nimmt den Eintrag aus dem Umlauf — bei zwei Eintraegen bleibt nur der andere; sind alle kaputt, steht pictureFrame.unavailable', () => { + renderWidget({ + images: [ + { kind: 'url', url: URL_A }, + { kind: 'url', url: URL_B }, + ], + intervalSeconds: 5, + }); + fireEvent.error(currentImg()); + expect(currentImg().getAttribute('src')).toBe(URL_B); + // nur noch ein Bild: kein Wechsel mehr + expect(vi.getTimerCount()).toBe(0); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(currentImg().getAttribute('src')).toBe(URL_B); + + fireEvent.error(currentImg()); + expect(screen.queryByTestId('picture-frame-image')).not.toBeInTheDocument(); + expect(screen.getByText('pictureFrame.unavailable')).toBeInTheDocument(); + }); + + it('Test 9: Klick auf das Bild oeffnet die Grossansicht (role=dialog, grosses Bild, Unterschrift); Escape schliesst, Fokus zurueck am Bild-Knopf', () => { + renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] }); + const openButton = screen.getByRole('button', { name: 'pictureFrame.open' }); + fireEvent.click(openButton); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(document.activeElement).toBe(dialog); + expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('src')).toBe(URL_A); + expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(screen.getByTestId('picture-frame-lightbox-caption')).toHaveTextContent('Urlaub'); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(openButton); + }); + + it('Test 10: Hintergrund-Knopf und Schliessen-Knopf schliessen die Grossansicht', () => { + renderWidget({ images: [{ kind: 'url', url: URL_A }] }); + const openButton = screen.getByRole('button', { name: 'pictureFrame.open' }); + + fireEvent.click(openButton); + fireEvent.click(screen.getByTestId('picture-frame-lightbox-backdrop')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + fireEvent.click(openButton); + fireEvent.click(screen.getByTestId('picture-frame-lightbox-close')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(openButton); + }); + + it('Test 11: bei offener Grossansicht pausiert der Wechsel; nach dem Schliessen laeuft er weiter', () => { + renderWidget({ + images: [ + { kind: 'url', url: URL_A }, + { kind: 'url', url: URL_B }, + ], + intervalSeconds: 5, + }); + fireEvent.click(screen.getByRole('button', { name: 'pictureFrame.open' })); + // Kein Timer-Zaehler hier: nach einer Interaktion haelt React selbst + // kurz einen Scheduler-Timer (gemessen: 1 nach dem Oeffnen, obwohl das + // Intervall geraeumt ist). Belegt wird das Verhalten — vier Intervalle + // vergehen, das Bild bleibt. + act(() => { + vi.advanceTimersByTime(20_000); + }); + expect(screen.getByTestId('picture-frame-lightbox-image').getAttribute('src')).toBe(URL_A); + expect(currentImg().getAttribute('src')).toBe(URL_A); + + fireEvent.keyDown(document, { key: 'Escape' }); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(currentImg().getAttribute('src')).toBe(URL_B); + }); + + it('Test 12: Bearbeitungsmodus — kein Knopf im Widget, Klick auf das Bild oeffnet nichts', () => { + renderWidget({ images: [{ kind: 'url', url: URL_A, caption: 'Urlaub' }] }, true); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + fireEvent.click(currentImg()); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + // Bild und Streifen werden trotzdem gezeigt + expect(currentImg().getAttribute('src')).toBe(URL_A); + expect(screen.getByTestId('picture-frame-caption')).toHaveTextContent('Urlaub'); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx b/apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx new file mode 100644 index 0000000..ca51189 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx @@ -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 + * `` — 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 ` + )} + + {lightboxOpen && !isEditMode && ( + + )} + + ); +} + +function entrySrc(entry: PictureFrameEntry): string { + return entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url; +} diff --git a/apps/web/src/components/settings/picture-frame-config-form.test.tsx b/apps/web/src/components/settings/picture-frame-config-form.test.tsx new file mode 100644 index 0000000..d68fdef --- /dev/null +++ b/apps/web/src/components/settings/picture-frame-config-form.test.tsx @@ -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; + const lookup = (path: string): string | undefined => + path.split('.').reduce((o, k) => (o && typeof o === 'object' ? (o as Record)[k] : undefined), messages) as + | string + | undefined; + return { + useTranslations: + (ns?: string) => + (key: string, values?: Record) => { + 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 } }).widgets.pictureFrame; +const URL_A = 'https://bilder.example/a.jpg'; + +function renderForm(config: Record) { + const onChange = vi.fn(); + render(); + 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); + }); +}); diff --git a/apps/web/src/components/settings/picture-frame-config-form.tsx b/apps/web/src/components/settings/picture-frame-config-form.tsx new file mode 100644 index 0000000..0462815 --- /dev/null +++ b/apps/web/src/components/settings/picture-frame-config-form.tsx @@ -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; + 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 ( + + ); +} diff --git a/apps/web/src/components/settings/widget-settings-panel.tsx b/apps/web/src/components/settings/widget-settings-panel.tsx index 3f40dc1..6d2bb80 100644 --- a/apps/web/src/components/settings/widget-settings-panel.tsx +++ b/apps/web/src/components/settings/widget-settings-panel.tsx @@ -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' && ( + + handleConfigChange(widget.id, cfg) + } + /> + )} )} diff --git a/apps/web/src/lib/dashboard-images-api.test.ts b/apps/web/src/lib/dashboard-images-api.test.ts new file mode 100644 index 0000000..90a066c --- /dev/null +++ b/apps/web/src/lib/dashboard-images-api.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + dashboardImageSrc, + deleteDashboardImage, + fetchDashboardImages, + uploadDashboardImage, +} from './dashboard-images-api'; + +/** + * dashboard-images-api.test — NEU (quick-260921-pi9). Die vier Aufrufe des + * Bilderrahmen-Widgets gegen /dashboard/images: FormData-Feld `image` ohne + * eigenen Content-Type, Cookie immer mit, 413 und 400 mit deutschen + * Meldungen, Proxy-Pfad fuer . + */ +const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('dashboard-images-api (quick-260921-pi9)', () => { + it('Test 1: uploadDashboardImage sendet POST mit FormData-Feld image, credentials include, ohne Content-Type von Hand', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ id: 'img-1', originalName: 'a.png', mimeType: 'image/png', size: 3 }), { + status: 201, + }), + ); + const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' }); + const result = await uploadDashboardImage(file); + expect(result.id).toBe('img-1'); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(String(url).endsWith('/dashboard/images')).toBe(true); + expect(init.method).toBe('POST'); + expect(init.credentials).toBe('include'); + expect(init.headers).toBeUndefined(); + const body = init.body as FormData; + expect(body.get('image')).toBeInstanceOf(File); + expect((body.get('image') as File).name).toBe('a.png'); + }); + + it('Test 2: Status 413 -> deutsche Meldung "zu gross"', async () => { + mockFetch.mockResolvedValue(new Response('{"message":"File too large"}', { status: 413 })); + await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow( + 'Die Datei ist zu groß – erlaubt sind höchstens 5 MB.', + ); + }); + + it('Test 3: Status 400 mit { message } -> genau diese Meldung; sonst allgemeine Meldung', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ message: 'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.' }), { + status: 400, + }), + ); + await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow( + 'Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.', + ); + + mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 })); + await expect(uploadDashboardImage(new File([1], 'x.png'))).rejects.toThrow( + 'Das Bild konnte nicht hochgeladen werden.', + ); + }); + + it('Test 4: fetchDashboardImages (GET) und deleteDashboardImage (DELETE, Kennung kodiert) mit Cookie', async () => { + mockFetch.mockResolvedValue(new Response('[]', { status: 200 })); + await expect(fetchDashboardImages()).resolves.toEqual([]); + const [listUrl, listInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(String(listUrl).endsWith('/dashboard/images')).toBe(true); + expect(listInit.credentials).toBe('include'); + + mockFetch.mockResolvedValue(new Response('{"id":"a b"}', { status: 200 })); + await deleteDashboardImage('a b'); + const [delUrl, delInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(String(delUrl).endsWith('/dashboard/images/a%20b')).toBe(true); + expect(delInit.method).toBe('DELETE'); + expect(delInit.credentials).toBe('include'); + + mockFetch.mockResolvedValue(new Response('', { status: 404 })); + await expect(deleteDashboardImage('weg')).rejects.toThrow(); + }); + + it('Test 5: dashboardImageSrc kodiert die Kennung im Proxy-Pfad', () => { + expect(dashboardImageSrc('a b')).toBe('/api-proxy/dashboard/images/a%20b'); + expect(dashboardImageSrc('img-1')).toBe('/api-proxy/dashboard/images/img-1'); + }); +}); diff --git a/apps/web/src/lib/dashboard-images-api.ts b/apps/web/src/lib/dashboard-images-api.ts new file mode 100644 index 0000000..a708706 --- /dev/null +++ b/apps/web/src/lib/dashboard-images-api.ts @@ -0,0 +1,76 @@ +/** + * dashboard-images-api — Aufrufe des Bilderrahmen-Widgets gegen + * /dashboard/images (quick-260921-pi9). Muster favorites-api.ts: Cookie + * immer mit (`credentials: 'include'`). + * + * `uploadDashboardImage` schickt `multipart/form-data` mit genau dem Feld + * `image` und OHNE eigenen Content-Type-Header — die Multipart-Grenze setzt + * der Browser (Muster bug-report-api.ts). Die deutsche Meldung fuer „zu + * gross“ entsteht HIER aus dem Status 413 (multer meldet englisch), die + * Meldungen fuer 400 kommen von der API (bereits deutsch). + * + * `dashboardImageSrc` liefert den Proxy-Pfad fuer ``: der + * Next-Rewrite `/api-proxy/:path*` (next.config.ts) reicht Cookies mit, + * die Bytes kommen aus Tesseras eigenem Origin (Muster FavoriteIcon). + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export interface DashboardImageMeta { + id: string; + originalName: string; + mimeType: string; + size: number; + createdAt: string; +} + +export async function fetchDashboardImages(): Promise { + const res = await fetch(`${API_URL}/dashboard/images`, { credentials: 'include' }); + if (!res.ok) throw new Error('Failed to fetch dashboard images'); + return res.json(); +} + +export async function uploadDashboardImage(file: File): Promise { + const body = new FormData(); + body.append('image', file, file.name); + const res = await fetch(`${API_URL}/dashboard/images`, { + method: 'POST', + credentials: 'include', + body, + }); + if (res.ok) return res.json(); + + if (res.status === 413) { + throw new Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.'); + } + if (res.status === 400) { + const message = await readMessage(res); + if (message) throw new Error(message); + } + throw new Error('Das Bild konnte nicht hochgeladen werden.'); +} + +export async function deleteDashboardImage(id: string): Promise { + const res = await fetch(`${API_URL}/dashboard/images/${encodeURIComponent(id)}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to delete dashboard image'); +} + +export function dashboardImageSrc(id: string): string { + return `/api-proxy/dashboard/images/${encodeURIComponent(id)}`; +} + +async function readMessage(res: Response): Promise { + try { + const data: unknown = await res.json(); + if (typeof data === 'object' && data !== null && 'message' in data) { + const message = (data as { message: unknown }).message; + if (typeof message === 'string' && message !== '') return message; + } + } catch { + // kein JSON — allgemeine Meldung + } + return null; +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 823317e..7e83e09 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -333,6 +333,36 @@ "stop": "Stop", "reset": "Reset", "lap": "Runde" + }, + "pictureFrame": { + "name": "Bilderrahmen", + "description": "Bilder hochladen oder verlinken, als Diashow", + "empty": "Noch keine Bilder — über die Einstellungen hinzufügen", + "unavailable": "Bild nicht verfügbar", + "open": "Bild groß anzeigen", + "close": "Großansicht schließen", + "fitLabel": "Bildausschnitt", + "fitContain": "Ganz sichtbar", + "fitCover": "Formatfüllend", + "intervalLabel": "Wechselintervall", + "intervalOff": "Kein Wechsel", + "intervalSeconds": "{n} Sekunden", + "intervalMinutes": "{n} Minuten", + "orderLabel": "Reihenfolge der Bilder", + "orderSequence": "Reihenfolge", + "orderRandom": "Zufall", + "imagesLabel": "Bilder", + "captionPlaceholder": "Bildunterschrift (optional)", + "uploadButton": "Bild hochladen", + "uploadHint": "PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder", + "urlPlaceholder": "https://…", + "urlAddButton": "Webadresse hinzufügen", + "urlInvalid": "Bitte geben Sie eine vollständige https-Adresse ein.", + "removeButton": "Bild entfernen", + "moveUpButton": "Nach oben", + "moveDownButton": "Nach unten", + "limitReached": "Die Höchstzahl von 30 Bildern ist erreicht.", + "uploadFailed": "Das Bild konnte nicht hochgeladen werden." } }, "admin": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 399fe14..659a898 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -333,6 +333,36 @@ "stop": "Stop", "reset": "Reset", "lap": "Lap" + }, + "pictureFrame": { + "name": "Picture frame", + "description": "Upload or link images as a slideshow", + "empty": "No images yet — add some in the settings", + "unavailable": "Image not available", + "open": "Show image enlarged", + "close": "Close enlarged view", + "fitLabel": "Image fit", + "fitContain": "Fully visible", + "fitCover": "Fill the frame", + "intervalLabel": "Change interval", + "intervalOff": "No change", + "intervalSeconds": "{n} seconds", + "intervalMinutes": "{n} minutes", + "orderLabel": "Image order", + "orderSequence": "In order", + "orderRandom": "Random", + "imagesLabel": "Images", + "captionPlaceholder": "Caption (optional)", + "uploadButton": "Upload image", + "uploadHint": "PNG, JPEG, GIF or WebP, at most 5 MB, up to 30 images", + "urlPlaceholder": "https://…", + "urlAddButton": "Add web address", + "urlInvalid": "Please enter a complete https address.", + "removeButton": "Remove image", + "moveUpButton": "Move up", + "moveDownButton": "Move down", + "limitReached": "The maximum of 30 images has been reached.", + "uploadFailed": "The image could not be uploaded." } }, "admin": {