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
This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 02:55:36 +00:00
parent 3b5b301e42
commit b29ea8211c
27 changed files with 2303 additions and 18 deletions
+68 -1
View File
@@ -18,4 +18,71 @@ class Book(models.Model):
ordering = ["title"]
def __str__(self) -> str:
return self.title
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}%)"
+45 -2
View File
@@ -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"]
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
+57 -6
View File
@@ -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
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<chapter_number>[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)