story-research-zapwall/components/SearchSuggestions.tsx
2026-01-15 11:31:09 +01:00

212 lines
6.8 KiB
TypeScript

import { useEffect, useState, useRef } from 'react'
import type { Article } from '@/types/nostr'
import { objectCache } from '@/lib/objectCache'
import { getSearchHistory } from '@/lib/searchHistory'
import { Card, Button } from './ui'
import { t } from '@/lib/i18n'
interface SearchSuggestionsProps {
query: string
onSelect: (query: string) => void
onClose: () => void
}
interface SuggestionItem {
type: 'article' | 'author' | 'history'
title: string
query: string
subtitle?: string
timestamp?: number
}
const loadHistorySuggestions = async (): Promise<SuggestionItem[]> => {
try {
const history = await getSearchHistory()
return history.slice(0, 5).map((item) => ({
type: 'history' as const,
title: item.query,
query: item.query,
timestamp: item.timestamp,
}))
} catch (error) {
console.error('Error loading search history:', error)
return []
}
}
const loadSuggestions = async (searchQuery: string): Promise<SuggestionItem[]> => {
try {
const allArticles = (await objectCache.getAll('publication')) as Article[]
const allAuthors = (await objectCache.getAll('author')) as Article[]
const queryLower = searchQuery.toLowerCase()
const articleSuggestions: SuggestionItem[] = allArticles
.filter((article) => article.title.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((article) => ({
type: 'article' as const,
title: article.title,
query: article.title,
subtitle: article.preview?.substring(0, 60),
}))
const authorSuggestions: SuggestionItem[] = allAuthors
.filter((author) => {
const name = extractAuthorName(author)
return name.toLowerCase().includes(queryLower)
})
.slice(0, 3)
.map((author) => ({
type: 'author' as const,
title: extractAuthorName(author),
query: extractAuthorName(author),
subtitle: author.description?.substring(0, 60),
}))
return [...articleSuggestions, ...authorSuggestions].slice(0, 8)
} catch (error) {
console.error('Error loading suggestions:', error)
return []
}
}
function useSuggestionsLoader(query: string): { suggestions: SuggestionItem[]; loading: boolean } {
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const updateSuggestions = async (): Promise<void> => {
if (!query.trim()) {
const historySuggestions = await loadHistorySuggestions()
setSuggestions(historySuggestions)
return
}
setLoading(true)
try {
const loadedSuggestions = await loadSuggestions(query)
setSuggestions(loadedSuggestions)
} finally {
setLoading(false)
}
}
void updateSuggestions()
}, [query])
return { suggestions, loading }
}
function useClickOutsideHandler(containerRef: React.RefObject<HTMLDivElement | null>, onClose: () => void): void {
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [containerRef, onClose])
}
export function SearchSuggestions({ query, onSelect, onClose }: SearchSuggestionsProps): React.ReactElement | null {
const containerRef = useRef<HTMLDivElement>(null)
const { suggestions, loading } = useSuggestionsLoader(query)
useClickOutsideHandler(containerRef, onClose)
if (suggestions.length === 0 && !loading) {
return null
}
return (
<div ref={containerRef} className="absolute top-full left-0 right-0 mt-1 z-50">
<Card variant="default" className="bg-cyber-dark border-neon-cyan/30 max-h-96 overflow-y-auto">
{loading && (
<div className="p-4 text-center text-cyber-accent/70">
<p>{t('common.loading')}</p>
</div>
)}
{!loading && suggestions.length > 0 && (
<div className="divide-y divide-neon-cyan/20">
{suggestions.map((suggestion, index) => (
<SuggestionItemComponent
key={`${suggestion.type}-${suggestion.query}-${suggestion.timestamp ?? index}`}
suggestion={suggestion}
onSelect={onSelect}
/>
))}
</div>
)}
</Card>
</div>
)
}
function getSuggestionIcon(type: SuggestionItem['type']): React.ReactElement {
if (type === 'article') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)
}
if (type === 'author') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
}
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
function getSuggestionAriaLabel(type: SuggestionItem['type'], title: string): string {
if (type === 'article') {
return `${t('search.suggestion.article')}: ${title}`
}
if (type === 'author') {
return `${t('search.suggestion.author')}: ${title}`
}
return `${t('search.suggestion.history')}: ${title}`
}
function SuggestionItemComponent({
suggestion,
onSelect,
}: {
suggestion: SuggestionItem
onSelect: (query: string) => void
}): React.ReactElement {
return (
<Button
variant="ghost"
onClick={() => {
onSelect(suggestion.query)
}}
className="w-full justify-start text-left p-3 h-auto"
aria-label={getSuggestionAriaLabel(suggestion.type, suggestion.title)}
>
<div className="flex items-start gap-3 flex-1">
<div className="text-neon-cyan mt-0.5 flex-shrink-0">{getSuggestionIcon(suggestion.type)}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-cyber-accent truncate">{suggestion.title}</div>
{suggestion.subtitle && (
<div className="text-xs text-cyber-accent/70 mt-1 truncate">{suggestion.subtitle}</div>
)}
</div>
</div>
</Button>
)
}
function extractAuthorName(author: Article): string {
if (author.isPresentation) {
return author.title
}
return author.title
}