7e2592b2af
- 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>
220 lines
7.1 KiB
TypeScript
220 lines
7.1 KiB
TypeScript
'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>
|
|
);
|
|
}
|