nice-bill's picture
initial commit
cdb73a8
import { useState, useEffect, useRef } from 'react';
import {
Search, BookOpen, Loader2, X, Sparkles, LayoutGrid,
CheckCircle, Moon, Sun, Info, Bookmark, ArrowLeft, Users
} from 'lucide-react';
import { api } from './api';
import { Loader } from './Loader';
import { BookCover } from './BookCover';
import { BookCard } from './BookCard';
import { DEMO_PERSONAS } from './data/personas';
import type { RecommendationResult, BookCluster } from './types';
function App() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<RecommendationResult[]>([]);
const [loading, setLoading] = useState(false);
const [longLoading, setLongLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
// Modal State
const [selectedResult, setSelectedResult] = useState<RecommendationResult | null>(null);
const [historyStack, setHistoryStack] = useState<RecommendationResult[]>([]);
const [explanation, setExplanation] = useState<{ summary: string; details: Record<string, number> } | null>(null);
const [explaining, setExplaining] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
// Related Books State
const [relatedBooks, setRelatedBooks] = useState<RecommendationResult[]>([]);
const [loadingRelated, setLoadingRelated] = useState(false);
// Dynamic Clusters State
const [clusters, setClusters] = useState<BookCluster[]>([]);
const [loadingClusters, setLoadingClusters] = useState(true);
// Toast State
const [toast, setToast] = useState<{ message: string; visible: boolean } | null>(null);
// Settings / Theme State
const [darkMode, setDarkMode] = useState(false);
// Personalization / History State
const [readHistory, setReadHistory] = useState<string[]>(() => {
try {
return JSON.parse(localStorage.getItem('bookfinder_read_history') || '[]');
} catch {
return [];
}
});
useEffect(() => {
if (selectedResult && modalRef.current) {
modalRef.current.scrollTop = 0;
}
}, [selectedResult]);
useEffect(() => {
const savedTheme = localStorage.getItem('bookfinder_theme_mode');
if (savedTheme) {
setDarkMode(savedTheme === 'dark');
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
setDarkMode(true);
}
}, []);
useEffect(() => {
localStorage.setItem('bookfinder_theme_mode', darkMode ? 'dark' : 'light');
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode]);
useEffect(() => {
localStorage.setItem('bookfinder_read_history', JSON.stringify(readHistory));
}, [readHistory]);
const toggleReadBook = (title: string) => {
setReadHistory(prev => {
if (prev.includes(title)) {
showToast("Removed from library");
return prev.filter(t => t !== title);
} else {
showToast("Added to library");
return [...prev, title];
}
});
triggerHaptic();
};
const handlePersonalize = async () => {
if (readHistory.length === 0) {
showToast("Mark some books as read first!");
return;
}
triggerHaptic();
setLoading(true);
setHasSearched(true);
setResults([]);
setQuery("✨ Selected for you");
try {
const data = await api.recommendPersonalized(readHistory);
setResults(data);
} catch (err) {
console.error(err);
showToast("Failed to personalize. Try again.");
} finally {
setLoading(false);
}
};
const handlePersonaSelect = (persona: typeof DEMO_PERSONAS[0]) => {
triggerHaptic();
setReadHistory(persona.history);
showToast(`Switched to ${persona.name} mode`);
setLoading(true);
setHasSearched(true);
setResults([]);
setQuery(`👤 Demo: ${persona.name}`);
api.recommendPersonalized(persona.history)
.then(setResults)
.catch(err => {
console.error(err);
showToast("Failed to load persona.");
})
.finally(() => setLoading(false));
};
const toggleTheme = async (e: React.MouseEvent) => {
const isDark = !darkMode;
if (!(document as any).startViewTransition) {
setDarkMode(isDark);
return;
}
const x = e.clientX;
const y = e.clientY;
const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
const transition = (document as any).startViewTransition(() => setDarkMode(isDark));
await transition.ready;
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{ clipPath: isDark ? clipPath : [...clipPath].reverse() },
{ duration: 500, easing: 'ease-in-out', pseudoElement: isDark ? '::view-transition-new(root)' : '::view-transition-old(root)' }
);
};
const triggerHaptic = () => {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(10);
}
};
useEffect(() => {
const fetchClusters = async () => {
try {
const data = await api.getClusters();
setClusters(data.slice(0, 6));
} catch (err) {
console.error("Failed to fetch clusters", err);
} finally {
setLoadingClusters(false);
}
};
fetchClusters();
}, []);
const showToast = (message: string) => {
setToast({ message, visible: true });
setTimeout(() => setToast(null), 3000);
};
const handleSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!query.trim()) return;
triggerHaptic();
setLoading(true);
setLongLoading(false);
setHasSearched(true);
setResults([]);
const timer = setTimeout(() => setLongLoading(true), 3000);
try {
const data = await api.recommendByQuery(query);
setResults(data);
} catch (err) {
console.error(err);
} finally {
clearTimeout(timer);
setLoading(false);
setLongLoading(false);
}
};
const handleQuickSearch = (text: string) => {
setQuery(text);
triggerHaptic();
setLoading(true);
setLongLoading(false);
setHasSearched(true);
setResults([]);
const timer = setTimeout(() => setLongLoading(true), 3000);
api.recommendByQuery(text).then(setResults).catch(console.error).finally(() => {
clearTimeout(timer);
setLoading(false);
setLongLoading(false);
});
};
const handleFeedback = async (bookId: string, type: 'positive' | 'negative') => {
triggerHaptic();
try {
await api.submitFeedback(query, bookId, type);
showToast(type === 'positive' ? "Thanks! We'll show more like this." : "Thanks! We'll tune our results.");
} catch (err) {
console.error(err);
}
};
const handleReadMore = async (result: RecommendationResult, isDrillingDown = false) => {
triggerHaptic();
if (isDrillingDown && selectedResult) {
setHistoryStack(prev => [...prev, selectedResult]);
} else if (!isDrillingDown) {
setHistoryStack([]);
}
setSelectedResult(result);
setExplaining(true);
setExplanation(null);
setRelatedBooks([]);
setLoadingRelated(true);
try {
const [expl, related] = await Promise.allSettled([
api.explainRecommendation(query || result.book.title, result.book, result.similarity_score),
api.getRelatedBooks(result.book.title)
]);
if (expl.status === 'fulfilled') setExplanation(expl.value);
if (related.status === 'fulfilled') setRelatedBooks(related.value);
} catch (err) {
console.error("Failed to fetch details", err);
} finally {
setExplaining(false);
setLoadingRelated(false);
}
};
const handleBack = () => {
triggerHaptic();
if (historyStack.length > 0) {
const prev = historyStack[historyStack.length - 1];
setHistoryStack(curr => curr.slice(0, -1));
setSelectedResult(prev);
setRelatedBooks([]);
setExplanation(null);
setLoadingRelated(true);
setExplaining(true);
Promise.allSettled([
api.explainRecommendation(query || prev.book.title, prev.book, prev.similarity_score),
api.getRelatedBooks(prev.book.title)
]).then(([expl, related]) => {
if (expl.status === 'fulfilled') setExplanation(expl.value);
if (related.status === 'fulfilled') setRelatedBooks(related.value);
setExplaining(false);
setLoadingRelated(false);
});
}
};
const closeModal = () => {
setSelectedResult(null);
setHistoryStack([]);
setExplanation(null);
setRelatedBooks([]);
};
const handleClusterClick = (clusterName: string) => {
triggerHaptic();
setQuery(clusterName);
setLoading(true);
setHasSearched(true);
setResults([]);
api.recommendByQuery(clusterName).then(setResults).catch(console.error).finally(() => setLoading(false));
};
return (
<div className={`min-h-screen font-sans selection:bg-indigo-500 selection:text-white transition-colors duration-300 ${darkMode ? 'bg-zinc-950 text-zinc-100' : 'bg-zinc-50 text-zinc-900'}`}>
<div className="fixed inset-0 -z-10 bg-zinc-50 dark:bg-zinc-950 transition-colors duration-500" />
<div className="fixed inset-0 -z-10 bg-noise opacity-[0.04] dark:opacity-[0.06] pointer-events-none mix-blend-overlay" />
<div className="fixed inset-0 -z-10 bg-dot-pattern pointer-events-none" />
<div className="fixed inset-0 -z-10 bg-gradient-to-br from-rose-100/40 via-white to-indigo-100/40 dark:from-indigo-950/30 dark:via-zinc-950 dark:to-purple-950/30 pointer-events-none" />
{toast && (
<div className="fixed bottom-6 right-6 bg-zinc-900 dark:bg-zinc-800 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 animate-slide-up z-[60] border border-zinc-800 dark:border-zinc-700">
<CheckCircle className="w-5 h-5 text-green-400" />
<span className="text-sm font-medium">{toast.message}</span>
</div>
)}
<header className="sticky top-0 z-30 border-b border-zinc-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-950/70 backdrop-blur-xl">
<div className="max-w-3xl mx-auto px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3 group cursor-pointer" onClick={() => setActiveView('search')}>
<div className="relative">
<div className="absolute -inset-1 bg-indigo-500/20 rounded-full blur-sm group-hover:bg-indigo-500/30 transition-colors"></div>
<BookOpen className="w-8 h-8 text-indigo-600 dark:text-indigo-400 relative" />
</div>
<span className="text-zinc-900 dark:text-white font-serif text-xl tracking-normal">Serendipity</span>
</div>
<div className="flex items-center gap-2">
<button onClick={toggleTheme} className="p-2 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-zinc-500 dark:text-zinc-400">
{darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<button onClick={() => setShowAbout(true)} className="p-2 ml-2 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-zinc-500 dark:text-zinc-400">
<Info className="w-5 h-5" />
</button>
</div>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 pb-24 relative z-10">
<div className={`transition-all duration-700 ease-out ${hasSearched ? 'py-10' : 'py-28'}`}>
{!hasSearched && (
<div className="text-center mb-10 animate-fade-in">
<h1 className="text-5xl sm:text-7xl font-black tracking-tighter mb-6 text-zinc-900 dark:text-white font-serif">
Find your next<br/>
<span className="text-indigo-600 dark:text-indigo-500 italic">obsession.</span>
</h1>
<p className="text-lg max-w-md mx-auto text-zinc-500 dark:text-zinc-400 leading-relaxed font-medium">
Describe the vibe, plot, or character you're looking for.
</p>
</div>
)}
<form onSubmit={handleSearch} className="relative max-w-xl mx-auto z-10">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. A sci-fi thriller about AI consciousness..."
className="relative w-full bg-white dark:bg-zinc-900 border-2 border-zinc-200 dark:border-zinc-800 focus:border-indigo-500 dark:focus:border-indigo-500 rounded-full py-4 pl-6 pr-24 text-lg outline-none transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-600 shadow-xl shadow-zinc-200/50 dark:shadow-black/50 text-zinc-900 dark:text-white"
autoFocus
/>
{query && (
<button
type="button"
onClick={() => { setQuery(''); triggerHaptic(); setResults([]); }}
className="absolute right-14 top-1/2 -translate-y-1/2 p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
<button
type="submit"
disabled={loading || !query.trim()}
className="absolute right-2 top-2 p-2.5 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:hover:bg-indigo-600 transition-all active:scale-90 shadow-lg shadow-indigo-500/30"
onClick={triggerHaptic}
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Search className="w-5 h-5" />}
</button>
</form>
{loading && longLoading && (
<div className="text-center mt-4 animate-fade-in">
<p className="text-sm font-medium text-indigo-600 dark:text-indigo-400 flex items-center justify-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Waking up... this might take a moment 😴
</p>
</div>
)}
{!hasSearched && !loading && (
<div className="mt-8 flex flex-col items-center gap-6 animate-fade-in animation-delay-200">
<div className="flex flex-wrap justify-center gap-3">
{[
"Cyberpunk noir detective",
"Cozy cottagecore mystery",
"Space opera with politics",
"Psychological horror 1920s"
].map((prompt) => (
<button key={prompt} onClick={() => handleQuickSearch(prompt)} className="px-4 py-2 rounded-full text-sm font-medium bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 hover:border-indigo-400 dark:hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-all shadow-sm hover:shadow-md active:scale-95">
✨ {prompt}
</button>
))}
</div>
{/* Demo Personas */}
<div className="w-full max-w-2xl mt-4 p-6 bg-zinc-50/50 dark:bg-zinc-900/30 rounded-3xl border border-zinc-100 dark:border-zinc-800/50 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2 text-xs uppercase tracking-widest font-bold mb-4 text-zinc-400 dark:text-zinc-600">
<Users className="w-4 h-4" />
Try a Demo Persona
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{DEMO_PERSONAS.map(persona => (
<button
key={persona.id}
onClick={() => handlePersonaSelect(persona)}
className="flex flex-col items-center gap-2 p-3 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:border-indigo-400 dark:hover:border-indigo-500 hover:shadow-lg transition-all active:scale-95 group"
>
<span className="text-2xl group-hover:scale-110 transition-transform">{persona.emoji}</span>
<div className="text-center">
<div className="text-xs font-bold text-zinc-900 dark:text-zinc-100">{persona.name}</div>
<div className="text-[10px] text-zinc-500 dark:text-zinc-500 leading-tight mt-1 line-clamp-2">{persona.description}</div>
</div>
</button>
))}
</div>
</div>
{/* Personalization Trigger */}
{readHistory.length > 0 && (
<button
onClick={handlePersonalize}
className="mt-2 flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-full font-bold text-sm shadow-xl shadow-indigo-500/20 hover:scale-105 active:scale-95 transition-all"
>
<Bookmark className="w-4 h-4 fill-white/20" />
Recommend based on my {readHistory.length} reads
</button>
)}
</div>
)}
{!hasSearched && (
<div className="mt-20 animate-slide-up animation-delay-500">
<div className="flex items-center justify-center gap-2 text-xs uppercase tracking-widest font-bold mb-8 text-zinc-400 dark:text-zinc-600">
<LayoutGrid className="w-4 h-4" />
Curated Collections
</div>
{loadingClusters ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => (
<div key={i} className="h-32 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{clusters.map(cluster => (
<button
key={cluster.id}
onClick={() => handleClusterClick(cluster.name)}
className="text-left p-4 bg-white dark:bg-zinc-900/50 hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-800 hover:border-indigo-300 dark:hover:border-indigo-500/50 transition-all rounded-2xl group active:scale-95 duration-200 shadow-sm hover:shadow-md"
>
<h3 className="font-bold text-zinc-800 dark:text-zinc-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 truncate transition-colors">{cluster.name}</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-500 mt-1 font-medium">{cluster.size} books</p>
<div className="flex mt-4 -space-x-3 overflow-hidden px-1 pb-1">
{cluster.top_books.slice(0, 3).map((book, i) => (
<div key={book.id} className={`w-8 h-12 bg-zinc-200 dark:bg-zinc-800 rounded-sm shadow-md border border-white/50 dark:border-zinc-700 relative z-${3-i} transform transition-transform group-hover:-translate-y-1 duration-300 overflow-hidden`} style={{transitionDelay: `${i*50}ms`}}>
<BookCover src={book.cover_image_url} title={book.title} author={book.authors[0]} className="w-full h-full" />
</div>
))}
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="space-y-6 min-h-[50vh]">
{loading && results.length === 0 ? (
<div className="animate-fade-in pt-10">
<Loader />
</div>
) : (
results.map((result) => (
<BookCard
key={result.book.id}
result={result}
isRead={readHistory.includes(result.book.title)}
onToggleRead={toggleReadBook}
onClick={() => handleReadMore(result)}
onFeedback={handleFeedback}
/>
))
)}
</div>
{hasSearched && results.length === 0 && !loading && (
<div className="text-center text-zinc-400 dark:text-zinc-600 py-12">
No books found matching that description.
</div>
)}
</main>
{selectedResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
<div className="absolute inset-0 bg-zinc-900/60 dark:bg-black/80 backdrop-blur-sm transition-opacity" onClick={closeModal} />
<div ref={modalRef} className="bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto relative animate-slide-up z-50 no-scrollbar border border-zinc-200 dark:border-zinc-800">
<button onClick={() => { triggerHaptic(); closeModal(); }} className="absolute right-4 top-4 p-2 bg-white/80 dark:bg-black/50 backdrop-blur hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-full transition-colors z-10 shadow-sm">
<X className="w-5 h-5" />
</button>
{historyStack.length > 0 && (
<button onClick={handleBack} className="absolute left-4 top-4 p-2 bg-white/80 dark:bg-black/50 backdrop-blur hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-full transition-colors z-10 shadow-sm flex items-center gap-1 pr-3">
<ArrowLeft className="w-5 h-5" />
<span className="text-xs font-bold">Back</span>
</button>
)}
<div className="grid sm:grid-cols-[220px_1fr] gap-0 sm:gap-8">
<div className="bg-zinc-100 dark:bg-zinc-800 h-64 sm:h-auto sm:aspect-[2/3] relative overflow-hidden">
<BookCover src={selectedResult.book.cover_image_url} title={selectedResult.book.title} author={selectedResult.book.authors[0]} className="w-full h-full" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent sm:hidden"></div>
<h2 className="absolute bottom-4 left-4 right-4 text-2xl font-bold font-serif text-white sm:hidden leading-tight shadow-black drop-shadow-md">{selectedResult.book.title}</h2>
</div>
<div className="p-6 sm:pl-0 sm:py-8 space-y-6">
<div className="hidden sm:block">
<h2 className="text-3xl sm:text-4xl font-bold font-serif leading-tight mb-2 text-zinc-900 dark:text-white">{selectedResult.book.title}</h2>
<p className="text-lg text-zinc-500 dark:text-zinc-400 font-medium">by {selectedResult.book.authors.join(', ')}</p>
</div>
<div className="prose prose-zinc dark:prose-invert prose-sm max-w-none leading-relaxed">
<p>{selectedResult.book.description}</p>
</div>
<div className="bg-indigo-50/50 dark:bg-indigo-900/20 rounded-2xl p-5 border border-indigo-100 dark:border-indigo-500/20">
<div className="flex items-center gap-2 mb-3 text-indigo-600 dark:text-indigo-400 font-bold text-xs uppercase tracking-widest">
<Sparkles className="w-4 h-4" />
Why this match?
</div>
{explaining ? (
<div className="flex items-center gap-2 text-indigo-400 dark:text-indigo-300 text-sm font-medium">
<Loader2 className="w-4 h-4 animate-spin" />
Reading your mind...
</div>
) : explanation ? (
<div className="space-y-3">
<p className="text-sm text-zinc-800 dark:text-zinc-200 font-medium leading-relaxed">{explanation.summary}</p>
<div className="flex flex-wrap gap-2">
{Object.entries(explanation.details).map(([key, val]) => (
<span key={key} className="text-[10px] font-bold uppercase bg-white dark:bg-zinc-800 border border-indigo-100 dark:border-indigo-500/30 px-2 py-1 rounded-md text-indigo-600 dark:text-indigo-300 shadow-sm">
{key} {val}%
</span>
))}
</div>
</div>
) : (
<p className="text-sm text-red-400">Could not generate explanation.</p>
)}
</div>
<div className="pt-4 flex flex-col sm:flex-row gap-3">
<a href={`https://www.goodreads.com/search?q=${encodeURIComponent(selectedResult.book.title + ' ' + selectedResult.book.authors.join(', '))}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 px-6 py-3 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white border border-zinc-200 dark:border-zinc-700 font-bold rounded-full hover:bg-zinc-50 dark:hover:bg-zinc-700 hover:scale-105 transition-all active:scale-95 flex-1 justify-center shadow-sm" onClick={triggerHaptic}>
<BookOpen className="w-4 h-4" />
Goodreads
</a>
<a href={`https://www.google.com/search?q=${encodeURIComponent(selectedResult.book.title + ' by ' + selectedResult.book.authors.join(', '))}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 px-6 py-3 bg-zinc-900 dark:bg-indigo-600 text-white font-bold rounded-full hover:bg-black dark:hover:bg-indigo-700 hover:scale-105 transition-all active:scale-95 flex-1 justify-center shadow-xl shadow-zinc-900/20 dark:shadow-indigo-500/30" onClick={triggerHaptic}>
<Search className="w-4 h-4" />
Google
</a>
</div>
<div className="pt-8 border-t border-dashed border-zinc-200 dark:border-zinc-800">
<h3 className="text-xs font-bold text-zinc-400 dark:text-zinc-500 mb-4 uppercase tracking-widest">You might also like</h3>
{loadingRelated ? (
<div className="flex gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="w-32 h-48 bg-zinc-100 dark:bg-zinc-800 rounded-xl animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{relatedBooks.map(rel => (
<div key={rel.book.id} onClick={() => handleReadMore(rel)} className="group cursor-pointer active:scale-95 transition-transform">
<div className="aspect-[2/3] bg-zinc-100 dark:bg-zinc-800 rounded-xl overflow-hidden mb-2 relative shadow-sm group-hover:shadow-md transition-all border border-zinc-100 dark:border-zinc-700">
<BookCover src={rel.book.cover_image_url} title={rel.book.title} author={rel.book.authors[0]} className="w-full h-full group-hover:scale-105 transition-transform duration-500" />
</div>
<h4 className="text-xs font-bold leading-tight text-zinc-800 dark:text-zinc-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors line-clamp-1">{rel.book.title}</h4>
<p className="text-[10px] text-zinc-400 dark:text-zinc-500 mt-0.5 truncate">{rel.book.authors[0]}</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{showAbout && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-zinc-900/60 dark:bg-black/80 backdrop-blur-sm transition-opacity" onClick={() => setShowAbout(false)} />
<div className="bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto relative animate-slide-up z-50 no-scrollbar border border-zinc-200 dark:border-zinc-800">
<button onClick={() => { triggerHaptic(); setShowAbout(false); }} className="absolute right-4 top-4 p-2 bg-white/80 dark:bg-black/50 backdrop-blur hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-full transition-colors z-10 shadow-sm">
<X className="w-5 h-5" />
</button>
<div className="p-6 sm:p-8 space-y-6">
<h3 className="text-2xl font-bold text-zinc-900 dark:text-white">About Serendipity</h3>
<div className="space-y-4 text-zinc-600 dark:text-zinc-300 leading-relaxed">
<p>
Serendipity is an intelligent book discovery interface powered by the <strong>DeepShelf Engine</strong>.
</p>
<p>
Unlike traditional keyword search, DeepShelf uses semantic vector embeddings to understand the <em>meaning</em> and <em>feeling</em> of your request. It connects you with books that match your specific wavelength, even if they don't share a single keyword.
</p>
</div>
<div className="space-y-3">
<h4 className="text-lg font-bold text-zinc-800 dark:text-zinc-200">How it works:</h4>
<ul className="list-disc list-inside text-zinc-600 dark:text-zinc-300 space-y-1">
<li><strong>Semantic Search:</strong> Understands the meaning and context of your queries.</li>
<li><strong>Vector Embeddings:</strong> DeepShelf maps books and queries into a high-dimensional vector space.</li>
<li><strong>Neural Retrieval:</strong> Finds the nearest neighbors to your thought in the library of 100,000+ books.</li>
</ul>
</div>
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed">
Describe your ideal read and let Serendipity uncover your next literary obsession!
</p>
</div>
</div>
</div>
)}
</div>
);
}
export default App;