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
+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)