2d8efe1a97
- widget-wrapper.tsx: Rumpf ist Groessen-Container (@container-size) fuer cqw/cqh - clock-font-size.ts (neu): Grenzen 8..200, resolveClockTimeFontSizePt nur fuer endliche Zahlen (T-BWO-01) - clock-widget.tsx: Container-Query-Klassen mit clamp(), data-font-mode auto/fixed, Inline-pt nur bei Zahl, keine vw-Formel mehr, p-1 - stopwatch-widget.tsx: Anzeige per Container-Query, p-1.5; calculator-widget.tsx: Anzeige und Tasten skalieren, Tasten h-full min-h-7, p-1 - widget-settings-panel.tsx: Zahlenfeld Schriftgroesse der Uhrzeit (Blur/Enter, leer = automatisch, Fehlermeldung ausserhalb 8..200) - de.json/en.json: widgets.clock.fontSizeLabel/fontSizeAuto/fontSizeHint/fontSizeInvalid (Sie-Form, Waechter gruen) - Tests: Uhr +4, Stoppuhr +1, Rechner +1, widget-settings-panel.test.tsx neu (4) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
345 lines
11 KiB
TypeScript
345 lines
11 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';
|
|
import {
|
|
CLOCK_FONT_SIZE_MAX_PT,
|
|
CLOCK_FONT_SIZE_MIN_PT,
|
|
resolveClockTimeFontSizePt,
|
|
} from '@/components/dashboard/widgets/clock-font-size';
|
|
|
|
/**
|
|
* 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 > Dashboard > 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;
|
|
|
|
// quick-260916-bwo: feste Schriftgroesse der Uhrzeit in Punkt (leer = automatisch).
|
|
// Entwurf als Text im Feld, Uebernahme bei Blur oder Enter; Grenzen aus
|
|
// clock-font-size.ts — dieselben, die das Widget beim Rendern anwendet (T-BWO-01).
|
|
const [fontSizeDraft, setFontSizeDraft] = useState(() => {
|
|
const pt = resolveClockTimeFontSizePt(config);
|
|
return pt === null ? '' : String(pt);
|
|
});
|
|
const [fontSizeError, setFontSizeError] = useState(false);
|
|
|
|
const commitFontSize = () => {
|
|
const raw = fontSizeDraft.trim();
|
|
if (raw === '') {
|
|
setFontSizeError(false);
|
|
if (resolveClockTimeFontSizePt(config) !== null) {
|
|
onChange({ timeFontSizePt: null });
|
|
}
|
|
return;
|
|
}
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n) || n < CLOCK_FONT_SIZE_MIN_PT || n > CLOCK_FONT_SIZE_MAX_PT) {
|
|
setFontSizeError(true);
|
|
return;
|
|
}
|
|
setFontSizeError(false);
|
|
if (n !== resolveClockTimeFontSizePt(config)) {
|
|
onChange({ timeFontSizePt: n });
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Schriftgroesse der Uhrzeit in Punkt (quick-260916-bwo) */}
|
|
<div>
|
|
<label htmlFor="clock-font-size" className="mb-1 block text-sm text-foreground">
|
|
{t('clock.fontSizeLabel')}
|
|
</label>
|
|
<input
|
|
id="clock-font-size"
|
|
type="number"
|
|
inputMode="decimal"
|
|
min={CLOCK_FONT_SIZE_MIN_PT}
|
|
max={CLOCK_FONT_SIZE_MAX_PT}
|
|
step={1}
|
|
placeholder={t('clock.fontSizeAuto')}
|
|
value={fontSizeDraft}
|
|
onChange={(e) => setFontSizeDraft(e.target.value)}
|
|
onBlur={commitFontSize}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
commitFontSize();
|
|
}
|
|
}}
|
|
aria-invalid={fontSizeError || undefined}
|
|
aria-describedby="clock-font-size-hint"
|
|
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
|
|
/>
|
|
<p id="clock-font-size-hint" className="mt-1 text-xs text-muted-foreground">
|
|
{t('clock.fontSizeHint')}
|
|
</p>
|
|
{fontSizeError && (
|
|
<p role="alert" className="mt-1 text-xs text-destructive">
|
|
{t('clock.fontSizeInvalid')}
|
|
</p>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|