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)
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
|
||||
"apps.users",
|
||||
"apps.books",
|
||||
"apps.annotations",
|
||||
"apps.reader",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
Reference in New Issue
Block a user