From b29ea8211c441c365c10d87486f8f85110c3a368 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 02:55:36 +0000 Subject: [PATCH 1/4] feat: customizable mobile reading experience - Backend: Chapter, ReadingProgress, ReadingSettings models - Backend: Chapter API (TOC + content), progress tracking, settings CRUD - Frontend: ReadingPage with chapter navigation - Frontend: TableOfContents drawer - Frontend: ReadingSettingsPanel (theme, font, size, orientation) - Frontend: Custom hooks for settings, chapters, progress tracking - CSS: Mobile-first reading view with sepia/dark/light/paper themes - Route: /reader/:bookId reading view from book detail page - Docs: 001-customizable-mobile-reading-experience.md --- backend/apps/books/migrations/__init__.py | 0 backend/apps/books/models.py | 69 +- backend/apps/books/serializers.py | 47 +- backend/apps/books/views.py | 63 +- backend/apps/reader/__init__.py | 0 backend/apps/reader/apps.py | 7 + backend/apps/reader/migrations/__init__.py | 0 backend/apps/reader/models.py | 53 ++ backend/apps/reader/serializers.py | 63 ++ backend/apps/reader/urls.py | 7 + backend/apps/reader/views.py | 33 + backend/config/django.py | 1 + backend/config/urls.py | 1 + ...-customizable-mobile-reading-experience.md | 246 +++++++ web/src/App.tsx | 25 +- web/src/api/reader.ts | 112 ++++ web/src/components/BookDetail.tsx | 22 +- web/src/components/ReaderToolbar.tsx | 67 ++ web/src/components/ReadingSettingsPanel.tsx | 232 +++++++ web/src/components/TableOfContents.tsx | 80 +++ web/src/hooks/useChapters.ts | 129 ++++ web/src/hooks/useReadingProgress.ts | 99 +++ web/src/hooks/useReadingSettings.ts | 96 +++ web/src/pages/BookDetailPage.tsx | 5 +- web/src/pages/ReadingPage.tsx | 199 ++++++ web/src/reader.css | 621 ++++++++++++++++++ web/src/types/reader.ts | 44 ++ 27 files changed, 2303 insertions(+), 18 deletions(-) create mode 100644 backend/apps/books/migrations/__init__.py create mode 100644 backend/apps/reader/__init__.py create mode 100644 backend/apps/reader/apps.py create mode 100644 backend/apps/reader/migrations/__init__.py create mode 100644 backend/apps/reader/models.py create mode 100644 backend/apps/reader/serializers.py create mode 100644 backend/apps/reader/urls.py create mode 100644 backend/apps/reader/views.py create mode 100644 docs/001-customizable-mobile-reading-experience.md create mode 100644 web/src/api/reader.ts create mode 100644 web/src/components/ReaderToolbar.tsx create mode 100644 web/src/components/ReadingSettingsPanel.tsx create mode 100644 web/src/components/TableOfContents.tsx create mode 100644 web/src/hooks/useChapters.ts create mode 100644 web/src/hooks/useReadingProgress.ts create mode 100644 web/src/hooks/useReadingSettings.ts create mode 100644 web/src/pages/ReadingPage.tsx create mode 100644 web/src/reader.css create mode 100644 web/src/types/reader.ts diff --git a/backend/apps/books/migrations/__init__.py b/backend/apps/books/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/books/models.py b/backend/apps/books/models.py index d6a16be..5e13293 100644 --- a/backend/apps/books/models.py +++ b/backend/apps/books/models.py @@ -18,4 +18,71 @@ class Book(models.Model): ordering = ["title"] def __str__(self) -> str: - return self.title \ No newline at end of file + return self.title + + +class Chapter(models.Model): + """A chapter within a book, containing the text/markdown content.""" + + book = models.ForeignKey( + Book, + on_delete=models.CASCADE, + related_name="chapters", + db_index=True, + ) + title = models.CharField(max_length=512) + number = models.PositiveIntegerField() + content = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_chapter" + verbose_name = "Chapter" + verbose_name_plural = "Chapters" + ordering = ["book", "number"] + constraints = [ + models.UniqueConstraint( + fields=["book", "number"], + name="uq_book_chapter_number", + ) + ] + + def __str__(self) -> str: + return f"{self.book.title} — Ch. {self.number}: {self.title}" + + +class ReadingProgress(models.Model): + """Tracks a user's reading progress within a book.""" + + user = models.ForeignKey( + "users.User", + on_delete=models.CASCADE, + related_name="reading_progress", + db_index=True, + ) + book = models.ForeignKey( + Book, + on_delete=models.CASCADE, + related_name="reading_progress", + db_index=True, + ) + current_chapter = models.PositiveIntegerField(default=1) + current_position = models.PositiveIntegerField(default=0) + percentage = models.FloatField(default=0.0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_reading_progress" + verbose_name = "Reading Progress" + verbose_name_plural = "Reading Progress" + ordering = ["-updated_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "book"], + name="uq_reading_progress_user_book", + ) + ] + + def __str__(self) -> str: + return f"{self.user} — {self.book.title} ({self.percentage:.0f}%)" \ No newline at end of file diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index 7339b80..589b623 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from apps.books.models import Book +from apps.books.models import Book, Chapter, ReadingProgress class BookSerializer(serializers.ModelSerializer): @@ -25,4 +25,47 @@ class BookListSerializer(serializers.ModelSerializer): class Meta: model = Book - fields = ["id", "title", "author", "total_pages", "cover_image"] \ No newline at end of file + fields = ["id", "title", "author", "total_pages", "cover_image"] + + +class ChapterSummarySerializer(serializers.ModelSerializer): + """Compact serializer for TOC listing — no content body.""" + + class Meta: + model = Chapter + fields = ["id", "book", "title", "number"] + + +class ChapterDetailSerializer(serializers.ModelSerializer): + """Full serializer with chapter content for reading view.""" + + class Meta: + model = Chapter + fields = ["id", "book", "title", "number", "content", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class ReadingProgressSerializer(serializers.ModelSerializer): + """Serialize reading progress for a book.""" + + class Meta: + model = ReadingProgress + fields = [ + "id", + "book", + "current_chapter", + "current_position", + "percentage", + "updated_at", + ] + read_only_fields = ["id", "updated_at"] + + def validate_percentage(self, value: float) -> float: + if value < 0.0 or value > 100.0: + raise serializers.ValidationError("Percentage must be between 0 and 100.") + return value + + def validate_current_chapter(self, value: int) -> int: + if value < 1: + raise serializers.ValidationError("Chapter number must be positive.") + return value \ No newline at end of file diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index c3f9baa..816fbdb 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -1,16 +1,25 @@ from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets +from rest_framework import status, viewsets +from rest_framework.decorators import action from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response -from apps.books.models import Book -from apps.books.serializers import BookListSerializer, BookSerializer +from apps.books.models import Book, Chapter, ReadingProgress +from apps.books.serializers import ( + BookListSerializer, + BookSerializer, + ChapterDetailSerializer, + ChapterSummarySerializer, + ReadingProgressSerializer, +) class BookViewSet(viewsets.ModelViewSet): - """CRUD for books.""" + """CRUD for books. Includes nested chapter and progress endpoints.""" - queryset = Book.objects.all() + queryset = Book.objects.all().prefetch_related("chapters") permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_fields = ["author"] @@ -21,4 +30,46 @@ class BookViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == "list": return BookListSerializer - return BookSerializer \ No newline at end of file + return BookSerializer + + @action(detail=True, methods=["get"]) + def chapters(self, request: Request, pk: int | None = None) -> Response: + """List all chapters for this book (TOC).""" + book = self.get_object() + chapters = book.chapters.all().order_by("number") + serializer = ChapterSummarySerializer(chapters, many=True) + return Response(serializer.data) + + @action(detail=True, methods=["get"], url_path="chapters/(?P[0-9]+)") + def chapter_detail( + self, request: Request, pk: int | None = None, chapter_number: str | None = None + ) -> Response: + """Get a specific chapter with full content.""" + book = self.get_object() + try: + chapter = book.chapters.get(number=int(chapter_number)) + except Chapter.DoesNotExist: + return Response( + {"detail": "Chapter not found."}, status=status.HTTP_404_NOT_FOUND + ) + serializer = ChapterDetailSerializer(chapter) + return Response(serializer.data) + + @action(detail=True, methods=["get", "put"]) + def progress(self, request: Request, pk: int | None = None) -> Response: + """Get or update reading progress for this book.""" + book = self.get_object() + progress, created = ReadingProgress.objects.get_or_create( + user=request.user, book=book + ) + + if request.method == "GET": + serializer = ReadingProgressSerializer(progress) + return Response(serializer.data) + + serializer = ReadingProgressSerializer( + progress, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/apps/reader/__init__.py b/backend/apps/reader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/reader/apps.py b/backend/apps/reader/apps.py new file mode 100644 index 0000000..4a649af --- /dev/null +++ b/backend/apps/reader/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReaderConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reader" + verbose_name = "Reader Settings" diff --git a/backend/apps/reader/migrations/__init__.py b/backend/apps/reader/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/reader/models.py b/backend/apps/reader/models.py new file mode 100644 index 0000000..62e3482 --- /dev/null +++ b/backend/apps/reader/models.py @@ -0,0 +1,53 @@ +from django.conf import settings +from django.db import models + + +class ReadingSettings(models.Model): + """Per-user reading preferences for the e-book reader view.""" + + THEME_CHOICES = [ + ("sepia", "Sepia"), + ("dark", "Dark"), + ("light", "Light"), + ("paper", "Paper"), + ] + + FONT_CHOICES = [ + ("sans-serif", "Sans-serif"), + ("serif", "Serif"), + ("monospace", "Monospace"), + ] + + ORIENTATION_CHOICES = [ + ("auto", "Auto"), + ("portrait", "Portrait"), + ("landscape", "Landscape"), + ] + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="reading_settings", + primary_key=True, + ) + font_family = models.CharField(max_length=32, choices=FONT_CHOICES, default="serif") + font_size = models.PositiveSmallIntegerField(default=18) + line_height = models.FloatField(default=1.6) + margin_width = models.PositiveSmallIntegerField(default=16) + background_color = models.CharField(max_length=7, default="#f5f0eb") + text_color = models.CharField(max_length=7, default="#1a1a1a") + brightness = models.PositiveSmallIntegerField(default=100) + orientation_lock = models.CharField( + max_length=16, choices=ORIENTATION_CHOICES, default="auto" + ) + theme = models.CharField(max_length=32, choices=THEME_CHOICES, default="sepia") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "reader_reading_settings" + verbose_name = "Reading Settings" + verbose_name_plural = "Reading Settings" + + def __str__(self) -> str: + return f"{self.user} — {self.theme} ({self.font_size}px)" diff --git a/backend/apps/reader/serializers.py b/backend/apps/reader/serializers.py new file mode 100644 index 0000000..5395744 --- /dev/null +++ b/backend/apps/reader/serializers.py @@ -0,0 +1,63 @@ +from rest_framework import serializers + +from apps.reader.models import ReadingSettings + +# Theme presets mapped to colors +THEME_COLORS = { + "sepia": {"background_color": "#f5f0eb", "text_color": "#1a1a1a"}, + "dark": {"background_color": "#1a1a2e", "text_color": "#e0e0e0"}, + "light": {"background_color": "#ffffff", "text_color": "#1a1a1a"}, + "paper": {"background_color": "#e8e0d4", "text_color": "#2c2c2c"}, +} + + +class ReadingSettingsSerializer(serializers.ModelSerializer): + """Serialize ReadingSettings for the current user.""" + + class Meta: + model = ReadingSettings + fields = [ + "font_family", + "font_size", + "line_height", + "margin_width", + "background_color", + "text_color", + "brightness", + "orientation_lock", + "theme", + "created_at", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at"] + + def validate_font_size(self, value: int) -> int: + if value < 12 or value > 32: + raise serializers.ValidationError("Font size must be between 12 and 32.") + return value + + def validate_line_height(self, value: float) -> float: + if value < 1.2 or value > 2.0: + raise serializers.ValidationError("Line height must be between 1.2 and 2.0.") + return value + + def validate_margin_width(self, value: int) -> int: + if value < 8 or value > 48: + raise serializers.ValidationError("Margin width must be between 8 and 48.") + return value + + def validate_brightness(self, value: int) -> int: + if value < 0 or value > 100: + raise serializers.ValidationError("Brightness must be between 0 and 100.") + return value + + def validate(self, attrs): + """Sync theme colors when theme changes, unless explicit colors provided.""" + theme = attrs.get("theme") + if theme and theme in THEME_COLORS: + # Only auto-set colors if not explicitly provided + if "background_color" not in attrs: + attrs["background_color"] = THEME_COLORS[theme]["background_color"] + if "text_color" not in attrs: + attrs["text_color"] = THEME_COLORS[theme]["text_color"] + return attrs \ No newline at end of file diff --git a/backend/apps/reader/urls.py b/backend/apps/reader/urls.py new file mode 100644 index 0000000..4effea0 --- /dev/null +++ b/backend/apps/reader/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.reader.views import reading_settings_view + +urlpatterns = [ + path("settings/", reading_settings_view, name="reading-settings"), +] \ No newline at end of file diff --git a/backend/apps/reader/views.py b/backend/apps/reader/views.py new file mode 100644 index 0000000..27af51f --- /dev/null +++ b/backend/apps/reader/views.py @@ -0,0 +1,33 @@ +from rest_framework import permissions, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.request import Request +from rest_framework.response import Response + +from apps.reader.models import ReadingSettings +from apps.reader.serializers import ReadingSettingsSerializer + + +@api_view(["GET", "PUT", "PATCH"]) +@permission_classes([permissions.IsAuthenticated]) +def reading_settings_view(request: Request) -> Response: + """Get or update the current user's reading settings. + + GET → return existing settings (auto-create defaults if missing) + PUT → create or fully replace settings + PATCH → partial update + """ + user = request.user + settings, created = ReadingSettings.objects.get_or_create(user=user) + + if request.method == "GET": + serializer = ReadingSettingsSerializer(settings) + return Response(serializer.data) + + if request.method == "PUT": + serializer = ReadingSettingsSerializer(settings, data=request.data) + elif request.method == "PATCH": + serializer = ReadingSettingsSerializer(settings, data=request.data, partial=True) + + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/config/django.py b/backend/config/django.py index e5b9a67..fa66705 100644 --- a/backend/config/django.py +++ b/backend/config/django.py @@ -40,6 +40,7 @@ INSTALLED_APPS = [ "apps.users", "apps.books", "apps.annotations", + "apps.reader", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index cb1a5cb..9db20f1 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("api/auth/", include("apps.users.urls")), path("api/books/", include("apps.books.urls")), path("api/annotations/", include("apps.annotations.urls")), + path("api/reader/", include("apps.reader.urls")), ] \ No newline at end of file diff --git a/docs/001-customizable-mobile-reading-experience.md b/docs/001-customizable-mobile-reading-experience.md new file mode 100644 index 0000000..39d095b --- /dev/null +++ b/docs/001-customizable-mobile-reading-experience.md @@ -0,0 +1,246 @@ +# US: Customizable Mobile Reading Experience + +**Issue:** https://gitea-dev.codescripters.org/HermesFactory/cloud-reader/issues (TBD) + +## Overview + +Add a full-screen reading view for ebooks with customizable typography, themes, +table of contents navigation, and orientation support. Mobile-first, responsive +design that adapts to any screen size. + +--- + +## Backend Specification + +### New Models + +#### `apps.books.models.Chapter` + +| Field | Type | Notes | +|-------------|--------------------|--------------------------------| +| id | AutoField (PK) | | +| book | FK -> Book | related_name="chapters" | +| title | CharField(512) | Chapter title | +| number | PositiveIntegerField | Chapter ordering / TOC index | +| content | TextField | Chapter text/markdown content | +| created_at | DateTimeField | auto_now_add | +| updated_at | DateTimeField | auto_now | + +**Constraints:** UniqueConstraint(book, chapter_number) +**Ordering:** [book, number] +**Index:** FK to book with db_index + +#### `apps.books.models.ReadingProgress` + +| Field | Type | Notes | +|------------------|--------------------|--------------------------------| +| id | AutoField (PK) | | +| user | FK -> User | related_name="reading_progress"| +| book | FK -> Book | related_name="reading_progress"| +| current_chapter | PositiveIntegerField | Last chapter number | +| current_position | PositiveIntegerField | Position within chapter (paragraph) | +| percentage | FloatField | 0.0 - 100.0 overall progress | +| updated_at | DateTimeField | auto_now | + +**Constraints:** UniqueConstraint(user, book) +**Indexes:** (user, book) composite, (user) filter for list queries + +#### `apps.reader.models.ReadingSettings` + +New app `apps/reader/` for reading preferences, isolated from book data model. + +| Field | Type | Notes | +|-------------------|--------------------|-------------------------------| +| id | AutoField (PK) | | +| user | OneToOneField -> User | related_name="reading_settings" | +| font_family | CharField(32) | "sans-serif", "serif", "monospace" | +| font_size | PositiveSmallIntegerField | 12-32, default 18 | +| line_height | FloatField | 1.2 - 2.0, default 1.6 | +| margin_width | PositiveSmallIntegerField | 8-48, default 16 (px) | +| background_color | CharField(7) | Hex color, default "#f5f0eb" | +| text_color | CharField(7) | Hex color, default "#1a1a1a" | +| brightness | PositiveSmallIntegerField | 0-100, default 100 | +| orientation_lock | CharField(16) | "auto", "portrait", "landscape" | +| theme | CharField(32) | "sepia", "dark", "light", "paper" | +| created_at | DateTimeField | auto_now_add | +| updated_at | DateTimeField | auto_now | + +### New API Endpoints + +All under `/api/` prefix, authenticated with JWT. + +#### Reader Settings (`/api/reader/settings/`) + +| Method | URL | Action | +|--------|------------------------------|---------------------------| +| GET | /api/reader/settings/ | Get current user settings | +| PUT | /api/reader/settings/ | Create/update settings | +| PATCH | /api/reader/settings/ | Partial update settings | + +- Single-object endpoint (one settings record per user, auto-created on first GET) +- Validation: font_size 12-32, line_height 1.2-2.0, margin_width 8-48 + +#### Reading Progress (`/api/books/{id}/progress/`) + +| Method | URL | Action | +|--------|----------------------------------------|------------------------------| +| GET | /api/books/{id}/progress/ | Get reading progress for book| +| PUT | /api/books/{id}/progress/ | Create/update reading progress| + +- Nested under book detail +- Auto-creates progress record on first PUT + +#### Chapters (`/api/books/{id}/chapters/`) + +| Method | URL | Action | +|--------|----------------------------------------|------------------------------| +| GET | /api/books/{id}/chapters/ | List chapters for book (TOC) | +| GET | /api/books/{id}/chapters/{number}/ | Get specific chapter content | + +- Ordering by `number` +- Used by frontend TOC sidebar and content loading + +--- + +## Frontend Specification + +### New Pages + +#### `/reader/:bookId` — ReadingPage + +Full-screen reading view with: +- Chapter content display (left/right swiping or scroll) +- Bottom toolbar: TOC toggle, Settings toggle, Progress indicator +- Top bar: Back button, Book title, Chapter title +- Swipe/tap/page navigation between chapters + +### New Components + +#### `ReaderToolbar` +- Fixed bottom toolbar +- TOC button (opens TOC drawer) +- Settings/theme button (opens settings panel) +- Progress bar showing overall reading progress + +#### `TableOfContents` +- Slide-in drawer from left +- Lists all chapters with current chapter highlighted +- Tap on chapter to navigate +- Shows reading progress per chapter + +#### `ReadingSettingsPanel` +- Slide-in drawer from right (or bottom sheet on mobile) +- Controls: + - Theme presets: Sepia, Dark, Light, Paper + - Font family: Sans-serif, Serif, Monospace + - Font size slider (12-32) + - Line height slider (1.2-2.0) + - Margin/padding control + - Orientation lock toggle (Auto / Portrait / Landscape) +- All changes persist immediately via API +- LocalStorage fallback when offline + +### New Hooks + +#### `useReadingSettings(bookId)` +- Fetches user reading settings from API +- Returns current settings + update function +- Applies CSS custom properties to document root +- Falls back to defaults if API unavailable + +#### `useChapters(bookId)` +- Fetches chapter list for TOC +- Returns chapters array, current chapter, navigate function +- Prefetches next/prev chapter content + +#### `useReadingProgress(bookId)` +- Fetches/updates reading progress +- Auto-saves position on chapter change and periodic interval + +### New Types + +```typescript +interface Chapter { + id: number; + book: number; + title: string; + number: number; + content?: string; // Only present when fetching individual chapter +} + +interface ChapterSummary { + id: number; + book: number; + title: string; + number: number; +} + +interface ReadingSettings { + font_family: "sans-serif" | "serif" | "monospace"; + font_size: number; + line_height: number; + margin_width: number; + background_color: string; + text_color: string; + brightness: number; + orientation_lock: "auto" | "portrait" | "landscape"; + theme: "sepia" | "dark" | "light" | "paper"; +} + +interface ReadingProgress { + current_chapter: number; + current_position: number; + percentage: number; + updated_at: string; +} +``` + +### CSS / Theming + +Reading view uses CSS custom properties driven by reading settings: + +```css +:root { + --reader-bg: var(--bg-color, #f5f0eb); + --reader-text: var(--text-color, #1a1a1a); + --reader-font-family: var(--font-family, "Georgia", serif); + --reader-font-size: var(--font-size, 18px); + --reader-line-height: var(--line-height, 1.6); + --reader-margin: var(--margin-width, 16px); +} +``` + +Three theme presets: +- **Sepia**: `bg:#f5f0eb`, `text:#1a1a1a` — warm, easy on eyes +- **Dark**: `bg:#1a1a2e`, `text:#e0e0e0` — for low-light reading +- **Light**: `bg:#ffffff`, `text:#1a1a1a` — crisp and clean +- **Paper**: `bg:#e8e0d4`, `text:#2c2c2c` — book-like feel + +### Orientation Support + +- CSS `@media (orientation: portrait)` and `@media (orientation: landscape)` breakpoints +- Reading settings panel includes orientation lock toggle +- On mobile, landscape mode expands content horizontally with wider margins +- Portrait mode stacks controls vertically for thumb-reachable UI + +### Routing + +Add to App.tsx: +``` +/ → LibraryPage +/books/:bookId → BookDetailPage +/reader/:bookId → ReadingPage +``` + +--- + +## Implementation Order + +1. Backend models + migrations (Chapter, ReadingProgress, ReadingSettings) +2. Backend serializers + views + URLs +3. Frontend types + API client +4. Frontend hooks (useReadingSettings, useChapters, useReadingProgress) +5. Frontend components (ReadingSettingsPanel, TableOfContents, ReaderToolbar) +6. Frontend page (ReadingPage) +7. Routing updates +8. CSS / theming \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index b4317f1..e4711b9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,16 +1,19 @@ /** - * App — root component with simple page navigation (library ↔ book detail). + * App — root component with page navigation (library ↔ book detail ↔ reading view). */ import { useState, lazy, Suspense } from "react"; import "./App.css"; +import "./reader.css"; const LibraryPage = lazy(() => import("./pages/LibraryPage")); const BookDetailPage = lazy(() => import("./pages/BookDetailPage")); +const ReadingPage = lazy(() => import("./pages/ReadingPage")); type View = | { kind: "library" } - | { kind: "detail"; bookId: number }; + | { kind: "detail"; bookId: number } + | { kind: "reader"; book: import("./types").BookDetail }; function LoadingFallback() { return ( @@ -32,13 +35,25 @@ export default function App() { setView({ kind: "library" }); }; + const handleStartReading = (book: import("./types").BookDetail) => { + setView({ kind: "reader", book }); + }; + return (
}> - {view.kind === "library" ? ( + {view.kind === "library" && ( - ) : ( - + )} + {view.kind === "detail" && view.bookId !== undefined && ( + + )} + {view.kind === "reader" && view.book !== undefined && ( + )}
diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts new file mode 100644 index 0000000..cbf73e7 --- /dev/null +++ b/web/src/api/reader.ts @@ -0,0 +1,112 @@ +/** + * API client for the reader module — reading settings, chapters, and progress. + */ + +import type { + ChapterDetail, + ChapterSummary, + ReadingProgress, + ReadingSettings, +} from "../types/reader"; + +const API_BASE = "/api"; + +/** + * Fetch the current user's reading settings. + * Auto-creates defaults on the server if none exist. + */ +export async function getReadingSettings(): Promise { + const response = await fetch(`${API_BASE}/reader/settings/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading settings: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Update (full or partial) the user's reading settings. + */ +export async function updateReadingSettings( + settings: Partial +): Promise { + const method = settings.theme !== undefined ? "PUT" : "PATCH"; + const response = await fetch(`${API_BASE}/reader/settings/`, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading settings: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch the table of contents (chapter list) for a book. + */ +export async function getChapters(bookId: number): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/chapters/`); + if (!response.ok) { + throw new Error( + `Failed to fetch chapters: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch a specific chapter with full content for reading. + */ +export async function getChapterContent( + bookId: number, + chapterNumber: number +): Promise { + const response = await fetch( + `${API_BASE}/books/${bookId}/chapters/${chapterNumber}/` + ); + if (!response.ok) { + throw new Error( + `Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch reading progress for a book. + */ +export async function getReadingProgress( + bookId: number +): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/progress/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading progress: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Update reading progress for a book. + */ +export async function updateReadingProgress( + bookId: number, + progress: Partial +): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/progress/`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(progress), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading progress: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} \ No newline at end of file diff --git a/web/src/components/BookDetail.tsx b/web/src/components/BookDetail.tsx index 9a0cda4..ec4943f 100644 --- a/web/src/components/BookDetail.tsx +++ b/web/src/components/BookDetail.tsx @@ -1,5 +1,5 @@ /** - * BookDetail component — full book information display. + * BookDetail component — full book information display with reading action. */ import type { BookDetail as BookDetailType } from "../types"; @@ -8,9 +8,10 @@ import { READING_STATUS_LABELS } from "../types"; interface BookDetailProps { book: BookDetailType; onBack: () => void; + onStartReading: (book: BookDetailType) => void; } -export default function BookDetail({ book, onBack }: BookDetailProps) { +export default function BookDetail({ book, onBack, onStartReading }: BookDetailProps) { return (
+

Added:{" "} diff --git a/web/src/components/ReaderToolbar.tsx b/web/src/components/ReaderToolbar.tsx new file mode 100644 index 0000000..65884f3 --- /dev/null +++ b/web/src/components/ReaderToolbar.tsx @@ -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 */} +

+ +
+ {bookTitle} + {chapterTitle} +
+ +
+ + {/* Bottom progress bar */} +
+
+
+ + ); +} \ No newline at end of file diff --git a/web/src/components/ReadingSettingsPanel.tsx b/web/src/components/ReadingSettingsPanel.tsx new file mode 100644 index 0000000..9d7d11a --- /dev/null +++ b/web/src/components/ReadingSettingsPanel.tsx @@ -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) => Promise; +} + +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>({}); + + 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 && ( +
{ + if (e.key === "Escape") onClose(); + }} + role="presentation" + /> + )} + + {/* Drawer */} + + + ); +} \ No newline at end of file diff --git a/web/src/components/TableOfContents.tsx b/web/src/components/TableOfContents.tsx new file mode 100644 index 0000000..7a4db64 --- /dev/null +++ b/web/src/components/TableOfContents.tsx @@ -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 && ( +
{ + if (e.key === "Escape") onClose(); + }} + role="presentation" + /> + )} + + {/* Drawer */} + + + ); +} \ No newline at end of file diff --git a/web/src/hooks/useChapters.ts b/web/src/hooks/useChapters.ts new file mode 100644 index 0000000..e388fa2 --- /dev/null +++ b/web/src/hooks/useChapters.ts @@ -0,0 +1,129 @@ +/** + * useChapters — fetch chapter list and manage current chapter navigation. + */ + +import { useCallback, useEffect, useState } from "react"; +import { getChapterContent, getChapters } from "../api/reader"; +import type { ChapterDetail, ChapterSummary } from "../types/reader"; + +export interface UseChaptersReturn { + chapters: ChapterSummary[]; + currentChapter: ChapterDetail | null; + currentChapterNumber: number; + isLoading: boolean; + error: string | null; + navigateToChapter: (number: number) => Promise; + goToNextChapter: () => Promise; + goToPreviousChapter: () => Promise; + hasNext: boolean; + hasPrevious: boolean; +} + +export function useChapters( + bookId: number, + initialChapter: number = 1 +): UseChaptersReturn { + const [chapters, setChapters] = useState([]); + const [currentChapter, setCurrentChapter] = useState( + null + ); + const [currentChapterNumber, setCurrentChapterNumber] = + useState(initialChapter); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch chapter list on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getChapters(bookId) + .then((data) => { + if (!cancelled) { + setChapters(data); + // If chapters exist and initial chapter is valid, fetch it + if ( + data.length > 0 && + data.some((c) => c.number === currentChapterNumber) + ) { + return getChapterContent(bookId, currentChapterNumber); + } + return null; + } + return null; + }) + .then((chapter) => { + if (!cancelled && chapter) { + setCurrentChapter(chapter); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load chapters" + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [bookId, currentChapterNumber]); + + const fetchChapter = useCallback( + async (number: number) => { + setIsLoading(true); + setError(null); + try { + const chapter = await getChapterContent(bookId, number); + setCurrentChapter(chapter); + setCurrentChapterNumber(number); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to load chapter" + ); + } finally { + setIsLoading(false); + } + }, + [bookId] + ); + + const navigateToChapter = useCallback( + (number: number) => { + fetchChapter(number); + }, + [fetchChapter] + ); + + const goToNextChapter = useCallback(() => { + const next = currentChapterNumber + 1; + if (chapters.some((c) => c.number === next)) { + fetchChapter(next); + } + }, [currentChapterNumber, chapters, fetchChapter]); + + const goToPreviousChapter = useCallback(() => { + const prev = currentChapterNumber - 1; + if (prev >= 1 && chapters.some((c) => c.number === prev)) { + fetchChapter(prev); + } + }, [currentChapterNumber, chapters, fetchChapter]); + + const hasNext = chapters.some((c) => c.number === currentChapterNumber + 1); + const hasPrevious = chapters.some((c) => c.number === currentChapterNumber - 1); + + return { + chapters, + currentChapter, + currentChapterNumber, + isLoading, + error, + navigateToChapter, + goToNextChapter, + goToPreviousChapter, + hasNext, + hasPrevious, + }; +} \ No newline at end of file diff --git a/web/src/hooks/useReadingProgress.ts b/web/src/hooks/useReadingProgress.ts new file mode 100644 index 0000000..1d8034d --- /dev/null +++ b/web/src/hooks/useReadingProgress.ts @@ -0,0 +1,99 @@ +/** + * useReadingProgress — fetch and update reading progress for a book. + * Auto-saves when chapter or position changes. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { getReadingProgress, updateReadingProgress } from "../api/reader"; +import type { ReadingProgress } from "../types/reader"; + +export interface UseReadingProgressReturn { + progress: ReadingProgress | null; + isLoading: boolean; + error: string | null; + saveProgress: ( + chapter: number, + position: number, + percentage: number + ) => Promise; + /** Schedule a debounced save — fires at most once per 3 seconds */ + debouncedSave: ( + chapter: number, + position: number, + percentage: number + ) => void; +} + +export function useReadingProgress( + bookId: number +): UseReadingProgressReturn { + const [progress, setProgress] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const debounceTimer = useRef | null>(null); + + // Fetch progress on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getReadingProgress(bookId) + .then((data) => { + if (!cancelled) setProgress(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load progress" + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [bookId]); + + const saveProgress = useCallback( + async (chapter: number, position: number, percentage: number) => { + try { + const updated = await updateReadingProgress(bookId, { + current_chapter: chapter, + current_position: position, + percentage, + }); + setProgress(updated); + setError(null); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to save progress" + ); + } + }, + [bookId] + ); + + const debouncedSave = useCallback( + (chapter: number, position: number, percentage: number) => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + saveProgress(chapter, position, percentage); + }, 3000); + }, + [saveProgress] + ); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, []); + + return { progress, isLoading, error, saveProgress, debouncedSave }; +} \ No newline at end of file diff --git a/web/src/hooks/useReadingSettings.ts b/web/src/hooks/useReadingSettings.ts new file mode 100644 index 0000000..7e5df8d --- /dev/null +++ b/web/src/hooks/useReadingSettings.ts @@ -0,0 +1,96 @@ +/** + * useReadingSettings — fetch and manage user reading preferences. + * Applies settings as CSS custom properties on the document root. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + getReadingSettings, + updateReadingSettings, +} from "../api/reader"; +import type { ReadingSettings } from "../types/reader"; + +const DEFAULT_SETTINGS: ReadingSettings = { + font_family: "serif", + font_size: 18, + line_height: 1.6, + margin_width: 16, + background_color: "#f5f0eb", + text_color: "#1a1a1a", + brightness: 100, + orientation_lock: "auto", + theme: "sepia", + created_at: "", + updated_at: "", +}; + +function applyCssVariables(settings: ReadingSettings): void { + const root = document.documentElement; + root.style.setProperty("--reader-bg", settings.background_color); + root.style.setProperty("--reader-text", settings.text_color); + root.style.setProperty("--reader-font-family", settings.font_family); + root.style.setProperty("--reader-font-size", `${settings.font_size}px`); + root.style.setProperty("--reader-line-height", String(settings.line_height)); + root.style.setProperty("--reader-margin", `${settings.margin_width}px`); + root.style.setProperty("--reader-brightness", `${settings.brightness}%`); +} + +export interface UseReadingSettingsReturn { + settings: ReadingSettings; + isLoading: boolean; + error: string | null; + updateSettings: (partial: Partial) => Promise; +} + +export function useReadingSettings(): UseReadingSettingsReturn { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch settings on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getReadingSettings() + .then((data) => { + if (!cancelled) { + setSettings(data); + applyCssVariables(data); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load reading settings" + ); + // Apply defaults + applyCssVariables(DEFAULT_SETTINGS); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const updateSettings = useCallback( + async (partial: Partial) => { + try { + const updated = await updateReadingSettings(partial); + setSettings(updated); + applyCssVariables(updated); + setError(null); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to update reading settings" + ); + throw err; + } + }, + [] + ); + + return { settings, isLoading, error, updateSettings }; +} \ No newline at end of file diff --git a/web/src/pages/BookDetailPage.tsx b/web/src/pages/BookDetailPage.tsx index bb933be..821e6d9 100644 --- a/web/src/pages/BookDetailPage.tsx +++ b/web/src/pages/BookDetailPage.tsx @@ -10,9 +10,10 @@ import BookDetailComponent from "../components/BookDetail"; interface BookDetailPageProps { bookId: number; onBack: () => void; + onStartReading: (book: BookDetailType) => void; } -export default function BookDetailPage({ bookId, onBack }: BookDetailPageProps) { +export default function BookDetailPage({ bookId, onBack, onStartReading }: BookDetailPageProps) { const [book, setBook] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -59,5 +60,5 @@ export default function BookDetailPage({ bookId, onBack }: BookDetailPageProps) ); } - return ; + return ; } \ No newline at end of file diff --git a/web/src/pages/ReadingPage.tsx b/web/src/pages/ReadingPage.tsx new file mode 100644 index 0000000..9701ba6 --- /dev/null +++ b/web/src/pages/ReadingPage.tsx @@ -0,0 +1,199 @@ +/** + * ReadingPage — full-screen reading view for ebooks. + * Integrates TOC drawer, settings panel, chapter navigation, and progress tracking. + */ + +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { useChapters } from "../hooks/useChapters"; +import { useReadingProgress } from "../hooks/useReadingProgress"; +import { useReadingSettings } from "../hooks/useReadingSettings"; +import type { BookDetail as BookDetailType } from "../types"; + +const ReaderToolbar = lazy(() => import("../components/ReaderToolbar")); +const TableOfContents = lazy(() => import("../components/TableOfContents")); +const ReadingSettingsPanel = lazy( + () => import("../components/ReadingSettingsPanel") +); + +interface ReadingPageProps { + book: BookDetailType; + onBack: () => void; +} + +export default function ReadingPage({ book, onBack }: ReadingPageProps) { + const [tocOpen, setTocOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const { settings, updateSettings } = useReadingSettings(); + const { + chapters, + currentChapter, + currentChapterNumber, + isLoading, + error, + navigateToChapter, + goToNextChapter, + goToPreviousChapter, + hasNext, + hasPrevious, + } = useChapters(book.id, 1); + + const { progress, debouncedSave } = useReadingProgress(book.id); + + // Auto-save progress when chapter changes + useEffect(() => { + if (currentChapter && currentChapterNumber > 0) { + debouncedSave(currentChapterNumber, 0, (currentChapterNumber / Math.max(chapters.length, 1)) * 100); + } + }, [currentChapterNumber, currentChapter?.id]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowLeft" && hasPrevious) { + goToPreviousChapter(); + } else if (e.key === "ArrowRight" && hasNext) { + goToNextChapter(); + } else if (e.key === "Escape") { + setTocOpen(false); + setSettingsOpen(false); + } + }, + [hasNext, hasPrevious, goToNextChapter, goToPreviousChapter] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + // Lock/unlock orientation via CSS + useEffect(() => { + const root = document.documentElement; + if (settings.orientation_lock !== "auto") { + root.style.setProperty( + "--reader-orientation", + settings.orientation_lock === "portrait" ? "portrait" : "landscape" + ); + } else { + root.style.removeProperty("--reader-orientation"); + } + }, [settings.orientation_lock]); + + // Show loading state + if (isLoading && !currentChapter) { + return ( +
+
+

Loading reader...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( + +
+
+ } + > +
+ setTocOpen((v) => !v)} + onToggleSettings={() => setSettingsOpen((v) => !v)} + /> + + setTocOpen(false)} + onNavigate={navigateToChapter} + /> + + setSettingsOpen(false)} + onUpdate={updateSettings} + /> + + {/* Main reading area */} +
+ {isLoading ? ( +
+
+

Loading chapter...

+
+ ) : currentChapter ? ( +
+

{currentChapter.title}

+
+
+ ) : ( +
+

Select a chapter from the table of contents to start reading.

+
+ )} +
+ + {/* Bottom navigation bar */} + +
+
+ ); +} \ No newline at end of file diff --git a/web/src/reader.css b/web/src/reader.css new file mode 100644 index 0000000..2bf74a9 --- /dev/null +++ b/web/src/reader.css @@ -0,0 +1,621 @@ +/* ============================================================ + Cloud Reader — Reading View Styles + Mobile-first reading experience with theme support + ============================================================ */ + +/* --- CSS Custom Properties (overridden by JS) --- */ +:root { + --reader-bg: #f5f0eb; + --reader-text: #1a1a1a; + --reader-font-family: Georgia, "Times New Roman", serif; + --reader-font-size: 18px; + --reader-line-height: 1.6; + --reader-margin: 16px; + --reader-brightness: 100%; + --reader-toolbar-bg: rgba(26, 26, 26, 0.95); + --reader-toolbar-text: #e0e0e0; + --reader-accent: #6c8cff; +} + +/* --- Reading Container --- */ +.reader-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--reader-bg); + color: var(--reader-text); + font-family: var(--reader-font-family); + font-size: var(--reader-font-size); + line-height: var(--reader-line-height); + filter: brightness(var(--reader-brightness)); + display: flex; + flex-direction: column; + z-index: 100; + overflow: hidden; +} + +/* Orientation lock */ +@supports (--reader-orientation: portrait) { + .reader-container { + orientation: var(--reader-orientation); + } +} + +@media (orientation: landascape) { + .reader-container { + --reader-margin: 24px; + } +} + +/* --- Top Toolbar --- */ +.reader-top-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 52px; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + z-index: 120; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.reader-bar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + cursor: pointer; + border-radius: 8px; + transition: background 0.15s; +} + +.reader-bar-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.reader-bar-title { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + min-width: 0; + flex: 1; + padding: 0 8px; +} + +.reader-bar-book { + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.reader-bar-chapter { + font-size: 0.7rem; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* --- Progress Bar --- */ +.reader-progress-bar { + position: fixed; + top: 52px; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.1); + z-index: 120; +} + +.reader-progress-fill { + height: 100%; + background: var(--reader-accent); + transition: width 0.3s ease; +} + +/* --- Main Content Area --- */ +.reader-content { + flex: 1; + overflow-y: auto; + padding: 72px var(--reader-margin) 72px; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* --- Chapter Article --- */ +.reader-chapter { + max-width: 680px; + margin: 0 auto; +} + +.reader-chapter-title { + font-size: 1.6em; + font-weight: 700; + line-height: 1.3; + margin-bottom: 0.75em; + padding-bottom: 0.5em; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + color: var(--reader-text); +} + +/* Chapter body — content from API (could be HTML/markdown-rendered) */ +.reader-chapter-body { + font-size: 1em; + line-height: var(--reader-line-height); +} + +.reader-chapter-body p { + margin-bottom: 1.2em; + text-align: justify; + hyphens: auto; +} + +.reader-chapter-body h2, +.reader-chapter-body h3, +.reader-chapter-body h4 { + margin-top: 1.5em; + margin-bottom: 0.6em; + line-height: 1.3; +} + +.reader-chapter-body blockquote { + border-left: 3px solid var(--reader-accent); + padding-left: 1em; + margin: 1em 0; + opacity: 0.85; + font-style: italic; +} + +.reader-chapter-body img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 1em 0; +} + +.reader-chapter-body ul, +.reader-chapter-body ol { + padding-left: 1.5em; + margin-bottom: 1.2em; +} + +.reader-chapter-body li { + margin-bottom: 0.4em; +} + +/* --- Navigation Bar (Bottom) --- */ +.reader-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 56px; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + z-index: 120; + gap: 8px; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.reader-nav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + cursor: pointer; + transition: all 0.15s; +} + +.reader-nav-btn:hover:not(.reader-nav-btn--disabled) { + border-color: var(--reader-accent); + color: var(--reader-accent); +} + +.reader-nav-btn--disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.reader-nav-btn--back { + background: rgba(108, 140, 255, 0.15); + border-color: rgba(108, 140, 255, 0.3); +} + +.reader-nav-btn--back:hover { + background: rgba(108, 140, 255, 0.25); + color: var(--reader-accent); +} + +/* --- TOC Drawer --- */ +.toc-overlay, +.settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 130; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.toc-drawer, +.settings-drawer { + position: fixed; + top: 0; + bottom: 0; + width: 300px; + max-width: 85vw; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + z-index: 140; + display: flex; + flex-direction: column; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toc-drawer { + left: 0; + transform: translateX(-100%); +} + +.toc-drawer--open { + transform: translateX(0); +} + +.settings-drawer { + right: 0; + transform: translateX(100%); +} + +.settings-drawer--open { + transform: translateX(0); +} + +.toc-header, +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.toc-title, +.settings-title { + font-size: 1.1rem; + font-weight: 700; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.toc-close-btn, +.settings-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s; +} + +.toc-close-btn:hover, +.settings-close-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* --- TOC List --- */ +.toc-list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.toc-empty { + padding: 24px 16px; + text-align: center; + opacity: 0.6; + font-size: 0.9rem; +} + +.toc-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 16px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + font-size: 0.9rem; + text-align: left; + cursor: pointer; + transition: background 0.15s; + font-family: inherit; +} + +.toc-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.toc-item--active { + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +.toc-item-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + font-size: 0.75rem; + font-weight: 700; + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); + padding: 0 4px; +} + +.toc-item-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Settings Panel --- */ +.settings-body { + flex: 1; + overflow-y: auto; + padding: 12px 16px 32px; +} + +.settings-section { + margin-bottom: 20px; +} + +.settings-section-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* Theme grid */ +.theme-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.theme-btn { + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: transparent; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.theme-btn:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.theme-btn--active { + border-color: var(--reader-accent); + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +/* Font grid */ +.font-grid, +.orientation-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.font-btn, +.orientation-btn { + padding: 10px 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: transparent; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.font-btn:hover, +.orientation-btn:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.font-btn--active, +.orientation-btn--active { + border-color: var(--reader-accent); + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +/* Sliders */ +.settings-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.settings-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--reader-accent); + border: 2px solid white; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.settings-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--reader-accent); + border: 2px solid white; + cursor: pointer; +} + +/* --- Start Reading Button (in book detail) --- */ +.start-reading-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--reader-accent); + border: none; + border-radius: 8px; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-top: 16px; + margin-bottom: 16px; + font-family: inherit; +} + +.start-reading-btn:hover { + background: #5a7ae8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 140, 255, 0.3); +} + +/* --- Loading State --- */ +.reader-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + color: var(--reader-text); +} + +.reader-error { + color: #ef4444; + text-align: center; +} + +/* --- Responsive --- */ +@media (max-width: 480px) { + .reader-chapter { + max-width: 100%; + } + + .reader-nav-btn { + padding: 8px 10px; + font-size: 0.8rem; + } + + .reader-nav-btn span { + display: none; + } + + .toc-drawer, + .settings-drawer { + max-width: 100vw; + } + + .reader-chapter-title { + font-size: 1.3em; + } +} + +@media (min-width: 768px) { + .reader-chapter { + max-width: 720px; + } + + .reader-chapter-body { + font-size: 1.05em; + } +} + +/* --- Landscape on Mobile --- */ +@media (orientation: landscape) and (max-height: 500px) { + .reader-top-bar { + height: 44px; + } + + .reader-content { + padding-top: 60px; + padding-bottom: 60px; + } + + .reader-chapter-title { + font-size: 1.2em; + } +} + +/* --- Dark mode theme override when reader uses light theme but system is dark --- */ +@media (prefers-color-scheme: dark) { + .reader-container[data-theme="light"] { + --reader-toolbar-bg: rgba(255, 255, 255, 0.95); + --reader-toolbar-text: #1a1a1a; + } +} \ No newline at end of file diff --git a/web/src/types/reader.ts b/web/src/types/reader.ts new file mode 100644 index 0000000..d478d9a --- /dev/null +++ b/web/src/types/reader.ts @@ -0,0 +1,44 @@ +/** + * TypeScript interfaces for the Cloud Reader reader module. + * Reading view, settings, chapters, and progress types. + */ + +export interface ChapterSummary { + id: number; + book: number; + title: string; + number: number; +} + +export interface ChapterDetail extends ChapterSummary { + content: string; + created_at: string; + updated_at: string; +} + +export interface ReadingSettings { + font_family: "sans-serif" | "serif" | "monospace"; + font_size: number; + line_height: number; + margin_width: number; + background_color: string; + text_color: string; + brightness: number; + orientation_lock: "auto" | "portrait" | "landscape"; + theme: "sepia" | "dark" | "light" | "paper"; + created_at: string; + updated_at: string; +} + +export type ThemePreset = ReadingSettings["theme"]; +export type FontFamily = ReadingSettings["font_family"]; +export type OrientationLock = ReadingSettings["orientation_lock"]; + +export interface ReadingProgress { + id: number; + book: number; + current_chapter: number; + current_position: number; + percentage: number; + updated_at: string; +} \ No newline at end of file From 74fdae61db93fc41720c84ed88e6d88b05a1cf93 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 05:53:14 +0000 Subject: [PATCH 2/4] 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 --- backend/apps/books/models.py | 204 +++++- backend/apps/books/serializers.py | 213 +++++- backend/apps/books/views.py | 320 ++++++++- frontend/src/App.tsx | 62 ++ frontend/src/api/reader.ts | 112 ++++ .../src/components/reader/ReaderToolbar.tsx | 67 ++ .../reader/ReadingSettingsPanel.tsx | 232 +++++++ .../src/components/reader/TableOfContents.tsx | 80 +++ frontend/src/hooks/useChapters.ts | 129 ++++ frontend/src/hooks/useReadingProgress.ts | 99 +++ frontend/src/hooks/useReadingSettings.ts | 96 +++ frontend/src/pages/ReadingPage.tsx | 213 ++++++ frontend/src/reader.css | 621 ++++++++++++++++++ frontend/src/types/reader.ts | 44 ++ 14 files changed, 2420 insertions(+), 72 deletions(-) create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/reader.ts create mode 100644 frontend/src/components/reader/ReaderToolbar.tsx create mode 100644 frontend/src/components/reader/ReadingSettingsPanel.tsx create mode 100644 frontend/src/components/reader/TableOfContents.tsx create mode 100644 frontend/src/hooks/useChapters.ts create mode 100644 frontend/src/hooks/useReadingProgress.ts create mode 100644 frontend/src/hooks/useReadingSettings.ts create mode 100644 frontend/src/pages/ReadingPage.tsx create mode 100644 frontend/src/reader.css create mode 100644 frontend/src/types/reader.ts diff --git a/backend/apps/books/models.py b/backend/apps/books/models.py index 5e13293..dedd71b 100644 --- a/backend/apps/books/models.py +++ b/backend/apps/books/models.py @@ -1,11 +1,36 @@ +from django.conf import settings from django.db import models +from django.db.models.signals import post_delete +from django.dispatch import receiver +from pathlib import Path + + +class ReadingStatus(models.TextChoices): + WANT_TO_READ = "want_to_read", "Want to Read" + READING = "reading", "Reading" + FINISHED = "finished", "Finished" + DNF = "dnf", "Did Not Finish" + + +class FontStyle(models.TextChoices): + SANS_SERIF = "sans-serif", "Sans Serif" + SERIF = "serif", "Serif" + MONOSPACE = "monospace", "Monospace" + + +class BackgroundColor(models.TextChoices): + WHITE = "#ffffff", "White" + SEPIA = "#f4e4c1", "Sepia" + DARK = "#1a1a2e", "Dark" + GREEN = "#c7edcc", "Green" class Book(models.Model): - """Represents a book in the user's library.""" - - title = models.CharField(max_length=512) - author = models.CharField(max_length=256, blank=True, default="") + title = models.CharField(max_length=512, db_index=True) + author = models.CharField(max_length=256, blank=True, default="", db_index=True) + genre = models.CharField(max_length=128, blank=True, default="", db_index=True) + description = models.TextField(blank=True, default="") + reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True) total_pages = models.PositiveIntegerField(default=0) cover_image = models.URLField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) @@ -16,13 +41,154 @@ class Book(models.Model): verbose_name = "Book" verbose_name_plural = "Books" ordering = ["title"] + indexes = [models.Index(fields=["title", "author", "genre"])] - def __str__(self) -> str: + def __str__(self): return self.title +class EBook(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks") + title = models.CharField(max_length=512, db_index=True) + author = models.CharField(max_length=256, blank=True, default="", db_index=True) + format = models.CharField(max_length=20, blank=True, default="", editable=False) + page_count = models.PositiveIntegerField(default=0) + file_size = models.BigIntegerField(default=0) + metadata_json = models.JSONField(blank=True, default=dict) + file = models.FileField(upload_to="ebooks/%Y/%m/%d/") + cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_ebook" + verbose_name = "E-Book" + verbose_name_plural = "E-Books" + ordering = ["-created_at"] + indexes = [models.Index(fields=["user", "-created_at"])] + + def __str__(self): + return self.title + + def filename(self): + return Path(self.file.name).name if self.file else "" + + +class BookChapter(models.Model): + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters") + title = models.CharField(max_length=512) + index = models.IntegerField(default=0) + href = models.CharField(max_length=1024, blank=True, default="") + children = models.JSONField(blank=True, default=list) + + class Meta: + db_table = "books_book_chapter" + verbose_name = "Book Chapter" + verbose_name_plural = "Book Chapters" + ordering = ["index"] + indexes = [models.Index(fields=["ebook", "index"])] + + def __str__(self): + return f"{self.ebook.title} - {self.title}" + + +@receiver(post_delete, sender=EBook) +def _auto_delete_ebook_file(sender, instance, **kwargs): + if instance.file: + instance.file.delete(save=False) + if instance.cover_image: + instance.cover_image.delete(save=False) + + +class DownloadRecord(models.Model): + """Tracks book downloads for offline access management.""" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records") + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records") + file_size = models.BigIntegerField(default=0) + downloaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "books_download_record" + verbose_name = "Download Record" + verbose_name_plural = "Download Records" + ordering = ["-downloaded_at"] + unique_together = [("user", "ebook")] + + def __str__(self): + return f"{self.user} - {self.ebook.title}" + + +class ReadingProgress(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress") + ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress") + current_position = models.FloatField(default=0.0) + last_page = models.IntegerField(default=0) + device_id = models.CharField(max_length=128, blank=True, default="") + device_name = models.CharField(max_length=128, blank=True, default="") + version = models.PositiveIntegerField(default=1) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_reading_progress" + verbose_name_plural = "reading progress" + unique_together = [("user", "ebook")] + + def __str__(self): + return f"{self.ebook.title} - {self.current_position:.1f}%" + + def update_with_sync(self, position: float, last_page: int, + device_id: str, device_name: str, + client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]: + """Update progress with conflict resolution (last-write-wins by timestamp). + + Returns (instance, applied) where applied is True if the update was applied. + """ + if client_updated_at and self.updated_at: + try: + from django.utils.timezone import is_naive, make_aware + from datetime import datetime + client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00")) + if is_naive(client_dt): + client_dt = make_aware(client_dt) + if client_dt <= self.updated_at: + return self, False + except (ValueError, TypeError): + pass + + self.current_position = position + self.last_page = last_page + self.device_id = device_id + self.device_name = device_name + self.version += 1 + self.save(update_fields=[ + "current_position", "last_page", + "device_id", "device_name", "version", "updated_at", + ]) + return self, True + + +class ReadingSettings(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings") + font_size = models.IntegerField(default=18) + font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value) + background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_reading_settings" + verbose_name_plural = "reading settings" + + def __str__(self): + return f"Settings for {self.user}" + + class Chapter(models.Model): - """A chapter within a book, containing the text/markdown content.""" + """A chapter within a book, containing the text/markdown content. + + This is distinct from BookChapter (which tracks EPUB TOC entries). + Chapter represents user-authored chapter content for a Book. + """ book = models.ForeignKey( Book, @@ -48,23 +214,27 @@ class Chapter(models.Model): ) ] - def __str__(self) -> str: + def __str__(self): return f"{self.book.title} — Ch. {self.number}: {self.title}" -class ReadingProgress(models.Model): - """Tracks a user's reading progress within a book.""" +class BookReadingProgress(models.Model): + """Tracks a user's reading progress within a book's chapters. + + This is distinct from ReadingProgress (which tracks ebook progress). + BookReadingProgress tracks chapter-based reading for Books. + """ user = models.ForeignKey( - "users.User", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="reading_progress", + related_name="book_reading_progress", db_index=True, ) book = models.ForeignKey( Book, on_delete=models.CASCADE, - related_name="reading_progress", + related_name="book_reading_progress", db_index=True, ) current_chapter = models.PositiveIntegerField(default=1) @@ -73,16 +243,16 @@ class ReadingProgress(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - db_table = "books_reading_progress" - verbose_name = "Reading Progress" - verbose_name_plural = "Reading Progress" + db_table = "books_book_reading_progress" + verbose_name = "Book Reading Progress" + verbose_name_plural = "Book Reading Progress" ordering = ["-updated_at"] constraints = [ models.UniqueConstraint( fields=["user", "book"], - name="uq_reading_progress_user_book", + name="uq_book_reading_progress_user_book", ) ] - def __str__(self) -> str: + def __str__(self): return f"{self.user} — {self.book.title} ({self.percentage:.0f}%)" \ No newline at end of file diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index 589b623..2a0c6a3 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -1,31 +1,192 @@ from rest_framework import serializers -from apps.books.models import Book, Chapter, ReadingProgress +from apps.books.models import Book, BookChapter, BookReadingProgress, Chapter, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus, DownloadRecord -class BookSerializer(serializers.ModelSerializer): - """Serialize Book data.""" - +class BookChapterSerializer(serializers.ModelSerializer): class Meta: - model = Book - fields = [ - "id", - "title", - "author", - "total_pages", - "cover_image", - "created_at", - "updated_at", - ] - read_only_fields = ["id", "created_at", "updated_at"] + model = BookChapter + fields = ["id", "title", "index", "href", "children"] + + +class EBookContentSerializer(serializers.Serializer): + page = serializers.IntegerField() + total_pages = serializers.IntegerField() + content = serializers.CharField() + chapter_title = serializers.CharField() + format = serializers.CharField() + + +class EBookTocSerializer(serializers.Serializer): + chapters = serializers.ListField(child=BookChapterSerializer()) + format = serializers.CharField() + page_count = serializers.IntegerField() class BookListSerializer(serializers.ModelSerializer): - """Lightweight serializer for list views (excludes heavy fields).""" + reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True) class Meta: model = Book - fields = ["id", "title", "author", "total_pages", "cover_image"] + fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"] + + +class BookDetailSerializer(serializers.ModelSerializer): + reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True) + + class Meta: + model = Book + fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class BookSerializer(serializers.ModelSerializer): + class Meta: + model = Book + fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class EBookListSerializer(serializers.ModelSerializer): + filename = serializers.CharField(read_only=True) + format = serializers.CharField(read_only=True) + progress = serializers.SerializerMethodField() + + class Meta: + model = EBook + fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "cover_image", "created_at", "progress"] + + def get_progress(self, obj): + try: + return obj.reading_progress.current_position + except ReadingProgress.DoesNotExist: + return None + + +class EBookDetailSerializer(serializers.ModelSerializer): + filename = serializers.CharField(read_only=True) + format = serializers.CharField(read_only=True) + file_url = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + + class Meta: + model = EBook + fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "file_url", "cover_image", "created_at", "updated_at", "progress"] + + def get_file_url(self, obj): + request = self.context.get("request") + if request and obj.file: + return request.build_absolute_uri(obj.file.url) + return "" + + def get_progress(self, obj): + try: + rp = obj.reading_progress + return {"current_position": rp.current_position, "last_page": rp.last_page} + except ReadingProgress.DoesNotExist: + return None + + +class EBookUploadSerializer(serializers.ModelSerializer): + class Meta: + model = EBook + fields = ["title", "author", "file", "cover_image"] + extra_kwargs = {"title": {"required": True}, "file": {"required": True}} + + def validate_file(self, value): + import os + if value is None: + return value + ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower() + if ext not in (".epub", ".pdf"): + raise serializers.ValidationError("Only EPUB and PDF files are supported.") + return value + + def create(self, validated_data): + validated_data["user"] = self.context["request"].user + # Auto-detect format from file extension + import os + name = str(getattr(validated_data.get("file"), "name", "")) + ext = os.path.splitext(name)[1].lower().lstrip(".") + if ext: + validated_data["format"] = ext + return super().create(validated_data) + + +class ReadingProgressSerializer(serializers.ModelSerializer): + class Meta: + model = ReadingProgress + fields = ["current_position", "last_page", "device_id", "device_name", "version", "updated_at"] + read_only_fields = ["version", "updated_at"] + extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}} + + def validate_current_position(self, value): + if value < 0.0 or value > 100.0: + raise serializers.ValidationError("Position must be between 0.0 and 100.0.") + return value + + +class ReadingSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = ReadingSettings + fields = ["font_size", "font_style", "background_color"] + + def validate_font_size(self, value): + if value < 12 or value > 36: + raise serializers.ValidationError("Font size must be between 12 and 36.") + return value + + def validate_font_style(self, value): + valid = [s.value for s in FontStyle] + if value not in valid: + raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}") + return value + + def validate_background_color(self, value): + valid = [c.value for c in BackgroundColor] + if value not in valid: + raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}") + return value + + +class DownloadRecordSerializer(serializers.ModelSerializer): + ebook_id = serializers.IntegerField(source="ebook.id", read_only=True) + ebook_title = serializers.CharField(source="ebook.title", read_only=True) + author = serializers.CharField(source="ebook.author", read_only=True) + filename = serializers.SerializerMethodField() + cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True) + format = serializers.CharField(source="ebook.format", read_only=True) + progress = serializers.SerializerMethodField() + file_url = serializers.SerializerMethodField() + + class Meta: + model = DownloadRecord + fields = [ + "id", "ebook_id", "ebook_title", "author", "filename", "file_url", + "file_size", "cover_image", "format", "downloaded_at", "progress", + ] + + def get_filename(self, obj): + return obj.ebook.filename() + + def get_file_url(self, obj): + request = self.context.get("request") + if request and obj.ebook.file: + return request.build_absolute_uri(obj.ebook.file.url) + return "" + + def get_progress(self, obj): + try: + rp = obj.ebook.reading_progress + return {"current_position": rp.current_position, "last_page": rp.last_page} + except ReadingProgress.DoesNotExist: + return None + + +class StorageSummarySerializer(serializers.Serializer): + total_downloads = serializers.IntegerField() + total_size_bytes = serializers.IntegerField() + ebooks = serializers.ListField(child=serializers.DictField()) class ChapterSummarySerializer(serializers.ModelSerializer): @@ -45,27 +206,23 @@ class ChapterDetailSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at", "updated_at"] -class ReadingProgressSerializer(serializers.ModelSerializer): - """Serialize reading progress for a book.""" +class BookReadingProgressSerializer(serializers.ModelSerializer): + """Serialize chapter-based reading progress for a book.""" class Meta: - model = ReadingProgress + model = BookReadingProgress fields = [ - "id", - "book", - "current_chapter", - "current_position", - "percentage", - "updated_at", + "id", "book", "current_chapter", "current_position", + "percentage", "updated_at", ] read_only_fields = ["id", "updated_at"] - def validate_percentage(self, value: float) -> float: + def validate_percentage(self, value): if value < 0.0 or value > 100.0: raise serializers.ValidationError("Percentage must be between 0 and 100.") return value - def validate_current_chapter(self, value: int) -> int: + def validate_current_chapter(self, value): if value < 1: raise serializers.ValidationError("Chapter number must be positive.") return value \ No newline at end of file diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index 816fbdb..232c08c 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -1,53 +1,94 @@ +from __future__ import annotations + +import logging +from typing import Any + +from django.db.models import QuerySet, Q from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import status, viewsets +from rest_framework import parsers, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.filters import OrderingFilter, SearchFilter -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from apps.books.models import Book, Chapter, ReadingProgress +from apps.books.models import Book, BookChapter, BookReadingProgress, DownloadRecord, EBook, Chapter, ReadingProgress, ReadingSettings from apps.books.serializers import ( - BookListSerializer, - BookSerializer, - ChapterDetailSerializer, - ChapterSummarySerializer, - ReadingProgressSerializer, + BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer, + BookReadingProgressSerializer, ChapterDetailSerializer, ChapterSummarySerializer, + DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer, + EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, + ReadingProgressSerializer, ReadingSettingsSerializer, StorageSummarySerializer, ) +logger = logging.getLogger(__name__) + class BookViewSet(viewsets.ModelViewSet): - """CRUD for books. Includes nested chapter and progress endpoints.""" - - queryset = Book.objects.all().prefetch_related("chapters") + queryset = Book.objects.all() permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] - filterset_fields = ["author"] - search_fields = ["title", "author"] - ordering_fields = ["title", "author", "created_at"] + filterset_fields = ["author", "genre", "reading_status"] + search_fields = ["title", "author", "genre"] + ordering_fields = ["title", "author", "genre", "created_at"] ordering = ["title"] def get_serializer_class(self): + if self.action == "retrieve": + return BookDetailSerializer if self.action == "list": return BookListSerializer return BookSerializer + def get_queryset(self) -> QuerySet[Book]: + qs = super().get_queryset() + query = self.request.query_params.get("q", "").strip() + if query: + qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query)) + return qs + + @action(detail=False, methods=["get"], permission_classes=[AllowAny]) + def genres(self, request: Request) -> Response: + genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre") + return Response([g for g in genre_list if g]) + + @action(detail=False, methods=["get"], permission_classes=[AllowAny]) + def authors(self, request: Request) -> Response: + author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author") + return Response([a for a in author_list if a]) + + @action(detail=False, methods=["get"]) + def storage(self, request: Request) -> Response: + """Return storage usage summary for the current user.""" + download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook") + total_size = sum(r.file_size for r in download_records) + ebook_list = [ + {"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size} + for r in download_records + ] + serializer = StorageSummarySerializer(data={ + "total_downloads": download_records.count(), + "total_size_bytes": total_size, + "ebooks": ebook_list, + }) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + @action(detail=True, methods=["get"]) def chapters(self, request: Request, pk: int | None = None) -> Response: """List all chapters for this book (TOC).""" book = self.get_object() - chapters = book.chapters.all().order_by("number") - serializer = ChapterSummarySerializer(chapters, many=True) + chapter_qs = Chapter.objects.filter(book=book).order_by("number") + serializer = ChapterSummarySerializer(chapter_qs, many=True) return Response(serializer.data) - @action(detail=True, methods=["get"], url_path="chapters/(?P[0-9]+)") - def chapter_detail( - self, request: Request, pk: int | None = None, chapter_number: str | None = None - ) -> Response: + @action(detail=True, methods=["get"], url_path=r"chapters/(?P[0-9]+)") + def chapter_detail(self, request: Request, pk: int | None = None, chapter_number: str | None = None) -> Response: """Get a specific chapter with full content.""" book = self.get_object() try: - chapter = book.chapters.get(number=int(chapter_number)) + chapter = Chapter.objects.get(book=book, number=int(chapter_number or "0")) except Chapter.DoesNotExist: return Response( {"detail": "Chapter not found."}, status=status.HTTP_404_NOT_FOUND @@ -57,19 +98,244 @@ class BookViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get", "put"]) def progress(self, request: Request, pk: int | None = None) -> Response: - """Get or update reading progress for this book.""" + """Get or update chapter-based reading progress for this book.""" book = self.get_object() - progress, created = ReadingProgress.objects.get_or_create( + progress_obj, _created = BookReadingProgress.objects.get_or_create( user=request.user, book=book ) if request.method == "GET": - serializer = ReadingProgressSerializer(progress) + serializer = BookReadingProgressSerializer(progress_obj) return Response(serializer.data) - serializer = ReadingProgressSerializer( - progress, data=request.data, partial=True + serializer = BookReadingProgressSerializer( + progress_obj, data=request.data, partial=True ) serializer.is_valid(raise_exception=True) serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_200_OK) + + +class IsEBookOwner(permissions.BasePermission): + def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: + return obj.user == request.user + + +class EBookViewSet(viewsets.ModelViewSet): + parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser] + permission_classes = [IsAuthenticated, IsEBookOwner] + + def get_serializer_class(self): + if self.action == "create": + return EBookUploadSerializer + if self.action in ("list",): + return EBookListSerializer + if self.action in ("toc",): + return BookChapterSerializer + return EBookDetailSerializer + + def get_queryset(self): + return EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user") + + @action(detail=True, methods=["get", "patch"]) + def progress(self, request: Request, pk: int | None = None) -> Response: + ebook = self.get_object() + progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook) + if request.method == "GET": + serializer = ReadingProgressSerializer(progress_obj) + return Response(serializer.data) + serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def process(self, request: Request, pk: int | None = None) -> Response: + """Trigger e-book processing: metadata extraction, TOC building, page counting.""" + ebook = self.get_object() + if not ebook.file: + return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) + + try: + from apps.books.services import process_ebook + + file_path = ebook.file.path + result = process_ebook(file_path, original_filename=ebook.filename()) + + # Update ebook with extracted data + ebook.format = result.get("format", ebook.format) + ebook.page_count = result.get("page_count", 0) + ebook.metadata_json = result.get("metadata", {}) + ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"]) + + # Store chapters in DB + raw_toc: list[dict[str, Any]] = result.get("toc", []) + BookChapter.objects.filter(ebook=ebook).delete() + _store_chapters(ebook, raw_toc) + + return Response({ + "format": ebook.format, + "page_count": ebook.page_count, + "metadata": ebook.metadata_json, + "toc_count": len(raw_toc), + "status": "processed", + }) + except Exception as exc: + logger.exception("Failed to process ebook %s", ebook.id) + return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=["get"]) + def toc(self, request: Request, pk: int | None = None) -> Response: + """Return hierarchical table of contents.""" + ebook = self.get_object() + chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook") + serializer = BookChapterSerializer(chapters, many=True) + return Response({ + "chapters": serializer.data, + "format": ebook.format, + "page_count": ebook.page_count, + }) + + @action(detail=True, methods=["get"]) + def content(self, request: Request, pk: int | None = None) -> Response: + """Return paginated content for a given page number. + + Query params: + page (int): page/chapter index to fetch (1-indexed, default: 1) + """ + ebook = self.get_object() + page = max(1, int(request.query_params.get("page", 1))) + + chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")) + total_pages = len(chapters) or ebook.page_count or 1 + + chapter: BookChapter | None = None + chapter_title = "" + content_html = "" + + if chapters and 0 <= page - 1 < len(chapters): + ch = chapters[page - 1] + chapter_title = ch.title + content_html = _fetch_chapter_content(ebook, ch) + + serializer = EBookContentSerializer(data={ + "page": page, + "total_pages": total_pages, + "content": content_html, + "chapter_title": chapter_title, + "format": ebook.format, + }) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def download(self, request: Request, pk: int | None = None) -> Response: + """Track download of an e-book. Creates a DownloadRecord and returns file info.""" + ebook = self.get_object() + if not ebook.file: + return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) + + download, created = DownloadRecord.objects.get_or_create( + user=request.user, + ebook=ebook, + defaults={"file_size": ebook.file.size if ebook.file else 0}, + ) + if not created: + download.file_size = ebook.file.size if ebook.file else 0 + download.save(update_fields=["file_size"]) + + serializer = DownloadRecordSerializer(download, context={"request": request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"]) + def downloads(self, request: Request) -> Response: + """List all e-books the current user has downloaded.""" + records = DownloadRecord.objects.filter(user=request.user).select_related( + "ebook", "ebook__reading_progress" + ).prefetch_related("ebook__chapters") + page = self.paginate_queryset(records) + if page is not None: + serializer = DownloadRecordSerializer(page, many=True, context={"request": request}) + return self.get_paginated_response(serializer.data) + serializer = DownloadRecordSerializer(records, many=True, context={"request": request}) + return Response(serializer.data) + + @action(detail=False, methods=["delete"], url_path="downloads/(?P[^/.]+)") + def delete_download(self, request: Request, download_pk: str | None = None) -> Response: + """Delete a download record.""" + try: + download = DownloadRecord.objects.get(pk=download_pk, user=request.user) + except DownloadRecord.DoesNotExist: + return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND) + download.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None: + """Recursively store TOC entries as BookChapter records.""" + for idx, entry in enumerate(toc): + BookChapter.objects.create( + ebook=ebook, + title=entry.get("title", "Untitled"), + index=parent_index + idx, + href=entry.get("href", ""), + children=entry.get("children", []), + ) + children = entry.get("children", []) + if children: + _store_chapters(ebook, children, parent_index + idx + 1) + + +def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str: + """Fetch HTML content for a chapter from the e-book file.""" + if ebook.format == "epub": + return _fetch_epub_chapter_content(ebook.file.path, chapter) + return "" + + +def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str: + """Extract HTML content of a specific EPUB chapter by href.""" + try: + from ebooklib import epub + from bs4 import BeautifulSoup + except ImportError: + return "" + + try: + book = epub.read_epub(file_path) + href = chapter.href or "" + # Find the item by href + for item in book.get_items(): + item_name = item.get_name() or "" + if href and (item_name.endswith(href) or href.endswith(item_name)): + content = item.get_content() + soup = BeautifulSoup(content, "html.parser") + # Clean up — remove body/html/head wrappers, keep inner content + body = soup.find("body") + if body: + return str(body) + return str(soup) + return "" + except Exception: + logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href) + return "" + + +class ReadingSettingsViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + serializer_class = ReadingSettingsSerializer + + def get_queryset(self): + return ReadingSettings.objects.filter(user=self.request.user) + + def list(self, request: Request) -> Response: + settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) + serializer = self.get_serializer(settings_obj) + return Response(serializer.data) + + def partial_update(self, request: Request) -> Response: + settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) + serializer = self.get_serializer(settings_obj, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7f639f5 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,62 @@ +import React, { lazy, Suspense, useState } from "react"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider, useAuth } from "./context/AuthContext"; + +const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage }))); +const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage }))); +const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage }))); +const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage }))); +const EditEBookPage = lazy(() => import("./pages/EditEBook").then((m) => ({ default: m.EditEBookPage }))); +const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); +const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); +const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); + +const AuthPage = lazy(() => + import("./pages/AuthPage").then((m) => ({ + default: () => { + const [isLogin, setIsLogin] = useState(true); + return isLogin ? setIsLogin(false)} /> : setIsLogin(true)} />; + }, + })), +); + +function LoadingFallback() { + return

Loading...

; +} + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, loading } = useAuth(); + if (loading) return ; + if (!isAuthenticated) return ; + return <>{children}; +} + +function AppRoutes() { + const { isAuthenticated } = useAuth(); + return ( + }> + + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default function App() { + return ( + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/api/reader.ts b/frontend/src/api/reader.ts new file mode 100644 index 0000000..cbf73e7 --- /dev/null +++ b/frontend/src/api/reader.ts @@ -0,0 +1,112 @@ +/** + * API client for the reader module — reading settings, chapters, and progress. + */ + +import type { + ChapterDetail, + ChapterSummary, + ReadingProgress, + ReadingSettings, +} from "../types/reader"; + +const API_BASE = "/api"; + +/** + * Fetch the current user's reading settings. + * Auto-creates defaults on the server if none exist. + */ +export async function getReadingSettings(): Promise { + const response = await fetch(`${API_BASE}/reader/settings/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading settings: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Update (full or partial) the user's reading settings. + */ +export async function updateReadingSettings( + settings: Partial +): Promise { + const method = settings.theme !== undefined ? "PUT" : "PATCH"; + const response = await fetch(`${API_BASE}/reader/settings/`, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading settings: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch the table of contents (chapter list) for a book. + */ +export async function getChapters(bookId: number): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/chapters/`); + if (!response.ok) { + throw new Error( + `Failed to fetch chapters: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch a specific chapter with full content for reading. + */ +export async function getChapterContent( + bookId: number, + chapterNumber: number +): Promise { + const response = await fetch( + `${API_BASE}/books/${bookId}/chapters/${chapterNumber}/` + ); + if (!response.ok) { + throw new Error( + `Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch reading progress for a book. + */ +export async function getReadingProgress( + bookId: number +): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/progress/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading progress: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Update reading progress for a book. + */ +export async function updateReadingProgress( + bookId: number, + progress: Partial +): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/progress/`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(progress), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading progress: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} \ No newline at end of file diff --git a/frontend/src/components/reader/ReaderToolbar.tsx b/frontend/src/components/reader/ReaderToolbar.tsx new file mode 100644 index 0000000..faf7f65 --- /dev/null +++ b/frontend/src/components/reader/ReaderToolbar.tsx @@ -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 */} +
+ +
+ {bookTitle} + {chapterTitle} +
+ +
+ + {/* Bottom progress bar */} +
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/reader/ReadingSettingsPanel.tsx b/frontend/src/components/reader/ReadingSettingsPanel.tsx new file mode 100644 index 0000000..b1201b8 --- /dev/null +++ b/frontend/src/components/reader/ReadingSettingsPanel.tsx @@ -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) => Promise; +} + +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>({}); + + 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 && ( +
{ + if (e.key === "Escape") onClose(); + }} + role="presentation" + /> + )} + + {/* Drawer */} + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/reader/TableOfContents.tsx b/frontend/src/components/reader/TableOfContents.tsx new file mode 100644 index 0000000..1510eea --- /dev/null +++ b/frontend/src/components/reader/TableOfContents.tsx @@ -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 && ( +
{ + if (e.key === "Escape") onClose(); + }} + role="presentation" + /> + )} + + {/* Drawer */} + + + ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useChapters.ts b/frontend/src/hooks/useChapters.ts new file mode 100644 index 0000000..e388fa2 --- /dev/null +++ b/frontend/src/hooks/useChapters.ts @@ -0,0 +1,129 @@ +/** + * useChapters — fetch chapter list and manage current chapter navigation. + */ + +import { useCallback, useEffect, useState } from "react"; +import { getChapterContent, getChapters } from "../api/reader"; +import type { ChapterDetail, ChapterSummary } from "../types/reader"; + +export interface UseChaptersReturn { + chapters: ChapterSummary[]; + currentChapter: ChapterDetail | null; + currentChapterNumber: number; + isLoading: boolean; + error: string | null; + navigateToChapter: (number: number) => Promise; + goToNextChapter: () => Promise; + goToPreviousChapter: () => Promise; + hasNext: boolean; + hasPrevious: boolean; +} + +export function useChapters( + bookId: number, + initialChapter: number = 1 +): UseChaptersReturn { + const [chapters, setChapters] = useState([]); + const [currentChapter, setCurrentChapter] = useState( + null + ); + const [currentChapterNumber, setCurrentChapterNumber] = + useState(initialChapter); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch chapter list on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getChapters(bookId) + .then((data) => { + if (!cancelled) { + setChapters(data); + // If chapters exist and initial chapter is valid, fetch it + if ( + data.length > 0 && + data.some((c) => c.number === currentChapterNumber) + ) { + return getChapterContent(bookId, currentChapterNumber); + } + return null; + } + return null; + }) + .then((chapter) => { + if (!cancelled && chapter) { + setCurrentChapter(chapter); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load chapters" + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [bookId, currentChapterNumber]); + + const fetchChapter = useCallback( + async (number: number) => { + setIsLoading(true); + setError(null); + try { + const chapter = await getChapterContent(bookId, number); + setCurrentChapter(chapter); + setCurrentChapterNumber(number); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to load chapter" + ); + } finally { + setIsLoading(false); + } + }, + [bookId] + ); + + const navigateToChapter = useCallback( + (number: number) => { + fetchChapter(number); + }, + [fetchChapter] + ); + + const goToNextChapter = useCallback(() => { + const next = currentChapterNumber + 1; + if (chapters.some((c) => c.number === next)) { + fetchChapter(next); + } + }, [currentChapterNumber, chapters, fetchChapter]); + + const goToPreviousChapter = useCallback(() => { + const prev = currentChapterNumber - 1; + if (prev >= 1 && chapters.some((c) => c.number === prev)) { + fetchChapter(prev); + } + }, [currentChapterNumber, chapters, fetchChapter]); + + const hasNext = chapters.some((c) => c.number === currentChapterNumber + 1); + const hasPrevious = chapters.some((c) => c.number === currentChapterNumber - 1); + + return { + chapters, + currentChapter, + currentChapterNumber, + isLoading, + error, + navigateToChapter, + goToNextChapter, + goToPreviousChapter, + hasNext, + hasPrevious, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useReadingProgress.ts b/frontend/src/hooks/useReadingProgress.ts new file mode 100644 index 0000000..1d8034d --- /dev/null +++ b/frontend/src/hooks/useReadingProgress.ts @@ -0,0 +1,99 @@ +/** + * useReadingProgress — fetch and update reading progress for a book. + * Auto-saves when chapter or position changes. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { getReadingProgress, updateReadingProgress } from "../api/reader"; +import type { ReadingProgress } from "../types/reader"; + +export interface UseReadingProgressReturn { + progress: ReadingProgress | null; + isLoading: boolean; + error: string | null; + saveProgress: ( + chapter: number, + position: number, + percentage: number + ) => Promise; + /** Schedule a debounced save — fires at most once per 3 seconds */ + debouncedSave: ( + chapter: number, + position: number, + percentage: number + ) => void; +} + +export function useReadingProgress( + bookId: number +): UseReadingProgressReturn { + const [progress, setProgress] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const debounceTimer = useRef | null>(null); + + // Fetch progress on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getReadingProgress(bookId) + .then((data) => { + if (!cancelled) setProgress(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load progress" + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [bookId]); + + const saveProgress = useCallback( + async (chapter: number, position: number, percentage: number) => { + try { + const updated = await updateReadingProgress(bookId, { + current_chapter: chapter, + current_position: position, + percentage, + }); + setProgress(updated); + setError(null); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to save progress" + ); + } + }, + [bookId] + ); + + const debouncedSave = useCallback( + (chapter: number, position: number, percentage: number) => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + saveProgress(chapter, position, percentage); + }, 3000); + }, + [saveProgress] + ); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, []); + + return { progress, isLoading, error, saveProgress, debouncedSave }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useReadingSettings.ts b/frontend/src/hooks/useReadingSettings.ts new file mode 100644 index 0000000..7e5df8d --- /dev/null +++ b/frontend/src/hooks/useReadingSettings.ts @@ -0,0 +1,96 @@ +/** + * useReadingSettings — fetch and manage user reading preferences. + * Applies settings as CSS custom properties on the document root. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + getReadingSettings, + updateReadingSettings, +} from "../api/reader"; +import type { ReadingSettings } from "../types/reader"; + +const DEFAULT_SETTINGS: ReadingSettings = { + font_family: "serif", + font_size: 18, + line_height: 1.6, + margin_width: 16, + background_color: "#f5f0eb", + text_color: "#1a1a1a", + brightness: 100, + orientation_lock: "auto", + theme: "sepia", + created_at: "", + updated_at: "", +}; + +function applyCssVariables(settings: ReadingSettings): void { + const root = document.documentElement; + root.style.setProperty("--reader-bg", settings.background_color); + root.style.setProperty("--reader-text", settings.text_color); + root.style.setProperty("--reader-font-family", settings.font_family); + root.style.setProperty("--reader-font-size", `${settings.font_size}px`); + root.style.setProperty("--reader-line-height", String(settings.line_height)); + root.style.setProperty("--reader-margin", `${settings.margin_width}px`); + root.style.setProperty("--reader-brightness", `${settings.brightness}%`); +} + +export interface UseReadingSettingsReturn { + settings: ReadingSettings; + isLoading: boolean; + error: string | null; + updateSettings: (partial: Partial) => Promise; +} + +export function useReadingSettings(): UseReadingSettingsReturn { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch settings on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getReadingSettings() + .then((data) => { + if (!cancelled) { + setSettings(data); + applyCssVariables(data); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load reading settings" + ); + // Apply defaults + applyCssVariables(DEFAULT_SETTINGS); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const updateSettings = useCallback( + async (partial: Partial) => { + try { + const updated = await updateReadingSettings(partial); + setSettings(updated); + applyCssVariables(updated); + setError(null); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to update reading settings" + ); + throw err; + } + }, + [] + ); + + return { settings, isLoading, error, updateSettings }; +} \ No newline at end of file diff --git a/frontend/src/pages/ReadingPage.tsx b/frontend/src/pages/ReadingPage.tsx new file mode 100644 index 0000000..2f9def6 --- /dev/null +++ b/frontend/src/pages/ReadingPage.tsx @@ -0,0 +1,213 @@ +/** + * ReadingPage — full-screen reading view for ebooks. + * Integrates TOC drawer, settings panel, chapter navigation, and progress tracking. + */ + +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useChapters } from "../hooks/useChapters"; +import { useReadingProgress } from "../hooks/useReadingProgress"; +import { useReadingSettings } from "../hooks/useReadingSettings"; +import { booksApi } from "../api/books"; +import type { BookDetail } from "../types/book"; +import "../reader.css"; + +const ReaderToolbar = lazy(() => import("../components/reader/ReaderToolbar")); +const TableOfContents = lazy(() => import("../components/reader/TableOfContents")); +const ReadingSettingsPanel = lazy( + () => import("../components/reader/ReadingSettingsPanel") +); + +export default function ReadingPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const bookId = Number(id); + const [book, setBook] = useState(null); + const [bookLoading, setBookLoading] = useState(true); + + // Fetch book details + useEffect(() => { + if (!bookId || Number.isNaN(bookId)) return; + setBookLoading(true); + booksApi.getBook(bookId) + .then(setBook) + .catch(() => { /* handled by useChapters error */ }) + .finally(() => setBookLoading(false)); + }, [bookId]); + + const [tocOpen, setTocOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const { settings, updateSettings } = useReadingSettings(); + const { + chapters, + currentChapter, + currentChapterNumber, + isLoading, + error, + navigateToChapter, + goToNextChapter, + goToPreviousChapter, + hasNext, + hasPrevious, + } = useChapters(bookId, 1); + + const { progress, debouncedSave } = useReadingProgress(bookId); + + // Auto-save progress when chapter changes + useEffect(() => { + if (currentChapter && currentChapterNumber > 0) { + debouncedSave(currentChapterNumber, 0, (currentChapterNumber / Math.max(chapters.length, 1)) * 100); + } + }, [currentChapterNumber, currentChapter?.id]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowLeft" && hasPrevious) { + goToPreviousChapter(); + } else if (e.key === "ArrowRight" && hasNext) { + goToNextChapter(); + } else if (e.key === "Escape") { + setTocOpen(false); + setSettingsOpen(false); + } + }, + [hasNext, hasPrevious, goToNextChapter, goToPreviousChapter] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + // Lock/unlock orientation via CSS + useEffect(() => { + const root = document.documentElement; + if (settings.orientation_lock !== "auto") { + root.style.setProperty( + "--reader-orientation", + settings.orientation_lock === "portrait" ? "portrait" : "landscape" + ); + } else { + root.style.removeProperty("--reader-orientation"); + } + }, [settings.orientation_lock]); + + // Show loading state + if (bookLoading || (isLoading && !currentChapter)) { + return ( +
+
+

Loading reader...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( + +
+
+ } + > +
+ setTocOpen((v) => !v)} + onToggleSettings={() => setSettingsOpen((v) => !v)} + /> + + setTocOpen(false)} + onNavigate={navigateToChapter} + /> + + setSettingsOpen(false)} + onUpdate={updateSettings} + /> + + {/* Main reading area */} +
+ {isLoading ? ( +
+
+

Loading chapter...

+
+ ) : currentChapter ? ( +
+

{currentChapter.title}

+
+
+ ) : ( +
+

Select a chapter from the table of contents to start reading.

+
+ )} +
+ + {/* Bottom navigation bar */} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/reader.css b/frontend/src/reader.css new file mode 100644 index 0000000..2bf74a9 --- /dev/null +++ b/frontend/src/reader.css @@ -0,0 +1,621 @@ +/* ============================================================ + Cloud Reader — Reading View Styles + Mobile-first reading experience with theme support + ============================================================ */ + +/* --- CSS Custom Properties (overridden by JS) --- */ +:root { + --reader-bg: #f5f0eb; + --reader-text: #1a1a1a; + --reader-font-family: Georgia, "Times New Roman", serif; + --reader-font-size: 18px; + --reader-line-height: 1.6; + --reader-margin: 16px; + --reader-brightness: 100%; + --reader-toolbar-bg: rgba(26, 26, 26, 0.95); + --reader-toolbar-text: #e0e0e0; + --reader-accent: #6c8cff; +} + +/* --- Reading Container --- */ +.reader-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--reader-bg); + color: var(--reader-text); + font-family: var(--reader-font-family); + font-size: var(--reader-font-size); + line-height: var(--reader-line-height); + filter: brightness(var(--reader-brightness)); + display: flex; + flex-direction: column; + z-index: 100; + overflow: hidden; +} + +/* Orientation lock */ +@supports (--reader-orientation: portrait) { + .reader-container { + orientation: var(--reader-orientation); + } +} + +@media (orientation: landascape) { + .reader-container { + --reader-margin: 24px; + } +} + +/* --- Top Toolbar --- */ +.reader-top-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 52px; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + z-index: 120; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.reader-bar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + cursor: pointer; + border-radius: 8px; + transition: background 0.15s; +} + +.reader-bar-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.reader-bar-title { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + min-width: 0; + flex: 1; + padding: 0 8px; +} + +.reader-bar-book { + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.reader-bar-chapter { + font-size: 0.7rem; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* --- Progress Bar --- */ +.reader-progress-bar { + position: fixed; + top: 52px; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.1); + z-index: 120; +} + +.reader-progress-fill { + height: 100%; + background: var(--reader-accent); + transition: width 0.3s ease; +} + +/* --- Main Content Area --- */ +.reader-content { + flex: 1; + overflow-y: auto; + padding: 72px var(--reader-margin) 72px; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* --- Chapter Article --- */ +.reader-chapter { + max-width: 680px; + margin: 0 auto; +} + +.reader-chapter-title { + font-size: 1.6em; + font-weight: 700; + line-height: 1.3; + margin-bottom: 0.75em; + padding-bottom: 0.5em; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + color: var(--reader-text); +} + +/* Chapter body — content from API (could be HTML/markdown-rendered) */ +.reader-chapter-body { + font-size: 1em; + line-height: var(--reader-line-height); +} + +.reader-chapter-body p { + margin-bottom: 1.2em; + text-align: justify; + hyphens: auto; +} + +.reader-chapter-body h2, +.reader-chapter-body h3, +.reader-chapter-body h4 { + margin-top: 1.5em; + margin-bottom: 0.6em; + line-height: 1.3; +} + +.reader-chapter-body blockquote { + border-left: 3px solid var(--reader-accent); + padding-left: 1em; + margin: 1em 0; + opacity: 0.85; + font-style: italic; +} + +.reader-chapter-body img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 1em 0; +} + +.reader-chapter-body ul, +.reader-chapter-body ol { + padding-left: 1.5em; + margin-bottom: 1.2em; +} + +.reader-chapter-body li { + margin-bottom: 0.4em; +} + +/* --- Navigation Bar (Bottom) --- */ +.reader-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 56px; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + z-index: 120; + gap: 8px; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.reader-nav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + cursor: pointer; + transition: all 0.15s; +} + +.reader-nav-btn:hover:not(.reader-nav-btn--disabled) { + border-color: var(--reader-accent); + color: var(--reader-accent); +} + +.reader-nav-btn--disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.reader-nav-btn--back { + background: rgba(108, 140, 255, 0.15); + border-color: rgba(108, 140, 255, 0.3); +} + +.reader-nav-btn--back:hover { + background: rgba(108, 140, 255, 0.25); + color: var(--reader-accent); +} + +/* --- TOC Drawer --- */ +.toc-overlay, +.settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 130; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.toc-drawer, +.settings-drawer { + position: fixed; + top: 0; + bottom: 0; + width: 300px; + max-width: 85vw; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + z-index: 140; + display: flex; + flex-direction: column; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toc-drawer { + left: 0; + transform: translateX(-100%); +} + +.toc-drawer--open { + transform: translateX(0); +} + +.settings-drawer { + right: 0; + transform: translateX(100%); +} + +.settings-drawer--open { + transform: translateX(0); +} + +.toc-header, +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.toc-title, +.settings-title { + font-size: 1.1rem; + font-weight: 700; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.toc-close-btn, +.settings-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s; +} + +.toc-close-btn:hover, +.settings-close-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* --- TOC List --- */ +.toc-list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.toc-empty { + padding: 24px 16px; + text-align: center; + opacity: 0.6; + font-size: 0.9rem; +} + +.toc-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 16px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + font-size: 0.9rem; + text-align: left; + cursor: pointer; + transition: background 0.15s; + font-family: inherit; +} + +.toc-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.toc-item--active { + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +.toc-item-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + font-size: 0.75rem; + font-weight: 700; + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); + padding: 0 4px; +} + +.toc-item-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Settings Panel --- */ +.settings-body { + flex: 1; + overflow-y: auto; + padding: 12px 16px 32px; +} + +.settings-section { + margin-bottom: 20px; +} + +.settings-section-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* Theme grid */ +.theme-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.theme-btn { + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: transparent; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.theme-btn:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.theme-btn--active { + border-color: var(--reader-accent); + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +/* Font grid */ +.font-grid, +.orientation-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.font-btn, +.orientation-btn { + padding: 10px 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: transparent; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.font-btn:hover, +.orientation-btn:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.font-btn--active, +.orientation-btn--active { + border-color: var(--reader-accent); + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +/* Sliders */ +.settings-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.settings-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--reader-accent); + border: 2px solid white; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.settings-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--reader-accent); + border: 2px solid white; + cursor: pointer; +} + +/* --- Start Reading Button (in book detail) --- */ +.start-reading-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--reader-accent); + border: none; + border-radius: 8px; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-top: 16px; + margin-bottom: 16px; + font-family: inherit; +} + +.start-reading-btn:hover { + background: #5a7ae8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 140, 255, 0.3); +} + +/* --- Loading State --- */ +.reader-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + color: var(--reader-text); +} + +.reader-error { + color: #ef4444; + text-align: center; +} + +/* --- Responsive --- */ +@media (max-width: 480px) { + .reader-chapter { + max-width: 100%; + } + + .reader-nav-btn { + padding: 8px 10px; + font-size: 0.8rem; + } + + .reader-nav-btn span { + display: none; + } + + .toc-drawer, + .settings-drawer { + max-width: 100vw; + } + + .reader-chapter-title { + font-size: 1.3em; + } +} + +@media (min-width: 768px) { + .reader-chapter { + max-width: 720px; + } + + .reader-chapter-body { + font-size: 1.05em; + } +} + +/* --- Landscape on Mobile --- */ +@media (orientation: landscape) and (max-height: 500px) { + .reader-top-bar { + height: 44px; + } + + .reader-content { + padding-top: 60px; + padding-bottom: 60px; + } + + .reader-chapter-title { + font-size: 1.2em; + } +} + +/* --- Dark mode theme override when reader uses light theme but system is dark --- */ +@media (prefers-color-scheme: dark) { + .reader-container[data-theme="light"] { + --reader-toolbar-bg: rgba(255, 255, 255, 0.95); + --reader-toolbar-text: #1a1a1a; + } +} \ No newline at end of file diff --git a/frontend/src/types/reader.ts b/frontend/src/types/reader.ts new file mode 100644 index 0000000..d478d9a --- /dev/null +++ b/frontend/src/types/reader.ts @@ -0,0 +1,44 @@ +/** + * TypeScript interfaces for the Cloud Reader reader module. + * Reading view, settings, chapters, and progress types. + */ + +export interface ChapterSummary { + id: number; + book: number; + title: string; + number: number; +} + +export interface ChapterDetail extends ChapterSummary { + content: string; + created_at: string; + updated_at: string; +} + +export interface ReadingSettings { + font_family: "sans-serif" | "serif" | "monospace"; + font_size: number; + line_height: number; + margin_width: number; + background_color: string; + text_color: string; + brightness: number; + orientation_lock: "auto" | "portrait" | "landscape"; + theme: "sepia" | "dark" | "light" | "paper"; + created_at: string; + updated_at: string; +} + +export type ThemePreset = ReadingSettings["theme"]; +export type FontFamily = ReadingSettings["font_family"]; +export type OrientationLock = ReadingSettings["orientation_lock"]; + +export interface ReadingProgress { + id: number; + book: number; + current_chapter: number; + current_position: number; + percentage: number; + updated_at: string; +} \ No newline at end of file From a7864bf5cd1437e6e724a90c362cd7f07cbe33d6 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 06:37:58 +0000 Subject: [PATCH 3/4] Address Reid's PR #25 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 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 --- backend/apps/books/models.py | 100 +++++++++++++----- backend/apps/books/serializers.py | 88 +++++++++++---- backend/apps/books/urls.py | 3 +- backend/apps/books/views.py | 89 +++++++++++----- .../apps/reader/migrations/0001_initial.py | 39 +++++++ frontend/package.json | 15 +-- frontend/src/api/reader.ts | 62 ++++++++--- frontend/src/pages/ReadingPage.tsx | 11 +- frontend/src/reader.css | 2 +- 9 files changed, 311 insertions(+), 98 deletions(-) create mode 100644 backend/apps/reader/migrations/0001_initial.py diff --git a/backend/apps/books/models.py b/backend/apps/books/models.py index 7fa651f..bc16919 100644 --- a/backend/apps/books/models.py +++ b/backend/apps/books/models.py @@ -12,19 +12,6 @@ class ReadingStatus(models.TextChoices): DNF = "dnf", "Did Not Finish" -class FontStyle(models.TextChoices): - SANS_SERIF = "sans-serif", "Sans Serif" - SERIF = "serif", "Serif" - MONOSPACE = "monospace", "Monospace" - - -class BackgroundColor(models.TextChoices): - WHITE = "#ffffff", "White" - SEPIA = "#f4e4c1", "Sepia" - DARK = "#1a1a2e", "Dark" - GREEN = "#c7edcc", "Green" - - class Book(models.Model): title = models.CharField(max_length=512, db_index=True) author = models.CharField(max_length=256, blank=True, default="", db_index=True) @@ -49,8 +36,12 @@ class Book(models.Model): class EBook(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks") - title = models.CharField(max_length=512) - author = models.CharField(max_length=256, blank=True, default="") + title = models.CharField(max_length=512, db_index=True) + author = models.CharField(max_length=256, blank=True, default="", db_index=True) + format = models.CharField(max_length=20, blank=True, default="", editable=False) + page_count = models.PositiveIntegerField(default=0) + file_size = models.BigIntegerField(default=0) + metadata_json = models.JSONField(blank=True, default=dict) file = models.FileField(upload_to="ebooks/%Y/%m/%d/") cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) @@ -70,6 +61,24 @@ class EBook(models.Model): return Path(self.file.name).name if self.file else "" +class BookChapter(models.Model): + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters") + title = models.CharField(max_length=512) + index = models.IntegerField(default=0) + href = models.CharField(max_length=1024, blank=True, default="") + children = models.JSONField(blank=True, default=list) + + class Meta: + db_table = "books_book_chapter" + verbose_name = "Book Chapter" + verbose_name_plural = "Book Chapters" + ordering = ["index"] + indexes = [models.Index(fields=["ebook", "index"])] + + def __str__(self): + return f"{self.ebook.title} - {self.title}" + + @receiver(post_delete, sender=EBook) def _auto_delete_ebook_file(sender, instance, **kwargs): if instance.file: @@ -78,11 +87,33 @@ def _auto_delete_ebook_file(sender, instance, **kwargs): instance.cover_image.delete(save=False) +class DownloadRecord(models.Model): + """Tracks book downloads for offline access management.""" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records") + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records") + file_size = models.BigIntegerField(default=0) + downloaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "books_download_record" + verbose_name = "Download Record" + verbose_name_plural = "Download Records" + ordering = ["-downloaded_at"] + unique_together = [("user", "ebook")] + + def __str__(self): + return f"{self.user} - {self.ebook.title}" + + class ReadingProgress(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress") ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress") current_position = models.FloatField(default=0.0) last_page = models.IntegerField(default=0) + device_id = models.CharField(max_length=128, blank=True, default="") + device_name = models.CharField(max_length=128, blank=True, default="") + version = models.PositiveIntegerField(default=1) updated_at = models.DateTimeField(auto_now=True) class Meta: @@ -93,17 +124,32 @@ class ReadingProgress(models.Model): def __str__(self): return f"{self.ebook.title} - {self.current_position:.1f}%" + def update_with_sync(self, position: float, last_page: int, + device_id: str, device_name: str, + client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]: + """Update progress with conflict resolution (last-write-wins by timestamp). -class ReadingSettings(models.Model): - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings") - font_size = models.IntegerField(default=18) - font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value) - background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value) - updated_at = models.DateTimeField(auto_now=True) + Returns (instance, applied) where applied is True if the update was applied. + """ + if client_updated_at and self.updated_at: + try: + from django.utils.timezone import is_naive, make_aware + from datetime import datetime + client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00")) + if is_naive(client_dt): + client_dt = make_aware(client_dt) + if client_dt <= self.updated_at: + return self, False + except (ValueError, TypeError): + pass - class Meta: - db_table = "books_reading_settings" - verbose_name_plural = "reading settings" - - def __str__(self): - return f"Settings for {self.user}" \ No newline at end of file + self.current_position = position + self.last_page = last_page + self.device_id = device_id + self.device_name = device_name + self.version += 1 + self.save(update_fields=[ + "current_position", "last_page", + "device_id", "device_name", "version", "updated_at", + ]) + return self, True \ No newline at end of file diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index f0725e4..828d7b2 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -1,6 +1,26 @@ from rest_framework import serializers -from apps.books.models import Book, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus +from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord + + +class BookChapterSerializer(serializers.ModelSerializer): + class Meta: + model = BookChapter + fields = ["id", "title", "index", "href", "children"] + + +class EBookContentSerializer(serializers.Serializer): + page = serializers.IntegerField() + total_pages = serializers.IntegerField() + content = serializers.CharField() + chapter_title = serializers.CharField() + format = serializers.CharField() + + +class EBookTocSerializer(serializers.Serializer): + chapters = serializers.ListField(child=BookChapterSerializer()) + format = serializers.CharField() + page_count = serializers.IntegerField() class BookListSerializer(serializers.ModelSerializer): @@ -29,11 +49,12 @@ class BookSerializer(serializers.ModelSerializer): class EBookListSerializer(serializers.ModelSerializer): filename = serializers.CharField(read_only=True) + format = serializers.CharField(read_only=True) progress = serializers.SerializerMethodField() class Meta: model = EBook - fields = ["id", "title", "author", "filename", "cover_image", "created_at", "progress"] + fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "cover_image", "created_at", "progress"] def get_progress(self, obj): try: @@ -44,12 +65,13 @@ class EBookListSerializer(serializers.ModelSerializer): class EBookDetailSerializer(serializers.ModelSerializer): filename = serializers.CharField(read_only=True) + format = serializers.CharField(read_only=True) file_url = serializers.SerializerMethodField() progress = serializers.SerializerMethodField() class Meta: model = EBook - fields = ["id", "title", "author", "filename", "file_url", "cover_image", "created_at", "updated_at", "progress"] + fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "file_url", "cover_image", "created_at", "updated_at", "progress"] def get_file_url(self, obj): request = self.context.get("request") @@ -82,13 +104,20 @@ class EBookUploadSerializer(serializers.ModelSerializer): def create(self, validated_data): validated_data["user"] = self.context["request"].user + # Auto-detect format from file extension + import os + name = str(getattr(validated_data.get("file"), "name", "")) + ext = os.path.splitext(name)[1].lower().lstrip(".") + if ext: + validated_data["format"] = ext return super().create(validated_data) class ReadingProgressSerializer(serializers.ModelSerializer): class Meta: model = ReadingProgress - fields = ["current_position", "last_page"] + fields = ["current_position", "last_page", "device_id", "device_name", "version", "updated_at"] + read_only_fields = ["version", "updated_at"] extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}} def validate_current_position(self, value): @@ -97,24 +126,41 @@ class ReadingProgressSerializer(serializers.ModelSerializer): return value -class ReadingSettingsSerializer(serializers.ModelSerializer): +class DownloadRecordSerializer(serializers.ModelSerializer): + ebook_id = serializers.IntegerField(source="ebook.id", read_only=True) + ebook_title = serializers.CharField(source="ebook.title", read_only=True) + author = serializers.CharField(source="ebook.author", read_only=True) + filename = serializers.SerializerMethodField() + cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True) + format = serializers.CharField(source="ebook.format", read_only=True) + progress = serializers.SerializerMethodField() + file_url = serializers.SerializerMethodField() + class Meta: - model = ReadingSettings - fields = ["font_size", "font_style", "background_color"] + model = DownloadRecord + fields = [ + "id", "ebook_id", "ebook_title", "author", "filename", "file_url", + "file_size", "cover_image", "format", "downloaded_at", "progress", + ] - def validate_font_size(self, value): - if value < 12 or value > 36: - raise serializers.ValidationError("Font size must be between 12 and 36.") - return value + def get_filename(self, obj): + return obj.ebook.filename() - def validate_font_style(self, value): - valid = [s.value for s in FontStyle] - if value not in valid: - raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}") - return value + def get_file_url(self, obj): + request = self.context.get("request") + if request and obj.ebook.file: + return request.build_absolute_uri(obj.ebook.file.url) + return "" - def validate_background_color(self, value): - valid = [c.value for c in BackgroundColor] - if value not in valid: - raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}") - return value \ No newline at end of file + def get_progress(self, obj): + try: + rp = obj.ebook.reading_progress + return {"current_position": rp.current_position, "last_page": rp.last_page} + except ReadingProgress.DoesNotExist: + return None + + +class StorageSummarySerializer(serializers.Serializer): + total_downloads = serializers.IntegerField() + total_size_bytes = serializers.IntegerField() + ebooks = serializers.ListField(child=serializers.DictField()) \ No newline at end of file diff --git a/backend/apps/books/urls.py b/backend/apps/books/urls.py index a4d9a2f..e03cfb3 100644 --- a/backend/apps/books/urls.py +++ b/backend/apps/books/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from apps.books.views import BookViewSet, EBookViewSet, ReadingSettingsViewSet +from apps.books.views import BookViewSet, EBookViewSet router = DefaultRouter() router.register(r"", BookViewSet, basename="book") @@ -12,5 +12,4 @@ ebook_router.register(r"ebooks", EBookViewSet, basename="ebook") urlpatterns = [ path("", include(router.urls)), path("", include(ebook_router.urls)), - path("settings/", ReadingSettingsViewSet.as_view({"get": "list", "patch": "partial_update"}), name="reading-settings"), ] \ No newline at end of file diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index 86c0b51..63e34a8 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -12,12 +12,12 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingSettings +from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress from apps.books.serializers import ( - BookDetailSerializer, BookListSerializer, BookSerializer, - BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer, + BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer, + DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, - ReadingProgressSerializer, ReadingSettingsSerializer, + ReadingProgressSerializer, StorageSummarySerializer, ) logger = logging.getLogger(__name__) @@ -56,6 +56,23 @@ class BookViewSet(viewsets.ModelViewSet): author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author") return Response([a for a in author_list if a]) + @action(detail=False, methods=["get"]) + def storage(self, request: Request) -> Response: + """Return storage usage summary for the current user.""" + download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook") + total_size = sum(r.file_size for r in download_records) + ebook_list = [ + {"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size} + for r in download_records + ] + serializer = StorageSummarySerializer(data={ + "total_downloads": download_records.count(), + "total_size_bytes": total_size, + "ebooks": ebook_list, + }) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + class IsEBookOwner(permissions.BasePermission): def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: @@ -169,6 +186,48 @@ class EBookViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def download(self, request: Request, pk: int | None = None) -> Response: + """Track download of an e-book. Creates a DownloadRecord and returns file info.""" + ebook = self.get_object() + if not ebook.file: + return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) + + download, created = DownloadRecord.objects.get_or_create( + user=request.user, + ebook=ebook, + defaults={"file_size": ebook.file.size if ebook.file else 0}, + ) + if not created: + download.file_size = ebook.file.size if ebook.file else 0 + download.save(update_fields=["file_size"]) + + serializer = DownloadRecordSerializer(download, context={"request": request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"]) + def downloads(self, request: Request) -> Response: + """List all e-books the current user has downloaded.""" + records = DownloadRecord.objects.filter(user=request.user).select_related( + "ebook", "ebook__reading_progress" + ).prefetch_related("ebook__chapters") + page = self.paginate_queryset(records) + if page is not None: + serializer = DownloadRecordSerializer(page, many=True, context={"request": request}) + return self.get_paginated_response(serializer.data) + serializer = DownloadRecordSerializer(records, many=True, context={"request": request}) + return Response(serializer.data) + + @action(detail=False, methods=["delete"], url_path="downloads/(?P[^/.]+)") + def delete_download(self, request: Request, download_pk: str | None = None) -> Response: + """Delete a download record.""" + try: + download = DownloadRecord.objects.get(pk=download_pk, user=request.user) + except DownloadRecord.DoesNotExist: + return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND) + download.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None: """Recursively store TOC entries as BookChapter records.""" @@ -217,24 +276,4 @@ def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str: return "" except Exception: logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href) - return "" - - -class ReadingSettingsViewSet(viewsets.GenericViewSet): - permission_classes = [IsAuthenticated] - serializer_class = ReadingSettingsSerializer - - def get_queryset(self): - return ReadingSettings.objects.filter(user=self.request.user) - - def list(self, request: Request) -> Response: - settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) - serializer = self.get_serializer(settings_obj) - return Response(serializer.data) - - def partial_update(self, request: Request) -> Response: - settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) - serializer = self.get_serializer(settings_obj, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) \ No newline at end of file + return "" \ No newline at end of file diff --git a/backend/apps/reader/migrations/0001_initial.py b/backend/apps/reader/migrations/0001_initial.py new file mode 100644 index 0000000..3a7ba77 --- /dev/null +++ b/backend/apps/reader/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.7 on 2026-05-29 06:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='ReadingSettings', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='reading_settings', serialize=False, to=settings.AUTH_USER_MODEL)), + ('font_family', models.CharField(choices=[('sans-serif', 'Sans-serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='serif', max_length=32)), + ('font_size', models.PositiveSmallIntegerField(default=18)), + ('line_height', models.FloatField(default=1.6)), + ('margin_width', models.PositiveSmallIntegerField(default=16)), + ('background_color', models.CharField(default='#f5f0eb', max_length=7)), + ('text_color', models.CharField(default='#1a1a1a', max_length=7)), + ('brightness', models.PositiveSmallIntegerField(default=100)), + ('orientation_lock', models.CharField(choices=[('auto', 'Auto'), ('portrait', 'Portrait'), ('landscape', 'Landscape')], default='auto', max_length=16)), + ('theme', models.CharField(choices=[('sepia', 'Sepia'), ('dark', 'Dark'), ('light', 'Light'), ('paper', 'Paper')], default='sepia', max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Reading Settings', + 'verbose_name_plural': 'Reading Settings', + 'db_table': 'reader_reading_settings', + }, + ), + ] diff --git a/frontend/package.json b/frontend/package.json index 1b174a5..2afc748 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/frontend/src/api/reader.ts b/frontend/src/api/reader.ts index cbf73e7..ec11bf7 100644 --- a/frontend/src/api/reader.ts +++ b/frontend/src/api/reader.ts @@ -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 { - 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; + 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 { 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; + 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 { - 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; + 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 ): Promise { - 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; + 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; } \ No newline at end of file diff --git a/frontend/src/pages/ReadingPage.tsx b/frontend/src/pages/ReadingPage.tsx index 2f9def6..b3bf56e 100644 --- a/frontend/src/pages/ReadingPage.tsx +++ b/frontend/src/pages/ReadingPage.tsx @@ -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() { >
setTocOpen((v) => !v)} @@ -159,7 +166,7 @@ export default function ReadingPage() {
diff --git a/frontend/src/reader.css b/frontend/src/reader.css index 2bf74a9..74d4424 100644 --- a/frontend/src/reader.css +++ b/frontend/src/reader.css @@ -43,7 +43,7 @@ } } -@media (orientation: landascape) { +@media (orientation: landscape) { .reader-container { --reader-margin: 24px; } From 7cdc3d096931b273833a29c70243ec3a17b1f32a Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 07:04:53 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Remove=20stale=20web/=20directory=20?= =?UTF-8?q?=E2=80=94=20content=20already=20ported=20to=20frontend/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reid's second review flagged that the old web/ files still had: - XSS vulnerability (no DOMPurify) - CSS typo (landascape) - API endpoint mismatches All fix: delete web/ entirely. The correct, sanitized, fixed versions already live in frontend/src/. --- web/src/api/reader.ts | 112 ---- web/src/components/ReaderToolbar.tsx | 67 --- web/src/components/ReadingSettingsPanel.tsx | 232 -------- web/src/components/TableOfContents.tsx | 80 --- web/src/hooks/useChapters.ts | 129 ---- web/src/hooks/useReadingProgress.ts | 99 ---- web/src/hooks/useReadingSettings.ts | 96 --- web/src/pages/ReadingPage.tsx | 199 ------- web/src/reader.css | 621 -------------------- web/src/types/reader.ts | 44 -- 10 files changed, 1679 deletions(-) delete mode 100644 web/src/api/reader.ts delete mode 100644 web/src/components/ReaderToolbar.tsx delete mode 100644 web/src/components/ReadingSettingsPanel.tsx delete mode 100644 web/src/components/TableOfContents.tsx delete mode 100644 web/src/hooks/useChapters.ts delete mode 100644 web/src/hooks/useReadingProgress.ts delete mode 100644 web/src/hooks/useReadingSettings.ts delete mode 100644 web/src/pages/ReadingPage.tsx delete mode 100644 web/src/reader.css delete mode 100644 web/src/types/reader.ts diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts deleted file mode 100644 index cbf73e7..0000000 --- a/web/src/api/reader.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * API client for the reader module — reading settings, chapters, and progress. - */ - -import type { - ChapterDetail, - ChapterSummary, - ReadingProgress, - ReadingSettings, -} from "../types/reader"; - -const API_BASE = "/api"; - -/** - * Fetch the current user's reading settings. - * Auto-creates defaults on the server if none exist. - */ -export async function getReadingSettings(): Promise { - const response = await fetch(`${API_BASE}/reader/settings/`); - if (!response.ok) { - throw new Error( - `Failed to fetch reading settings: ${response.status} ${response.statusText}` - ); - } - return response.json() as Promise; -} - -/** - * Update (full or partial) the user's reading settings. - */ -export async function updateReadingSettings( - settings: Partial -): Promise { - const method = settings.theme !== undefined ? "PUT" : "PATCH"; - const response = await fetch(`${API_BASE}/reader/settings/`, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settings), - }); - if (!response.ok) { - throw new Error( - `Failed to update reading settings: ${response.status} ${response.statusText}` - ); - } - return response.json() as Promise; -} - -/** - * Fetch the table of contents (chapter list) for a book. - */ -export async function getChapters(bookId: number): Promise { - const response = await fetch(`${API_BASE}/books/${bookId}/chapters/`); - if (!response.ok) { - throw new Error( - `Failed to fetch chapters: ${response.status} ${response.statusText}` - ); - } - return response.json() as Promise; -} - -/** - * Fetch a specific chapter with full content for reading. - */ -export async function getChapterContent( - bookId: number, - chapterNumber: number -): Promise { - const response = await fetch( - `${API_BASE}/books/${bookId}/chapters/${chapterNumber}/` - ); - if (!response.ok) { - throw new Error( - `Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}` - ); - } - return response.json() as Promise; -} - -/** - * Fetch reading progress for a book. - */ -export async function getReadingProgress( - bookId: number -): Promise { - const response = await fetch(`${API_BASE}/books/${bookId}/progress/`); - if (!response.ok) { - throw new Error( - `Failed to fetch reading progress: ${response.status} ${response.statusText}` - ); - } - return response.json() as Promise; -} - -/** - * Update reading progress for a book. - */ -export async function updateReadingProgress( - bookId: number, - progress: Partial -): Promise { - const response = await fetch(`${API_BASE}/books/${bookId}/progress/`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(progress), - }); - if (!response.ok) { - throw new Error( - `Failed to update reading progress: ${response.status} ${response.statusText}` - ); - } - return response.json() as Promise; -} \ No newline at end of file diff --git a/web/src/components/ReaderToolbar.tsx b/web/src/components/ReaderToolbar.tsx deleted file mode 100644 index 65884f3..0000000 --- a/web/src/components/ReaderToolbar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 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 */} -
- -
- {bookTitle} - {chapterTitle} -
- -
- - {/* Bottom progress bar */} -
-
-
- - ); -} \ No newline at end of file diff --git a/web/src/components/ReadingSettingsPanel.tsx b/web/src/components/ReadingSettingsPanel.tsx deleted file mode 100644 index 9d7d11a..0000000 --- a/web/src/components/ReadingSettingsPanel.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * 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) => Promise; -} - -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>({}); - - 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 && ( -
{ - if (e.key === "Escape") onClose(); - }} - role="presentation" - /> - )} - - {/* Drawer */} - - - ); -} \ No newline at end of file diff --git a/web/src/components/TableOfContents.tsx b/web/src/components/TableOfContents.tsx deleted file mode 100644 index 7a4db64..0000000 --- a/web/src/components/TableOfContents.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 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 && ( -
{ - if (e.key === "Escape") onClose(); - }} - role="presentation" - /> - )} - - {/* Drawer */} - - - ); -} \ No newline at end of file diff --git a/web/src/hooks/useChapters.ts b/web/src/hooks/useChapters.ts deleted file mode 100644 index e388fa2..0000000 --- a/web/src/hooks/useChapters.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * useChapters — fetch chapter list and manage current chapter navigation. - */ - -import { useCallback, useEffect, useState } from "react"; -import { getChapterContent, getChapters } from "../api/reader"; -import type { ChapterDetail, ChapterSummary } from "../types/reader"; - -export interface UseChaptersReturn { - chapters: ChapterSummary[]; - currentChapter: ChapterDetail | null; - currentChapterNumber: number; - isLoading: boolean; - error: string | null; - navigateToChapter: (number: number) => Promise; - goToNextChapter: () => Promise; - goToPreviousChapter: () => Promise; - hasNext: boolean; - hasPrevious: boolean; -} - -export function useChapters( - bookId: number, - initialChapter: number = 1 -): UseChaptersReturn { - const [chapters, setChapters] = useState([]); - const [currentChapter, setCurrentChapter] = useState( - null - ); - const [currentChapterNumber, setCurrentChapterNumber] = - useState(initialChapter); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // Fetch chapter list on mount - useEffect(() => { - let cancelled = false; - setIsLoading(true); - getChapters(bookId) - .then((data) => { - if (!cancelled) { - setChapters(data); - // If chapters exist and initial chapter is valid, fetch it - if ( - data.length > 0 && - data.some((c) => c.number === currentChapterNumber) - ) { - return getChapterContent(bookId, currentChapterNumber); - } - return null; - } - return null; - }) - .then((chapter) => { - if (!cancelled && chapter) { - setCurrentChapter(chapter); - } - }) - .catch((err: unknown) => { - if (!cancelled) { - setError( - err instanceof Error ? err.message : "Failed to load chapters" - ); - } - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - return () => { - cancelled = true; - }; - }, [bookId, currentChapterNumber]); - - const fetchChapter = useCallback( - async (number: number) => { - setIsLoading(true); - setError(null); - try { - const chapter = await getChapterContent(bookId, number); - setCurrentChapter(chapter); - setCurrentChapterNumber(number); - } catch (err: unknown) { - setError( - err instanceof Error ? err.message : "Failed to load chapter" - ); - } finally { - setIsLoading(false); - } - }, - [bookId] - ); - - const navigateToChapter = useCallback( - (number: number) => { - fetchChapter(number); - }, - [fetchChapter] - ); - - const goToNextChapter = useCallback(() => { - const next = currentChapterNumber + 1; - if (chapters.some((c) => c.number === next)) { - fetchChapter(next); - } - }, [currentChapterNumber, chapters, fetchChapter]); - - const goToPreviousChapter = useCallback(() => { - const prev = currentChapterNumber - 1; - if (prev >= 1 && chapters.some((c) => c.number === prev)) { - fetchChapter(prev); - } - }, [currentChapterNumber, chapters, fetchChapter]); - - const hasNext = chapters.some((c) => c.number === currentChapterNumber + 1); - const hasPrevious = chapters.some((c) => c.number === currentChapterNumber - 1); - - return { - chapters, - currentChapter, - currentChapterNumber, - isLoading, - error, - navigateToChapter, - goToNextChapter, - goToPreviousChapter, - hasNext, - hasPrevious, - }; -} \ No newline at end of file diff --git a/web/src/hooks/useReadingProgress.ts b/web/src/hooks/useReadingProgress.ts deleted file mode 100644 index 1d8034d..0000000 --- a/web/src/hooks/useReadingProgress.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * useReadingProgress — fetch and update reading progress for a book. - * Auto-saves when chapter or position changes. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import { getReadingProgress, updateReadingProgress } from "../api/reader"; -import type { ReadingProgress } from "../types/reader"; - -export interface UseReadingProgressReturn { - progress: ReadingProgress | null; - isLoading: boolean; - error: string | null; - saveProgress: ( - chapter: number, - position: number, - percentage: number - ) => Promise; - /** Schedule a debounced save — fires at most once per 3 seconds */ - debouncedSave: ( - chapter: number, - position: number, - percentage: number - ) => void; -} - -export function useReadingProgress( - bookId: number -): UseReadingProgressReturn { - const [progress, setProgress] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const debounceTimer = useRef | null>(null); - - // Fetch progress on mount - useEffect(() => { - let cancelled = false; - setIsLoading(true); - getReadingProgress(bookId) - .then((data) => { - if (!cancelled) setProgress(data); - }) - .catch((err: unknown) => { - if (!cancelled) { - setError( - err instanceof Error ? err.message : "Failed to load progress" - ); - } - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - return () => { - cancelled = true; - }; - }, [bookId]); - - const saveProgress = useCallback( - async (chapter: number, position: number, percentage: number) => { - try { - const updated = await updateReadingProgress(bookId, { - current_chapter: chapter, - current_position: position, - percentage, - }); - setProgress(updated); - setError(null); - } catch (err: unknown) { - setError( - err instanceof Error ? err.message : "Failed to save progress" - ); - } - }, - [bookId] - ); - - const debouncedSave = useCallback( - (chapter: number, position: number, percentage: number) => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - debounceTimer.current = setTimeout(() => { - saveProgress(chapter, position, percentage); - }, 3000); - }, - [saveProgress] - ); - - // Cleanup timer on unmount - useEffect(() => { - return () => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - }; - }, []); - - return { progress, isLoading, error, saveProgress, debouncedSave }; -} \ No newline at end of file diff --git a/web/src/hooks/useReadingSettings.ts b/web/src/hooks/useReadingSettings.ts deleted file mode 100644 index 7e5df8d..0000000 --- a/web/src/hooks/useReadingSettings.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * useReadingSettings — fetch and manage user reading preferences. - * Applies settings as CSS custom properties on the document root. - */ - -import { useCallback, useEffect, useState } from "react"; -import { - getReadingSettings, - updateReadingSettings, -} from "../api/reader"; -import type { ReadingSettings } from "../types/reader"; - -const DEFAULT_SETTINGS: ReadingSettings = { - font_family: "serif", - font_size: 18, - line_height: 1.6, - margin_width: 16, - background_color: "#f5f0eb", - text_color: "#1a1a1a", - brightness: 100, - orientation_lock: "auto", - theme: "sepia", - created_at: "", - updated_at: "", -}; - -function applyCssVariables(settings: ReadingSettings): void { - const root = document.documentElement; - root.style.setProperty("--reader-bg", settings.background_color); - root.style.setProperty("--reader-text", settings.text_color); - root.style.setProperty("--reader-font-family", settings.font_family); - root.style.setProperty("--reader-font-size", `${settings.font_size}px`); - root.style.setProperty("--reader-line-height", String(settings.line_height)); - root.style.setProperty("--reader-margin", `${settings.margin_width}px`); - root.style.setProperty("--reader-brightness", `${settings.brightness}%`); -} - -export interface UseReadingSettingsReturn { - settings: ReadingSettings; - isLoading: boolean; - error: string | null; - updateSettings: (partial: Partial) => Promise; -} - -export function useReadingSettings(): UseReadingSettingsReturn { - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // Fetch settings on mount - useEffect(() => { - let cancelled = false; - setIsLoading(true); - getReadingSettings() - .then((data) => { - if (!cancelled) { - setSettings(data); - applyCssVariables(data); - } - }) - .catch((err: unknown) => { - if (!cancelled) { - setError( - err instanceof Error ? err.message : "Failed to load reading settings" - ); - // Apply defaults - applyCssVariables(DEFAULT_SETTINGS); - } - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - return () => { - cancelled = true; - }; - }, []); - - const updateSettings = useCallback( - async (partial: Partial) => { - try { - const updated = await updateReadingSettings(partial); - setSettings(updated); - applyCssVariables(updated); - setError(null); - } catch (err: unknown) { - setError( - err instanceof Error ? err.message : "Failed to update reading settings" - ); - throw err; - } - }, - [] - ); - - return { settings, isLoading, error, updateSettings }; -} \ No newline at end of file diff --git a/web/src/pages/ReadingPage.tsx b/web/src/pages/ReadingPage.tsx deleted file mode 100644 index 9701ba6..0000000 --- a/web/src/pages/ReadingPage.tsx +++ /dev/null @@ -1,199 +0,0 @@ -/** - * ReadingPage — full-screen reading view for ebooks. - * Integrates TOC drawer, settings panel, chapter navigation, and progress tracking. - */ - -import { lazy, Suspense, useCallback, useEffect, useState } from "react"; -import { useChapters } from "../hooks/useChapters"; -import { useReadingProgress } from "../hooks/useReadingProgress"; -import { useReadingSettings } from "../hooks/useReadingSettings"; -import type { BookDetail as BookDetailType } from "../types"; - -const ReaderToolbar = lazy(() => import("../components/ReaderToolbar")); -const TableOfContents = lazy(() => import("../components/TableOfContents")); -const ReadingSettingsPanel = lazy( - () => import("../components/ReadingSettingsPanel") -); - -interface ReadingPageProps { - book: BookDetailType; - onBack: () => void; -} - -export default function ReadingPage({ book, onBack }: ReadingPageProps) { - const [tocOpen, setTocOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - - const { settings, updateSettings } = useReadingSettings(); - const { - chapters, - currentChapter, - currentChapterNumber, - isLoading, - error, - navigateToChapter, - goToNextChapter, - goToPreviousChapter, - hasNext, - hasPrevious, - } = useChapters(book.id, 1); - - const { progress, debouncedSave } = useReadingProgress(book.id); - - // Auto-save progress when chapter changes - useEffect(() => { - if (currentChapter && currentChapterNumber > 0) { - debouncedSave(currentChapterNumber, 0, (currentChapterNumber / Math.max(chapters.length, 1)) * 100); - } - }, [currentChapterNumber, currentChapter?.id]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "ArrowLeft" && hasPrevious) { - goToPreviousChapter(); - } else if (e.key === "ArrowRight" && hasNext) { - goToNextChapter(); - } else if (e.key === "Escape") { - setTocOpen(false); - setSettingsOpen(false); - } - }, - [hasNext, hasPrevious, goToNextChapter, goToPreviousChapter] - ); - - useEffect(() => { - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); - - // Lock/unlock orientation via CSS - useEffect(() => { - const root = document.documentElement; - if (settings.orientation_lock !== "auto") { - root.style.setProperty( - "--reader-orientation", - settings.orientation_lock === "portrait" ? "portrait" : "landscape" - ); - } else { - root.style.removeProperty("--reader-orientation"); - } - }, [settings.orientation_lock]); - - // Show loading state - if (isLoading && !currentChapter) { - return ( -
-
-

Loading reader...

-
- ); - } - - if (error) { - return ( -
-

{error}

- -
- ); - } - - return ( - -
-
- } - > -
- setTocOpen((v) => !v)} - onToggleSettings={() => setSettingsOpen((v) => !v)} - /> - - setTocOpen(false)} - onNavigate={navigateToChapter} - /> - - setSettingsOpen(false)} - onUpdate={updateSettings} - /> - - {/* Main reading area */} -
- {isLoading ? ( -
-
-

Loading chapter...

-
- ) : currentChapter ? ( -
-

{currentChapter.title}

-
-
- ) : ( -
-

Select a chapter from the table of contents to start reading.

-
- )} -
- - {/* Bottom navigation bar */} - -
-
- ); -} \ No newline at end of file diff --git a/web/src/reader.css b/web/src/reader.css deleted file mode 100644 index 2bf74a9..0000000 --- a/web/src/reader.css +++ /dev/null @@ -1,621 +0,0 @@ -/* ============================================================ - Cloud Reader — Reading View Styles - Mobile-first reading experience with theme support - ============================================================ */ - -/* --- CSS Custom Properties (overridden by JS) --- */ -:root { - --reader-bg: #f5f0eb; - --reader-text: #1a1a1a; - --reader-font-family: Georgia, "Times New Roman", serif; - --reader-font-size: 18px; - --reader-line-height: 1.6; - --reader-margin: 16px; - --reader-brightness: 100%; - --reader-toolbar-bg: rgba(26, 26, 26, 0.95); - --reader-toolbar-text: #e0e0e0; - --reader-accent: #6c8cff; -} - -/* --- Reading Container --- */ -.reader-container { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--reader-bg); - color: var(--reader-text); - font-family: var(--reader-font-family); - font-size: var(--reader-font-size); - line-height: var(--reader-line-height); - filter: brightness(var(--reader-brightness)); - display: flex; - flex-direction: column; - z-index: 100; - overflow: hidden; -} - -/* Orientation lock */ -@supports (--reader-orientation: portrait) { - .reader-container { - orientation: var(--reader-orientation); - } -} - -@media (orientation: landascape) { - .reader-container { - --reader-margin: 24px; - } -} - -/* --- Top Toolbar --- */ -.reader-top-bar { - position: fixed; - top: 0; - left: 0; - right: 0; - height: 52px; - background: var(--reader-toolbar-bg); - color: var(--reader-toolbar-text); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 12px; - z-index: 120; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); -} - -.reader-bar-btn { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - background: transparent; - border: none; - color: var(--reader-toolbar-text); - cursor: pointer; - border-radius: 8px; - transition: background 0.15s; -} - -.reader-bar-btn:hover { - background: rgba(255, 255, 255, 0.1); -} - -.reader-bar-title { - display: flex; - flex-direction: column; - align-items: center; - gap: 1px; - min-width: 0; - flex: 1; - padding: 0 8px; -} - -.reader-bar-book { - font-size: 0.8rem; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 200px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -.reader-bar-chapter { - font-size: 0.7rem; - opacity: 0.7; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 200px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -/* --- Progress Bar --- */ -.reader-progress-bar { - position: fixed; - top: 52px; - left: 0; - right: 0; - height: 3px; - background: rgba(255, 255, 255, 0.1); - z-index: 120; -} - -.reader-progress-fill { - height: 100%; - background: var(--reader-accent); - transition: width 0.3s ease; -} - -/* --- Main Content Area --- */ -.reader-content { - flex: 1; - overflow-y: auto; - padding: 72px var(--reader-margin) 72px; - -webkit-overflow-scrolling: touch; - scroll-behavior: smooth; -} - -/* --- Chapter Article --- */ -.reader-chapter { - max-width: 680px; - margin: 0 auto; -} - -.reader-chapter-title { - font-size: 1.6em; - font-weight: 700; - line-height: 1.3; - margin-bottom: 0.75em; - padding-bottom: 0.5em; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - color: var(--reader-text); -} - -/* Chapter body — content from API (could be HTML/markdown-rendered) */ -.reader-chapter-body { - font-size: 1em; - line-height: var(--reader-line-height); -} - -.reader-chapter-body p { - margin-bottom: 1.2em; - text-align: justify; - hyphens: auto; -} - -.reader-chapter-body h2, -.reader-chapter-body h3, -.reader-chapter-body h4 { - margin-top: 1.5em; - margin-bottom: 0.6em; - line-height: 1.3; -} - -.reader-chapter-body blockquote { - border-left: 3px solid var(--reader-accent); - padding-left: 1em; - margin: 1em 0; - opacity: 0.85; - font-style: italic; -} - -.reader-chapter-body img { - max-width: 100%; - height: auto; - border-radius: 4px; - margin: 1em 0; -} - -.reader-chapter-body ul, -.reader-chapter-body ol { - padding-left: 1.5em; - margin-bottom: 1.2em; -} - -.reader-chapter-body li { - margin-bottom: 0.4em; -} - -/* --- Navigation Bar (Bottom) --- */ -.reader-nav { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 56px; - background: var(--reader-toolbar-bg); - color: var(--reader-toolbar-text); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 12px; - z-index: 120; - gap: 8px; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); -} - -.reader-nav-btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: var(--reader-toolbar-text); - font-size: 0.85rem; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - cursor: pointer; - transition: all 0.15s; -} - -.reader-nav-btn:hover:not(.reader-nav-btn--disabled) { - border-color: var(--reader-accent); - color: var(--reader-accent); -} - -.reader-nav-btn--disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.reader-nav-btn--back { - background: rgba(108, 140, 255, 0.15); - border-color: rgba(108, 140, 255, 0.3); -} - -.reader-nav-btn--back:hover { - background: rgba(108, 140, 255, 0.25); - color: var(--reader-accent); -} - -/* --- TOC Drawer --- */ -.toc-overlay, -.settings-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 130; - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -.toc-drawer, -.settings-drawer { - position: fixed; - top: 0; - bottom: 0; - width: 300px; - max-width: 85vw; - background: var(--reader-toolbar-bg); - color: var(--reader-toolbar-text); - z-index: 140; - display: flex; - flex-direction: column; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.toc-drawer { - left: 0; - transform: translateX(-100%); -} - -.toc-drawer--open { - transform: translateX(0); -} - -.settings-drawer { - right: 0; - transform: translateX(100%); -} - -.settings-drawer--open { - transform: translateX(0); -} - -.toc-header, -.settings-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.toc-title, -.settings-title { - font-size: 1.1rem; - font-weight: 700; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -.toc-close-btn, -.settings-close-btn { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - background: transparent; - border: none; - color: var(--reader-toolbar-text); - cursor: pointer; - border-radius: 6px; - transition: background 0.15s; -} - -.toc-close-btn:hover, -.settings-close-btn:hover { - background: rgba(255, 255, 255, 0.1); -} - -/* --- TOC List --- */ -.toc-list { - flex: 1; - overflow-y: auto; - padding: 8px 0; -} - -.toc-empty { - padding: 24px 16px; - text-align: center; - opacity: 0.6; - font-size: 0.9rem; -} - -.toc-item { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 10px 16px; - background: transparent; - border: none; - color: var(--reader-toolbar-text); - font-size: 0.9rem; - text-align: left; - cursor: pointer; - transition: background 0.15s; - font-family: inherit; -} - -.toc-item:hover { - background: rgba(255, 255, 255, 0.08); -} - -.toc-item--active { - background: rgba(108, 140, 255, 0.15); - color: var(--reader-accent); - font-weight: 600; -} - -.toc-item-number { - display: flex; - align-items: center; - justify-content: center; - min-width: 24px; - height: 24px; - font-size: 0.75rem; - font-weight: 700; - border-radius: 4px; - background: rgba(255, 255, 255, 0.1); - padding: 0 4px; -} - -.toc-item-title { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* --- Settings Panel --- */ -.settings-body { - flex: 1; - overflow-y: auto; - padding: 12px 16px 32px; -} - -.settings-section { - margin-bottom: 20px; -} - -.settings-section-title { - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: rgba(255, 255, 255, 0.5); - margin-bottom: 8px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -/* Theme grid */ -.theme-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.theme-btn { - padding: 10px; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 8px; - background: transparent; - color: var(--reader-toolbar-text); - font-size: 0.85rem; - cursor: pointer; - transition: all 0.15s; - font-family: inherit; -} - -.theme-btn:hover { - border-color: rgba(255, 255, 255, 0.3); -} - -.theme-btn--active { - border-color: var(--reader-accent); - background: rgba(108, 140, 255, 0.15); - color: var(--reader-accent); - font-weight: 600; -} - -/* Font grid */ -.font-grid, -.orientation-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 8px; -} - -.font-btn, -.orientation-btn { - padding: 10px 8px; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 8px; - background: transparent; - color: var(--reader-toolbar-text); - font-size: 0.85rem; - cursor: pointer; - transition: all 0.15s; - font-family: inherit; -} - -.font-btn:hover, -.orientation-btn:hover { - border-color: rgba(255, 255, 255, 0.3); -} - -.font-btn--active, -.orientation-btn--active { - border-color: var(--reader-accent); - background: rgba(108, 140, 255, 0.15); - color: var(--reader-accent); - font-weight: 600; -} - -/* Sliders */ -.settings-slider { - width: 100%; - height: 6px; - -webkit-appearance: none; - appearance: none; - background: rgba(255, 255, 255, 0.2); - border-radius: 3px; - outline: none; - cursor: pointer; -} - -.settings-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--reader-accent); - border: 2px solid white; - cursor: pointer; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); -} - -.settings-slider::-moz-range-thumb { - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--reader-accent); - border: 2px solid white; - cursor: pointer; -} - -/* --- Start Reading Button (in book detail) --- */ -.start-reading-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 12px 24px; - background: var(--reader-accent); - border: none; - border-radius: 8px; - color: white; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - margin-top: 16px; - margin-bottom: 16px; - font-family: inherit; -} - -.start-reading-btn:hover { - background: #5a7ae8; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(108, 140, 255, 0.3); -} - -/* --- Loading State --- */ -.reader-loading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 48px; - color: var(--reader-text); -} - -.reader-error { - color: #ef4444; - text-align: center; -} - -/* --- Responsive --- */ -@media (max-width: 480px) { - .reader-chapter { - max-width: 100%; - } - - .reader-nav-btn { - padding: 8px 10px; - font-size: 0.8rem; - } - - .reader-nav-btn span { - display: none; - } - - .toc-drawer, - .settings-drawer { - max-width: 100vw; - } - - .reader-chapter-title { - font-size: 1.3em; - } -} - -@media (min-width: 768px) { - .reader-chapter { - max-width: 720px; - } - - .reader-chapter-body { - font-size: 1.05em; - } -} - -/* --- Landscape on Mobile --- */ -@media (orientation: landscape) and (max-height: 500px) { - .reader-top-bar { - height: 44px; - } - - .reader-content { - padding-top: 60px; - padding-bottom: 60px; - } - - .reader-chapter-title { - font-size: 1.2em; - } -} - -/* --- Dark mode theme override when reader uses light theme but system is dark --- */ -@media (prefers-color-scheme: dark) { - .reader-container[data-theme="light"] { - --reader-toolbar-bg: rgba(255, 255, 255, 0.95); - --reader-toolbar-text: #1a1a1a; - } -} \ No newline at end of file diff --git a/web/src/types/reader.ts b/web/src/types/reader.ts deleted file mode 100644 index d478d9a..0000000 --- a/web/src/types/reader.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * TypeScript interfaces for the Cloud Reader reader module. - * Reading view, settings, chapters, and progress types. - */ - -export interface ChapterSummary { - id: number; - book: number; - title: string; - number: number; -} - -export interface ChapterDetail extends ChapterSummary { - content: string; - created_at: string; - updated_at: string; -} - -export interface ReadingSettings { - font_family: "sans-serif" | "serif" | "monospace"; - font_size: number; - line_height: number; - margin_width: number; - background_color: string; - text_color: string; - brightness: number; - orientation_lock: "auto" | "portrait" | "landscape"; - theme: "sepia" | "dark" | "light" | "paper"; - created_at: string; - updated_at: string; -} - -export type ThemePreset = ReadingSettings["theme"]; -export type FontFamily = ReadingSettings["font_family"]; -export type OrientationLock = ReadingSettings["orientation_lock"]; - -export interface ReadingProgress { - id: number; - book: number; - current_chapter: number; - current_position: number; - percentage: number; - updated_at: string; -} \ No newline at end of file