Archived
Resolve merge conflicts for PR #25: customizable mobile reading experience
- Backend: Replace PR's Book-only models with main's full models (EBook, BookChapter, DownloadRecord, ReadingProgress, ReadingSettings) - Backend: Add reader app (ReadingSettings model, serializer, view, URL) - Frontend: Port reader feature files from web/ to frontend/ (api, hooks, components/reader/, pages/ReadingPage, types/reader, reader.css) - Frontend: Add /read/:id route to App.tsx for chapter-based reading view - All imports adjusted for frontend/src directory structure
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ReaderToolbar — fixed bottom toolbar for the reading view.
|
||||
* Provides TOC toggle, settings toggle, and progress indicator.
|
||||
*/
|
||||
|
||||
import type { ReadingProgress } from "../../types/reader";
|
||||
|
||||
interface ReaderToolbarProps {
|
||||
bookTitle: string;
|
||||
chapterTitle: string;
|
||||
progress: ReadingProgress | null;
|
||||
onToggleToc: () => void;
|
||||
onToggleSettings: () => void;
|
||||
}
|
||||
|
||||
export default function ReaderToolbar({
|
||||
bookTitle,
|
||||
chapterTitle,
|
||||
progress,
|
||||
onToggleToc,
|
||||
onToggleSettings,
|
||||
}: ReaderToolbarProps) {
|
||||
const percentage = progress?.percentage ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<header className="reader-top-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="reader-bar-btn"
|
||||
onClick={onToggleToc}
|
||||
aria-label="Table of contents"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="reader-bar-title">
|
||||
<span className="reader-bar-book">{bookTitle}</span>
|
||||
<span className="reader-bar-chapter">{chapterTitle}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="reader-bar-btn"
|
||||
onClick={onToggleSettings}
|
||||
aria-label="Reading settings"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Bottom progress bar */}
|
||||
<div className="reader-progress-bar">
|
||||
<div
|
||||
className="reader-progress-fill"
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ReadingSettingsPanel — slide-in drawer from the right for customizing
|
||||
* the reading experience: theme, font, sizing, orientation.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
FontFamily,
|
||||
OrientationLock,
|
||||
ReadingSettings,
|
||||
ThemePreset,
|
||||
} from "../../types/reader";
|
||||
|
||||
interface ReadingSettingsPanelProps {
|
||||
settings: ReadingSettings;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpdate: (partial: Partial<ReadingSettings>) => Promise<void>;
|
||||
}
|
||||
|
||||
const THEME_OPTIONS: { value: ThemePreset; label: string }[] = [
|
||||
{ value: "sepia", label: "Sepia" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "paper", label: "Paper" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS: { value: FontFamily; label: string }[] = [
|
||||
{ value: "sans-serif", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "monospace", label: "Monospace" },
|
||||
];
|
||||
|
||||
const ORIENTATION_OPTIONS: { value: OrientationLock; label: string }[] = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "portrait", label: "Portrait" },
|
||||
{ value: "landscape", label: "Landscape" },
|
||||
];
|
||||
|
||||
export default function ReadingSettingsPanel({
|
||||
settings,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
}: ReadingSettingsPanelProps) {
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({});
|
||||
|
||||
const handleChange = async (
|
||||
key: keyof ReadingSettings,
|
||||
value: string | number
|
||||
) => {
|
||||
setSaving((prev) => ({ ...prev, [key]: true }));
|
||||
try {
|
||||
await onUpdate({ [key]: value as never });
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="settings-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<aside
|
||||
className={`settings-drawer ${isOpen ? "settings-drawer--open" : ""}`}
|
||||
>
|
||||
<div className="settings-header">
|
||||
<h2 className="settings-title">Reading Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
{/* Theme Presets */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">Theme</h3>
|
||||
<div className="theme-grid">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`theme-btn ${
|
||||
settings.theme === opt.value ? "theme-btn--active" : ""
|
||||
}`}
|
||||
onClick={() => handleChange("theme", opt.value)}
|
||||
disabled={saving.theme}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Font Family */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">Font</h3>
|
||||
<div className="font-grid">
|
||||
{FONT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`font-btn ${
|
||||
settings.font_family === opt.value ? "font-btn--active" : ""
|
||||
}`}
|
||||
onClick={() => handleChange("font_family", opt.value)}
|
||||
disabled={saving.font_family}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Font Size Slider */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">
|
||||
Font Size: {settings.font_size}px
|
||||
</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="32"
|
||||
value={settings.font_size}
|
||||
onChange={(e) =>
|
||||
handleChange("font_size", Number(e.target.value))
|
||||
}
|
||||
className="settings-slider"
|
||||
aria-label="Font size"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Line Height Slider */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">
|
||||
Line Height: {settings.line_height.toFixed(1)}
|
||||
</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="1.2"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={settings.line_height}
|
||||
onChange={(e) =>
|
||||
handleChange("line_height", Number(e.target.value))
|
||||
}
|
||||
className="settings-slider"
|
||||
aria-label="Line height"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Margin Width Slider */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">
|
||||
Margins: {settings.margin_width}px
|
||||
</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="8"
|
||||
max="48"
|
||||
value={settings.margin_width}
|
||||
onChange={(e) =>
|
||||
handleChange("margin_width", Number(e.target.value))
|
||||
}
|
||||
className="settings-slider"
|
||||
aria-label="Margin width"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Brightness Slider */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">
|
||||
Brightness: {settings.brightness}%
|
||||
</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={settings.brightness}
|
||||
onChange={(e) =>
|
||||
handleChange("brightness", Number(e.target.value))
|
||||
}
|
||||
className="settings-slider"
|
||||
aria-label="Brightness"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Orientation Lock */}
|
||||
<section className="settings-section">
|
||||
<h3 className="settings-section-title">Orientation</h3>
|
||||
<div className="orientation-grid">
|
||||
{ORIENTATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`orientation-btn ${
|
||||
settings.orientation_lock === opt.value
|
||||
? "orientation-btn--active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleChange("orientation_lock", opt.value)}
|
||||
disabled={saving.orientation_lock}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* TableOfContents — slide-in drawer listing all chapters.
|
||||
* Tap a chapter to navigate. Current chapter is highlighted.
|
||||
*/
|
||||
|
||||
import type { ChapterSummary } from "../../types/reader";
|
||||
|
||||
interface TableOfContentsProps {
|
||||
chapters: ChapterSummary[];
|
||||
currentChapterNumber: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNavigate: (number: number) => void;
|
||||
}
|
||||
|
||||
export default function TableOfContents({
|
||||
chapters,
|
||||
currentChapterNumber,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: TableOfContentsProps) {
|
||||
const handleChapterClick = (number: number) => {
|
||||
onNavigate(number);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="toc-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
<aside className={`toc-drawer ${isOpen ? "toc-drawer--open" : ""}`}>
|
||||
<div className="toc-header">
|
||||
<h2 className="toc-title">Contents</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="toc-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Close table of contents"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="toc-list">
|
||||
{chapters.length === 0 && (
|
||||
<p className="toc-empty">No chapters available.</p>
|
||||
)}
|
||||
{chapters.map((chapter) => (
|
||||
<button
|
||||
key={chapter.number}
|
||||
type="button"
|
||||
className={`toc-item ${
|
||||
chapter.number === currentChapterNumber ? "toc-item--active" : ""
|
||||
}`}
|
||||
onClick={() => handleChapterClick(chapter.number)}
|
||||
>
|
||||
<span className="toc-item-number">{chapter.number}</span>
|
||||
<span className="toc-item-title">{chapter.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user