Files
tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx
T
schalli 7e2592b2af feat(05-02): add widget settings panel with search provider form
- Settings > Dashboard page with per-widget-instance config
- Clock config: timezone select (IANA list) + date toggle (D-12/D-13)
- Note config: editable title field (D-17)
- Search config: SearchProviderForm with add/delete and {query} validation (D-15)
- Calendar config: link to calendar-specific settings
- i18n keys for search provider management (en + de)
- Fix useRef initialization for React 19 strict mode (note-widget)
- Fix unknown type narrowing in widget title display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:58:42 +02:00

275 lines
8.8 KiB
TypeScript

'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
import { SearchProviderForm } from '@/components/settings/search-provider-form';
import { updateWidgetConfig } from '@/lib/dashboard-api';
/**
* IANA timezones offered for clock configuration (D-12).
* Covers the minimum set specified in the plan plus common ones.
*/
const TIMEZONE_OPTIONS = [
'UTC',
'Europe/Berlin',
'Europe/London',
'Europe/Paris',
'Europe/Zurich',
'America/New_York',
'America/Chicago',
'America/Los_Angeles',
'Asia/Tokyo',
'Asia/Shanghai',
'Australia/Sydney',
];
interface WidgetSettingsPanelProps {
widgets: Array<{
id: string;
widgetType: string;
config: Record<string, unknown>;
}>;
onWidgetUpdate: (id: string, config: Record<string, unknown>) => void;
}
/**
* Widget Settings Panel (D-03, D-12, D-13, D-15, D-17).
* Lists all placed widget instances grouped by type.
* Each instance is expandable to its type-specific config form.
*/
export function WidgetSettingsPanel({
widgets,
onWidgetUpdate,
}: WidgetSettingsPanelProps) {
const t = useTranslations('widgets');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const handleConfigChange = useCallback(
async (id: string, partialConfig: Record<string, unknown>) => {
setSaving(id);
try {
await updateWidgetConfig(id, partialConfig);
onWidgetUpdate(id, partialConfig);
} catch {
// Silent fail — config will revert on next load
} finally {
setSaving(null);
}
},
[onWidgetUpdate],
);
// Group widgets by type for display
const groupedByType = widgets.reduce(
(acc, w) => {
const type = w.widgetType as WidgetType;
if (!acc[type]) acc[type] = [];
acc[type].push(w);
return acc;
},
{} as Record<WidgetType, typeof widgets>,
);
return (
<div className="space-y-6">
{Object.entries(groupedByType).map(([type, instances]) => {
const def = WIDGET_REGISTRY[type as WidgetType];
if (!def) return null;
return (
<div key={type}>
{/* Type group heading */}
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<def.icon className="h-4 w-4 text-muted-foreground" />
{t(def.nameKey)}
</h2>
<div className="space-y-2">
{instances.map((widget, idx) => {
const isExpanded = expandedId === widget.id;
const isSaving = saving === widget.id;
return (
<div
key={widget.id}
className="rounded-md border border-border bg-card"
>
{/* Instance header (click to expand) */}
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-foreground hover:bg-muted/50 transition-colors"
onClick={() =>
setExpandedId(isExpanded ? null : widget.id)
}
>
<span>
{t(def.nameKey)} #{idx + 1}
{widget.widgetType === 'note' &&
typeof widget.config.title === 'string' &&
widget.config.title !== '' && (
<span className="ml-2 text-muted-foreground">
— {widget.config.title}
</span>
)}
</span>
<svg
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"
className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{/* Expanded config form */}
{isExpanded && (
<div className="border-t border-border px-4 py-4">
{isSaving && (
<p className="mb-2 text-xs text-muted-foreground">
Saving...
</p>
)}
{/* Clock config (D-12, D-13) */}
{widget.widgetType === 'clock' && (
<ClockConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
/>
)}
{/* Search config (D-15) */}
{widget.widgetType === 'search' && (
<SearchProviderForm />
)}
{/* Note config (D-17) */}
{widget.widgetType === 'note' && (
<NoteConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
/>
)}
{/* Calendar config — managed elsewhere */}
{widget.widgetType === 'calendar' && (
<div className="text-sm text-muted-foreground">
<p>
Calendar sources are managed under{' '}
<Link
href="/settings/dashboard/calendar"
className="text-primary underline hover:text-primary/90"
>
Settings &gt; Dashboard &gt; Calendar
</Link>
.
</p>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// --- Type-specific config forms ---
function ClockConfig({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (cfg: Record<string, unknown>) => void;
}) {
const t = useTranslations('widgets');
const timezone = (config.timezone as string) || 'Europe/Berlin';
const showDate = config.showDate === true;
return (
<div className="space-y-4">
{/* Timezone select (D-12) */}
<div>
<label
htmlFor="timezone-select"
className="mb-1 block text-sm text-foreground"
>
Timezone
</label>
<select
id="timezone-select"
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
value={timezone}
onChange={(e) => onChange({ timezone: e.target.value })}
>
{TIMEZONE_OPTIONS.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
{/* Show date toggle (D-13) */}
<div className="flex items-center gap-3">
<input
id="show-date-toggle"
type="checkbox"
className="h-4 w-4 rounded border-border text-primary"
checked={showDate}
onChange={(e) => onChange({ showDate: e.target.checked })}
/>
<label htmlFor="show-date-toggle" className="text-sm text-foreground">
{t('clock.dateHint')}
</label>
</div>
</div>
);
}
function NoteConfig({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (cfg: Record<string, unknown>) => void;
}) {
const t = useTranslations('widgets');
const title = (config.title as string) || t('note.defaultTitle');
return (
<div>
<label htmlFor="note-title" className="mb-1 block text-sm text-foreground">
Title
</label>
<input
id="note-title"
type="text"
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
value={title}
onChange={(e) => onChange({ title: e.target.value })}
/>
</div>
);
}