/* @component-map
* App — Main Myreads container with tab navigation
* StarRating — Interactive 5-star rating component
* BookCard — Displays a saved book with notes editing and removal
* DiscoverTab — Form + AI suggestions for finding new books
* SuggestionCard — Single AI recommendation with save buttons
* BookList — List view for saved books in Want/Read tabs
* @end-component-map */
import { useCollection, useAI } from '@deplixo/sdk';
const TABS = ["Discover", "Want to Read", "Previously Read"];
function StarRating({ value, onChange }) {
return (
{[1, 2, 3, 4, 5].map((s) => (
onChange?.(s)}
className={`star ${(value ?? 0) >= s ? 'star-filled' : ''} ${onChange ? 'star-clickable' : ''}`}
>
★
))}
);
}
function BookCard({ book, list, onRemove, onUpdateRating, onUpdateNotes }) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(book.notes || "");
const save = () => {
onUpdateNotes(book.id, draft);
setEditing(false);
};
return (
{book.title}
by {book.author}
{list === "read" &&
onUpdateRating(book.id, r)} />}
Why suggested: {book.reason}
onRemove(book.id)} className="remove-btn">
✕
{editing ? (
) : (
{book.notes &&
{book.notes}
}
{
setEditing(true);
setDraft(book.notes || "");
}}
className="notes-edit-btn"
>
{book.notes ? "Edit notes" : "+ Add notes"}
)}
);
}
function SuggestionCard({ suggestion, index, isSaved, inRead, inWant, onMark }) {
return (
📖
{suggestion.title}
by {suggestion.author}
{suggestion.why}
{isSaved ? (
{inRead ? "✓ In Previously Read" : "✓ In Want to Read"}
) : (
onMark(suggestion, "want")} className="btn-primary">
+ Want to Read
onMark(suggestion, "read")} className="btn-dark">
✓ Already Read
)}
);
}
function DiscoverTab({ readBooks, wantBooks, addRead, addWant }) {
const { generate, loading } = useAI();
const [bookTitle, setBookTitle] = React.useState("");
const [author, setAuthor] = React.useState("");
const [liked, setLiked] = React.useState("");
const [genre, setGenre] = React.useState("");
const [addToAlreadyRead, setAddToAlreadyRead] = React.useState(false);
const [suggestions, setSuggestions] = React.useState([]);
const [error, setError] = React.useState("");
const [confirmMsg, setConfirmMsg] = React.useState("");
const allSavedTitles = [...readBooks, ...wantBooks].map((b) => b.title.toLowerCase());
const getSuggestions = async () => {
if (!bookTitle.trim()) {
setError("Please enter a book title.");
return;
}
setError("");
setConfirmMsg("");
setSuggestions([]);
// If the user checked "Add this to Already Read", save the entered book
// to the Previously Read collection before fetching suggestions.
if (addToAlreadyRead) {
const entered = {
title: bookTitle.trim(),
author: author.trim(),
reason: liked.trim() || "Added from Discover",
addedAt: new Date().toISOString(),
};
const alreadyInRead = readBooks.find(
(b) => b.title.toLowerCase() === entered.title.toLowerCase()
);
if (!alreadyInRead) {
try {
await addRead(entered);
setConfirmMsg(`Added "${entered.title}" to Previously Read.`);
} catch (e) {
setError("Could not save to Previously Read. Please try again.");
return;
}
} else {
setConfirmMsg(`"${entered.title}" is already in Previously Read.`);
}
}
try {
const skipList = allSavedTitles.length
? `Do NOT suggest any of these books (already saved by user): ${allSavedTitles.join(", ")}.`
: "";
const genreNote = genre ? `Mood/genre filter: ${genre}.` : "";
const prompt = `You are a literary recommendation engine. A reader enjoyed the book "${bookTitle}"${
author ? ` by ${author}` : ""
}. What they liked: "${liked || "general enjoyment"}". ${genreNote} ${skipList}\n\nSuggest exactly 3 books they would love. Respond ONLY with a valid JSON array, no markdown, no preamble:\n[{"title":"...","author":"...","why":"A 1-2 sentence explanation of why this matches their taste"}]`;
const text = await generate(prompt);
const clean = String(text).replace(/```json|```/g, "").trim();
const parsed = JSON.parse(clean);
setSuggestions(parsed);
} catch (e) {
setError("Something went wrong fetching suggestions. Please try again.");
}
};
const markBook = (s, status) => {
const book = {
title: s.title,
author: s.author,
reason: s.why,
addedAt: new Date().toISOString(),
};
if (status === "read") {
if (!readBooks.find((b) => b.title.toLowerCase() === s.title.toLowerCase())) addRead(book);
} else {
if (!wantBooks.find((b) => b.title.toLowerCase() === s.title.toLowerCase())) addWant(book);
}
};
const isSaved = (title) => allSavedTitles.includes(title.toLowerCase());
return (
<>
Find Your Next Read
Tell us about a book you loved and we'll find your next favorite.
What did you love about it?
Mood / Genre Filter (optional)
setGenre(e.target.value)}
className={`form-select ${genre ? "form-select-chosen" : ""}`}
>
Any mood / genre
Same genre only
Lighter read
Darker & more intense
Page-turner / fast-paced
Literary & slow-burn
Cozy & comforting
setAddToAlreadyRead(e.target.checked)}
className="checkbox-input"
/>
Add this to Already Read
{error &&
{error}
}
{confirmMsg &&
{confirmMsg}
}
{loading ? "Finding books…" : "Get Recommendations"}
{suggestions.length > 0 && !loading && (
↻ Re-roll
)}
{suggestions.length > 0 && (
Your Recommendations
{suggestions.map((s, i) => {
const saved = isSaved(s.title);
const inRead = readBooks.some((b) => b.title.toLowerCase() === s.title.toLowerCase());
const inWant = wantBooks.some((b) => b.title.toLowerCase() === s.title.toLowerCase());
return (
);
})}
)}
>
);
}
function BookList({ books, list, emptyIcon, emptyText, title, subtitle, onRemove, onUpdateRating, onUpdateNotes }) {
return (
<>
{title}
{subtitle}
{books.length === 0 ? (
) : (
books.map((b) => (
))
)}
>
);
}
function App() {
const [tab, setTab] = React.useState("Discover");
const readCol = useCollection("jeffreads_read");
const wantCol = useCollection("jeffreads_want");
const readBooks = (readCol.items || []).map((it) => ({ id: it.id, ...(it.value || {}) }));
const wantBooks = (wantCol.items || []).map((it) => ({ id: it.id, ...(it.value || {}) }));
const addRead = (book) => readCol.add(book);
const addWant = (book) => wantCol.add(book);
const removeRead = (id) => readCol.remove(id);
const removeWant = (id) => wantCol.remove(id);
const updateRating = (id, rating) => {
const b = readBooks.find((x) => x.id === id);
if (!b) return;
const { id: _, ...rest } = b;
readCol.update(id, { ...rest, rating });
};
const updateNotesRead = (id, notes) => {
const b = readBooks.find((x) => x.id === id);
if (!b) return;
const { id: _, ...rest } = b;
readCol.update(id, { ...rest, notes });
};
const updateNotesWant = (id, notes) => {
const b = wantBooks.find((x) => x.id === id);
if (!b) return;
const { id: _, ...rest } = b;
wantCol.update(id, { ...rest, notes });
};
return (
📚
Myreads
{TABS.map((t) => (
setTab(t)}
className={`tab-btn ${tab === t ? "tab-active" : ""}`}
>
{t}
{t === "Want to Read" && wantBooks.length > 0 ? ` (${wantBooks.length})` : ""}
{t === "Previously Read" && readBooks.length > 0 ? ` (${readBooks.length})` : ""}
))}
{tab === "Discover" && (
)}
{tab === "Want to Read" && (
{}}
onUpdateNotes={updateNotesWant}
/>
)}
{tab === "Previously Read" && (
)}
Myreads — Your personal reading companion
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );