fix(quick-260921-pi9): Grossansicht per Portal, "1 Minute" statt "1 Minuten", Kachel-Vorgabe 8x12

Befunde aus dem Browser-Rundgang am 21.09.2026:

- Die Grossansicht lag in einem `react-grid-item` mit CSS-`transform`; ein
  transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen, der
  Dialog war deshalb auf die Kachelflaeche (531x216) beschraenkt statt den
  Viewport zu fuellen. Jetzt per `createPortal` in `document.body`, wie der
  Kalender-Tooltip.
- Wechselintervall "1 Minuten" -> ICU-Plural (`one {# Minute}`), de + en; der
  Formular-Test nutzt dafuer den echten `createTranslator` von next-intl auf
  der echten de.json statt eines `{n}`-Ersatzes.
- Standardgroesse 8x8 (216 px hoch) war zu flach fuer ein Foto -> 8x12 wie die
  Kalender-Vorgabe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:10:52 +02:00
parent c3b45974f5
commit 8bf3601a18
6 changed files with 28 additions and 21 deletions
@@ -66,7 +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 }, 'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
}); });
let counted = 0; let counted = 0;
@@ -55,10 +55,11 @@ 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 // quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der
// Groesse einer halben Notiz (6x8 -> 8x8), 4x4 die kleinste Kachel, in der // Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im
// Bild und Unterschrift-Streifen noch erkennbar sind. // Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel,
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 8 }, // in der Bild und Unterschrift-Streifen noch erkennbar sind.
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
}; };
/** /**
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
interface PictureFrameLightboxProps { interface PictureFrameLightboxProps {
@@ -21,6 +22,12 @@ interface PictureFrameLightboxProps {
* `referrerPolicy="no-referrer"` auch hier (T-PI9-09): ein Fremdhost erfaehrt * `referrerPolicy="no-referrer"` auch hier (T-PI9-09): ein Fremdhost erfaehrt
* die Tessera-Adresse nicht. Die Unterschrift ist ein React-Textknoten, * die Tessera-Adresse nicht. Die Unterschrift ist ein React-Textknoten,
* kein HTML (T-PI9-06). * kein HTML (T-PI9-06).
*
* Gerendert per `createPortal` in `document.body` (wie der Kalender-Tooltip):
* die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein
* transformierter Vorfahr macht sich fuer `position: fixed` zum Bezugsrahmen —
* ohne Portal waere die Grossansicht auf die Kachelflaeche beschraenkt
* (im Browser-Rundgang am 21.09.2026 so gesehen: Dialog 531x216 statt Viewport).
*/ */
export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLightboxProps) { export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLightboxProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
@@ -38,7 +45,9 @@ export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLigh
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]); }, [onClose]);
return ( if (typeof document === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<button <button
type="button" type="button"
@@ -93,6 +102,7 @@ export function PictureFrameLightbox({ src, caption, onClose }: PictureFrameLigh
</p> </p>
)} )}
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -8,19 +8,14 @@ import de from '@/messages/de.json';
* Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette, * Eintragsliste mit Vorschau/Unterschrift/Pfeilen/Entfernen, Upload-Kette,
* https-Pruefung, Grenze 30. * https-Pruefung, Grenze 30.
*/ */
vi.mock('next-intl', async () => { vi.mock('next-intl', async (importOriginal) => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>; // Echter ICU-Uebersetzer von next-intl auf der echten de.json: die
const lookup = (path: string): string | undefined => // Intervall-Beschriftung nutzt `{n, plural, one {…} other {…}}`, ein
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[k] : undefined), messages) as // handgeschriebener `{n}`-Ersatz wuerde das Muster nicht verstehen.
| string const actual = await importOriginal<typeof import('next-intl')>();
| undefined; const messages = (await import('@/messages/de.json')).default;
return { return {
useTranslations: useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }),
(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;
},
}; };
}); });
@@ -70,6 +65,7 @@ describe('PictureFrameConfigForm (quick-260921-pi9)', () => {
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('Kein Wechsel'); 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('5 Sekunden');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute');
fireEvent.change(fit, { target: { value: 'cover' } }); fireEvent.change(fit, { target: { value: 'cover' } });
expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' }); expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' });
+1 -1
View File
@@ -347,7 +347,7 @@
"intervalLabel": "Wechselintervall", "intervalLabel": "Wechselintervall",
"intervalOff": "Kein Wechsel", "intervalOff": "Kein Wechsel",
"intervalSeconds": "{n} Sekunden", "intervalSeconds": "{n} Sekunden",
"intervalMinutes": "{n} Minuten", "intervalMinutes": "{n, plural, one {# Minute} other {# Minuten}}",
"orderLabel": "Reihenfolge der Bilder", "orderLabel": "Reihenfolge der Bilder",
"orderSequence": "Reihenfolge", "orderSequence": "Reihenfolge",
"orderRandom": "Zufall", "orderRandom": "Zufall",
+1 -1
View File
@@ -347,7 +347,7 @@
"intervalLabel": "Change interval", "intervalLabel": "Change interval",
"intervalOff": "No change", "intervalOff": "No change",
"intervalSeconds": "{n} seconds", "intervalSeconds": "{n} seconds",
"intervalMinutes": "{n} minutes", "intervalMinutes": "{n, plural, one {# minute} other {# minutes}}",
"orderLabel": "Image order", "orderLabel": "Image order",
"orderSequence": "In order", "orderSequence": "In order",
"orderRandom": "Random", "orderRandom": "Random",