book-rec-with-LLMs / web /src /components /BookDetailModal.jsx
ymlin105's picture
chore: update requirements and refactor benchmark methods to use synchronous recommendations
65b86c6
import React, { useState, useEffect } from "react";
import { X, Sparkles, Info, MessageSquare, MessageCircle, Send, Star, Bookmark } from "lucide-react";
import { getSimilarBooks } from "../api";
const PLACEHOLDER_IMG = "/content/cover-not-found.jpg";
const StudyCard = ({ children, className }) => (
<div className={`bg-white border-2 border-[#333] shadow-md ${className || ""}`}>
{children}
</div>
);
const StudyButton = ({ children, active, color, className, onClick }) => {
const colors = {
purple: "bg-[#b392ac] text-white hover:bg-[#9d7799]",
peach: "bg-[#f4acb7] text-white hover:bg-[#e89ba3]",
};
return (
<button
onClick={onClick}
className={`px-4 py-2 text-sm font-bold transition-all ${colors[color] || colors.purple} ${className || ""}`}
>
{children}
</button>
);
};
const BookDetailModal = ({
book,
onClose,
messages,
onSend,
input,
onInputChange,
myCollection,
onToggleCollect,
onRatingChange,
onStatusChange,
onUpdateComment,
onOpenBook,
}) => {
const [similarBooks, setSimilarBooks] = useState([]);
const [loadingSimilar, setLoadingSimilar] = useState(false);
useEffect(() => {
if (!book?.isbn) return;
setLoadingSimilar(true);
getSimilarBooks(book.isbn, 6)
.then((recs) => {
const mapped = recs.map((r) => ({
id: r.isbn,
title: r.title,
author: r.authors,
desc: r.description,
img: r.thumbnail,
isbn: r.isbn,
rating: r.average_rating || 0,
tags: r.tags || [],
review_highlights: r.review_highlights || [],
emotions: r.emotions || {},
aiHighlight: r.review_highlights?.[0] || "\u2014",
suggestedQuestions: ["Any similar recommendations?", "What's the core highlight?"],
}));
setSimilarBooks(mapped);
})
.catch(() => setSimilarBooks([]))
.finally(() => setLoadingSimilar(false));
}, [book?.isbn]);
if (!book) return null;
const isInCollection = myCollection.some((b) => b.isbn === book.isbn);
const userBook = myCollection.find((b) => b.isbn === book.isbn);
const displayRating =
userBook?.rating && userBook.rating > 0 ? userBook.rating : book.rating || 0;
const isUserRating = userBook?.rating && userBook.rating > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/5 backdrop-blur-sm animate-in fade-in duration-300 overflow-y-auto">
<StudyCard className="relative bg-white max-w-5xl w-full shadow-2xl border-[#333] my-8">
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-300 hover:text-gray-600 transition-colors z-10"
>
<X className="w-6 h-6" />
</button>
<div className="grid md:grid-cols-12 gap-8 md:gap-10 px-6 md:px-10 py-6">
{/* Left Column */}
<div className="md:col-span-5 flex flex-col items-center border-r border-[#f5f5f5] pr-0 md:pr-6">
<div className="border border-[#eee] p-1 bg-white shadow-sm mb-2 w-52 md:w-56">
<img
src={book.img || PLACEHOLDER_IMG}
alt="cover"
className="w-full aspect-[3/4] object-cover"
onError={(e) => {
e.target.onerror = null;
e.target.src = PLACEHOLDER_IMG;
}}
/>
</div>
<p className="text-xs text-[#999] mb-2 tracking-tighter text-center w-full">
{book.author}
</p>
<h2 className="text-xl font-bold text-[#333] mb-1 text-center md:text-left w-full">
{book.title}
</h2>
<p className="text-xs text-[#999] mb-2 tracking-tighter text-center md:text-left w-full">
ISBN: {book.isbn}
</p>
{/* AI Highlight Box */}
<div className="bg-[#fff9f9] border border-[#f4acb7] p-4 w-full relative mb-4">
<Sparkles className="w-3 h-3 text-[#f4acb7] absolute -top-1.5 -left-1.5 fill-current" />
<div className="flex items-center justify-between mb-2">
<div className="flex flex-col">
<span className="text-[11px] font-bold text-[#f4acb7]">
{displayRating > 0 ? displayRating.toFixed(1) : "0.0"}
{isUserRating ? " (Your Rating)" : " (Average)"}
</span>
<div className="flex gap-0.5 text-[#f4acb7]">
{[1, 2, 3, 4, 5].map((i) => (
<Star key={i} className={`w-3 h-3 ${i <= displayRating ? "fill-current" : ""}`} />
))}
</div>
</div>
</div>
<p className="text-[11px] font-bold text-[#f4acb7] italic leading-relaxed">
{book.aiHighlight}
</p>
</div>
{/* Why This Recommendation — SHAP Explanations (V2.7) */}
{book.explanations && book.explanations.length > 0 && (
<div className="bg-[#f8f5ff] border border-[#b392ac]/40 p-4 w-full relative mb-4">
<Info className="w-3 h-3 text-[#b392ac] absolute -top-1.5 -left-1.5" />
<p className="text-[11px] font-bold text-[#b392ac] uppercase tracking-wider mb-3">
Why This Recommendation
</p>
<div className="space-y-2">
{book.explanations.map((exp, idx) => (
<div key={idx} className="flex items-center gap-2">
<span
className={`text-[9px] font-bold w-4 text-center ${exp.direction === "positive" ? "text-[#b392ac]" : "text-gray-400"
}`}
>
{exp.direction === "positive" ? "+" : "\u2212"}
</span>
<div className="flex-1 bg-gray-100 h-2 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${exp.direction === "positive"
? "bg-gradient-to-r from-[#b392ac] to-[#9d7799]"
: "bg-gray-300"
}`}
style={{
width: `${Math.min(Math.abs(exp.contribution) * 150, 100)}%`,
}}
/>
</div>
<span className="text-[10px] text-[#555] font-medium min-w-[100px]">
{exp.feature}
</span>
</div>
))}
</div>
</div>
)}
{/* Review Highlights */}
{book.review_highlights && book.review_highlights.length > 0 && (
<div className="w-full space-y-2 text-left">
{book.review_highlights.slice(0, 3).map((highlight, idx) => {
const isCompleteSentence = /^[A-Z]/.test(highlight.trim());
const prefix = isCompleteSentence ? "" : "...";
return (
<p key={idx} className="text-[10px] text-[#666] leading-relaxed italic pl-2">
- &ldquo;{prefix}{highlight}&rdquo;
</p>
);
})}
</div>
)}
</div>
{/* Right Column */}
<div className="md:col-span-7 flex flex-col space-y-6">
{/* Description */}
<div className="space-y-2">
<h4 className="flex items-center gap-2 text-[10px] font-bold uppercase text-gray-400 tracking-wider">
<Info className="w-3.5 h-3.5" /> Description
</h4>
<div className="p-4 bg-white border border-[#eee] text-[12px] leading-relaxed text-[#666] italic border-l-[4px] border-l-[#b392ac]">
<div style={{ maxHeight: "180px", overflowY: "auto", whiteSpace: "pre-line" }}>
{book.desc}
</div>
</div>
</div>
{/* Similar Reads (Content-Based, Session-Level) */}
<div className="space-y-2">
<h4 className="flex items-center gap-2 text-[10px] font-bold uppercase text-gray-400 tracking-wider">
Similar Reads
</h4>
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1">
{loadingSimilar ? (
<div className="text-[10px] text-gray-400 py-4">Loading similar books...</div>
) : similarBooks.length > 0 ? (
similarBooks.map((sb) => (
<button
key={sb.isbn}
onClick={() => onOpenBook && onOpenBook(sb)}
className="flex-shrink-0 w-16 text-left group focus:outline-none"
>
<div className="border border-[#eee] p-0.5 bg-white group-hover:border-[#b392ac] transition-colors">
<img
src={sb.img || PLACEHOLDER_IMG}
alt={sb.title}
className="w-full aspect-[3/4] object-cover"
onError={(e) => { e.target.onerror = null; e.target.src = PLACEHOLDER_IMG; }}
/>
</div>
<p className="text-[9px] text-[#666] mt-1 truncate group-hover:text-[#b392ac]" title={sb.title}>
{sb.title}
</p>
</button>
))
) : (
<div className="text-[10px] text-gray-400 py-4">No similar books found</div>
)}
</div>
</div>
{/* Chat */}
<div className="flex-grow flex flex-col border border-[#eee] bg-[#faf9f6] overflow-hidden h-[300px]">
<div className="p-2 border-b border-[#eee] bg-white flex justify-between items-center">
<span className="text-[10px] font-bold text-[#b392ac] flex items-center gap-2 uppercase tracking-widest">
<MessageSquare className="w-3 h-3" /> Discussion
</span>
</div>
<div className="flex-grow overflow-y-auto p-4 space-y-3">
<div className="flex justify-start">
<div className="max-w-[85%] p-2 bg-white border border-[#eee] text-[11px] text-[#735d78] shadow-sm">
Hello! Based on your collection preferences, I found this book&apos;s{" "}
{book.mood} atmosphere pairs beautifully with your taste. Would you like to
explore its themes?
</div>
</div>
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] p-2 border text-[11px] shadow-sm ${m.role === "user"
? "bg-[#b392ac] text-white border-[#b392ac]"
: "bg-white text-[#666] border-[#eee]"
}`}
>
{m.content}
</div>
</div>
))}
</div>
<div className="p-3 bg-white border-t border-[#eee] space-y-3">
<div className="flex flex-wrap gap-2">
{(book.suggestedQuestions || []).map((q, idx) => (
<button
key={idx}
onClick={() => onSend(q)}
className="text-[9px] px-2 py-1 bg-[#f8f9fa] border border-[#eee] text-gray-500 hover:border-[#b392ac] hover:text-[#b392ac] transition-colors"
>
{q}
</button>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSend(input)}
className="flex-grow border border-[#eee] p-2 text-[11px] outline-none focus:border-[#b392ac] bg-[#faf9f6] font-serif"
placeholder="Ask a question..."
/>
<button onClick={() => onSend(input)} className="bg-[#333] text-white p-2">
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-3">
{/* Rating & Status (if in collection) */}
{isInCollection && (
<div className="p-3 bg-[#fff9f9] border border-[#f4acb7] space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-[#f4acb7] uppercase tracking-wider">
My Rating
</span>
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => onRatingChange(book.isbn, star)}
className="focus:outline-none transform hover:scale-110 transition-transform"
>
<Star
className={`w-4 h-4 transition-colors ${star <= (userBook?.rating || 0)
? "text-[#f4acb7] fill-current"
: "text-gray-200 hover:text-[#f4acb7]"
}`}
/>
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-[#b392ac] uppercase tracking-wider">
Status
</span>
<select
value={userBook?.status || "want_to_read"}
onChange={(e) => onStatusChange(book.isbn, e.target.value)}
className="bg-white border border-[#eee] text-[10px] text-gray-500 p-1 outline-none focus:border-[#b392ac] w-28 cursor-pointer"
>
<option value="want_to_read">Want to Read</option>
<option value="reading">Reading</option>
<option value="finished">Finished</option>
</select>
</div>
</div>
)}
{/* Collect Button */}
<StudyButton
active
color={isInCollection ? "peach" : "purple"}
className="w-full py-3 text-sm flex items-center justify-center gap-2 font-bold transition-all"
onClick={() => onToggleCollect(book)}
>
<Bookmark className={`w-4 h-4 ${isInCollection ? "fill-current" : ""}`} />
{isInCollection ? "In Collection" : "Add to Collection"}
</StudyButton>
{/* Notes */}
{isInCollection && (
<div className="mt-2 pt-3 border-t border-[#eee]">
<label className="text-[10px] font-bold text-[#b392ac] uppercase tracking-wider mb-2 block flex items-center gap-2">
<MessageCircle className="w-3 h-3" /> My Private Notes
</label>
<textarea
value={userBook?.comment || ""}
onChange={(e) => onUpdateComment(book.isbn, e.target.value, false)}
onBlur={(e) => onUpdateComment(book.isbn, e.target.value, true)}
className="w-full text-[11px] p-3 border border-[#eee] focus:border-[#b392ac] outline-none h-24 resize-none bg-[#fff9f9] text-[#666] placeholder:text-gray-300 shadow-inner"
placeholder="Write your thoughts, review, or memorable quotes here..."
/>
</div>
)}
</div>
</div>
</div>
</StudyCard>
</div>
);
};
export default BookDetailModal;