diff --git a/apps/web/src/app/(portal)/settings/dashboard/page.tsx b/apps/web/src/app/(portal)/settings/dashboard/page.tsx new file mode 100644 index 0000000..876dff8 --- /dev/null +++ b/apps/web/src/app/(portal)/settings/dashboard/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel'; +import { fetchWidgets } from '@/lib/dashboard-api'; + +/** + * Settings > Dashboard > Widgets page (D-03). + * Fetches user's widget instances and renders per-instance config forms. + */ +export default function WidgetSettingsPage() { + const t = useTranslations('settings'); + const [widgets, setWidgets] = useState< + Array<{ id: string; widgetType: string; config: Record }> + >([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchWidgets() + .then(setWidgets) + .catch(() => { + // Silent fail — empty widget list shown + }) + .finally(() => setIsLoading(false)); + }, []); + + return ( +
+

+ {t('categoryWidgets')} +

+ + {isLoading ? ( +

Loading...

+ ) : widgets.length === 0 ? ( +

+ No widgets placed on dashboard yet. +

+ ) : ( + { + setWidgets((prev) => + prev.map((w) => + w.id === id ? { ...w, config: { ...w.config, ...config } } : w, + ), + ); + }} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/widgets/note-widget.tsx b/apps/web/src/components/dashboard/widgets/note-widget.tsx index 96ef43b..6680ab7 100644 --- a/apps/web/src/components/dashboard/widgets/note-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/note-widget.tsx @@ -41,8 +41,8 @@ export function NoteWidget({ instanceId, config }: WidgetProps) { ); const [saveError, setSaveError] = useState(false); - const timerRef = useRef>(); - const abortRef = useRef(); + const timerRef = useRef | undefined>(undefined); + const abortRef = useRef(undefined); // Cleanup on unmount useEffect(() => { diff --git a/apps/web/src/components/settings/search-provider-form.tsx b/apps/web/src/components/settings/search-provider-form.tsx new file mode 100644 index 0000000..f3d602f --- /dev/null +++ b/apps/web/src/components/settings/search-provider-form.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Add form state + const [newName, setNewName] = useState(''); + const [newUrlTemplate, setNewUrlTemplate] = useState(''); + const [validationError, setValidationError] = useState(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 ( +

Loading providers...

+ ); + } + + if (error) { + return

{error}

; + } + + return ( +
+ {/* Existing providers list */} +
+

+ {t('search.providers')} +

+ + {providers.length === 0 ? ( +

No providers found.

+ ) : ( +
    + {providers.map((provider) => ( +
  • +
    + + {provider.name} + + {provider.isDefault && ( + + (Default) + + )} +

    + {provider.urlTemplate} +

    +
    + + {/* Only custom (non-default) providers can be deleted */} + {!provider.isDefault && ( + + )} +
  • + ))} +
+ )} +
+ + {/* Add new provider form */} +
+

+ {t('search.addProvider')} +

+ +
+ + { + setNewName(e.target.value); + setValidationError(null); + }} + /> +
+ +
+ + { + setNewUrlTemplate(e.target.value); + setValidationError(null); + }} + /> +

+ Must contain {'{query}'} as + placeholder for the search term. +

+
+ + {validationError && ( +

{validationError}

+ )} + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/widget-settings-panel.tsx b/apps/web/src/components/settings/widget-settings-panel.tsx new file mode 100644 index 0000000..168cc8a --- /dev/null +++ b/apps/web/src/components/settings/widget-settings-panel.tsx @@ -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; + }>; + onWidgetUpdate: (id: string, config: Record) => 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(null); + const [saving, setSaving] = useState(null); + + const handleConfigChange = useCallback( + async (id: string, partialConfig: Record) => { + 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, + ); + + return ( +
+ {Object.entries(groupedByType).map(([type, instances]) => { + const def = WIDGET_REGISTRY[type as WidgetType]; + if (!def) return null; + + return ( +
+ {/* Type group heading */} +

+ + {t(def.nameKey)} +

+ +
+ {instances.map((widget, idx) => { + const isExpanded = expandedId === widget.id; + const isSaving = saving === widget.id; + + return ( +
+ {/* Instance header (click to expand) */} + + + {/* Expanded config form */} + {isExpanded && ( +
+ {isSaving && ( +

+ Saving... +

+ )} + + {/* Clock config (D-12, D-13) */} + {widget.widgetType === 'clock' && ( + + handleConfigChange(widget.id, cfg) + } + /> + )} + + {/* Search config (D-15) */} + {widget.widgetType === 'search' && ( + + )} + + {/* Note config (D-17) */} + {widget.widgetType === 'note' && ( + + handleConfigChange(widget.id, cfg) + } + /> + )} + + {/* Calendar config — managed elsewhere */} + {widget.widgetType === 'calendar' && ( +
+

+ Calendar sources are managed under{' '} + + Settings > Dashboard > Calendar + + . +

+
+ )} +
+ )} +
+ ); + })} +
+
+ ); + })} +
+ ); +} + +// --- Type-specific config forms --- + +function ClockConfig({ + config, + onChange, +}: { + config: Record; + onChange: (cfg: Record) => void; +}) { + const t = useTranslations('widgets'); + const timezone = (config.timezone as string) || 'Europe/Berlin'; + const showDate = config.showDate === true; + + return ( +
+ {/* Timezone select (D-12) */} +
+ + +
+ + {/* Show date toggle (D-13) */} +
+ onChange({ showDate: e.target.checked })} + /> + +
+
+ ); +} + +function NoteConfig({ + config, + onChange, +}: { + config: Record; + onChange: (cfg: Record) => void; +}) { + const t = useTranslations('widgets'); + const title = (config.title as string) || t('note.defaultTitle'); + + return ( +
+ + onChange({ title: e.target.value })} + /> +
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index dcd040a..615ec11 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -139,7 +139,10 @@ "note.name": "Notizen", "note.description": "Freitext-Notizen mit Markdown", "note.defaultTitle": "Notiz", - "note.autosaveError": "Speichern fehlgeschlagen" + "note.autosaveError": "Speichern fehlgeschlagen", + "search.providers": "Suchanbieter", + "search.addProvider": "Eigenen Suchanbieter hinzufuegen", + "search.addProviderButton": "Anbieter hinzufuegen" }, "admin": { "users": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index baecf91..5e9e697 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -139,7 +139,10 @@ "note.name": "Notes", "note.description": "Free-text notes with Markdown", "note.defaultTitle": "Note", - "note.autosaveError": "Save failed" + "note.autosaveError": "Save failed", + "search.providers": "Search Providers", + "search.addProvider": "Add Custom Provider", + "search.addProviderButton": "Add Provider" }, "admin": { "users": {