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:
@@ -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<string, unknown> }>
|
||||||
|
>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWidgets()
|
||||||
|
.then(setWidgets)
|
||||||
|
.catch(() => {
|
||||||
|
// Silent fail — empty widget list shown
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||||
|
{t('categoryWidgets')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : widgets.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No widgets placed on dashboard yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<WidgetSettingsPanel
|
||||||
|
widgets={widgets}
|
||||||
|
onWidgetUpdate={(id, config) => {
|
||||||
|
setWidgets((prev) =>
|
||||||
|
prev.map((w) =>
|
||||||
|
w.id === id ? { ...w, config: { ...w.config, ...config } } : w,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,8 +41,8 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
);
|
);
|
||||||
const [saveError, setSaveError] = useState(false);
|
const [saveError, setSaveError] = useState(false);
|
||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const abortRef = useRef<AbortController>();
|
const abortRef = useRef<AbortController | undefined>(undefined);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -139,7 +139,10 @@
|
|||||||
"note.name": "Notizen",
|
"note.name": "Notizen",
|
||||||
"note.description": "Freitext-Notizen mit Markdown",
|
"note.description": "Freitext-Notizen mit Markdown",
|
||||||
"note.defaultTitle": "Notiz",
|
"note.defaultTitle": "Notiz",
|
||||||
"note.autosaveError": "Speichern fehlgeschlagen"
|
"note.autosaveError": "Speichern fehlgeschlagen",
|
||||||
|
"search.providers": "Suchanbieter",
|
||||||
|
"search.addProvider": "Eigenen Suchanbieter hinzufuegen",
|
||||||
|
"search.addProviderButton": "Anbieter hinzufuegen"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"users": {
|
"users": {
|
||||||
|
|||||||
@@ -139,7 +139,10 @@
|
|||||||
"note.name": "Notes",
|
"note.name": "Notes",
|
||||||
"note.description": "Free-text notes with Markdown",
|
"note.description": "Free-text notes with Markdown",
|
||||||
"note.defaultTitle": "Note",
|
"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": {
|
"admin": {
|
||||||
"users": {
|
"users": {
|
||||||
|
|||||||
Reference in New Issue
Block a user