From b29ea8211c441c365c10d87486f8f85110c3a368 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Fri, 29 May 2026 02:55:36 +0000 Subject: [PATCH] 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