Files
tessera-ctl/apps/web/src/components/settings/search-provider-form.tsx
T
schalli 7e2592b2af 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>
2026-06-24 14:58:42 +02:00

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>
);
}