Address Reid's PR #25 review comments

🔴 Blocking fixes:
- Add reader app migration (apps/reader/migrations/0001_initial.py)
- Remove duplicate ReadingSettings from apps.books to resolve model clash
  with apps.reader.ReadingSettings (keep richer reader version)
- Redirect books serializers/views to use reader app's ReadingSettings
- Update frontend API endpoints to match main's backend routes:
  /api/books/ebooks/{id}/toc/ (chapters)
  /api/books/ebooks/{id}/content/?page=N (chapter content)
  /api/books/ebooks/{id}/progress/ (GET/PATCH progress)
- Add DOMPurify sanitization for dangerouslySetInnerHTML (XSS fix)

🟡 Non-blocking fixes:
- Fix CSS typo: landascape -> landscape in reader.css
- Add null-safe fallback for book.title in ReadingPage
This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 06:37:58 +00:00
parent edac7cb08a
commit a7864bf5cd
9 changed files with 311 additions and 98 deletions
+8 -7
View File
@@ -11,20 +11,21 @@
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.7.9",
"dompurify": "^3.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"axios": "^1.7.9"
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.0",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vitest": "^2.1.0",
"@testing-library/react": "^16.2.0",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.0"
"vitest": "^2.1.0"
}
}
}
+49 -13
View File
@@ -46,67 +46,103 @@ export async function updateReadingSettings(
}
/**
* Fetch the table of contents (chapter list) for a book.
* Fetch the table of contents (chapter list) for a book (ebook).
* Maps to EBookViewSet.toc → GET /api/books/ebooks/{id}/toc/
*/
export async function getChapters(bookId: number): Promise<ChapterSummary[]> {
const response = await fetch(`${API_BASE}/books/${bookId}/chapters/`);
const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/toc/`);
if (!response.ok) {
throw new Error(
`Failed to fetch chapters: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<ChapterSummary[]>;
const data = await response.json();
// Main backend wraps chapters under a "chapters" key
return (data.chapters ?? data) as ChapterSummary[];
}
/**
* Fetch a specific chapter with full content for reading.
* Maps to EBookViewSet.content → GET /api/books/ebooks/{id}/content/?page={number}
*/
export async function getChapterContent(
bookId: number,
chapterNumber: number
): Promise<ChapterDetail> {
const response = await fetch(
`${API_BASE}/books/${bookId}/chapters/${chapterNumber}/`
`${API_BASE}/books/ebooks/${bookId}/content/?page=${chapterNumber}`
);
if (!response.ok) {
throw new Error(
`Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<ChapterDetail>;
const data = await response.json();
// Main backend returns: { page, total_pages, content, chapter_title, format }
return {
id: chapterNumber,
book: bookId,
title: data.chapter_title ?? "",
number: data.page,
content: data.content ?? "",
created_at: "",
updated_at: "",
} as ChapterDetail;
}
/**
* Fetch reading progress for a book.
* Fetch reading progress for a book (ebook).
* Maps to EBookViewSet.progress → GET /api/books/ebooks/{id}/progress/
*/
export async function getReadingProgress(
bookId: number
): Promise<ReadingProgress> {
const response = await fetch(`${API_BASE}/books/${bookId}/progress/`);
const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/progress/`);
if (!response.ok) {
throw new Error(
`Failed to fetch reading progress: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<ReadingProgress>;
const data = await response.json();
// Main backend returns: { current_position, last_page, version, updated_at }
return {
id: bookId,
book: bookId,
current_chapter: Math.floor((data.current_position ?? 0) / 10) + 1,
current_position: data.current_position ?? 0,
percentage: data.current_position ?? 0,
updated_at: data.updated_at ?? "",
} as ReadingProgress;
}
/**
* Update reading progress for a book.
* Update reading progress for a book (ebook).
* Maps to EBookViewSet.progress → PATCH /api/books/ebooks/{id}/progress/
*/
export async function updateReadingProgress(
bookId: number,
progress: Partial<ReadingProgress>
): Promise<ReadingProgress> {
const response = await fetch(`${API_BASE}/books/${bookId}/progress/`, {
method: "PUT",
const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/progress/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(progress),
body: JSON.stringify({
current_position: progress.percentage ?? progress.current_position ?? 0,
last_page: progress.current_chapter ?? 0,
}),
});
if (!response.ok) {
throw new Error(
`Failed to update reading progress: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<ReadingProgress>;
const data = await response.json();
return {
id: bookId,
book: bookId,
current_chapter: Math.floor((data.current_position ?? 0) / 10) + 1,
current_position: data.current_position ?? 0,
percentage: data.current_position ?? 0,
updated_at: data.updated_at ?? "",
} as ReadingProgress;
}
+9 -2
View File
@@ -1,10 +1,12 @@
/**
* ReadingPage — full-screen reading view for ebooks.
* Integrates TOC drawer, settings panel, chapter navigation, and progress tracking.
* Sanitizes HTML content with DOMPurify to prevent XSS.
*/
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import DOMPurify from "dompurify";
import { useChapters } from "../hooks/useChapters";
import { useReadingProgress } from "../hooks/useReadingProgress";
import { useReadingSettings } from "../hooks/useReadingSettings";
@@ -93,6 +95,11 @@ export default function ReadingPage() {
}
}, [settings.orientation_lock]);
// Sanitize chapter content to prevent XSS
const sanitizedContent = currentChapter?.content
? DOMPurify.sanitize(currentChapter.content)
: "";
// Show loading state
if (bookLoading || (isLoading && !currentChapter)) {
return (
@@ -124,7 +131,7 @@ export default function ReadingPage() {
>
<div className="reader-container">
<ReaderToolbar
bookTitle={book.title}
bookTitle={book?.title ?? "Loading..."}
chapterTitle={currentChapter?.title ?? "Loading..."}
progress={progress}
onToggleToc={() => setTocOpen((v) => !v)}
@@ -159,7 +166,7 @@ export default function ReadingPage() {
<div
className="reader-chapter-body"
dangerouslySetInnerHTML={{
__html: currentChapter.content,
__html: sanitizedContent,
}}
/>
</article>
+1 -1
View File
@@ -43,7 +43,7 @@
}
}
@media (orientation: landascape) {
@media (orientation: landscape) {
.reader-container {
--reader-margin: 24px;
}