Files
tessera-ctl/apps/web/src/components/dashboard/widgets/search-widget.tsx
T
schalli a175c00c18 feat(quick-260916-bwo): Abstaende halbiert — Seitenrahmen p-3 (alle Seiten), Dashboard p-2, Grid 8 px, Widget-Innenabstaende
- app-shell.tsx: main p-3 (12 px statt 24) — gilt fuer alle Seiten, Nachtrag des Users
- page.tsx: Container relative p-2, Umschalter right-2 top-2; mt-8 bleibt (Umschalter 36 px hoch, sonst Ueberlappung)
- link/favorites p-1, calendar p-1.5 (Zustandsansichten p-2), search px-1.5, note-Kopfzeile px-1.5
- Grid-Abstand 8 px bereits in Task 1, Uhr/Stoppuhr/Rechner-Innenabstaende bereits in 2a

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 09:13:23 +02:00

138 lines
4.3 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { WidgetProps } from '../widget-registry';
import { fetchSearchProviders, updateWidgetConfig } from '@/lib/dashboard-api';
/**
* Default hardcoded search providers (D-15).
* Used as fallback if the API fetch fails so the widget always works.
*/
const DEFAULT_PROVIDERS = [
{ id: 'google', name: 'Google', urlTemplate: 'https://www.google.com/search?q={query}', isDefault: true },
{ id: 'bing', name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={query}', isDefault: true },
{ id: 'ddg', name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={query}', isDefault: true },
];
export interface SearchProvider {
id: string;
name: string;
urlTemplate: string;
isDefault: boolean;
}
/**
* Search widget (DASH-04, D-14/D-15).
* Horizontal layout: provider dropdown left, text input center, search button right.
* Opens web search in a new tab via window.open with noopener,noreferrer.
*/
export function SearchWidget({ instanceId, config }: WidgetProps) {
const t = useTranslations('widgets');
const [providers, setProviders] = useState<SearchProvider[]>(DEFAULT_PROVIDERS);
const [selectedProviderId, setSelectedProviderId] = useState<string>(
(config.providerId as string) || 'google',
);
const [query, setQuery] = useState('');
// Fetch providers from API, fallback to defaults on error
useEffect(() => {
let cancelled = false;
fetchSearchProviders()
.then((data) => {
if (!cancelled && data.length > 0) {
setProviders(data);
}
})
.catch(() => {
// Keep default providers — widget always works
});
return () => {
cancelled = true;
};
}, []);
// Persist selected provider to widget config
const handleProviderChange = useCallback(
(providerId: string) => {
setSelectedProviderId(providerId);
updateWidgetConfig(instanceId, { providerId }).catch(() => {
// Silently fail — provider selection is non-critical
});
},
[instanceId],
);
const handleSearch = useCallback(() => {
if (!query.trim()) return;
const provider = providers.find((p) => p.id === selectedProviderId) ?? providers[0];
if (!provider) return;
const url = provider.urlTemplate.replace(
'{query}',
encodeURIComponent(query.trim()),
);
window.open(url, '_blank', 'noopener,noreferrer');
}, [query, selectedProviderId, providers]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch],
);
return (
<div className="flex h-full items-center gap-2 px-1.5">
{/* Provider dropdown (D-14: ~120px left) */}
<select
role="combobox"
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
value={selectedProviderId}
onChange={(e) => handleProviderChange(e.target.value)}
>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{/* Search input (flex-1 center) */}
<input
type="text"
className="h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder={t('search.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
{/* Search button (right) */}
<button
type="button"
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={handleSearch}
>
<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"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
</div>
);
}