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:
@@ -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/calculator-widget', () => ({ CalculatorWidget: () => null }));
|
||||||
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
|
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
|
||||||
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||||
|
vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStore.isEditMode = false;
|
mockStore.isEditMode = false;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
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 { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-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 { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-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 { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ wireNoteWidget(NoteWidget);
|
|||||||
wireCalculatorWidget(CalculatorWidget);
|
wireCalculatorWidget(CalculatorWidget);
|
||||||
wireStopwatchWidget(StopwatchWidget);
|
wireStopwatchWidget(StopwatchWidget);
|
||||||
wireFavoritesWidget(FavoritesWidget);
|
wireFavoritesWidget(FavoritesWidget);
|
||||||
|
wirePictureFrameWidget(PictureFrameWidget);
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ vi.mock('next-intl', () => ({
|
|||||||
'favorites.description': 'Schnellzugriff auf Favoriten',
|
'favorites.description': 'Schnellzugriff auf Favoriten',
|
||||||
'stopwatch.name': 'Stoppuhr',
|
'stopwatch.name': 'Stoppuhr',
|
||||||
'stopwatch.description': 'Zeit messen',
|
'stopwatch.description': 'Zeit messen',
|
||||||
|
'pictureFrame.name': 'Bilderrahmen',
|
||||||
|
'pictureFrame.description': 'Bilder als Diashow',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const WIDGET_TYPES: WidgetType[] = [
|
|||||||
'calculator',
|
'calculator',
|
||||||
'favorites',
|
'favorites',
|
||||||
'stopwatch',
|
'stopwatch',
|
||||||
|
'picture-frame',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
|||||||
'calculator',
|
'calculator',
|
||||||
'favorites',
|
'favorites',
|
||||||
'stopwatch',
|
'stopwatch',
|
||||||
|
// Bilderrahmen (quick-260921-pi9)
|
||||||
|
'picture-frame',
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||||
@@ -51,7 +53,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
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
|
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
// (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 },
|
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||||
|
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 8 },
|
||||||
});
|
});
|
||||||
|
|
||||||
let counted = 0;
|
let counted = 0;
|
||||||
@@ -73,6 +76,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
counted++;
|
counted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(counted).toBe(28);
|
expect(counted).toBe(32);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ComponentType } from 'react';
|
|||||||
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
|
||||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||||
* decken den Fall ab).
|
* decken den Fall ab).
|
||||||
|
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||||
*/
|
*/
|
||||||
export type WidgetType =
|
export type WidgetType =
|
||||||
| 'clock'
|
| 'clock'
|
||||||
@@ -14,7 +15,8 @@ export type WidgetType =
|
|||||||
| 'note'
|
| 'note'
|
||||||
| 'calculator'
|
| 'calculator'
|
||||||
| 'favorites'
|
| 'favorites'
|
||||||
| 'stopwatch';
|
| 'stopwatch'
|
||||||
|
| 'picture-frame';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props contract that every widget component must accept.
|
* 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 },
|
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
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 (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry of all widget types. Used by the widget catalog modal
|
* Registry of all widget types. Used by the widget catalog modal
|
||||||
* and the grid renderer to look up components and constraints.
|
* and the grid renderer to look up components and constraints.
|
||||||
@@ -309,6 +337,14 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
...WIDGET_CONSTRAINTS.stopwatch,
|
...WIDGET_CONSTRAINTS.stopwatch,
|
||||||
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
|
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
|
// Wire actual widget components lazily to avoid circular deps
|
||||||
@@ -370,3 +406,11 @@ export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
|||||||
stopwatchWired = true;
|
stopwatchWired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pictureFrameWired = false;
|
||||||
|
export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
|
||||||
|
if (!pictureFrameWired) {
|
||||||
|
WIDGET_REGISTRY['picture-frame'].component = component;
|
||||||
|
pictureFrameWired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
* `<img src>` 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<img src>` 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<string, unknown>;
|
||||||
|
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<string, unknown>): 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}`;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('pictureFrame.close')}
|
||||||
|
data-testid="picture-frame-lightbox-backdrop"
|
||||||
|
className="fixed inset-0 bg-black/80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={caption ?? t('pictureFrame.open')}
|
||||||
|
className="relative z-50 flex max-h-[95vh] max-w-[95vw] flex-col items-center gap-2 p-4"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="picture-frame-lightbox-close"
|
||||||
|
className="absolute -right-1 -top-1 z-10 rounded-full bg-black/60 p-1.5 text-white transition-colors hover:bg-black/80"
|
||||||
|
aria-label={tCommon('close')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={caption ?? ''}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
data-testid="picture-frame-lightbox-image"
|
||||||
|
className="max-h-[85vh] max-w-[90vw] rounded object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<p className="max-w-[90vw] truncate text-center text-sm text-white" data-testid="picture-frame-lightbox-caption">
|
||||||
|
{caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, <img> 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<string, unknown>, isEditMode = false) {
|
||||||
|
return render(<PictureFrameWidget instanceId="pf-1" config={config} isEditMode={isEditMode} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -> <img> 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/<id>', () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
* `<img>` — der Server ruft nie eine Adresse ab (T-PI9-05: keine
|
||||||
|
* SSRF-Flaeche, kein Proxy-Endpunkt, der eine URL entgegennimmt; interne
|
||||||
|
* Hosts sieht damit nur, wer sie ohnehin erreicht). `referrerPolicy=
|
||||||
|
* "no-referrer"` an jedem Bild, damit der Fremdhost die Tessera-Adresse
|
||||||
|
* nicht erfaehrt (T-PI9-09).
|
||||||
|
*
|
||||||
|
* Was gerendert wird, entscheidet `resolvePictureFrameConfig` (T-PI9-07):
|
||||||
|
* nur https-Adressen, nur Upload-Eintraege mit Kennung. Ein Bild, das nicht
|
||||||
|
* laedt (`onError`), verlaesst den Umlauf; sind alle kaputt, steht
|
||||||
|
* „Bild nicht verfuegbar“ (Entscheidung 7: leerer Zustand als zentrierter
|
||||||
|
* grauer Text wie die anderen Widgets).
|
||||||
|
*
|
||||||
|
* Wechsel per Timer nur bei > 1 sichtbarem Bild, Intervall > 0 und
|
||||||
|
* geschlossener Grossansicht; Zufall waehlt nie das aktuelle erneut
|
||||||
|
* (`pickNextIndex`); der Timer wird beim Aushaengen und bei jeder Aenderung
|
||||||
|
* der Eingaben geraeumt (Entscheidung 6).
|
||||||
|
*
|
||||||
|
* Klick auf das Bild NUR ausserhalb des Bearbeitungsmodus (Entscheidung 5):
|
||||||
|
* dort liegt Bild + Streifen in einem `<button>` (Grossansicht, Fokus kehrt
|
||||||
|
* nach dem Schliessen zum Knopf zurueck). Im Bearbeitungsmodus ist es ein
|
||||||
|
* `<div>` ohne Handler — die ganze Karte bleibt der Ziehgriff
|
||||||
|
* (widget-wrapper.tsx), ein Knopf wuerde ueber den cancel-Selektor des
|
||||||
|
* Rasters das Ziehen an dieser Stelle verhindern.
|
||||||
|
*/
|
||||||
|
export function PictureFrameWidget({ config, isEditMode }: WidgetProps) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
const resolved = useMemo(() => resolvePictureFrameConfig(config), [config]);
|
||||||
|
const { images, fit, intervalSeconds, order } = resolved;
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [brokenKeys, setBrokenKeys] = useState<string[]>([]);
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
const openButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
images
|
||||||
|
.map((entry, i) => ({ entry, key: entryKey(entry, i) }))
|
||||||
|
.filter(({ key }) => !brokenKeys.includes(key)),
|
||||||
|
[images, brokenKeys],
|
||||||
|
);
|
||||||
|
const count = visible.length;
|
||||||
|
const safeIndex = count === 0 ? 0 : index % count;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intervalSeconds <= 0 || count <= 1 || lightboxOpen) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setIndex((current) => pickNextIndex(current % count, count, order, Math.random));
|
||||||
|
}, intervalSeconds * 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [intervalSeconds, count, order, lightboxOpen]);
|
||||||
|
|
||||||
|
const markBroken = useCallback((key: string) => {
|
||||||
|
setBrokenKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeLightbox = useCallback(() => {
|
||||||
|
setLightboxOpen(false);
|
||||||
|
// Fokus-Rueckgabe an den Ausloeser (Muster: Dialog fokussiert beim
|
||||||
|
// Oeffnen, der Ausloeser bekommt ihn nach dem Schliessen zurueck).
|
||||||
|
openButtonRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||||
|
{t('pictureFrame.empty')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||||
|
{t('pictureFrame.unavailable')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = visible[safeIndex];
|
||||||
|
const src = entrySrc(current.entry);
|
||||||
|
const caption = current.entry.caption;
|
||||||
|
const imgClassName = fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain';
|
||||||
|
|
||||||
|
const picture = (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
key={current.key}
|
||||||
|
src={src}
|
||||||
|
alt={caption ?? ''}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
data-testid="picture-frame-image"
|
||||||
|
className={imgClassName}
|
||||||
|
onError={() => markBroken(current.key)}
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-left text-xs text-white"
|
||||||
|
data-testid="picture-frame-caption"
|
||||||
|
>
|
||||||
|
{caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
|
{isEditMode ? (
|
||||||
|
<div className="relative h-full w-full">{picture}</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={openButtonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t('pictureFrame.open')}
|
||||||
|
className="relative block h-full w-full cursor-zoom-in"
|
||||||
|
onClick={() => setLightboxOpen(true)}
|
||||||
|
>
|
||||||
|
{picture}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxOpen && !isEditMode && (
|
||||||
|
<PictureFrameLightbox src={src} caption={caption} onClose={closeLightbox} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entrySrc(entry: PictureFrameEntry): string {
|
||||||
|
return entry.kind === 'upload' ? dashboardImageSrc(entry.imageId) : entry.url;
|
||||||
|
}
|
||||||
@@ -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 { useTranslations } from 'next-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
|
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 { SearchProviderForm } from '@/components/settings/search-provider-form';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
import {
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 <img src>.
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<img src>`: 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<DashboardImageMeta[]> {
|
||||||
|
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<DashboardImageMeta> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -333,6 +333,36 @@
|
|||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"lap": "Runde"
|
"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": {
|
"admin": {
|
||||||
|
|||||||
@@ -333,6 +333,36 @@
|
|||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"lap": "Lap"
|
"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": {
|
"admin": {
|
||||||
|
|||||||
Reference in New Issue
Block a user