Archived
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:
@@ -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}%)"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user