b06a2d1066
Begruessung nach Tageszeit mit Datum, Befehlsleiste oben rechts statt schwebendem Knopf, einheitliche Kachel-Kopfzeilen mit Symbol, Kalender mit Pfeilknoepfen, runden Tagen und Terminpunkten, Favoriten als 36-px-Zeilen bzw. App-Starter-Kacheln, Notiz-Kaestchen im Akzent, leerer Zustand mit drei Vorschlaegen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
235 lines
9.2 KiB
TypeScript
235 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import { WidgetIcon } from './widget-icon';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
|
import { XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
|
import { computeCropLayout } from './xframe-crop';
|
|
|
|
/**
|
|
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
|
|
* in der Kachel (quick-260921-qd3).
|
|
*
|
|
* Sandbox: `XFRAME_SANDBOX` (Begruendung in xframe-config.ts, T-QD3-01) —
|
|
* keine Freigabe der Navigation des obersten Fensters, keine Modaldialoge;
|
|
* `allow=""` delegiert keine Berechtigungen (Kamera, Mikrofon, Standort,
|
|
* T-QD3-06); `referrerPolicy="no-referrer"`, damit der Fremdhost die
|
|
* Tessera-Adresse nicht erfaehrt (T-QD3-05).
|
|
*
|
|
* Woher die Seite kommt: AUSSCHLIESSLICH der Browser des Benutzers laedt
|
|
* die Adresse — der Server ruft sie nie ab (T-QD3-04: keine SSRF-Flaeche,
|
|
* kein Proxy-Endpunkt; interne Hosts sieht damit nur, wer sie ohnehin
|
|
* erreicht). Was gerendert wird, entscheidet `resolveXframeConfig`
|
|
* (T-QD3-03): nur eine https-Adresse, sonst der Leerzustand.
|
|
*
|
|
* Bearbeitungsmodus (T-QD3-07): ein <iframe> schluckt Mausereignisse und
|
|
* bricht das Ziehen. Deshalb liegt bei `isEditMode` eine transparente
|
|
* Flaeche NACH dem Rahmen im DOM ueber ihm, damit `mousedown` zur Karte
|
|
* `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte,
|
|
* eine div startet das Ziehen). Im Ansichtsmodus gibt es die Flaeche nicht,
|
|
* die Seite bleibt bedienbar.
|
|
*
|
|
* Neuladen (T-QD3-08): bei `reloadSeconds > 0` bumpt ein Intervall einen
|
|
* Zaehler, der Teil des `key` des <iframe> ist — der Rahmen wird neu
|
|
* eingehaengt. Aufraeumfunktion raeumt den Timer; im Bearbeitungsmodus
|
|
* laeuft kein Timer.
|
|
*
|
|
* Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der
|
|
* Fremdseite) ist cross-origin nicht zuverlaessig erkennbar — wird nicht
|
|
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
|
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
|
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
|
|
*
|
|
* Ausschnitt (quick-260922-ge2): cross-origin kann Tessera die Seite nicht
|
|
* von aussen scrollen oder zuschneiden. Deshalb wird der <iframe> SELBST
|
|
* mit `left/top` verschoben und mit `transform: scale` skaliert, und ein
|
|
* Clip-`div` (overflow hidden) schneidet ihn auf den Ausschnitt. Der Rahmen
|
|
* behaelt die feste Layoutbreite `XFRAME_PAGE_WIDTH` (1280): sie haelt den
|
|
* Seitenaufbau ueber alle Kachelgroessen stabil — sonst braeche die Seite
|
|
* bei jeder Kachelgroesse anders um und der in den Einstellungen gewaehlte
|
|
* Ausschnitt verrutschte. Die Kachelgroesse liefert ein ResizeObserver am
|
|
* Koerper (nur im Ausschnitt-Modus angehaengt); `computeCropLayout` passt
|
|
* den Ausschnitt ein (contain) und zentriert ihn. Ohne Ausschnitt gilt die
|
|
* Vergroesserung `zoom` (Prozentmasse + scale); 100 % rendert wie bisher.
|
|
*
|
|
* „Nur anzeigen“ (`readOnly`, T-GE2-02): eine transparente Flaeche NACH dem
|
|
* Rahmen sperrt Zeigerereignisse — Klicken und Scrollen in der Seite. Das
|
|
* ist Bedienkomfort, KEINE Sicherheitsmassnahme: die Seite laedt und laeuft
|
|
* weiter (Skripte, Cookies, Neuladen), die Sicherheitsgrenze bleibt die
|
|
* Sandbox. Im Bearbeitungsmodus liegt nur die Bearbeitungsflaeche, nie
|
|
* beide.
|
|
*/
|
|
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
|
const t = useTranslations('widgets');
|
|
const { url, title, reloadSeconds, crop, zoom, readOnly } = useMemo(
|
|
() => resolveXframeConfig(config),
|
|
[config],
|
|
);
|
|
const [reloadNonce, setReloadNonce] = useState(0);
|
|
const [tile, setTile] = useState({ w: 0, h: 0 });
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
const hasCrop = crop !== null;
|
|
|
|
useEffect(() => {
|
|
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
|
const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000);
|
|
return () => clearInterval(timer);
|
|
}, [url, reloadSeconds, isEditMode]);
|
|
|
|
// Kachelgroesse nur im Ausschnitt-Modus messen (Muster dashboard-grid.tsx).
|
|
useEffect(() => {
|
|
const el = bodyRef.current;
|
|
if (!el || !hasCrop) return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const rect = entries[0].contentRect;
|
|
setTile({ w: rect.width, h: rect.height });
|
|
});
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [hasCrop]);
|
|
|
|
if (url === null) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
|
{t('xframe.empty')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const openLabel = t('xframe.openInNewTab');
|
|
const frameKey = `${url}#${reloadNonce}`;
|
|
const frameAttrs = {
|
|
src: url,
|
|
title: title !== '' ? title : url,
|
|
sandbox: XFRAME_SANDBOX,
|
|
allow: '',
|
|
referrerPolicy: 'no-referrer' as const,
|
|
loading: 'lazy' as const,
|
|
'data-testid': 'xframe-frame',
|
|
'data-reload-nonce': reloadNonce,
|
|
};
|
|
const layout = crop === null ? null : computeCropLayout(crop, tile.w, tile.h);
|
|
const zoomFactor = zoom / 100;
|
|
// 60 % -> 166.67 %, 150 % -> 66.67 %: der Rahmen wird groesser gelegt und dann verkleinert.
|
|
const zoomPct = `${Math.round(10000 / zoomFactor) / 100}%`;
|
|
|
|
return (
|
|
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
|
{title !== '' && (
|
|
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
|
|
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
|
|
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
|
|
<div className="widget-head">
|
|
<WidgetIcon type="xframe" />
|
|
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
|
|
<NewTabLink
|
|
url={url}
|
|
label={openLabel}
|
|
className="widget-action shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={bodyRef}
|
|
className="relative min-h-0 flex-1 overflow-hidden"
|
|
data-tile-size={hasCrop ? `${Math.round(tile.w)}x${Math.round(tile.h)}` : undefined}
|
|
>
|
|
{crop !== null && layout !== null ? (
|
|
layout.scale > 0 && (
|
|
<div
|
|
data-testid="xframe-crop-clip"
|
|
style={{
|
|
position: 'absolute',
|
|
left: layout.left,
|
|
top: layout.top,
|
|
width: crop.w * layout.scale,
|
|
height: crop.h * layout.scale,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<iframe
|
|
key={frameKey}
|
|
{...frameAttrs}
|
|
className="border-0 bg-background"
|
|
style={{
|
|
position: 'absolute',
|
|
left: -crop.x * layout.scale,
|
|
top: -crop.y * layout.scale,
|
|
width: XFRAME_PAGE_WIDTH,
|
|
height: layout.frameHeight,
|
|
transform: `scale(${layout.scale})`,
|
|
transformOrigin: '0 0',
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
) : zoomFactor !== 1 ? (
|
|
<iframe
|
|
key={frameKey}
|
|
{...frameAttrs}
|
|
className="absolute left-0 top-0 border-0 bg-background"
|
|
style={{
|
|
width: zoomPct,
|
|
height: zoomPct,
|
|
transform: `scale(${zoomFactor})`,
|
|
transformOrigin: '0 0',
|
|
}}
|
|
/>
|
|
) : (
|
|
<iframe key={frameKey} {...frameAttrs} className="h-full w-full border-0 bg-background" />
|
|
)}
|
|
{isEditMode && (
|
|
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
|
)}
|
|
{!isEditMode && readOnly && (
|
|
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-readonly-overlay" />
|
|
)}
|
|
{title === '' && (
|
|
<NewTabLink
|
|
url={url}
|
|
label={openLabel}
|
|
className={`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground ${
|
|
isEditMode ? 'top-6' : 'top-1'
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** „In neuem Tab öffnen“ — echter Link (Tastatur), ohne opener und ohne Referrer (T-QD3-05). */
|
|
function NewTabLink({ url, label, className }: { url: string; label: string; className: string }) {
|
|
return (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label={label}
|
|
title={label}
|
|
className={className}
|
|
>
|
|
<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"
|
|
>
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
<polyline points="15 3 21 3 21 9" />
|
|
<line x1="10" y1="14" x2="21" y2="3" />
|
|
</svg>
|
|
{/* Biome useAnchorContent zaehlt aria-label nicht als Inhalt — der
|
|
sr-only-Text traegt denselben Namen fuer Vorleser. */}
|
|
<span className="sr-only">{label}</span>
|
|
</a>
|
|
);
|
|
}
|