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