feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall

- xframe-config.ts: Resolver (https-Pruefung via isHttpsUrl des Bilderrahmens,
  Titel bis 100 Zeichen, Neuladen 0/60/300/600/1800/3600 s geklemmt),
  XFRAME_SANDBOX ohne allow-top-navigation und allow-modals; 12 Tests zuerst rot
- xframe-widget.tsx: genau ein <iframe> (sandbox, allow="", no-referrer, lazy),
  Kopfleiste mit Titel oder Ecksymbol "In neuem Tab oeffnen", Neuladen ueber
  key-Wechsel mit Timer-Raeumung, transparente Flaeche im Bearbeitungsmodus
  damit die Kachel Ziehgriff bleibt; 12 Tests zuerst rot
- xframe-config-form.tsx: Adresse/Titel mit Uebernahme bei Blur/Enter, http wird
  mit Meldung abgewiesen und nicht gespeichert, Intervall-Auswahl, dauerhafter
  Hinweis auf verweigertes Einbetten; 8 Tests
- Panel-Zweig samt "— Titel" in der Kopfzeile, Registry (12x12, Fenster-Symbol),
  Katalog, Seite, DTO @IsIn, de/en widgets.xframe (15 Schluessel), Umlaut-Allowlist
  "neuem"; der Server ruft die Adresse nie ab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:18:48 +02:00
parent 8bf3601a18
commit d63d9f5563
18 changed files with 904 additions and 7 deletions
@@ -7,6 +7,7 @@ import type { ComponentType } from 'react';
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
* decken den Fall ab).
* picture-frame: Bilderrahmen (quick-260921-pi9).
* xframe: Webseite als Rahmen (quick-260921-qd3).
*/
export type WidgetType =
| 'clock'
@@ -16,7 +17,8 @@ export type WidgetType =
| 'calculator'
| 'favorites'
| 'stopwatch'
| 'picture-frame';
| 'picture-frame'
| 'xframe';
/**
* Props contract that every widget component must accept.
@@ -60,6 +62,11 @@ export const WIDGET_CONSTRAINTS: Record<
// Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel,
// in der Bild und Unterschrift-Streifen noch erkennbar sind.
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
// quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 12x12 = halbe
// Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren nur 216 px, zu flach
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
// Rahmen noch Sinn hat.
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
};
/**
@@ -277,6 +284,29 @@ function PictureFrameIcon({ className }: { className?: string }) {
);
}
function XframeIcon({ 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="4" width="18" height="16" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<circle cx="6.5" cy="6.5" r="0.5" />
<circle cx="9.5" cy="6.5" r="0.5" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
@@ -346,6 +376,14 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
...WIDGET_CONSTRAINTS['picture-frame'],
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget()
},
xframe: {
type: 'xframe',
nameKey: 'xframe.name',
descriptionKey: 'xframe.description',
icon: XframeIcon,
...WIDGET_CONSTRAINTS.xframe,
component: PlaceholderWidget, // Replaced via wireXframeWidget()
},
};
// Wire actual widget components lazily to avoid circular deps
@@ -415,3 +453,11 @@ export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
pictureFrameWired = true;
}
}
let xframeWired = false;
export function wireXframeWidget(component: ComponentType<WidgetProps>) {
if (!xframeWired) {
WIDGET_REGISTRY.xframe.component = component;
xframeWired = true;
}
}