Skip to content

Commit 00b5960

Browse files
committed
Ch 5.1 - Adding AI Backend to App
1 parent 8cc1825 commit 00b5960

7 files changed

Lines changed: 112 additions & 44 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ node_modules
1111
dist
1212
dist-ssr
1313
*.local
14+
.env
1415

1516
# Editor directories and files
1617
.vscode/*

book-lessons-app/src/context/AppContext.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ export function AppProvider({ children }) {
99
const [isLoading, setIsLoading] = useState(false);
1010
const [error, setError] = useState(null);
1111

12-
const selectBook = useCallback(async (bookTitle) => {
12+
const selectBook = useCallback(async (book) => {
1313
setIsLoading(true);
1414
setError(null);
1515

1616
try {
17-
const result = await extractLessons(bookTitle);
17+
const result = await extractLessons(book);
1818
setSelectedBook(result.book);
1919
setLessons(result.lessons);
2020
} catch (err) {

book-lessons-app/src/hooks/useBookSearch.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
import { useState, useCallback } from "react";
2-
import { books } from "../data/books";
1+
import { useState, useCallback, useRef } from "react";
2+
import { searchBooks } from "../services/bookService";
33

44
export function useBookSearch() {
55
const [query, setQuery] = useState("");
66
const [results, setResults] = useState([]);
77
const [isSearching, setIsSearching] = useState(false);
8+
const debounceRef = useRef(null);
89

910
const search = useCallback((searchTerm) => {
1011
setQuery(searchTerm);
1112

13+
if (debounceRef.current) {
14+
clearTimeout(debounceRef.current);
15+
}
16+
1217
if (!searchTerm.trim()) {
1318
setResults([]);
1419
setIsSearching(false);
@@ -17,24 +22,25 @@ export function useBookSearch() {
1722

1823
setIsSearching(true);
1924

20-
// Simulated 500ms search delay
21-
const timeoutId = setTimeout(() => {
22-
const matches = books.filter(
23-
(book) =>
24-
book.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
25-
book.author.toLowerCase().includes(searchTerm.toLowerCase()),
26-
);
27-
setResults(matches);
28-
setIsSearching(false);
25+
debounceRef.current = setTimeout(async () => {
26+
try {
27+
const books = await searchBooks(searchTerm);
28+
setResults(books);
29+
} catch {
30+
setResults([]);
31+
} finally {
32+
setIsSearching(false);
33+
}
2934
}, 500);
30-
31-
return () => clearTimeout(timeoutId);
3235
}, []);
3336

3437
const clear = useCallback(() => {
3538
setQuery("");
3639
setResults([]);
3740
setIsSearching(false);
41+
if (debounceRef.current) {
42+
clearTimeout(debounceRef.current);
43+
}
3844
}, []);
3945

4046
return { query, results, isSearching, search, clear };
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const OPEN_LIBRARY_API = "https://openlibrary.org/search.json";
2+
3+
export async function searchBooks(query) {
4+
if (!query.trim()) return [];
5+
6+
const url = `${OPEN_LIBRARY_API}?q=${encodeURIComponent(query)}&limit=8&fields=key,title,author_name,subject,first_sentence,cover_i`;
7+
const res = await fetch(url);
8+
9+
if (!res.ok) {
10+
throw new Error("Failed to search books");
11+
}
12+
13+
const data = await res.json();
14+
15+
if (!data.docs || data.docs.length === 0) return [];
16+
17+
return data.docs.map((doc) => ({
18+
id: doc.key,
19+
title: doc.title || "Untitled",
20+
author: (doc.author_name || []).join(", ") || "Unknown Author",
21+
genre: (doc.subject || ["General"])[0],
22+
description: doc.first_sentence?.[0] || "No description available.",
23+
coverImage: doc.cover_i
24+
? `https://covers.openlibrary.org/b/id/${doc.cover_i}-M.jpg`
25+
: null,
26+
coverColor: "#3B82F6",
27+
}));
28+
}
Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,61 @@
1-
import { books } from "../data/books";
2-
import { lessons } from "../data/lessons";
3-
4-
/**
5-
* Simulates extracting lessons from a book.
6-
* Returns a Promise that resolves after a 1-second delay (simulating AI processing).
7-
* Later, this can be swapped for a real AI API call.
8-
*/
9-
export function extractLessons(bookTitle) {
10-
return new Promise((resolve, reject) => {
11-
setTimeout(() => {
12-
const book = books.find(
13-
(b) => b.title.toLowerCase() === bookTitle.toLowerCase(),
14-
);
15-
16-
if (!book) {
17-
reject(new Error(`Book "${bookTitle}" not found in database.`));
18-
return;
19-
}
20-
21-
const bookLessons = lessons
22-
.filter((l) => l.bookId === book.id)
23-
.sort((a, b) => a.dayOfWeek - b.dayOfWeek);
24-
25-
resolve({ book, lessons: bookLessons });
26-
}, 1000);
1+
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
2+
3+
export async function extractLessons(book) {
4+
const apiKey = import.meta.env.VITE_OPENAI_API_KEY;
5+
6+
if (!apiKey) {
7+
throw new Error(
8+
"OpenAI API key is not configured. Add VITE_OPENAI_API_KEY to your .env file.",
9+
);
10+
}
11+
12+
const prompt = `You are a book lesson extractor. Given the book "${book.title}" by ${book.author}, generate exactly 7 practical daily lessons a reader can apply over one week.
13+
14+
Return a JSON array of 7 objects with these fields:
15+
- id (number, 1-7)
16+
- title (string, short lesson title)
17+
- description (string, 2-3 sentences explaining the lesson)
18+
- actionStep (string, one concrete action the reader can take today)
19+
- category (string, a short category label like "Habits", "Focus", "Mindset", etc.)
20+
- dayOfWeek (number, 1-7 for Monday-Sunday)
21+
22+
Return ONLY the JSON array, no markdown fences or extra text.`;
23+
24+
const res = await fetch(OPENAI_API_URL, {
25+
method: "POST",
26+
headers: {
27+
"Content-Type": "application/json",
28+
Authorization: `Bearer ${apiKey}`,
29+
},
30+
body: JSON.stringify({
31+
model: "gpt-4o-mini",
32+
messages: [{ role: "user", content: prompt }],
33+
temperature: 0.7,
34+
max_tokens: 2000,
35+
}),
2736
});
37+
38+
if (!res.ok) {
39+
const errorData = await res.json().catch(() => ({}));
40+
throw new Error(
41+
errorData.error?.message || `OpenAI API error: ${res.status}`,
42+
);
43+
}
44+
45+
const data = await res.json();
46+
const content = data.choices[0].message.content.trim();
47+
48+
let lessons;
49+
try {
50+
lessons = JSON.parse(content);
51+
} catch {
52+
const match = content.match(/\[[\s\S]*\]/);
53+
if (match) {
54+
lessons = JSON.parse(match[0]);
55+
} else {
56+
throw new Error("Failed to parse lessons from AI response.");
57+
}
58+
}
59+
60+
return { book, lessons };
2861
}

book-lessons-app/src/versions/data-dense/DenseLayout.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export default function DenseLayout() {
3434
}, []);
3535

3636
const handleSelect = useCallback(
37-
(bookTitle) => {
38-
selectBook(bookTitle);
37+
(book) => {
38+
selectBook(book);
3939
clear();
4040
setCompletedLessons(new Set());
4141
},

book-lessons-app/src/versions/data-dense/components/DenseSearch.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function DenseSearch({
3030
}
3131

3232
function handleSelect(book) {
33-
onSelect(book.title);
33+
onSelect(book);
3434
setIsOpen(false);
3535
}
3636

0 commit comments

Comments
 (0)