c9432a16ba
- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
'use client';
|
|
|
|
/* ════════════════════════════ SearchBar ════════════════════════════
|
|
*
|
|
* Reusable search bar met debounce, resultaten dropdown, en toetsenbordnavigatie.
|
|
* Gebruikt de client-side search index (search-index.ts) of een custom onSearch callback.
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Search, X, FileIcon, FolderIcon, Loader2 } from 'lucide-react';
|
|
import { searchIndex, type SearchResult } from '@/lib/search-index';
|
|
|
|
/* ════════════════════════════ Types ════════════════════════════ */
|
|
|
|
export interface SearchBarProps {
|
|
/** External search handler (optioneel — gebruikt search-index.ts anders) */
|
|
onSearch?: (query: string) => Promise<SearchResult[]> | SearchResult[];
|
|
/** Called when user selects a result */
|
|
onSelect: (result: SearchResult) => void;
|
|
/** Placeholder text */
|
|
placeholder?: string;
|
|
/** Auto-focus on mount */
|
|
autoFocus?: boolean;
|
|
/** Extra CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
/* ════════════════════════════ Component ════════════════════════════ */
|
|
|
|
export default function SearchBar({
|
|
onSearch,
|
|
onSelect,
|
|
placeholder = 'Search files, CIDs, and content…',
|
|
autoFocus = false,
|
|
className = '',
|
|
}: SearchBarProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
/* ── Search logic ── */
|
|
|
|
const doSearch = useCallback(async (q: string) => {
|
|
if (!q || q.trim().length < 2) {
|
|
setResults([]);
|
|
setShowResults(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const hits = onSearch
|
|
? await onSearch(q)
|
|
: searchIndex(q);
|
|
setResults(hits);
|
|
setShowResults(true);
|
|
setSelectedIdx(-1);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [onSearch]);
|
|
|
|
/* ── Debounced input ── */
|
|
|
|
function handleChange(value: string) {
|
|
setQuery(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => doSearch(value), 250);
|
|
}
|
|
|
|
/* ── Keyboard navigation ── */
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (!showResults || results.length === 0) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1));
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setSelectedIdx((prev) => Math.max(prev - 1, 0));
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (selectedIdx >= 0 && selectedIdx < results.length) {
|
|
handleSelect(results[selectedIdx]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
setShowResults(false);
|
|
inputRef.current?.blur();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* ── Select ── */
|
|
|
|
function handleSelect(result: SearchResult) {
|
|
setShowResults(false);
|
|
setQuery('');
|
|
onSelect(result);
|
|
}
|
|
|
|
/* ── Click outside ── */
|
|
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
setShowResults(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick);
|
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
}, []);
|
|
|
|
/* ── Cleanup ── */
|
|
|
|
useEffect(() => {
|
|
if (autoFocus) inputRef.current?.focus();
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
/* ════════════════════════════ Render ════════════════════════════ */
|
|
|
|
return (
|
|
<div ref={panelRef} className={`relative ${className}`}>
|
|
{/* Input */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => { if (results.length > 0) setShowResults(true); }}
|
|
placeholder={placeholder}
|
|
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
|
|
autoFocus={autoFocus}
|
|
role="combobox"
|
|
aria-expanded={showResults}
|
|
aria-haspopup="listbox"
|
|
aria-autocomplete="list"
|
|
aria-controls="search-results-listbox"
|
|
/>
|
|
{loading && (
|
|
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
|
|
)}
|
|
{!loading && query && (
|
|
<button
|
|
onClick={() => { setQuery(''); setResults([]); setShowResults(false); inputRef.current?.focus(); }}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
|
>
|
|
<X className="w-4 h-4 text-surface-500" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Live region for screen readers */}
|
|
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
|
{showResults ? `${results.length} resultaten` : ''}
|
|
</div>
|
|
|
|
{/* Results dropdown */}
|
|
{showResults && (
|
|
<div
|
|
id="search-results-listbox"
|
|
role="listbox"
|
|
className="absolute top-full left-0 right-0 mt-2 glass rounded-xl overflow-hidden shadow-xl z-50 max-h-72 overflow-y-auto animate-fade-in">
|
|
{results.length === 0 && !loading ? (
|
|
<div className="p-4 text-sm text-surface-500 text-center">No results found</div>
|
|
) : (
|
|
results.map((result, idx) => (
|
|
<button
|
|
key={result.entry.cid + result.matchField}
|
|
id={`search-result-${idx}`}
|
|
role="option"
|
|
aria-selected={idx === selectedIdx}
|
|
onClick={() => handleSelect(result)}
|
|
onMouseEnter={() => setSelectedIdx(idx)}
|
|
className={`w-full flex items-start gap-3 px-4 py-3 text-left text-sm transition-colors ${
|
|
idx === selectedIdx ? 'bg-surface-700/50' : 'hover:bg-surface-800/50'
|
|
}`}
|
|
>
|
|
{result.entry.type === 'dir'
|
|
? <FolderIcon className="w-4 h-4 text-accent-amber shrink-0 mt-0.5" />
|
|
: <FileIcon className="w-4 h-4 text-accent-blue shrink-0 mt-0.5" />
|
|
}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-surface-200 font-medium truncate">
|
|
{result.entry.name || result.entry.cid.slice(0, 20) + '…'}
|
|
</span>
|
|
{result.matchField === 'name' && (
|
|
<span className="text-[10px] text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full shrink-0">Name</span>
|
|
)}
|
|
{result.matchField === 'cid' && (
|
|
<span className="text-[10px] text-accent-cyan bg-accent-cyan/10 px-1.5 py-0.5 rounded-full shrink-0">CID</span>
|
|
)}
|
|
</div>
|
|
{result.entry.text && (
|
|
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">
|
|
{result.entry.text.slice(0, 120)}
|
|
</p>
|
|
)}
|
|
<p className="text-[11px] text-surface-600 font-mono mt-0.5">
|
|
{result.entry.cid.slice(0, 16)}…
|
|
</p>
|
|
</div>
|
|
<span className="text-[11px] text-surface-500 shrink-0">
|
|
{result.entry.size > 0
|
|
? result.entry.size < 1024
|
|
? `${result.entry.size} B`
|
|
: `${(result.entry.size / 1024).toFixed(1)} KB`
|
|
: ''}
|
|
</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|