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>
This commit is contained in:
@@ -41,8 +41,8 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const abortRef = useRef<AbortController>();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const abortRef = useRef<AbortController | undefined>(undefined);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
type SearchProviderResponse,
|
||||
addSearchProvider,
|
||||
fetchSearchProviders,
|
||||
removeSearchProvider,
|
||||
} from '@/lib/dashboard-api';
|
||||
|
||||
/**
|
||||
* SearchProviderForm (D-15).
|
||||
* Lists current search providers (defaults read-only, custom deletable)
|
||||
* plus an add form for creating custom providers with client-side
|
||||
* validation that urlTemplate contains {query}.
|
||||
*/
|
||||
export function SearchProviderForm() {
|
||||
const t = useTranslations('widgets');
|
||||
const [providers, setProviders] = useState<SearchProviderResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Add form state
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newUrlTemplate, setNewUrlTemplate] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loadProviders = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSearchProviders();
|
||||
setProviders(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Failed to load search providers');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
}, [loadProviders]);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
// Client-side validation: urlTemplate must contain {query}
|
||||
if (!newName.trim()) {
|
||||
setValidationError('Name is required');
|
||||
return;
|
||||
}
|
||||
if (!newUrlTemplate.includes('{query}')) {
|
||||
setValidationError('URL template must contain {query}');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const created = await addSearchProvider({
|
||||
name: newName.trim(),
|
||||
urlTemplate: newUrlTemplate.trim(),
|
||||
});
|
||||
setProviders((prev) => [...prev, created]);
|
||||
setNewName('');
|
||||
setNewUrlTemplate('');
|
||||
} catch {
|
||||
setValidationError('Failed to add provider');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [newName, newUrlTemplate]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await removeSearchProvider(id);
|
||||
setProviders((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch {
|
||||
// Silent fail — provider stays in list
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Existing providers list */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t('search.providers')}
|
||||
</h3>
|
||||
|
||||
{providers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No providers found.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{providers.map((provider) => (
|
||||
<li
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between rounded border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium text-foreground">
|
||||
{provider.name}
|
||||
</span>
|
||||
{provider.isDefault && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Default)
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{provider.urlTemplate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Only custom (non-default) providers can be deleted */}
|
||||
{!provider.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
className="ml-3 shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
aria-label={`Remove ${provider.name}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new provider form */}
|
||||
<div className="space-y-3 rounded border border-border bg-muted/30 p-3">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t('search.addProvider')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-name"
|
||||
className="mb-1 block text-xs text-muted-foreground"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="provider-name"
|
||||
type="text"
|
||||
placeholder="e.g. GitHub"
|
||||
className="h-8 w-full rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-url"
|
||||
className="mb-1 block text-xs text-muted-foreground"
|
||||
>
|
||||
URL Template
|
||||
</label>
|
||||
<input
|
||||
id="provider-url"
|
||||
type="text"
|
||||
placeholder="https://example.com/search?q={query}"
|
||||
className="h-8 w-full rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
value={newUrlTemplate}
|
||||
onChange={(e) => {
|
||||
setNewUrlTemplate(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Must contain <code className="rounded bg-muted px-1">{'{query}'}</code> as
|
||||
placeholder for the search term.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<p className="text-xs text-destructive">{validationError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || !newName.trim() || !newUrlTemplate.trim()}
|
||||
className="h-8 rounded bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : t('search.addProviderButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
'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 > 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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user