Files
tessera-ctl/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
T
schalli 12eea333ba
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m24s
feat(web): Widget-Titel je Widget ausblenden, Seitenleiste gegliedert
Neuer Schalter im Bearbeitungsmodus (neben dem Griff) setzt hideTitle in
der Widget-Konfiguration (setWidgetConfig im Dashboard-Store, optimistisch
mit Ruecksetzen); in der Ansicht entfaellt die Titelzeile per
data-hide-title, Aktionen der Titelzeile bleiben oben rechts. Kategorien der
Seitenleiste 14 px, Moduleintraege 13 px / 32 px hoch. Version 1.5.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:26 +02:00

198 lines
8.3 KiB
TypeScript

'use client';
import { useTranslations } from 'next-intl';
import type { CSSProperties } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
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']);
/**
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
* Schalter „Titel ausblenden“ (quick-260928-wtt). Die Wahl steht als
* `hideTitle: true` in der Widget-Konfiguration und gilt nur in der Ansicht;
* im Bearbeitungsmodus bleibt der Titel sichtbar (und editierbar).
*/
const TITLE_TYPES = new Set([...FRAME_HEADER_TYPES, 'note', 'favorites', 'proxmox', 'xframe']);
interface WidgetWrapperProps {
widget: {
id: string;
widgetType: string;
config: Record<string, unknown>;
};
isEditMode: boolean;
onRemove: (id: string) => void;
/** Position im Raster — staffelt das Einblenden beim Laden (Runde 3). */
enterIndex?: number;
}
/**
* Wrapper card for all dashboard widgets.
*
* quick-260916-dyv: Im Bearbeitungsmodus ist die GANZE Karte der Griff
* (`widget-drag-handle`, `cursor-grab`). Eine 20 px hohe Kopfleiste mit
* Griff-Symbol liegt als Overlay ueber dem oberen Kachelrand — nur optischer
* Hinweis (Tooltip `widgets.dragHint`), kein eigener Griff. Der Loesch-Knopf
* sitzt rechts in dieser Kopfleiste und traegt `data-no-drag`; welche Ziele
* KEIN Ziehen starten (Eingabefelder, Knoepfe, Links, [data-no-drag],
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
const setWidgetConfig = useDashboardStore((state) => state.setWidgetConfig);
const hasTitle = TITLE_TYPES.has(widget.widgetType);
const titleHidden = widget.config?.hideTitle === true;
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
const cardClassName = isEditMode
? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
: 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
return (
<article
className={cardClassName}
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
data-widget-id={widget.id}
data-hide-title={!isEditMode && hasTitle && titleHidden ? '' : undefined}
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
Bewusst `absolute` und NICHT im Fluss: der Rumpf bleibt h-full, die
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"
title={t('dragHint')}
data-testid="widget-drag-head"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
className="text-muted-foreground"
aria-hidden="true"
>
<circle cx="5" cy="9" r="1.5" />
<circle cx="12" cy="9" r="1.5" />
<circle cx="19" cy="9" r="1.5" />
<circle cx="5" cy="15" r="1.5" />
<circle cx="12" cy="15" r="1.5" />
<circle cx="19" cy="15" r="1.5" />
</svg>
{hasTitle && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
title={titleHidden ? t('showTitle') : t('hideTitle')}
aria-pressed={titleHidden}
data-no-drag=""
>
{/* „T“ mit Querstrich, solange der Titel ausgeblendet ist */}
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
{titleHidden && <line x1="3" y1="3" x2="21" y2="21" />}
</svg>
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(widget.id);
}}
className="absolute right-0.5 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t('deleteTooltip')}
title={t('deleteTooltip')}
data-no-drag=""
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
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>
</div>
)}
{/* Widget body.
quick-260916-bwo: Groessen-Container (@container-size -> container-type: size),
damit die Widget-Inhalte per cqw/cqh mit der Kachel skalieren. Braucht eine
definite Hoehe — die kommt ueber h-full aus der Karte, die das RGL-Element
mit Pixelhoehe fuellt. Steht am Rumpf statt an der Karte, weil die Karte
im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<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}
config={widget.config}
isEditMode={isEditMode}
/>
) : (
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">
{t('unavailable')}
</p>
</div>
)}
</div>
</div>
</article>
);
}