feat(web): Dashboard mit Begruessung, Befehlsleiste und ruhigeren Kacheln

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>
This commit is contained in:
2026-09-26 13:09:05 +02:00
parent 228112014c
commit b06a2d1066
18 changed files with 484 additions and 135 deletions
@@ -169,7 +169,7 @@ describe('CalendarWidget', () => {
const badge = within(day20).getByTestId('calendar-day-count');
expect(badge).toHaveTextContent('2');
expect(badge).toHaveStyle({ backgroundColor: '#c44040' });
expect(badge).toHaveClass('text-white');
expect(badge).toHaveAttribute('data-count', '2');
expect(badge).not.toHaveClass('bg-primary');
fireEvent.mouseEnter(day20);
@@ -183,7 +183,7 @@ describe('CalendarWidget', () => {
);
});
it('Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary', async () => {
it('Test 3c: Terminpunkt ohne Kalenderfarbe traegt den Akzentton', async () => {
mockFetchEvents.mockResolvedValue([
ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), {
color: undefined,
@@ -199,9 +199,9 @@ describe('CalendarWidget', () => {
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
const badge = within(day21).getByTestId('calendar-day-count');
expect(badge).toHaveClass('bg-primary');
expect(badge).toHaveClass('text-primary-foreground');
expect(badge).not.toHaveClass('text-white');
// Design „Mosaik“: Terminpunkt ohne Kalenderfarbe im abgedunkelten Akzent.
expect(badge).toHaveClass('bg-primary-strong');
expect(badge).toHaveAttribute('data-count', '1');
expect((badge as HTMLElement).style.backgroundColor).toBe('');
fireEvent.mouseEnter(day21);
@@ -240,9 +240,9 @@ describe('CalendarWidget', () => {
expect(badges[1]).toHaveTextContent('2');
expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' });
for (const badge of badges) {
expect(badge).toHaveClass('text-white');
expect(badge).toHaveClass('rounded-full');
expect(badge).not.toHaveClass('bg-primary');
expect(badge).toHaveClass('h-[clamp(8px,2.4cqw,12px)]');
expect(badge).toHaveClass('h-1');
}
expect(within(day20).queryByTestId('calendar-day-count-rest')).toBeNull();
expect(within(day20).getByTestId('calendar-day-badges').childElementCount).toBe(2);
@@ -300,7 +300,7 @@ describe('CalendarWidget', () => {
const restBadge = within(day21).getByTestId('calendar-day-count-rest');
expect(restBadge).toHaveTextContent('4');
expect(restBadge).toHaveClass('bg-muted-foreground');
expect(restBadge).toHaveClass('text-background');
expect(restBadge).toHaveAttribute('data-count', '4');
expect((restBadge as HTMLElement).style.backgroundColor).toBe('');
expect(restBadge).not.toHaveClass('text-white');
});
@@ -243,21 +243,23 @@ export function CalendarWidget({ config }: WidgetProps) {
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
return (
<div className="flex h-full flex-col gap-2 overflow-hidden p-3">
<div className="flex h-full flex-col gap-2 overflow-hidden px-3 pb-3 pt-1">
{showMonth && (
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
{/* Nav-Zeile */}
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
{/* Nav-Zeile: Pfeilknoepfe links/rechts, Monatsname mittig */}
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-1">
<button
type="button"
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('calendar.monthPrev')}
title={t('calendar.monthPrev')}
className="widgetNoDrag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={showPrev}
>
{t('calendar.monthPrev')}
<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="m15 18-6-6 6-6" /></svg>
</button>
<button
type="button"
className="widgetNoDrag truncate rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(11px,2.8cqw,14px)] font-semibold leading-none text-foreground transition-colors hover:bg-muted"
className="widgetNoDrag mx-auto truncate rounded-md px-2 py-1 text-sm font-semibold leading-5 text-foreground transition-colors hover:bg-muted"
title={t('calendar.monthToday')}
onClick={showToday}
>
@@ -265,31 +267,35 @@ export function CalendarWidget({ config }: WidgetProps) {
</button>
<button
type="button"
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('calendar.monthNext')}
title={t('calendar.monthNext')}
className="widgetNoDrag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={showNext}
>
{t('calendar.monthNext')}
<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="m9 18 6-6-6-6" /></svg>
</button>
</div>
{/* Wochentagskopf */}
<div className="grid grid-cols-7 gap-px text-center text-[clamp(9px,2.2cqw,12px)] font-medium text-muted-foreground">
<div className="grid grid-cols-7 text-center text-[11px] font-medium leading-4 text-muted-foreground">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div>
))}
</div>
{/* Raster */}
<div className="grid grid-cols-7 gap-px">
<div className="grid grid-cols-7 gap-y-0.5">
{days.map((day) => {
const hasEvents = day.events.length > 0;
const badges = buildDayBadges(day.events);
const single = badges.length === 1;
// Tag als Kreis (Design „Mosaik“): heute gelb gefuellt, Termine
// als kleine Punkte unter der Zahl — je Kalenderquelle einer,
// in deren Farbe; ohne Farbe im Akzentton, Rest grau.
const cellClass = [
'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded-md px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
'widgetNoDrag relative mx-auto flex h-[clamp(20px,7cqw,28px)] w-[clamp(20px,7cqw,28px)] items-center justify-center rounded-full text-[clamp(11px,2.9cqw,13px)] leading-none tabular-nums',
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
hasEvents ? 'cursor-default hover:bg-muted' : '',
hasEvents && !day.isToday ? 'cursor-default font-medium hover:bg-muted' : '',
]
.filter(Boolean)
.join(' ');
@@ -298,23 +304,20 @@ export function CalendarWidget({ config }: WidgetProps) {
<>
<span>{day.date.getDate()}</span>
{hasEvents && (
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
// mehr (mehrere Kreise stehen sonst uebereinander).
<span
data-testid="calendar-day-badges"
className="absolute bottom-px right-px flex items-end gap-px"
className="absolute bottom-[3px] left-1/2 flex -translate-x-1/2 items-center gap-[2px]"
>
{badges.map((badge) => {
const badgeClass = [
'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none',
single
? 'h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]'
: 'h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]',
'block h-1 w-1 rounded-full',
badge.rest
? 'bg-muted-foreground text-background'
? 'bg-muted-foreground'
: badge.color
? 'text-white'
: 'bg-primary text-primary-foreground',
? ''
: day.isToday
? 'bg-primary-foreground'
: 'bg-primary-strong',
]
.filter(Boolean)
.join(' ');
@@ -322,10 +325,11 @@ export function CalendarWidget({ config }: WidgetProps) {
<span
key={badge.key}
data-testid={badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'}
data-count={badge.count}
className={badgeClass}
style={badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined}
>
{badge.count}
<span className="sr-only">{badge.count}</span>
</span>
);
})}
@@ -360,7 +364,7 @@ export function CalendarWidget({ config }: WidgetProps) {
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={`${cellClass} w-full text-left`}
className={cellClass}
aria-label={ariaLabel}
onMouseEnter={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
@@ -395,7 +399,7 @@ export function CalendarWidget({ config }: WidgetProps) {
{maxEvents > 0 && (
<section className="flex min-h-0 flex-1 flex-col gap-1">
<h3 className="shrink-0 text-[clamp(10px,2.6cqw,13px)] font-semibold text-foreground">
<h3 className="shrink-0 text-xs font-semibold text-muted-foreground">
{t('calendar.upcomingTitle')}
</h3>
{upcoming.length === 0 ? (
@@ -403,24 +407,24 @@ export function CalendarWidget({ config }: WidgetProps) {
{t('calendar.emptyNoEvents')}
</p>
) : (
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{upcoming.map((event) => (
<li key={event.id} className="flex items-start gap-2 rounded-md px-2 py-1 hover:bg-muted">
<li key={event.id} className="flex items-stretch gap-2.5 rounded-md px-1.5 py-1 hover:bg-muted">
<span
data-testid="event-color-dot"
className="mt-1 h-2 w-2 shrink-0 rounded-full"
className="w-[3px] shrink-0 rounded-full"
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
<p className="truncate text-[11px] leading-4 text-muted-foreground">
{formatEventDate(event)}
</p>
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-medium text-foreground">
<p className="truncate text-[13px] font-medium leading-[18px] text-foreground">
{event.title}
</p>
{event.location && (
<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground">
<p className="truncate text-[11px] leading-4 text-muted-foreground">
{event.location}
</p>
)}
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { updateWidgetConfig } from '@/lib/dashboard-api';
@@ -322,6 +323,7 @@ export function FavoritesWidget({
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && (
<div className="widget-head">
<WidgetIcon type="favorites" />
{isEditMode ? (
<input
type="text"
@@ -396,10 +398,11 @@ export function FavoritesWidget({
viewMode === 'grid' ? (
<div
data-testid="favorites-grid"
className="grid grid-cols-3 gap-2"
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
launcher
key={fav.id}
fav={fav}
isEditMode={isEditMode}
@@ -429,7 +432,7 @@ export function FavoritesWidget({
) : (
<div
data-testid="favorites-list"
className="flex flex-col gap-1"
className="flex flex-col gap-0.5"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
@@ -558,10 +561,16 @@ function getDirectFaviconSrc(url: string): string | null {
function FavoriteIcon({
fav,
getFallbackLetter,
large = false,
}: {
fav: FavoriteLink;
getFallbackLetter: (title: string) => string;
/** Kachelansicht: 40-px-Feld mit 24-px-Symbol, sonst 24-px-Feld mit 16-px-Symbol. */
large?: boolean;
}) {
const box = large ? 'h-10 w-10 rounded-lg' : 'h-6 w-6 rounded-md';
const img = large ? 'h-6 w-6' : 'h-4 w-4';
const px = large ? 24 : 16;
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
@@ -572,10 +581,10 @@ function FavoriteIcon({
);
return (
<div className="relative flex-shrink-0 w-5 h-5">
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
<span
data-testid={`letter-fallback-${fav.id}`}
className="absolute inset-0 flex items-center justify-center text-xs font-semibold bg-muted rounded"
className={`absolute inset-0 flex items-center justify-center font-semibold text-muted-foreground ${large ? 'text-base' : 'text-xs'}`}
>
{getFallbackLetter(fav.title)}
</span>
@@ -585,10 +594,10 @@ function FavoriteIcon({
src={proxySrc}
alt=""
aria-hidden="true"
width={20}
height={20}
width={px}
height={px}
loading="lazy"
className="absolute inset-0 w-5 h-5 rounded"
className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')}
/>
)}
@@ -598,11 +607,11 @@ function FavoriteIcon({
src={directSrc}
alt=""
aria-hidden="true"
width={20}
height={20}
width={px}
height={px}
loading="lazy"
referrerPolicy="no-referrer"
className="absolute inset-0 w-5 h-5 rounded"
className={`relative rounded-sm ${img}`}
onError={() => setStage('none')}
/>
)}
@@ -638,6 +647,8 @@ type FavoriteTileProps = {
canMoveDown: boolean;
onMove: (id: string, direction: 'up' | 'down') => void;
t: (key: string) => string;
/** Kachelansicht wie ein App-Starter (Symbol oben, Name darunter). */
launcher?: boolean;
};
function FavoriteTile({
@@ -663,19 +674,24 @@ function FavoriteTile({
canMoveDown,
onMove,
t,
launcher = false,
}: FavoriteTileProps) {
const isEditing = editingId === fav.id;
return (
<div className="flex flex-col">
{/* Main link row */}
<div className="flex items-center gap-2">
<div className={launcher ? 'flex flex-col items-stretch gap-1' : 'flex items-center gap-2'}>
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
<a
href={fav.url}
target="_blank"
rel="noreferrer"
className="flex h-8 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag"
className={
launcher
? 'flex min-w-0 flex-col items-center gap-1.5 rounded-lg px-1 py-2 text-center transition-colors hover:bg-muted widgetNoDrag'
: 'flex h-9 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag'
}
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
>
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
@@ -683,9 +699,12 @@ function FavoriteTile({
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
fav={fav}
getFallbackLetter={getFallbackLetter}
large={launcher}
/>
<span className="truncate text-sm">{fav.title}</span>
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
{fav.title}
</span>
</a>
{/* Action buttons (edit mode only, D-04) */}
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
@@ -164,6 +165,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */}
<div className="widget-head relative">
<WidgetIcon type="note" />
<input
type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
@@ -163,6 +164,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
if (isEditMode) {
header = (
<div className="widget-head">
<WidgetIcon type="proxmox" />
<input
type="text"
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
@@ -187,6 +189,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
} else if (trimmedTitle !== '') {
header = (
<div className="widget-head">
<WidgetIcon type="proxmox" />
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
</div>
);
@@ -0,0 +1,88 @@
import type { ReactNode } from 'react';
/**
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
* (Design „Mosaik“, Runde 2): 16 px, gedaempftes Grau, rein dekorativ.
*/
const PATHS: Record<string, ReactNode> = {
calendar: (
<>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</>
),
note: (
<>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 13h6M10 17h6M8 13h.01M8 17h.01" />
</>
),
favorites: <path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />,
clock: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</>
),
calculator: (
<>
<rect x="4" y="2" width="16" height="20" rx="2" />
<path d="M8 6h8M16 14v4M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M8 18h.01M12 18h.01" />
</>
),
stopwatch: (
<>
<circle cx="12" cy="14" r="8" />
<path d="M10 2h4M12 14l3-3M12 2v4" />
</>
),
'picture-frame': (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</>
),
xframe: (
<>
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M2 8h20M6 5.5h.01M9 5.5h.01" />
</>
),
proxmox: (
<>
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<path d="M6 6h.01M6 18h.01" />
</>
),
};
export function WidgetIcon({ type }: { type: string }) {
const paths = PATHS[type];
if (!paths) return null;
return (
<svg
aria-hidden="true"
data-widget-icon={type}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 text-muted-foreground"
>
{paths}
</svg>
);
}
@@ -2,6 +2,16 @@
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
import { WidgetIcon } from './widget-icon';
/**
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
* ein Foto braucht keine Ueberschrift.
*/
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
interface WidgetWrapperProps {
widget: {
@@ -47,7 +57,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && (
<div
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/80"
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center"
title={t('dragHint')}
data-testid="widget-drag-head"
>
@@ -105,7 +115,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="@container-size h-full">
<div className="flex h-full flex-col">
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div>
)}
<div className="@container-size min-h-0 flex-1">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
@@ -124,6 +141,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
</div>
)}
</div>
</div>
</article>
);
}
@@ -1,5 +1,6 @@
'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';
@@ -120,6 +121,7 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
// 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}