stagent 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/cli.js +39 -10
- package/drizzle.config.ts +3 -1
- package/package.json +3 -1
- package/src/app/api/book/bookmarks/route.ts +73 -0
- package/src/app/api/book/progress/route.ts +79 -0
- package/src/app/api/book/regenerate/route.ts +111 -0
- package/src/app/api/book/stage/route.ts +13 -0
- package/src/app/api/chat/conversations/[id]/respond/route.ts +19 -20
- package/src/app/api/chat/conversations/[id]/route.ts +2 -1
- package/src/app/api/documents/[id]/route.ts +34 -2
- package/src/app/api/documents/route.ts +91 -0
- package/src/app/api/settings/runtime/route.ts +29 -8
- package/src/app/book/page.tsx +14 -0
- package/src/app/chat/page.tsx +7 -1
- package/src/app/globals.css +375 -0
- package/src/app/projects/[id]/page.tsx +31 -6
- package/src/app/{playbook → user-guide}/[slug]/page.tsx +12 -2
- package/src/app/{playbook → user-guide}/page.tsx +2 -2
- package/src/app/workflows/[id]/page.tsx +28 -2
- package/src/components/book/book-reader.tsx +801 -0
- package/src/components/book/chapter-generation-bar.tsx +109 -0
- package/src/components/book/content-blocks.tsx +432 -0
- package/src/components/book/path-progress.tsx +33 -0
- package/src/components/book/path-selector.tsx +42 -0
- package/src/components/book/try-it-now.tsx +164 -0
- package/src/components/chat/chat-activity-indicator.tsx +92 -0
- package/src/components/chat/chat-message-list.tsx +3 -0
- package/src/components/chat/chat-message.tsx +22 -6
- package/src/components/chat/chat-permission-request.tsx +5 -1
- package/src/components/chat/chat-question.tsx +3 -0
- package/src/components/chat/chat-shell.tsx +130 -19
- package/src/components/chat/conversation-list.tsx +8 -2
- package/src/components/playbook/adoption-heatmap.tsx +1 -1
- package/src/components/playbook/journey-card.tsx +1 -1
- package/src/components/playbook/playbook-card.tsx +1 -1
- package/src/components/playbook/playbook-detail-view.tsx +15 -5
- package/src/components/playbook/playbook-homepage.tsx +1 -1
- package/src/components/playbook/playbook-updated-badge.tsx +1 -1
- package/src/components/projects/project-detail.tsx +147 -27
- package/src/components/projects/project-form-sheet.tsx +6 -2
- package/src/components/projects/project-list.tsx +1 -1
- package/src/components/settings/runtime-timeout-section.tsx +117 -37
- package/src/components/shared/app-sidebar.tsx +7 -1
- package/src/components/shared/command-palette.tsx +4 -4
- package/src/hooks/use-chapter-generation.ts +255 -0
- package/src/lib/agents/claude-agent.ts +12 -6
- package/src/lib/book/chapter-generator.ts +193 -0
- package/src/lib/book/chapter-mapping.ts +91 -0
- package/src/lib/book/content.ts +251 -0
- package/src/lib/book/markdown-parser.ts +317 -0
- package/src/lib/book/reading-paths.ts +82 -0
- package/src/lib/book/types.ts +152 -0
- package/src/lib/book/update-detector.ts +157 -0
- package/src/lib/chat/codex-engine.ts +537 -0
- package/src/lib/chat/context-builder.ts +18 -4
- package/src/lib/chat/engine.ts +116 -39
- package/src/lib/chat/model-discovery.ts +13 -5
- package/src/lib/chat/permission-bridge.ts +14 -2
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/system-prompt.ts +16 -1
- package/src/lib/chat/tools/chat-history-tools.ts +177 -0
- package/src/lib/chat/tools/document-tools.ts +204 -0
- package/src/lib/chat/tools/settings-tools.ts +29 -3
- package/src/lib/chat/types.ts +8 -1
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/data/chat.ts +83 -2
- package/src/lib/data/clear.ts +8 -0
- package/src/lib/db/bootstrap.ts +24 -0
- package/src/lib/db/schema.ts +32 -0
- package/src/lib/docs/types.ts +9 -0
- /package/src/app/api/{playbook → user-guide}/status/route.ts +0 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import {
|
|
5
|
+
BookOpen,
|
|
6
|
+
BookmarkPlus,
|
|
7
|
+
BookmarkMinus,
|
|
8
|
+
ChevronLeft,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
List,
|
|
11
|
+
Settings2,
|
|
12
|
+
Clock,
|
|
13
|
+
Check,
|
|
14
|
+
Sparkles,
|
|
15
|
+
Bookmark as BookmarkIcon,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import { Button } from "@/components/ui/button";
|
|
18
|
+
import {
|
|
19
|
+
Sheet,
|
|
20
|
+
SheetContent,
|
|
21
|
+
SheetHeader,
|
|
22
|
+
SheetTitle,
|
|
23
|
+
SheetTrigger,
|
|
24
|
+
} from "@/components/ui/sheet";
|
|
25
|
+
import { Slider } from "@/components/ui/slider";
|
|
26
|
+
import { cn } from "@/lib/utils";
|
|
27
|
+
import { PARTS } from "@/lib/book/content";
|
|
28
|
+
import type { BookChapter, ReaderPreferences, ReadingProgress, Bookmark } from "@/lib/book/types";
|
|
29
|
+
import { DEFAULT_READER_PREFS } from "@/lib/book/types";
|
|
30
|
+
import { useRouter } from "next/navigation";
|
|
31
|
+
import { ContentBlockRenderer } from "./content-blocks";
|
|
32
|
+
import { TryItNow } from "./try-it-now";
|
|
33
|
+
import { ChapterGenerationBar } from "./chapter-generation-bar";
|
|
34
|
+
import { PathSelector } from "./path-selector";
|
|
35
|
+
import { PathProgress } from "./path-progress";
|
|
36
|
+
import { getReadingPath, getNextPathChapter, isChapterInPath } from "@/lib/book/reading-paths";
|
|
37
|
+
|
|
38
|
+
const PREFS_KEY = "stagent-book-prefs";
|
|
39
|
+
const PROGRESS_KEY = "stagent-book-progress";
|
|
40
|
+
const SYNC_DEBOUNCE_MS = 2000;
|
|
41
|
+
|
|
42
|
+
function loadPrefs(): ReaderPreferences {
|
|
43
|
+
if (typeof window === "undefined") return DEFAULT_READER_PREFS;
|
|
44
|
+
try {
|
|
45
|
+
const saved = localStorage.getItem(PREFS_KEY);
|
|
46
|
+
return saved ? { ...DEFAULT_READER_PREFS, ...JSON.parse(saved) } : DEFAULT_READER_PREFS;
|
|
47
|
+
} catch {
|
|
48
|
+
return DEFAULT_READER_PREFS;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function savePrefs(prefs: ReaderPreferences) {
|
|
53
|
+
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadProgress(): Record<string, ReadingProgress> {
|
|
57
|
+
if (typeof window === "undefined") return {};
|
|
58
|
+
try {
|
|
59
|
+
const saved = localStorage.getItem(PROGRESS_KEY);
|
|
60
|
+
return saved ? JSON.parse(saved) : {};
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function saveProgress(progress: Record<string, ReadingProgress>) {
|
|
67
|
+
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Debounced sync of a single chapter's progress to the DB */
|
|
71
|
+
function syncProgressToDb(chapterId: string, pct: number, scrollPosition: number) {
|
|
72
|
+
fetch("/api/book/progress", {
|
|
73
|
+
method: "PUT",
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ chapterId, progress: pct, scrollPosition }),
|
|
76
|
+
}).catch(() => {
|
|
77
|
+
// Silently fail — localStorage is the primary fallback
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function BookReader({ chapters: CHAPTERS }: { chapters: BookChapter[] }) {
|
|
82
|
+
const router = useRouter();
|
|
83
|
+
const [currentChapter, setCurrentChapter] = useState<BookChapter>(CHAPTERS[0]);
|
|
84
|
+
const [prefs, setPrefs] = useState<ReaderPreferences>(DEFAULT_READER_PREFS);
|
|
85
|
+
const [progress, setProgress] = useState<Record<string, ReadingProgress>>({});
|
|
86
|
+
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
|
87
|
+
const [tocOpen, setTocOpen] = useState(false);
|
|
88
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
89
|
+
const [tocTab, setTocTab] = useState<"chapters" | "bookmarks">("chapters");
|
|
90
|
+
const [activePath, setActivePath] = useState<string | null>(null);
|
|
91
|
+
const [recommendedPath, setRecommendedPath] = useState<string | null>(null);
|
|
92
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
93
|
+
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
94
|
+
|
|
95
|
+
// Sync currentChapter when chapters prop refreshes (e.g., after regeneration)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const refreshed = CHAPTERS.find((ch) => ch.id === currentChapter.id);
|
|
98
|
+
if (refreshed && refreshed !== currentChapter) {
|
|
99
|
+
setCurrentChapter(refreshed);
|
|
100
|
+
}
|
|
101
|
+
}, [CHAPTERS]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
102
|
+
|
|
103
|
+
// Load progress from DB first, fall back to localStorage
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
setPrefs(loadPrefs());
|
|
106
|
+
const localProgress = loadProgress();
|
|
107
|
+
setProgress(localProgress);
|
|
108
|
+
|
|
109
|
+
// Fetch from DB and merge (DB wins for higher progress)
|
|
110
|
+
fetch("/api/book/progress")
|
|
111
|
+
.then((r) => r.json())
|
|
112
|
+
.then((dbProgress: Record<string, { progress: number; scrollPosition: number; lastReadAt: string }>) => {
|
|
113
|
+
setProgress((prev) => {
|
|
114
|
+
const merged = { ...prev };
|
|
115
|
+
for (const [chId, dbEntry] of Object.entries(dbProgress)) {
|
|
116
|
+
const localEntry = merged[chId];
|
|
117
|
+
if (!localEntry || dbEntry.progress > localEntry.progress) {
|
|
118
|
+
merged[chId] = {
|
|
119
|
+
chapterId: chId,
|
|
120
|
+
progress: dbEntry.progress,
|
|
121
|
+
scrollPosition: dbEntry.scrollPosition,
|
|
122
|
+
lastReadAt: dbEntry.lastReadAt,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
saveProgress(merged);
|
|
127
|
+
return merged;
|
|
128
|
+
});
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
// DB unavailable — use localStorage only
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Fetch bookmarks from DB
|
|
135
|
+
fetch("/api/book/bookmarks")
|
|
136
|
+
.then((r) => r.json())
|
|
137
|
+
.then((bms: Bookmark[]) => setBookmarks(bms))
|
|
138
|
+
.catch(() => {});
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
// Load reading path preference and fetch recommendation
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const saved = localStorage.getItem("stagent-book-path");
|
|
144
|
+
if (saved) setActivePath(saved);
|
|
145
|
+
|
|
146
|
+
fetch("/api/book/stage")
|
|
147
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
148
|
+
.then((data) => {
|
|
149
|
+
if (data?.recommendedPath) setRecommendedPath(data.recommendedPath);
|
|
150
|
+
})
|
|
151
|
+
.catch(() => {}); // Silently fail — recommendation is optional
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const handlePathChange = useCallback((pathId: string | null) => {
|
|
155
|
+
setActivePath(pathId);
|
|
156
|
+
if (pathId) {
|
|
157
|
+
localStorage.setItem("stagent-book-path", pathId);
|
|
158
|
+
} else {
|
|
159
|
+
localStorage.removeItem("stagent-book-path");
|
|
160
|
+
}
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// Track scroll progress
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const el = contentRef.current;
|
|
166
|
+
if (!el) return;
|
|
167
|
+
|
|
168
|
+
const handleScroll = () => {
|
|
169
|
+
const scrollTop = el.scrollTop;
|
|
170
|
+
const scrollHeight = el.scrollHeight - el.clientHeight;
|
|
171
|
+
if (scrollHeight <= 0) return;
|
|
172
|
+
const pct = Math.min(1, scrollTop / scrollHeight);
|
|
173
|
+
|
|
174
|
+
setProgress((prev) => {
|
|
175
|
+
const highWater = Math.max(pct, prev[currentChapter.id]?.progress ?? 0);
|
|
176
|
+
const updated = {
|
|
177
|
+
...prev,
|
|
178
|
+
[currentChapter.id]: {
|
|
179
|
+
chapterId: currentChapter.id,
|
|
180
|
+
progress: highWater,
|
|
181
|
+
scrollPosition: scrollTop,
|
|
182
|
+
lastReadAt: new Date().toISOString(),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
saveProgress(updated);
|
|
186
|
+
|
|
187
|
+
// Debounced DB sync
|
|
188
|
+
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
|
189
|
+
syncTimerRef.current = setTimeout(() => {
|
|
190
|
+
syncProgressToDb(currentChapter.id, highWater, scrollTop);
|
|
191
|
+
}, SYNC_DEBOUNCE_MS);
|
|
192
|
+
|
|
193
|
+
return updated;
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
198
|
+
return () => {
|
|
199
|
+
el.removeEventListener("scroll", handleScroll);
|
|
200
|
+
// Flush pending sync on cleanup
|
|
201
|
+
if (syncTimerRef.current) {
|
|
202
|
+
clearTimeout(syncTimerRef.current);
|
|
203
|
+
const entry = progress[currentChapter.id];
|
|
204
|
+
if (entry) {
|
|
205
|
+
syncProgressToDb(currentChapter.id, entry.progress, entry.scrollPosition);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
210
|
+
}, [currentChapter.id]);
|
|
211
|
+
|
|
212
|
+
const updatePrefs = useCallback((patch: Partial<ReaderPreferences>) => {
|
|
213
|
+
setPrefs((prev) => {
|
|
214
|
+
const next = { ...prev, ...patch };
|
|
215
|
+
savePrefs(next);
|
|
216
|
+
return next;
|
|
217
|
+
});
|
|
218
|
+
}, []);
|
|
219
|
+
|
|
220
|
+
const goToChapter = useCallback(
|
|
221
|
+
(chapter: BookChapter, scrollTo?: number) => {
|
|
222
|
+
setCurrentChapter(chapter);
|
|
223
|
+
setTocOpen(false);
|
|
224
|
+
if (scrollTo !== undefined && scrollTo > 0) {
|
|
225
|
+
// Delay to let content render
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
contentRef.current?.scrollTo({ top: scrollTo, behavior: "smooth" });
|
|
228
|
+
}, 100);
|
|
229
|
+
} else {
|
|
230
|
+
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Bookmark: add for current position
|
|
237
|
+
const addBookmark = useCallback(() => {
|
|
238
|
+
const el = contentRef.current;
|
|
239
|
+
if (!el) return;
|
|
240
|
+
|
|
241
|
+
// Find the nearest visible section
|
|
242
|
+
let nearestSection: { id: string; title: string } | null = null;
|
|
243
|
+
for (const section of currentChapter.sections) {
|
|
244
|
+
const sectionEl = document.getElementById(section.id);
|
|
245
|
+
if (sectionEl) {
|
|
246
|
+
const rect = sectionEl.getBoundingClientRect();
|
|
247
|
+
const containerRect = el.getBoundingClientRect();
|
|
248
|
+
if (rect.top <= containerRect.top + 200) {
|
|
249
|
+
nearestSection = section;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const label = nearestSection
|
|
255
|
+
? `Ch. ${currentChapter.number}: ${nearestSection.title}`
|
|
256
|
+
: `Ch. ${currentChapter.number}: ${currentChapter.title}`;
|
|
257
|
+
|
|
258
|
+
fetch("/api/book/bookmarks", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/json" },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
chapterId: currentChapter.id,
|
|
263
|
+
sectionId: nearestSection?.id ?? null,
|
|
264
|
+
scrollPosition: el.scrollTop,
|
|
265
|
+
label,
|
|
266
|
+
}),
|
|
267
|
+
})
|
|
268
|
+
.then((r) => r.json())
|
|
269
|
+
.then((bm: Bookmark) => {
|
|
270
|
+
setBookmarks((prev) => [...prev, bm]);
|
|
271
|
+
})
|
|
272
|
+
.catch(() => {});
|
|
273
|
+
}, [currentChapter]);
|
|
274
|
+
|
|
275
|
+
// Bookmark: remove
|
|
276
|
+
const removeBookmark = useCallback((id: string) => {
|
|
277
|
+
fetch(`/api/book/bookmarks?id=${encodeURIComponent(id)}`, { method: "DELETE" })
|
|
278
|
+
.then(() => {
|
|
279
|
+
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
|
280
|
+
})
|
|
281
|
+
.catch(() => {});
|
|
282
|
+
}, []);
|
|
283
|
+
|
|
284
|
+
// Navigate to bookmark
|
|
285
|
+
const goToBookmark = useCallback(
|
|
286
|
+
(bm: Bookmark) => {
|
|
287
|
+
const chapter = CHAPTERS.find((ch) => ch.id === bm.chapterId);
|
|
288
|
+
if (chapter) {
|
|
289
|
+
goToChapter(chapter, bm.scrollPosition);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[goToChapter]
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Check if current position has a bookmark nearby
|
|
296
|
+
const currentChapterBookmarks = bookmarks.filter((b) => b.chapterId === currentChapter.id);
|
|
297
|
+
|
|
298
|
+
const currentIndex = CHAPTERS.findIndex((ch) => ch.id === currentChapter.id);
|
|
299
|
+
|
|
300
|
+
// Path-aware navigation: when a path is active, prev/next follow the path order
|
|
301
|
+
const prevChapter = (() => {
|
|
302
|
+
if (!activePath) {
|
|
303
|
+
return currentIndex > 0 ? CHAPTERS[currentIndex - 1] : null;
|
|
304
|
+
}
|
|
305
|
+
const path = getReadingPath(activePath);
|
|
306
|
+
if (!path) return currentIndex > 0 ? CHAPTERS[currentIndex - 1] : null;
|
|
307
|
+
const pathIdx = path.chapterIds.indexOf(currentChapter.id);
|
|
308
|
+
if (pathIdx <= 0) return null;
|
|
309
|
+
return CHAPTERS.find((ch) => ch.id === path.chapterIds[pathIdx - 1]) ?? null;
|
|
310
|
+
})();
|
|
311
|
+
|
|
312
|
+
const nextChapter = (() => {
|
|
313
|
+
if (!activePath) {
|
|
314
|
+
return currentIndex < CHAPTERS.length - 1 ? CHAPTERS[currentIndex + 1] : null;
|
|
315
|
+
}
|
|
316
|
+
const nextId = getNextPathChapter(activePath, currentChapter.id);
|
|
317
|
+
if (!nextId) return null;
|
|
318
|
+
return CHAPTERS.find((ch) => ch.id === nextId) ?? null;
|
|
319
|
+
})();
|
|
320
|
+
|
|
321
|
+
// Keyboard navigation: Left/Right arrow keys for chapter nav
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
324
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
325
|
+
if (e.key === "ArrowLeft" && prevChapter) {
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
goToChapter(prevChapter);
|
|
328
|
+
} else if (e.key === "ArrowRight" && nextChapter) {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
goToChapter(nextChapter);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
334
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
335
|
+
}, [prevChapter, nextChapter, goToChapter]);
|
|
336
|
+
|
|
337
|
+
const chaptersByPart = (() => {
|
|
338
|
+
const grouped = new Map<number, BookChapter[]>();
|
|
339
|
+
for (const ch of CHAPTERS) {
|
|
340
|
+
const part = ch.part.number;
|
|
341
|
+
if (!grouped.has(part)) grouped.set(part, []);
|
|
342
|
+
grouped.get(part)!.push(ch);
|
|
343
|
+
}
|
|
344
|
+
return grouped;
|
|
345
|
+
})();
|
|
346
|
+
|
|
347
|
+
const fontFamilyClass =
|
|
348
|
+
prefs.fontFamily === "serif"
|
|
349
|
+
? "font-serif"
|
|
350
|
+
: prefs.fontFamily === "mono"
|
|
351
|
+
? "font-mono"
|
|
352
|
+
: "font-sans";
|
|
353
|
+
|
|
354
|
+
const themeClass = "book-reader-container";
|
|
355
|
+
|
|
356
|
+
const overallProgress =
|
|
357
|
+
CHAPTERS.length > 0
|
|
358
|
+
? CHAPTERS.reduce((sum, ch) => sum + (progress[ch.id]?.progress ?? 0), 0) /
|
|
359
|
+
CHAPTERS.length
|
|
360
|
+
: 0;
|
|
361
|
+
|
|
362
|
+
const completedChapters = CHAPTERS.filter((ch) => (progress[ch.id]?.progress ?? 0) >= 0.9).length;
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className={cn("flex flex-col h-[calc(100vh-3.5rem)]", themeClass)} data-book-theme={prefs.theme}>
|
|
366
|
+
{/* Top bar */}
|
|
367
|
+
<header className="flex items-center justify-between border-b border-border px-4 py-2 shrink-0">
|
|
368
|
+
<div className="flex items-center gap-2">
|
|
369
|
+
{/* TOC toggle */}
|
|
370
|
+
<Sheet open={tocOpen} onOpenChange={setTocOpen}>
|
|
371
|
+
<SheetTrigger asChild>
|
|
372
|
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
373
|
+
<List className="h-4 w-4" />
|
|
374
|
+
</Button>
|
|
375
|
+
</SheetTrigger>
|
|
376
|
+
<SheetContent side="left" className="w-80">
|
|
377
|
+
<SheetHeader className="p-4">
|
|
378
|
+
<SheetTitle className="flex items-center gap-2">
|
|
379
|
+
<BookOpen className="h-4 w-4" />
|
|
380
|
+
Contents
|
|
381
|
+
</SheetTitle>
|
|
382
|
+
</SheetHeader>
|
|
383
|
+
<div className="px-4 pb-4 space-y-4 overflow-y-auto">
|
|
384
|
+
{/* Tab switcher */}
|
|
385
|
+
<div className="flex gap-1 p-1 rounded-lg bg-muted">
|
|
386
|
+
<button
|
|
387
|
+
onClick={() => setTocTab("chapters")}
|
|
388
|
+
className={cn(
|
|
389
|
+
"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors cursor-pointer",
|
|
390
|
+
tocTab === "chapters"
|
|
391
|
+
? "bg-background shadow-sm"
|
|
392
|
+
: "text-muted-foreground hover:text-foreground"
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
395
|
+
Chapters
|
|
396
|
+
</button>
|
|
397
|
+
<button
|
|
398
|
+
onClick={() => setTocTab("bookmarks")}
|
|
399
|
+
className={cn(
|
|
400
|
+
"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors cursor-pointer",
|
|
401
|
+
tocTab === "bookmarks"
|
|
402
|
+
? "bg-background shadow-sm"
|
|
403
|
+
: "text-muted-foreground hover:text-foreground"
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
Bookmarks{bookmarks.length > 0 && ` (${bookmarks.length})`}
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{/* Overall progress summary */}
|
|
411
|
+
{tocTab === "chapters" && (
|
|
412
|
+
<div className="flex items-center gap-3 px-1">
|
|
413
|
+
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
414
|
+
<div
|
|
415
|
+
className="h-full bg-primary rounded-full transition-all"
|
|
416
|
+
style={{ width: `${overallProgress * 100}%` }}
|
|
417
|
+
/>
|
|
418
|
+
</div>
|
|
419
|
+
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
420
|
+
{completedChapters}/{CHAPTERS.length} complete
|
|
421
|
+
</span>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{tocTab === "chapters" ? (
|
|
426
|
+
<div className="space-y-6">
|
|
427
|
+
{/* Reading path selector */}
|
|
428
|
+
<PathSelector
|
|
429
|
+
activePath={activePath}
|
|
430
|
+
recommendedPath={recommendedPath}
|
|
431
|
+
onSelectPath={handlePathChange}
|
|
432
|
+
/>
|
|
433
|
+
|
|
434
|
+
{PARTS.map((part) => (
|
|
435
|
+
<div key={part.number}>
|
|
436
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
|
437
|
+
Part {part.number}: {part.title}
|
|
438
|
+
</p>
|
|
439
|
+
<p className="text-xs text-muted-foreground mb-3">
|
|
440
|
+
{part.description}
|
|
441
|
+
</p>
|
|
442
|
+
<div className="space-y-1">
|
|
443
|
+
{(chaptersByPart.get(part.number) ?? []).map((ch) => {
|
|
444
|
+
const chProgress = progress[ch.id]?.progress ?? 0;
|
|
445
|
+
const chPct = Math.round(chProgress * 100);
|
|
446
|
+
const inPath = !activePath || isChapterInPath(activePath, ch.id);
|
|
447
|
+
return (
|
|
448
|
+
<button
|
|
449
|
+
key={ch.id}
|
|
450
|
+
onClick={() => goToChapter(ch)}
|
|
451
|
+
className={cn(
|
|
452
|
+
"w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer",
|
|
453
|
+
ch.id === currentChapter.id
|
|
454
|
+
? "bg-primary/10 text-primary font-medium"
|
|
455
|
+
: "hover:bg-muted",
|
|
456
|
+
!inPath && "opacity-40"
|
|
457
|
+
)}
|
|
458
|
+
>
|
|
459
|
+
<div className="flex items-center justify-between">
|
|
460
|
+
<span>
|
|
461
|
+
{ch.number}. {ch.title}
|
|
462
|
+
</span>
|
|
463
|
+
<span className="flex items-center gap-1.5">
|
|
464
|
+
{!inPath && (
|
|
465
|
+
<span className="text-[10px] text-muted-foreground">Not in path</span>
|
|
466
|
+
)}
|
|
467
|
+
{ch.sections.length === 0 ? (
|
|
468
|
+
<Sparkles className="h-3.5 w-3.5 text-muted-foreground/40" />
|
|
469
|
+
) : chProgress >= 0.9 ? (
|
|
470
|
+
<Check className="h-3.5 w-3.5 text-status-completed" />
|
|
471
|
+
) : chProgress > 0 ? (
|
|
472
|
+
<span className="text-xs text-muted-foreground">{chPct}%</span>
|
|
473
|
+
) : null}
|
|
474
|
+
</span>
|
|
475
|
+
</div>
|
|
476
|
+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
|
477
|
+
{ch.subtitle}
|
|
478
|
+
</p>
|
|
479
|
+
{chProgress > 0 && chProgress < 0.9 && (
|
|
480
|
+
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
|
|
481
|
+
<div
|
|
482
|
+
className="h-full bg-primary/40 rounded-full transition-all"
|
|
483
|
+
style={{ width: `${chPct}%` }}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
</button>
|
|
488
|
+
);
|
|
489
|
+
})}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
))}
|
|
493
|
+
</div>
|
|
494
|
+
) : (
|
|
495
|
+
<div className="space-y-1">
|
|
496
|
+
{bookmarks.length === 0 ? (
|
|
497
|
+
<div className="text-center py-8">
|
|
498
|
+
<BookmarkIcon className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
|
499
|
+
<p className="text-sm text-muted-foreground">No bookmarks yet</p>
|
|
500
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
501
|
+
Use the bookmark button while reading to save your place
|
|
502
|
+
</p>
|
|
503
|
+
</div>
|
|
504
|
+
) : (
|
|
505
|
+
bookmarks.map((bm) => (
|
|
506
|
+
<div
|
|
507
|
+
key={bm.id}
|
|
508
|
+
className="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-muted group"
|
|
509
|
+
>
|
|
510
|
+
<button
|
|
511
|
+
onClick={() => goToBookmark(bm)}
|
|
512
|
+
className="flex-1 text-left cursor-pointer"
|
|
513
|
+
>
|
|
514
|
+
<p className="text-sm font-medium">{bm.label}</p>
|
|
515
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
516
|
+
{new Date(bm.createdAt).toLocaleDateString(undefined, {
|
|
517
|
+
month: "short",
|
|
518
|
+
day: "numeric",
|
|
519
|
+
hour: "numeric",
|
|
520
|
+
minute: "2-digit",
|
|
521
|
+
})}
|
|
522
|
+
</p>
|
|
523
|
+
</button>
|
|
524
|
+
<button
|
|
525
|
+
onClick={() => removeBookmark(bm.id)}
|
|
526
|
+
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all cursor-pointer p-1"
|
|
527
|
+
title="Remove bookmark"
|
|
528
|
+
>
|
|
529
|
+
<BookmarkMinus className="h-3.5 w-3.5" />
|
|
530
|
+
</button>
|
|
531
|
+
</div>
|
|
532
|
+
))
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
</div>
|
|
537
|
+
</SheetContent>
|
|
538
|
+
</Sheet>
|
|
539
|
+
|
|
540
|
+
<div className="hidden sm:block">
|
|
541
|
+
<p className="text-sm font-medium">
|
|
542
|
+
Chapter {currentChapter.number}: {currentChapter.title}
|
|
543
|
+
</p>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<div className="flex items-center gap-1">
|
|
548
|
+
<span className="text-xs text-muted-foreground mr-2 hidden sm:inline-flex items-center gap-1">
|
|
549
|
+
<Clock className="h-3 w-3" />
|
|
550
|
+
{currentChapter.readingTime} min
|
|
551
|
+
</span>
|
|
552
|
+
|
|
553
|
+
{/* Overall progress or path progress */}
|
|
554
|
+
{activePath ? (
|
|
555
|
+
<div className="hidden sm:flex mr-2">
|
|
556
|
+
<PathProgress pathId={activePath} progress={progress} />
|
|
557
|
+
</div>
|
|
558
|
+
) : (
|
|
559
|
+
<div className="hidden sm:flex items-center gap-2 mr-2">
|
|
560
|
+
<div className="w-20 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
561
|
+
<div
|
|
562
|
+
className="h-full bg-primary rounded-full transition-all"
|
|
563
|
+
style={{ width: `${overallProgress * 100}%` }}
|
|
564
|
+
/>
|
|
565
|
+
</div>
|
|
566
|
+
<span className="text-xs text-muted-foreground">
|
|
567
|
+
{Math.round(overallProgress * 100)}%
|
|
568
|
+
</span>
|
|
569
|
+
</div>
|
|
570
|
+
)}
|
|
571
|
+
|
|
572
|
+
{/* Bookmark button */}
|
|
573
|
+
<Button
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="icon"
|
|
576
|
+
className="h-8 w-8"
|
|
577
|
+
onClick={addBookmark}
|
|
578
|
+
title="Bookmark this position"
|
|
579
|
+
>
|
|
580
|
+
{currentChapterBookmarks.length > 0 ? (
|
|
581
|
+
<BookmarkIcon className="h-4 w-4 fill-primary text-primary" />
|
|
582
|
+
) : (
|
|
583
|
+
<BookmarkPlus className="h-4 w-4" />
|
|
584
|
+
)}
|
|
585
|
+
</Button>
|
|
586
|
+
|
|
587
|
+
{/* Settings */}
|
|
588
|
+
<Sheet open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
589
|
+
<SheetTrigger asChild>
|
|
590
|
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
591
|
+
<Settings2 className="h-4 w-4" />
|
|
592
|
+
</Button>
|
|
593
|
+
</SheetTrigger>
|
|
594
|
+
<SheetContent side="right" className="w-80">
|
|
595
|
+
<SheetHeader className="p-4">
|
|
596
|
+
<SheetTitle>Reading Settings</SheetTitle>
|
|
597
|
+
</SheetHeader>
|
|
598
|
+
<div className="px-6 pb-6 space-y-6">
|
|
599
|
+
{/* Font size */}
|
|
600
|
+
<div>
|
|
601
|
+
<label className="text-sm font-medium mb-2 block">
|
|
602
|
+
Font Size: {prefs.fontSize}px
|
|
603
|
+
</label>
|
|
604
|
+
<Slider
|
|
605
|
+
value={[prefs.fontSize]}
|
|
606
|
+
min={14}
|
|
607
|
+
max={22}
|
|
608
|
+
step={2}
|
|
609
|
+
onValueChange={([v]) => updatePrefs({ fontSize: v })}
|
|
610
|
+
/>
|
|
611
|
+
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
|
612
|
+
<span>Small</span>
|
|
613
|
+
<span>Large</span>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
{/* Line height */}
|
|
618
|
+
<div>
|
|
619
|
+
<label className="text-sm font-medium mb-2 block">
|
|
620
|
+
Line Height: {prefs.lineHeight.toFixed(2)}
|
|
621
|
+
</label>
|
|
622
|
+
<Slider
|
|
623
|
+
value={[prefs.lineHeight * 100]}
|
|
624
|
+
min={150}
|
|
625
|
+
max={200}
|
|
626
|
+
step={5}
|
|
627
|
+
onValueChange={([v]) => updatePrefs({ lineHeight: v / 100 })}
|
|
628
|
+
/>
|
|
629
|
+
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
|
630
|
+
<span>Compact</span>
|
|
631
|
+
<span>Relaxed</span>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
|
|
635
|
+
{/* Font family */}
|
|
636
|
+
<div>
|
|
637
|
+
<label className="text-sm font-medium mb-3 block">Font</label>
|
|
638
|
+
<div className="grid grid-cols-3 gap-2">
|
|
639
|
+
{(["sans", "serif", "mono"] as const).map((f) => (
|
|
640
|
+
<button
|
|
641
|
+
key={f}
|
|
642
|
+
onClick={() => updatePrefs({ fontFamily: f })}
|
|
643
|
+
className={cn(
|
|
644
|
+
"px-3 py-2 rounded-lg border text-sm capitalize transition-colors cursor-pointer",
|
|
645
|
+
prefs.fontFamily === f
|
|
646
|
+
? "border-primary bg-primary/10 text-primary"
|
|
647
|
+
: "border-border hover:bg-muted"
|
|
648
|
+
)}
|
|
649
|
+
>
|
|
650
|
+
<span className={f === "serif" ? "font-serif" : f === "mono" ? "font-mono" : "font-sans"}>
|
|
651
|
+
{f}
|
|
652
|
+
</span>
|
|
653
|
+
</button>
|
|
654
|
+
))}
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
{/* Theme */}
|
|
659
|
+
<div>
|
|
660
|
+
<label className="text-sm font-medium mb-3 block">Reader Theme</label>
|
|
661
|
+
<div className="grid grid-cols-3 gap-2">
|
|
662
|
+
{(["light", "sepia", "dark"] as const).map((t) => (
|
|
663
|
+
<button
|
|
664
|
+
key={t}
|
|
665
|
+
onClick={() => updatePrefs({ theme: t })}
|
|
666
|
+
className={cn(
|
|
667
|
+
"px-3 py-2 rounded-lg border text-sm capitalize transition-colors cursor-pointer",
|
|
668
|
+
`book-theme-preview-${t}`,
|
|
669
|
+
prefs.theme === t
|
|
670
|
+
? "border-primary bg-primary/10 text-primary"
|
|
671
|
+
: "border-border hover:bg-muted"
|
|
672
|
+
)}
|
|
673
|
+
>
|
|
674
|
+
{t}
|
|
675
|
+
</button>
|
|
676
|
+
))}
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</SheetContent>
|
|
681
|
+
</Sheet>
|
|
682
|
+
</div>
|
|
683
|
+
</header>
|
|
684
|
+
|
|
685
|
+
{/* Reading area */}
|
|
686
|
+
<div ref={contentRef} className="flex-1 overflow-y-auto">
|
|
687
|
+
<article
|
|
688
|
+
className={cn("mx-auto max-w-2xl px-6 py-10 sm:px-8 sm:py-14", fontFamilyClass)}
|
|
689
|
+
style={{ fontSize: `${prefs.fontSize}px`, lineHeight: prefs.lineHeight }}
|
|
690
|
+
>
|
|
691
|
+
{/* Chapter header */}
|
|
692
|
+
<header className="mb-12">
|
|
693
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
|
694
|
+
Part {currentChapter.part.number}: {currentChapter.part.title}
|
|
695
|
+
</p>
|
|
696
|
+
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight mb-3">
|
|
697
|
+
Chapter {currentChapter.number}: {currentChapter.title}
|
|
698
|
+
</h1>
|
|
699
|
+
<p className="text-lg text-muted-foreground">{currentChapter.subtitle}</p>
|
|
700
|
+
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
|
|
701
|
+
<span className="inline-flex items-center gap-1">
|
|
702
|
+
<Clock className="h-3.5 w-3.5" />
|
|
703
|
+
{currentChapter.readingTime} min read
|
|
704
|
+
</span>
|
|
705
|
+
<span>
|
|
706
|
+
{currentChapter.sections.length} section{currentChapter.sections.length !== 1 && "s"}
|
|
707
|
+
</span>
|
|
708
|
+
{(progress[currentChapter.id]?.progress ?? 0) > 0 && (
|
|
709
|
+
<span className="inline-flex items-center gap-1">
|
|
710
|
+
{Math.round((progress[currentChapter.id]?.progress ?? 0) * 100)}% read
|
|
711
|
+
</span>
|
|
712
|
+
)}
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
{/* Chapter generation bar */}
|
|
716
|
+
<ChapterGenerationBar
|
|
717
|
+
chapterId={currentChapter.id}
|
|
718
|
+
chapterTitle={currentChapter.title}
|
|
719
|
+
chapterNumber={currentChapter.number}
|
|
720
|
+
hasContent={currentChapter.sections.length > 0}
|
|
721
|
+
onComplete={() => router.refresh()}
|
|
722
|
+
/>
|
|
723
|
+
|
|
724
|
+
<hr className="mt-8 border-border/50" />
|
|
725
|
+
</header>
|
|
726
|
+
|
|
727
|
+
{/* Sections or empty state */}
|
|
728
|
+
{currentChapter.sections.length > 0 ? (
|
|
729
|
+
currentChapter.sections.map((section) => (
|
|
730
|
+
<section key={section.id} id={section.id} className="mb-12">
|
|
731
|
+
<h2 className="text-2xl font-semibold tracking-tight mb-6">
|
|
732
|
+
{section.title}
|
|
733
|
+
</h2>
|
|
734
|
+
<div className="space-y-2">
|
|
735
|
+
{section.content.map((block, i) => (
|
|
736
|
+
<ContentBlockRenderer key={i} block={block} />
|
|
737
|
+
))}
|
|
738
|
+
</div>
|
|
739
|
+
</section>
|
|
740
|
+
))
|
|
741
|
+
) : (
|
|
742
|
+
<div className="text-center py-16 space-y-4">
|
|
743
|
+
<Sparkles className="h-12 w-12 text-muted-foreground/30 mx-auto" />
|
|
744
|
+
<h3 className="text-lg font-medium">This chapter hasn't been written yet</h3>
|
|
745
|
+
<p className="text-muted-foreground text-sm max-w-md mx-auto">
|
|
746
|
+
Generate it from the source material using the button above.
|
|
747
|
+
</p>
|
|
748
|
+
</div>
|
|
749
|
+
)}
|
|
750
|
+
|
|
751
|
+
{/* Chapter footer */}
|
|
752
|
+
<footer className="mt-12 pt-6 border-t border-border/30 text-xs text-muted-foreground/60">
|
|
753
|
+
<span>
|
|
754
|
+
Chapter {currentChapter.number} of {CHAPTERS.length}
|
|
755
|
+
</span>
|
|
756
|
+
</footer>
|
|
757
|
+
|
|
758
|
+
{/* Try It Now — related Playbook docs */}
|
|
759
|
+
{currentChapter.relatedDocs && currentChapter.relatedDocs.length > 0 && (
|
|
760
|
+
<TryItNow
|
|
761
|
+
relatedDocs={currentChapter.relatedDocs}
|
|
762
|
+
relatedJourney={currentChapter.relatedJourney}
|
|
763
|
+
/>
|
|
764
|
+
)}
|
|
765
|
+
|
|
766
|
+
{/* Chapter navigation */}
|
|
767
|
+
<nav className="flex items-center justify-between border-t border-border/50 pt-8 mt-16">
|
|
768
|
+
{prevChapter ? (
|
|
769
|
+
<button
|
|
770
|
+
onClick={() => goToChapter(prevChapter)}
|
|
771
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-lg px-3 py-2 -mx-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
772
|
+
>
|
|
773
|
+
<ChevronLeft className="h-4 w-4" />
|
|
774
|
+
<div className="text-left">
|
|
775
|
+
<p className="text-xs text-muted-foreground">Previous</p>
|
|
776
|
+
<p className="font-medium">Ch. {prevChapter.number}: {prevChapter.title}</p>
|
|
777
|
+
</div>
|
|
778
|
+
</button>
|
|
779
|
+
) : (
|
|
780
|
+
<div />
|
|
781
|
+
)}
|
|
782
|
+
{nextChapter ? (
|
|
783
|
+
<button
|
|
784
|
+
onClick={() => goToChapter(nextChapter)}
|
|
785
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-lg px-3 py-2 -mx-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
786
|
+
>
|
|
787
|
+
<div className="text-right">
|
|
788
|
+
<p className="text-xs text-muted-foreground">Next</p>
|
|
789
|
+
<p className="font-medium">Ch. {nextChapter.number}: {nextChapter.title}</p>
|
|
790
|
+
</div>
|
|
791
|
+
<ChevronRight className="h-4 w-4" />
|
|
792
|
+
</button>
|
|
793
|
+
) : (
|
|
794
|
+
<div />
|
|
795
|
+
)}
|
|
796
|
+
</nav>
|
|
797
|
+
</article>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
);
|
|
801
|
+
}
|