8792819e34
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
import type { ComponentType } from 'react';
|
|
|
|
/**
|
|
* Supported widget types for the dashboard.
|
|
* clock/search/calendar/note: implemented in previous plans.
|
|
* calculator/favorites/link/stopwatch: Phase 8 additions.
|
|
*/
|
|
export type WidgetType =
|
|
| 'clock'
|
|
| 'search'
|
|
| 'calendar'
|
|
| 'note'
|
|
| 'calculator'
|
|
| 'favorites'
|
|
| 'link'
|
|
| 'stopwatch';
|
|
|
|
/**
|
|
* Props contract that every widget component must accept.
|
|
*/
|
|
export interface WidgetProps {
|
|
instanceId: string;
|
|
config: Record<string, unknown>;
|
|
isEditMode: boolean;
|
|
}
|
|
|
|
/**
|
|
* Per-type size constraints for the react-grid-layout grid.
|
|
* Values from UI-SPEC size table (D-06) and Phase-8 Grid-Constraints (D-01).
|
|
*/
|
|
export const WIDGET_CONSTRAINTS: Record<
|
|
WidgetType,
|
|
{ minW: number; minH: number; defaultW: number; defaultH: number }
|
|
> = {
|
|
// quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im
|
|
// 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 +
|
|
// Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 =
|
|
// 268 px -> 10 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH =
|
|
// altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in
|
|
// dashboard-grid.tsx aus dieser Tabelle ueberschrieben.
|
|
// Existing widgets
|
|
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
|
|
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
|
calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
|
|
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
|
// Phase 8 new widgets (D-01)
|
|
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
|
|
// SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=),
|
|
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
|
|
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
|
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
|
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
|
|
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
|
};
|
|
|
|
/**
|
|
* Definition for a widget type in the catalog.
|
|
*/
|
|
export interface WidgetDefinition {
|
|
type: WidgetType;
|
|
nameKey: string;
|
|
descriptionKey: string;
|
|
/** Inline SVG icon as React component */
|
|
icon: ComponentType<{ className?: string }>;
|
|
minW: number;
|
|
minH: number;
|
|
defaultW: number;
|
|
defaultH: number;
|
|
/** Lazy-loaded widget component */
|
|
component: ComponentType<WidgetProps>;
|
|
}
|
|
|
|
/**
|
|
* Placeholder widget for types not yet implemented.
|
|
* Renders the widget type name as a centered label.
|
|
*/
|
|
function PlaceholderWidget({ config }: WidgetProps) {
|
|
const label = (config as Record<string, string>).__widgetType ?? 'Widget';
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Icon components (inline SVG, established project pattern) ---
|
|
|
|
function ClockIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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}
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SearchIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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}
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CalendarIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function NoteIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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}
|
|
>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<polyline points="10 9 9 9 8 9" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CalculatorIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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="4" y="2" width="16" height="20" rx="2" />
|
|
<line x1="8" y1="6" x2="16" y2="6" />
|
|
<line x1="8" y1="10" x2="8" y2="10" />
|
|
<line x1="12" y1="10" x2="12" y2="10" />
|
|
<line x1="16" y1="10" x2="16" y2="10" />
|
|
<line x1="8" y1="14" x2="8" y2="14" />
|
|
<line x1="12" y1="14" x2="12" y2="14" />
|
|
<line x1="16" y1="14" x2="16" y2="18" />
|
|
<line x1="8" y1="18" x2="8" y2="18" />
|
|
<line x1="12" y1="18" x2="12" y2="18" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FavoritesIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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}
|
|
>
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LinkIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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}
|
|
>
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function StopwatchIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
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}
|
|
>
|
|
<circle cx="12" cy="13" r="8" />
|
|
<polyline points="12 9 12 13 14 15" />
|
|
<line x1="9" y1="2" x2="15" y2="2" />
|
|
<line x1="12" y1="2" x2="12" y2="4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Registry of all widget types. Used by the widget catalog modal
|
|
* and the grid renderer to look up components and constraints.
|
|
*/
|
|
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|
clock: {
|
|
type: 'clock',
|
|
nameKey: 'clock.name',
|
|
descriptionKey: 'clock.description',
|
|
icon: ClockIcon,
|
|
...WIDGET_CONSTRAINTS.clock,
|
|
component: PlaceholderWidget, // Replaced via wireClockWidget()
|
|
},
|
|
search: {
|
|
type: 'search',
|
|
nameKey: 'search.name',
|
|
descriptionKey: 'search.description',
|
|
icon: SearchIcon,
|
|
...WIDGET_CONSTRAINTS.search,
|
|
component: PlaceholderWidget, // Replaced via wireSearchWidget()
|
|
},
|
|
calendar: {
|
|
type: 'calendar',
|
|
nameKey: 'calendar.name',
|
|
descriptionKey: 'calendar.description',
|
|
icon: CalendarIcon,
|
|
...WIDGET_CONSTRAINTS.calendar,
|
|
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
|
|
},
|
|
note: {
|
|
type: 'note',
|
|
nameKey: 'note.name',
|
|
descriptionKey: 'note.description',
|
|
icon: NoteIcon,
|
|
...WIDGET_CONSTRAINTS.note,
|
|
component: PlaceholderWidget, // Replaced via wireNoteWidget()
|
|
},
|
|
calculator: {
|
|
type: 'calculator',
|
|
nameKey: 'calculator.name',
|
|
descriptionKey: 'calculator.description',
|
|
icon: CalculatorIcon,
|
|
...WIDGET_CONSTRAINTS.calculator,
|
|
component: PlaceholderWidget, // Replaced via wireCalculatorWidget()
|
|
},
|
|
favorites: {
|
|
type: 'favorites',
|
|
nameKey: 'favorites.name',
|
|
descriptionKey: 'favorites.description',
|
|
icon: FavoritesIcon,
|
|
...WIDGET_CONSTRAINTS.favorites,
|
|
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
|
|
},
|
|
link: {
|
|
type: 'link',
|
|
nameKey: 'link.name',
|
|
descriptionKey: 'link.description',
|
|
icon: LinkIcon,
|
|
...WIDGET_CONSTRAINTS.link,
|
|
component: PlaceholderWidget, // Replaced via wireLinkWidget()
|
|
},
|
|
stopwatch: {
|
|
type: 'stopwatch',
|
|
nameKey: 'stopwatch.name',
|
|
descriptionKey: 'stopwatch.description',
|
|
icon: StopwatchIcon,
|
|
...WIDGET_CONSTRAINTS.stopwatch,
|
|
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
|
|
},
|
|
};
|
|
|
|
// Wire actual widget components lazily to avoid circular deps
|
|
// (imports are deferred so widget-registry can be imported by tests without
|
|
// pulling in the entire React tree)
|
|
|
|
let clockWired = false;
|
|
export function wireClockWidget(component: ComponentType<WidgetProps>) {
|
|
if (!clockWired) {
|
|
WIDGET_REGISTRY.clock.component = component;
|
|
clockWired = true;
|
|
}
|
|
}
|
|
|
|
let searchWired = false;
|
|
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
|
|
if (!searchWired) {
|
|
WIDGET_REGISTRY.search.component = component;
|
|
searchWired = true;
|
|
}
|
|
}
|
|
|
|
let calendarWired = false;
|
|
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
|
|
if (!calendarWired) {
|
|
WIDGET_REGISTRY.calendar.component = component;
|
|
calendarWired = true;
|
|
}
|
|
}
|
|
|
|
let noteWired = false;
|
|
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
|
|
if (!noteWired) {
|
|
WIDGET_REGISTRY.note.component = component;
|
|
noteWired = true;
|
|
}
|
|
}
|
|
|
|
let calculatorWired = false;
|
|
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
|
|
if (!calculatorWired) {
|
|
WIDGET_REGISTRY.calculator.component = component;
|
|
calculatorWired = true;
|
|
}
|
|
}
|
|
|
|
let favoritesWired = false;
|
|
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
|
|
if (!favoritesWired) {
|
|
WIDGET_REGISTRY.favorites.component = component;
|
|
favoritesWired = true;
|
|
}
|
|
}
|
|
|
|
let linkWired = false;
|
|
export function wireLinkWidget(component: ComponentType<WidgetProps>) {
|
|
if (!linkWired) {
|
|
WIDGET_REGISTRY.link.component = component;
|
|
linkWired = true;
|
|
}
|
|
}
|
|
|
|
let stopwatchWired = false;
|
|
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
|
|
if (!stopwatchWired) {
|
|
WIDGET_REGISTRY.stopwatch.component = component;
|
|
stopwatchWired = true;
|
|
}
|
|
}
|