diff --git a/backend/apps/books/models.py b/backend/apps/books/models.py index 7fa651f..bc16919 100644 --- a/backend/apps/books/models.py +++ b/backend/apps/books/models.py @@ -12,19 +12,6 @@ class ReadingStatus(models.TextChoices): DNF = "dnf", "Did Not Finish" -class FontStyle(models.TextChoices): - SANS_SERIF = "sans-serif", "Sans Serif" - SERIF = "serif", "Serif" - MONOSPACE = "monospace", "Monospace" - - -class BackgroundColor(models.TextChoices): - WHITE = "#ffffff", "White" - SEPIA = "#f4e4c1", "Sepia" - DARK = "#1a1a2e", "Dark" - GREEN = "#c7edcc", "Green" - - class Book(models.Model): title = models.CharField(max_length=512, db_index=True) author = models.CharField(max_length=256, blank=True, default="", db_index=True) @@ -49,8 +36,12 @@ class Book(models.Model): class EBook(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks") - title = models.CharField(max_length=512) - author = models.CharField(max_length=256, blank=True, default="") + title = models.CharField(max_length=512, db_index=True) + author = models.CharField(max_length=256, blank=True, default="", db_index=True) + format = models.CharField(max_length=20, blank=True, default="", editable=False) + page_count = models.PositiveIntegerField(default=0) + file_size = models.BigIntegerField(default=0) + metadata_json = models.JSONField(blank=True, default=dict) file = models.FileField(upload_to="ebooks/%Y/%m/%d/") cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) @@ -70,6 +61,24 @@ class EBook(models.Model): return Path(self.file.name).name if self.file else "" +class BookChapter(models.Model): + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters") + title = models.CharField(max_length=512) + index = models.IntegerField(default=0) + href = models.CharField(max_length=1024, blank=True, default="") + children = models.JSONField(blank=True, default=list) + + class Meta: + db_table = "books_book_chapter" + verbose_name = "Book Chapter" + verbose_name_plural = "Book Chapters" + ordering = ["index"] + indexes = [models.Index(fields=["ebook", "index"])] + + def __str__(self): + return f"{self.ebook.title} - {self.title}" + + @receiver(post_delete, sender=EBook) def _auto_delete_ebook_file(sender, instance, **kwargs): if instance.file: @@ -78,11 +87,33 @@ def _auto_delete_ebook_file(sender, instance, **kwargs): instance.cover_image.delete(save=False) +class DownloadRecord(models.Model): + """Tracks book downloads for offline access management.""" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records") + ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records") + file_size = models.BigIntegerField(default=0) + downloaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "books_download_record" + verbose_name = "Download Record" + verbose_name_plural = "Download Records" + ordering = ["-downloaded_at"] + unique_together = [("user", "ebook")] + + def __str__(self): + return f"{self.user} - {self.ebook.title}" + + class ReadingProgress(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress") ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress") current_position = models.FloatField(default=0.0) last_page = models.IntegerField(default=0) + device_id = models.CharField(max_length=128, blank=True, default="") + device_name = models.CharField(max_length=128, blank=True, default="") + version = models.PositiveIntegerField(default=1) updated_at = models.DateTimeField(auto_now=True) class Meta: @@ -93,17 +124,32 @@ class ReadingProgress(models.Model): def __str__(self): return f"{self.ebook.title} - {self.current_position:.1f}%" + def update_with_sync(self, position: float, last_page: int, + device_id: str, device_name: str, + client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]: + """Update progress with conflict resolution (last-write-wins by timestamp). -class ReadingSettings(models.Model): - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings") - font_size = models.IntegerField(default=18) - font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value) - background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value) - updated_at = models.DateTimeField(auto_now=True) + Returns (instance, applied) where applied is True if the update was applied. + """ + if client_updated_at and self.updated_at: + try: + from django.utils.timezone import is_naive, make_aware + from datetime import datetime + client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00")) + if is_naive(client_dt): + client_dt = make_aware(client_dt) + if client_dt <= self.updated_at: + return self, False + except (ValueError, TypeError): + pass - class Meta: - db_table = "books_reading_settings" - verbose_name_plural = "reading settings" - - def __str__(self): - return f"Settings for {self.user}" \ No newline at end of file + self.current_position = position + self.last_page = last_page + self.device_id = device_id + self.device_name = device_name + self.version += 1 + self.save(update_fields=[ + "current_position", "last_page", + "device_id", "device_name", "version", "updated_at", + ]) + return self, True \ No newline at end of file diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index f0725e4..828d7b2 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -1,6 +1,26 @@ from rest_framework import serializers -from apps.books.models import Book, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus +from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord + + +class BookChapterSerializer(serializers.ModelSerializer): + class Meta: + model = BookChapter + fields = ["id", "title", "index", "href", "children"] + + +class EBookContentSerializer(serializers.Serializer): + page = serializers.IntegerField() + total_pages = serializers.IntegerField() + content = serializers.CharField() + chapter_title = serializers.CharField() + format = serializers.CharField() + + +class EBookTocSerializer(serializers.Serializer): + chapters = serializers.ListField(child=BookChapterSerializer()) + format = serializers.CharField() + page_count = serializers.IntegerField() class BookListSerializer(serializers.ModelSerializer): @@ -29,11 +49,12 @@ class BookSerializer(serializers.ModelSerializer): class EBookListSerializer(serializers.ModelSerializer): filename = serializers.CharField(read_only=True) + format = serializers.CharField(read_only=True) progress = serializers.SerializerMethodField() class Meta: model = EBook - fields = ["id", "title", "author", "filename", "cover_image", "created_at", "progress"] + fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "cover_image", "created_at", "progress"] def get_progress(self, obj): try: @@ -44,12 +65,13 @@ class EBookListSerializer(serializers.ModelSerializer): class EBookDetailSerializer(serializers.ModelSerializer): filename = serializers.CharField(read_only=True) + format = serializers.CharField(read_only=True) file_url = serializers.SerializerMethodField() progress = serializers.SerializerMethodField() class Meta: model = EBook - fields = ["id", "title", "author", "filename", "file_url", "cover_image", "created_at", "updated_at", "progress"] + fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "file_url", "cover_image", "created_at", "updated_at", "progress"] def get_file_url(self, obj): request = self.context.get("request") @@ -82,13 +104,20 @@ class EBookUploadSerializer(serializers.ModelSerializer): def create(self, validated_data): validated_data["user"] = self.context["request"].user + # Auto-detect format from file extension + import os + name = str(getattr(validated_data.get("file"), "name", "")) + ext = os.path.splitext(name)[1].lower().lstrip(".") + if ext: + validated_data["format"] = ext return super().create(validated_data) class ReadingProgressSerializer(serializers.ModelSerializer): class Meta: model = ReadingProgress - fields = ["current_position", "last_page"] + fields = ["current_position", "last_page", "device_id", "device_name", "version", "updated_at"] + read_only_fields = ["version", "updated_at"] extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}} def validate_current_position(self, value): @@ -97,24 +126,41 @@ class ReadingProgressSerializer(serializers.ModelSerializer): return value -class ReadingSettingsSerializer(serializers.ModelSerializer): +class DownloadRecordSerializer(serializers.ModelSerializer): + ebook_id = serializers.IntegerField(source="ebook.id", read_only=True) + ebook_title = serializers.CharField(source="ebook.title", read_only=True) + author = serializers.CharField(source="ebook.author", read_only=True) + filename = serializers.SerializerMethodField() + cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True) + format = serializers.CharField(source="ebook.format", read_only=True) + progress = serializers.SerializerMethodField() + file_url = serializers.SerializerMethodField() + class Meta: - model = ReadingSettings - fields = ["font_size", "font_style", "background_color"] + model = DownloadRecord + fields = [ + "id", "ebook_id", "ebook_title", "author", "filename", "file_url", + "file_size", "cover_image", "format", "downloaded_at", "progress", + ] - def validate_font_size(self, value): - if value < 12 or value > 36: - raise serializers.ValidationError("Font size must be between 12 and 36.") - return value + def get_filename(self, obj): + return obj.ebook.filename() - def validate_font_style(self, value): - valid = [s.value for s in FontStyle] - if value not in valid: - raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}") - return value + def get_file_url(self, obj): + request = self.context.get("request") + if request and obj.ebook.file: + return request.build_absolute_uri(obj.ebook.file.url) + return "" - def validate_background_color(self, value): - valid = [c.value for c in BackgroundColor] - if value not in valid: - raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}") - return value \ No newline at end of file + def get_progress(self, obj): + try: + rp = obj.ebook.reading_progress + return {"current_position": rp.current_position, "last_page": rp.last_page} + except ReadingProgress.DoesNotExist: + return None + + +class StorageSummarySerializer(serializers.Serializer): + total_downloads = serializers.IntegerField() + total_size_bytes = serializers.IntegerField() + ebooks = serializers.ListField(child=serializers.DictField()) \ No newline at end of file diff --git a/backend/apps/books/urls.py b/backend/apps/books/urls.py index a4d9a2f..e03cfb3 100644 --- a/backend/apps/books/urls.py +++ b/backend/apps/books/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from apps.books.views import BookViewSet, EBookViewSet, ReadingSettingsViewSet +from apps.books.views import BookViewSet, EBookViewSet router = DefaultRouter() router.register(r"", BookViewSet, basename="book") @@ -12,5 +12,4 @@ ebook_router.register(r"ebooks", EBookViewSet, basename="ebook") urlpatterns = [ path("", include(router.urls)), path("", include(ebook_router.urls)), - path("settings/", ReadingSettingsViewSet.as_view({"get": "list", "patch": "partial_update"}), name="reading-settings"), ] \ No newline at end of file diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index 86c0b51..63e34a8 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -12,12 +12,12 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingSettings +from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress from apps.books.serializers import ( - BookDetailSerializer, BookListSerializer, BookSerializer, - BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer, + BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer, + DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, - ReadingProgressSerializer, ReadingSettingsSerializer, + ReadingProgressSerializer, StorageSummarySerializer, ) logger = logging.getLogger(__name__) @@ -56,6 +56,23 @@ class BookViewSet(viewsets.ModelViewSet): author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author") return Response([a for a in author_list if a]) + @action(detail=False, methods=["get"]) + def storage(self, request: Request) -> Response: + """Return storage usage summary for the current user.""" + download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook") + total_size = sum(r.file_size for r in download_records) + ebook_list = [ + {"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size} + for r in download_records + ] + serializer = StorageSummarySerializer(data={ + "total_downloads": download_records.count(), + "total_size_bytes": total_size, + "ebooks": ebook_list, + }) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + class IsEBookOwner(permissions.BasePermission): def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: @@ -169,6 +186,48 @@ class EBookViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) return Response(serializer.data) + @action(detail=True, methods=["post"]) + def download(self, request: Request, pk: int | None = None) -> Response: + """Track download of an e-book. Creates a DownloadRecord and returns file info.""" + ebook = self.get_object() + if not ebook.file: + return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) + + download, created = DownloadRecord.objects.get_or_create( + user=request.user, + ebook=ebook, + defaults={"file_size": ebook.file.size if ebook.file else 0}, + ) + if not created: + download.file_size = ebook.file.size if ebook.file else 0 + download.save(update_fields=["file_size"]) + + serializer = DownloadRecordSerializer(download, context={"request": request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"]) + def downloads(self, request: Request) -> Response: + """List all e-books the current user has downloaded.""" + records = DownloadRecord.objects.filter(user=request.user).select_related( + "ebook", "ebook__reading_progress" + ).prefetch_related("ebook__chapters") + page = self.paginate_queryset(records) + if page is not None: + serializer = DownloadRecordSerializer(page, many=True, context={"request": request}) + return self.get_paginated_response(serializer.data) + serializer = DownloadRecordSerializer(records, many=True, context={"request": request}) + return Response(serializer.data) + + @action(detail=False, methods=["delete"], url_path="downloads/(?P[^/.]+)") + def delete_download(self, request: Request, download_pk: str | None = None) -> Response: + """Delete a download record.""" + try: + download = DownloadRecord.objects.get(pk=download_pk, user=request.user) + except DownloadRecord.DoesNotExist: + return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND) + download.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None: """Recursively store TOC entries as BookChapter records.""" @@ -217,24 +276,4 @@ def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str: return "" except Exception: logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href) - return "" - - -class ReadingSettingsViewSet(viewsets.GenericViewSet): - permission_classes = [IsAuthenticated] - serializer_class = ReadingSettingsSerializer - - def get_queryset(self): - return ReadingSettings.objects.filter(user=self.request.user) - - def list(self, request: Request) -> Response: - settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) - serializer = self.get_serializer(settings_obj) - return Response(serializer.data) - - def partial_update(self, request: Request) -> Response: - settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) - serializer = self.get_serializer(settings_obj, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) \ No newline at end of file + return "" \ No newline at end of file diff --git a/backend/apps/reader/__init__.py b/backend/apps/reader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/reader/apps.py b/backend/apps/reader/apps.py new file mode 100644 index 0000000..4a649af --- /dev/null +++ b/backend/apps/reader/apps.py @@ -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" diff --git a/backend/apps/reader/migrations/0001_initial.py b/backend/apps/reader/migrations/0001_initial.py new file mode 100644 index 0000000..3a7ba77 --- /dev/null +++ b/backend/apps/reader/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.7 on 2026-05-29 06:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='ReadingSettings', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='reading_settings', serialize=False, to=settings.AUTH_USER_MODEL)), + ('font_family', models.CharField(choices=[('sans-serif', 'Sans-serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='serif', max_length=32)), + ('font_size', models.PositiveSmallIntegerField(default=18)), + ('line_height', models.FloatField(default=1.6)), + ('margin_width', models.PositiveSmallIntegerField(default=16)), + ('background_color', models.CharField(default='#f5f0eb', max_length=7)), + ('text_color', models.CharField(default='#1a1a1a', max_length=7)), + ('brightness', models.PositiveSmallIntegerField(default=100)), + ('orientation_lock', models.CharField(choices=[('auto', 'Auto'), ('portrait', 'Portrait'), ('landscape', 'Landscape')], default='auto', max_length=16)), + ('theme', models.CharField(choices=[('sepia', 'Sepia'), ('dark', 'Dark'), ('light', 'Light'), ('paper', 'Paper')], default='sepia', max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Reading Settings', + 'verbose_name_plural': 'Reading Settings', + 'db_table': 'reader_reading_settings', + }, + ), + ] diff --git a/backend/apps/reader/migrations/__init__.py b/backend/apps/reader/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/reader/models.py b/backend/apps/reader/models.py new file mode 100644 index 0000000..62e3482 --- /dev/null +++ b/backend/apps/reader/models.py @@ -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)" diff --git a/backend/apps/reader/serializers.py b/backend/apps/reader/serializers.py new file mode 100644 index 0000000..5395744 --- /dev/null +++ b/backend/apps/reader/serializers.py @@ -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 \ No newline at end of file diff --git a/backend/apps/reader/urls.py b/backend/apps/reader/urls.py new file mode 100644 index 0000000..4effea0 --- /dev/null +++ b/backend/apps/reader/urls.py @@ -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"), +] \ No newline at end of file diff --git a/backend/apps/reader/views.py b/backend/apps/reader/views.py new file mode 100644 index 0000000..27af51f --- /dev/null +++ b/backend/apps/reader/views.py @@ -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) \ No newline at end of file diff --git a/backend/config/django.py b/backend/config/django.py index 5e88bae..21cf721 100644 --- a/backend/config/django.py +++ b/backend/config/django.py @@ -40,6 +40,7 @@ INSTALLED_APPS = [ "apps.users", "apps.books", "apps.annotations", + "apps.reader", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index cb1a5cb..9db20f1 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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")), ] \ No newline at end of file diff --git a/docs/001-customizable-mobile-reading-experience.md b/docs/001-customizable-mobile-reading-experience.md new file mode 100644 index 0000000..39d095b --- /dev/null +++ b/docs/001-customizable-mobile-reading-experience.md @@ -0,0 +1,246 @@ +# US: Customizable Mobile Reading Experience + +**Issue:** https://gitea-dev.codescripters.org/HermesFactory/cloud-reader/issues (TBD) + +## Overview + +Add a full-screen reading view for ebooks with customizable typography, themes, +table of contents navigation, and orientation support. Mobile-first, responsive +design that adapts to any screen size. + +--- + +## Backend Specification + +### New Models + +#### `apps.books.models.Chapter` + +| Field | Type | Notes | +|-------------|--------------------|--------------------------------| +| id | AutoField (PK) | | +| book | FK -> Book | related_name="chapters" | +| title | CharField(512) | Chapter title | +| number | PositiveIntegerField | Chapter ordering / TOC index | +| content | TextField | Chapter text/markdown content | +| created_at | DateTimeField | auto_now_add | +| updated_at | DateTimeField | auto_now | + +**Constraints:** UniqueConstraint(book, chapter_number) +**Ordering:** [book, number] +**Index:** FK to book with db_index + +#### `apps.books.models.ReadingProgress` + +| Field | Type | Notes | +|------------------|--------------------|--------------------------------| +| id | AutoField (PK) | | +| user | FK -> User | related_name="reading_progress"| +| book | FK -> Book | related_name="reading_progress"| +| current_chapter | PositiveIntegerField | Last chapter number | +| current_position | PositiveIntegerField | Position within chapter (paragraph) | +| percentage | FloatField | 0.0 - 100.0 overall progress | +| updated_at | DateTimeField | auto_now | + +**Constraints:** UniqueConstraint(user, book) +**Indexes:** (user, book) composite, (user) filter for list queries + +#### `apps.reader.models.ReadingSettings` + +New app `apps/reader/` for reading preferences, isolated from book data model. + +| Field | Type | Notes | +|-------------------|--------------------|-------------------------------| +| id | AutoField (PK) | | +| user | OneToOneField -> User | related_name="reading_settings" | +| font_family | CharField(32) | "sans-serif", "serif", "monospace" | +| font_size | PositiveSmallIntegerField | 12-32, default 18 | +| line_height | FloatField | 1.2 - 2.0, default 1.6 | +| margin_width | PositiveSmallIntegerField | 8-48, default 16 (px) | +| background_color | CharField(7) | Hex color, default "#f5f0eb" | +| text_color | CharField(7) | Hex color, default "#1a1a1a" | +| brightness | PositiveSmallIntegerField | 0-100, default 100 | +| orientation_lock | CharField(16) | "auto", "portrait", "landscape" | +| theme | CharField(32) | "sepia", "dark", "light", "paper" | +| created_at | DateTimeField | auto_now_add | +| updated_at | DateTimeField | auto_now | + +### New API Endpoints + +All under `/api/` prefix, authenticated with JWT. + +#### Reader Settings (`/api/reader/settings/`) + +| Method | URL | Action | +|--------|------------------------------|---------------------------| +| GET | /api/reader/settings/ | Get current user settings | +| PUT | /api/reader/settings/ | Create/update settings | +| PATCH | /api/reader/settings/ | Partial update settings | + +- Single-object endpoint (one settings record per user, auto-created on first GET) +- Validation: font_size 12-32, line_height 1.2-2.0, margin_width 8-48 + +#### Reading Progress (`/api/books/{id}/progress/`) + +| Method | URL | Action | +|--------|----------------------------------------|------------------------------| +| GET | /api/books/{id}/progress/ | Get reading progress for book| +| PUT | /api/books/{id}/progress/ | Create/update reading progress| + +- Nested under book detail +- Auto-creates progress record on first PUT + +#### Chapters (`/api/books/{id}/chapters/`) + +| Method | URL | Action | +|--------|----------------------------------------|------------------------------| +| GET | /api/books/{id}/chapters/ | List chapters for book (TOC) | +| GET | /api/books/{id}/chapters/{number}/ | Get specific chapter content | + +- Ordering by `number` +- Used by frontend TOC sidebar and content loading + +--- + +## Frontend Specification + +### New Pages + +#### `/reader/:bookId` — ReadingPage + +Full-screen reading view with: +- Chapter content display (left/right swiping or scroll) +- Bottom toolbar: TOC toggle, Settings toggle, Progress indicator +- Top bar: Back button, Book title, Chapter title +- Swipe/tap/page navigation between chapters + +### New Components + +#### `ReaderToolbar` +- Fixed bottom toolbar +- TOC button (opens TOC drawer) +- Settings/theme button (opens settings panel) +- Progress bar showing overall reading progress + +#### `TableOfContents` +- Slide-in drawer from left +- Lists all chapters with current chapter highlighted +- Tap on chapter to navigate +- Shows reading progress per chapter + +#### `ReadingSettingsPanel` +- Slide-in drawer from right (or bottom sheet on mobile) +- Controls: + - Theme presets: Sepia, Dark, Light, Paper + - Font family: Sans-serif, Serif, Monospace + - Font size slider (12-32) + - Line height slider (1.2-2.0) + - Margin/padding control + - Orientation lock toggle (Auto / Portrait / Landscape) +- All changes persist immediately via API +- LocalStorage fallback when offline + +### New Hooks + +#### `useReadingSettings(bookId)` +- Fetches user reading settings from API +- Returns current settings + update function +- Applies CSS custom properties to document root +- Falls back to defaults if API unavailable + +#### `useChapters(bookId)` +- Fetches chapter list for TOC +- Returns chapters array, current chapter, navigate function +- Prefetches next/prev chapter content + +#### `useReadingProgress(bookId)` +- Fetches/updates reading progress +- Auto-saves position on chapter change and periodic interval + +### New Types + +```typescript +interface Chapter { + id: number; + book: number; + title: string; + number: number; + content?: string; // Only present when fetching individual chapter +} + +interface ChapterSummary { + id: number; + book: number; + title: string; + number: number; +} + +interface ReadingSettings { + font_family: "sans-serif" | "serif" | "monospace"; + font_size: number; + line_height: number; + margin_width: number; + background_color: string; + text_color: string; + brightness: number; + orientation_lock: "auto" | "portrait" | "landscape"; + theme: "sepia" | "dark" | "light" | "paper"; +} + +interface ReadingProgress { + current_chapter: number; + current_position: number; + percentage: number; + updated_at: string; +} +``` + +### CSS / Theming + +Reading view uses CSS custom properties driven by reading settings: + +```css +:root { + --reader-bg: var(--bg-color, #f5f0eb); + --reader-text: var(--text-color, #1a1a1a); + --reader-font-family: var(--font-family, "Georgia", serif); + --reader-font-size: var(--font-size, 18px); + --reader-line-height: var(--line-height, 1.6); + --reader-margin: var(--margin-width, 16px); +} +``` + +Three theme presets: +- **Sepia**: `bg:#f5f0eb`, `text:#1a1a1a` — warm, easy on eyes +- **Dark**: `bg:#1a1a2e`, `text:#e0e0e0` — for low-light reading +- **Light**: `bg:#ffffff`, `text:#1a1a1a` — crisp and clean +- **Paper**: `bg:#e8e0d4`, `text:#2c2c2c` — book-like feel + +### Orientation Support + +- CSS `@media (orientation: portrait)` and `@media (orientation: landscape)` breakpoints +- Reading settings panel includes orientation lock toggle +- On mobile, landscape mode expands content horizontally with wider margins +- Portrait mode stacks controls vertically for thumb-reachable UI + +### Routing + +Add to App.tsx: +``` +/ → LibraryPage +/books/:bookId → BookDetailPage +/reader/:bookId → ReadingPage +``` + +--- + +## Implementation Order + +1. Backend models + migrations (Chapter, ReadingProgress, ReadingSettings) +2. Backend serializers + views + URLs +3. Frontend types + API client +4. Frontend hooks (useReadingSettings, useChapters, useReadingProgress) +5. Frontend components (ReadingSettingsPanel, TableOfContents, ReaderToolbar) +6. Frontend page (ReadingPage) +7. Routing updates +8. CSS / theming \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 1b174a5..2afc748 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,20 +11,21 @@ "lint": "eslint ." }, "dependencies": { + "axios": "^1.7.9", + "dompurify": "^3.4.7", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.0", - "axios": "^1.7.9" + "react-router-dom": "^7.1.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.0", "typescript": "~5.7.0", "vite": "^6.0.0", - "vitest": "^2.1.0", - "@testing-library/react": "^16.2.0", - "@testing-library/jest-dom": "^6.6.3", - "jsdom": "^25.0.0" + "vitest": "^2.1.0" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d33748..7f639f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,8 +6,10 @@ const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage }))); const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage }))); const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage }))); +const EditEBookPage = lazy(() => import("./pages/EditEBook").then((m) => ({ default: m.EditEBookPage }))); const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); +const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); const AuthPage = lazy(() => import("./pages/AuthPage").then((m) => ({ @@ -38,7 +40,9 @@ function AppRoutes() { } /> } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/reader.ts b/frontend/src/api/reader.ts new file mode 100644 index 0000000..ec11bf7 --- /dev/null +++ b/frontend/src/api/reader.ts @@ -0,0 +1,148 @@ +/** + * API client for the reader module — reading settings, chapters, and progress. + */ + +import type { + ChapterDetail, + ChapterSummary, + ReadingProgress, + ReadingSettings, +} from "../types/reader"; + +const API_BASE = "/api"; + +/** + * Fetch the current user's reading settings. + * Auto-creates defaults on the server if none exist. + */ +export async function getReadingSettings(): Promise { + const response = await fetch(`${API_BASE}/reader/settings/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading settings: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Update (full or partial) the user's reading settings. + */ +export async function updateReadingSettings( + settings: Partial +): Promise { + const method = settings.theme !== undefined ? "PUT" : "PATCH"; + const response = await fetch(`${API_BASE}/reader/settings/`, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading settings: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch the table of contents (chapter list) for a book (ebook). + * Maps to EBookViewSet.toc → GET /api/books/ebooks/{id}/toc/ + */ +export async function getChapters(bookId: number): Promise { + const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/toc/`); + if (!response.ok) { + throw new Error( + `Failed to fetch chapters: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + // Main backend wraps chapters under a "chapters" key + return (data.chapters ?? data) as ChapterSummary[]; +} + +/** + * Fetch a specific chapter with full content for reading. + * Maps to EBookViewSet.content → GET /api/books/ebooks/{id}/content/?page={number} + */ +export async function getChapterContent( + bookId: number, + chapterNumber: number +): Promise { + const response = await fetch( + `${API_BASE}/books/ebooks/${bookId}/content/?page=${chapterNumber}` + ); + if (!response.ok) { + throw new Error( + `Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + // Main backend returns: { page, total_pages, content, chapter_title, format } + return { + id: chapterNumber, + book: bookId, + title: data.chapter_title ?? "", + number: data.page, + content: data.content ?? "", + created_at: "", + updated_at: "", + } as ChapterDetail; +} + +/** + * Fetch reading progress for a book (ebook). + * Maps to EBookViewSet.progress → GET /api/books/ebooks/{id}/progress/ + */ +export async function getReadingProgress( + bookId: number +): Promise { + const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/progress/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading progress: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + // Main backend returns: { current_position, last_page, version, updated_at } + return { + id: bookId, + book: bookId, + current_chapter: Math.floor((data.current_position ?? 0) / 10) + 1, + current_position: data.current_position ?? 0, + percentage: data.current_position ?? 0, + updated_at: data.updated_at ?? "", + } as ReadingProgress; +} + +/** + * Update reading progress for a book (ebook). + * Maps to EBookViewSet.progress → PATCH /api/books/ebooks/{id}/progress/ + */ +export async function updateReadingProgress( + bookId: number, + progress: Partial +): Promise { + const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/progress/`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + current_position: progress.percentage ?? progress.current_position ?? 0, + last_page: progress.current_chapter ?? 0, + }), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading progress: ${response.status} ${response.statusText}` + ); + } + const data = await response.json(); + return { + id: bookId, + book: bookId, + current_chapter: Math.floor((data.current_position ?? 0) / 10) + 1, + current_position: data.current_position ?? 0, + percentage: data.current_position ?? 0, + updated_at: data.updated_at ?? "", + } as ReadingProgress; +} \ No newline at end of file diff --git a/frontend/src/components/reader/ReaderToolbar.tsx b/frontend/src/components/reader/ReaderToolbar.tsx new file mode 100644 index 0000000..faf7f65 --- /dev/null +++ b/frontend/src/components/reader/ReaderToolbar.tsx @@ -0,0 +1,67 @@ +/** + * ReaderToolbar — fixed bottom toolbar for the reading view. + * Provides TOC toggle, settings toggle, and progress indicator. + */ + +import type { ReadingProgress } from "../../types/reader"; + +interface ReaderToolbarProps { + bookTitle: string; + chapterTitle: string; + progress: ReadingProgress | null; + onToggleToc: () => void; + onToggleSettings: () => void; +} + +export default function ReaderToolbar({ + bookTitle, + chapterTitle, + progress, + onToggleToc, + onToggleSettings, +}: ReaderToolbarProps) { + const percentage = progress?.percentage ?? 0; + + return ( + <> + {/* Top bar */} +
+ +
+ {bookTitle} + {chapterTitle} +
+ +
+ + {/* Bottom progress bar */} +
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/reader/ReadingSettingsPanel.tsx b/frontend/src/components/reader/ReadingSettingsPanel.tsx new file mode 100644 index 0000000..b1201b8 --- /dev/null +++ b/frontend/src/components/reader/ReadingSettingsPanel.tsx @@ -0,0 +1,232 @@ +/** + * ReadingSettingsPanel — slide-in drawer from the right for customizing + * the reading experience: theme, font, sizing, orientation. + */ + +import { useState } from "react"; +import type { + FontFamily, + OrientationLock, + ReadingSettings, + ThemePreset, +} from "../../types/reader"; + +interface ReadingSettingsPanelProps { + settings: ReadingSettings; + isOpen: boolean; + onClose: () => void; + onUpdate: (partial: Partial) => Promise; +} + +const THEME_OPTIONS: { value: ThemePreset; label: string }[] = [ + { value: "sepia", label: "Sepia" }, + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, + { value: "paper", label: "Paper" }, +]; + +const FONT_OPTIONS: { value: FontFamily; label: string }[] = [ + { value: "sans-serif", label: "Sans-serif" }, + { value: "serif", label: "Serif" }, + { value: "monospace", label: "Monospace" }, +]; + +const ORIENTATION_OPTIONS: { value: OrientationLock; label: string }[] = [ + { value: "auto", label: "Auto" }, + { value: "portrait", label: "Portrait" }, + { value: "landscape", label: "Landscape" }, +]; + +export default function ReadingSettingsPanel({ + settings, + isOpen, + onClose, + onUpdate, +}: ReadingSettingsPanelProps) { + const [saving, setSaving] = useState>({}); + + const handleChange = async ( + key: keyof ReadingSettings, + value: string | number + ) => { + setSaving((prev) => ({ ...prev, [key]: true })); + try { + await onUpdate({ [key]: value as never }); + } finally { + setSaving((prev) => ({ ...prev, [key]: false })); + } + }; + + return ( + <> + {/* Overlay */} + {isOpen && ( +
{ + if (e.key === "Escape") onClose(); + }} + role="presentation" + /> + )} + + {/* Drawer */} + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/reader/TableOfContents.tsx b/frontend/src/components/reader/TableOfContents.tsx new file mode 100644 index 0000000..1510eea --- /dev/null +++ b/frontend/src/components/reader/TableOfContents.tsx @@ -0,0 +1,80 @@ +/** + * TableOfContents — slide-in drawer listing all chapters. + * Tap a chapter to navigate. Current chapter is highlighted. + */ + +import type { ChapterSummary } from "../../types/reader"; + +interface TableOfContentsProps { + chapters: ChapterSummary[]; + currentChapterNumber: number; + isOpen: boolean; + onClose: () => void; + onNavigate: (number: number) => void; +} + +export default function TableOfContents({ + chapters, + currentChapterNumber, + isOpen, + onClose, + onNavigate, +}: TableOfContentsProps) { + const handleChapterClick = (number: number) => { + onNavigate(number); + onClose(); + }; + + return ( + <> + {/* Overlay */} + {isOpen && ( +
{ + if (e.key === "Escape") onClose(); + }} + role="presentation" + /> + )} + + {/* Drawer */} + + + ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useChapters.ts b/frontend/src/hooks/useChapters.ts new file mode 100644 index 0000000..e388fa2 --- /dev/null +++ b/frontend/src/hooks/useChapters.ts @@ -0,0 +1,129 @@ +/** + * useChapters — fetch chapter list and manage current chapter navigation. + */ + +import { useCallback, useEffect, useState } from "react"; +import { getChapterContent, getChapters } from "../api/reader"; +import type { ChapterDetail, ChapterSummary } from "../types/reader"; + +export interface UseChaptersReturn { + chapters: ChapterSummary[]; + currentChapter: ChapterDetail | null; + currentChapterNumber: number; + isLoading: boolean; + error: string | null; + navigateToChapter: (number: number) => Promise; + goToNextChapter: () => Promise; + goToPreviousChapter: () => Promise; + hasNext: boolean; + hasPrevious: boolean; +} + +export function useChapters( + bookId: number, + initialChapter: number = 1 +): UseChaptersReturn { + const [chapters, setChapters] = useState([]); + const [currentChapter, setCurrentChapter] = useState( + null + ); + const [currentChapterNumber, setCurrentChapterNumber] = + useState(initialChapter); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch chapter list on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getChapters(bookId) + .then((data) => { + if (!cancelled) { + setChapters(data); + // If chapters exist and initial chapter is valid, fetch it + if ( + data.length > 0 && + data.some((c) => c.number === currentChapterNumber) + ) { + return getChapterContent(bookId, currentChapterNumber); + } + return null; + } + return null; + }) + .then((chapter) => { + if (!cancelled && chapter) { + setCurrentChapter(chapter); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load chapters" + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [bookId, currentChapterNumber]); + + const fetchChapter = useCallback( + async (number: number) => { + setIsLoading(true); + setError(null); + try { + const chapter = await getChapterContent(bookId, number); + setCurrentChapter(chapter); + setCurrentChapterNumber(number); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to load chapter" + ); + } finally { + setIsLoading(false); + } + }, + [bookId] + ); + + const navigateToChapter = useCallback( + (number: number) => { + fetchChapter(number); + }, + [fetchChapter] + ); + + const goToNextChapter = useCallback(() => { + const next = currentChapterNumber + 1; + if (chapters.some((c) => c.number === next)) { + fetchChapter(next); + } + }, [currentChapterNumber, chapters, fetchChapter]); + + const goToPreviousChapter = useCallback(() => { + const prev = currentChapterNumber - 1; + if (prev >= 1 && chapters.some((c) => c.number === prev)) { + fetchChapter(prev); + } + }, [currentChapterNumber, chapters, fetchChapter]); + + const hasNext = chapters.some((c) => c.number === currentChapterNumber + 1); + const hasPrevious = chapters.some((c) => c.number === currentChapterNumber - 1); + + return { + chapters, + currentChapter, + currentChapterNumber, + isLoading, + error, + navigateToChapter, + goToNextChapter, + goToPreviousChapter, + hasNext, + hasPrevious, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useReadingProgress.ts b/frontend/src/hooks/useReadingProgress.ts new file mode 100644 index 0000000..1d8034d --- /dev/null +++ b/frontend/src/hooks/useReadingProgress.ts @@ -0,0 +1,99 @@ +/** + * useReadingProgress — fetch and update reading progress for a book. + * Auto-saves when chapter or position changes. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { getReadingProgress, updateReadingProgress } from "../api/reader"; +import type { ReadingProgress } from "../types/reader"; + +export interface UseReadingProgressReturn { + progress: ReadingProgress | null; + isLoading: boolean; + error: string | null; + saveProgress: ( + chapter: number, + position: number, + percentage: number + ) => Promise; + /** Schedule a debounced save — fires at most once per 3 seconds */ + debouncedSave: ( + chapter: number, + position: number, + percentage: number + ) => void; +} + +export function useReadingProgress( + bookId: number +): UseReadingProgressReturn { + const [progress, setProgress] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const debounceTimer = useRef | null>(null); + + // Fetch progress on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getReadingProgress(bookId) + .then((data) => { + if (!cancelled) setProgress(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load progress" + ); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [bookId]); + + const saveProgress = useCallback( + async (chapter: number, position: number, percentage: number) => { + try { + const updated = await updateReadingProgress(bookId, { + current_chapter: chapter, + current_position: position, + percentage, + }); + setProgress(updated); + setError(null); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to save progress" + ); + } + }, + [bookId] + ); + + const debouncedSave = useCallback( + (chapter: number, position: number, percentage: number) => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + saveProgress(chapter, position, percentage); + }, 3000); + }, + [saveProgress] + ); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, []); + + return { progress, isLoading, error, saveProgress, debouncedSave }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useReadingSettings.ts b/frontend/src/hooks/useReadingSettings.ts new file mode 100644 index 0000000..7e5df8d --- /dev/null +++ b/frontend/src/hooks/useReadingSettings.ts @@ -0,0 +1,96 @@ +/** + * useReadingSettings — fetch and manage user reading preferences. + * Applies settings as CSS custom properties on the document root. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + getReadingSettings, + updateReadingSettings, +} from "../api/reader"; +import type { ReadingSettings } from "../types/reader"; + +const DEFAULT_SETTINGS: ReadingSettings = { + font_family: "serif", + font_size: 18, + line_height: 1.6, + margin_width: 16, + background_color: "#f5f0eb", + text_color: "#1a1a1a", + brightness: 100, + orientation_lock: "auto", + theme: "sepia", + created_at: "", + updated_at: "", +}; + +function applyCssVariables(settings: ReadingSettings): void { + const root = document.documentElement; + root.style.setProperty("--reader-bg", settings.background_color); + root.style.setProperty("--reader-text", settings.text_color); + root.style.setProperty("--reader-font-family", settings.font_family); + root.style.setProperty("--reader-font-size", `${settings.font_size}px`); + root.style.setProperty("--reader-line-height", String(settings.line_height)); + root.style.setProperty("--reader-margin", `${settings.margin_width}px`); + root.style.setProperty("--reader-brightness", `${settings.brightness}%`); +} + +export interface UseReadingSettingsReturn { + settings: ReadingSettings; + isLoading: boolean; + error: string | null; + updateSettings: (partial: Partial) => Promise; +} + +export function useReadingSettings(): UseReadingSettingsReturn { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch settings on mount + useEffect(() => { + let cancelled = false; + setIsLoading(true); + getReadingSettings() + .then((data) => { + if (!cancelled) { + setSettings(data); + applyCssVariables(data); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load reading settings" + ); + // Apply defaults + applyCssVariables(DEFAULT_SETTINGS); + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const updateSettings = useCallback( + async (partial: Partial) => { + try { + const updated = await updateReadingSettings(partial); + setSettings(updated); + applyCssVariables(updated); + setError(null); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Failed to update reading settings" + ); + throw err; + } + }, + [] + ); + + return { settings, isLoading, error, updateSettings }; +} \ No newline at end of file diff --git a/frontend/src/pages/ReadingPage.tsx b/frontend/src/pages/ReadingPage.tsx new file mode 100644 index 0000000..b3bf56e --- /dev/null +++ b/frontend/src/pages/ReadingPage.tsx @@ -0,0 +1,220 @@ +/** + * ReadingPage — full-screen reading view for ebooks. + * Integrates TOC drawer, settings panel, chapter navigation, and progress tracking. + * Sanitizes HTML content with DOMPurify to prevent XSS. + */ + +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import DOMPurify from "dompurify"; +import { useChapters } from "../hooks/useChapters"; +import { useReadingProgress } from "../hooks/useReadingProgress"; +import { useReadingSettings } from "../hooks/useReadingSettings"; +import { booksApi } from "../api/books"; +import type { BookDetail } from "../types/book"; +import "../reader.css"; + +const ReaderToolbar = lazy(() => import("../components/reader/ReaderToolbar")); +const TableOfContents = lazy(() => import("../components/reader/TableOfContents")); +const ReadingSettingsPanel = lazy( + () => import("../components/reader/ReadingSettingsPanel") +); + +export default function ReadingPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const bookId = Number(id); + const [book, setBook] = useState(null); + const [bookLoading, setBookLoading] = useState(true); + + // Fetch book details + useEffect(() => { + if (!bookId || Number.isNaN(bookId)) return; + setBookLoading(true); + booksApi.getBook(bookId) + .then(setBook) + .catch(() => { /* handled by useChapters error */ }) + .finally(() => setBookLoading(false)); + }, [bookId]); + + const [tocOpen, setTocOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const { settings, updateSettings } = useReadingSettings(); + const { + chapters, + currentChapter, + currentChapterNumber, + isLoading, + error, + navigateToChapter, + goToNextChapter, + goToPreviousChapter, + hasNext, + hasPrevious, + } = useChapters(bookId, 1); + + const { progress, debouncedSave } = useReadingProgress(bookId); + + // Auto-save progress when chapter changes + useEffect(() => { + if (currentChapter && currentChapterNumber > 0) { + debouncedSave(currentChapterNumber, 0, (currentChapterNumber / Math.max(chapters.length, 1)) * 100); + } + }, [currentChapterNumber, currentChapter?.id]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowLeft" && hasPrevious) { + goToPreviousChapter(); + } else if (e.key === "ArrowRight" && hasNext) { + goToNextChapter(); + } else if (e.key === "Escape") { + setTocOpen(false); + setSettingsOpen(false); + } + }, + [hasNext, hasPrevious, goToNextChapter, goToPreviousChapter] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + // Lock/unlock orientation via CSS + useEffect(() => { + const root = document.documentElement; + if (settings.orientation_lock !== "auto") { + root.style.setProperty( + "--reader-orientation", + settings.orientation_lock === "portrait" ? "portrait" : "landscape" + ); + } else { + root.style.removeProperty("--reader-orientation"); + } + }, [settings.orientation_lock]); + + // Sanitize chapter content to prevent XSS + const sanitizedContent = currentChapter?.content + ? DOMPurify.sanitize(currentChapter.content) + : ""; + + // Show loading state + if (bookLoading || (isLoading && !currentChapter)) { + return ( +
+
+

Loading reader...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( + +
+
+ } + > +
+ setTocOpen((v) => !v)} + onToggleSettings={() => setSettingsOpen((v) => !v)} + /> + + setTocOpen(false)} + onNavigate={navigateToChapter} + /> + + setSettingsOpen(false)} + onUpdate={updateSettings} + /> + + {/* Main reading area */} +
+ {isLoading ? ( +
+
+

Loading chapter...

+
+ ) : currentChapter ? ( +
+

{currentChapter.title}

+
+
+ ) : ( +
+

Select a chapter from the table of contents to start reading.

+
+ )} +
+ + {/* Bottom navigation bar */} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/reader.css b/frontend/src/reader.css new file mode 100644 index 0000000..74d4424 --- /dev/null +++ b/frontend/src/reader.css @@ -0,0 +1,621 @@ +/* ============================================================ + Cloud Reader — Reading View Styles + Mobile-first reading experience with theme support + ============================================================ */ + +/* --- CSS Custom Properties (overridden by JS) --- */ +:root { + --reader-bg: #f5f0eb; + --reader-text: #1a1a1a; + --reader-font-family: Georgia, "Times New Roman", serif; + --reader-font-size: 18px; + --reader-line-height: 1.6; + --reader-margin: 16px; + --reader-brightness: 100%; + --reader-toolbar-bg: rgba(26, 26, 26, 0.95); + --reader-toolbar-text: #e0e0e0; + --reader-accent: #6c8cff; +} + +/* --- Reading Container --- */ +.reader-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--reader-bg); + color: var(--reader-text); + font-family: var(--reader-font-family); + font-size: var(--reader-font-size); + line-height: var(--reader-line-height); + filter: brightness(var(--reader-brightness)); + display: flex; + flex-direction: column; + z-index: 100; + overflow: hidden; +} + +/* Orientation lock */ +@supports (--reader-orientation: portrait) { + .reader-container { + orientation: var(--reader-orientation); + } +} + +@media (orientation: landscape) { + .reader-container { + --reader-margin: 24px; + } +} + +/* --- Top Toolbar --- */ +.reader-top-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 52px; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + z-index: 120; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.reader-bar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + cursor: pointer; + border-radius: 8px; + transition: background 0.15s; +} + +.reader-bar-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.reader-bar-title { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + min-width: 0; + flex: 1; + padding: 0 8px; +} + +.reader-bar-book { + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.reader-bar-chapter { + font-size: 0.7rem; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* --- Progress Bar --- */ +.reader-progress-bar { + position: fixed; + top: 52px; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.1); + z-index: 120; +} + +.reader-progress-fill { + height: 100%; + background: var(--reader-accent); + transition: width 0.3s ease; +} + +/* --- Main Content Area --- */ +.reader-content { + flex: 1; + overflow-y: auto; + padding: 72px var(--reader-margin) 72px; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* --- Chapter Article --- */ +.reader-chapter { + max-width: 680px; + margin: 0 auto; +} + +.reader-chapter-title { + font-size: 1.6em; + font-weight: 700; + line-height: 1.3; + margin-bottom: 0.75em; + padding-bottom: 0.5em; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + color: var(--reader-text); +} + +/* Chapter body — content from API (could be HTML/markdown-rendered) */ +.reader-chapter-body { + font-size: 1em; + line-height: var(--reader-line-height); +} + +.reader-chapter-body p { + margin-bottom: 1.2em; + text-align: justify; + hyphens: auto; +} + +.reader-chapter-body h2, +.reader-chapter-body h3, +.reader-chapter-body h4 { + margin-top: 1.5em; + margin-bottom: 0.6em; + line-height: 1.3; +} + +.reader-chapter-body blockquote { + border-left: 3px solid var(--reader-accent); + padding-left: 1em; + margin: 1em 0; + opacity: 0.85; + font-style: italic; +} + +.reader-chapter-body img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 1em 0; +} + +.reader-chapter-body ul, +.reader-chapter-body ol { + padding-left: 1.5em; + margin-bottom: 1.2em; +} + +.reader-chapter-body li { + margin-bottom: 0.4em; +} + +/* --- Navigation Bar (Bottom) --- */ +.reader-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 56px; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + z-index: 120; + gap: 8px; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.reader-nav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + cursor: pointer; + transition: all 0.15s; +} + +.reader-nav-btn:hover:not(.reader-nav-btn--disabled) { + border-color: var(--reader-accent); + color: var(--reader-accent); +} + +.reader-nav-btn--disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.reader-nav-btn--back { + background: rgba(108, 140, 255, 0.15); + border-color: rgba(108, 140, 255, 0.3); +} + +.reader-nav-btn--back:hover { + background: rgba(108, 140, 255, 0.25); + color: var(--reader-accent); +} + +/* --- TOC Drawer --- */ +.toc-overlay, +.settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 130; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.toc-drawer, +.settings-drawer { + position: fixed; + top: 0; + bottom: 0; + width: 300px; + max-width: 85vw; + background: var(--reader-toolbar-bg); + color: var(--reader-toolbar-text); + z-index: 140; + display: flex; + flex-direction: column; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toc-drawer { + left: 0; + transform: translateX(-100%); +} + +.toc-drawer--open { + transform: translateX(0); +} + +.settings-drawer { + right: 0; + transform: translateX(100%); +} + +.settings-drawer--open { + transform: translateX(0); +} + +.toc-header, +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.toc-title, +.settings-title { + font-size: 1.1rem; + font-weight: 700; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.toc-close-btn, +.settings-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s; +} + +.toc-close-btn:hover, +.settings-close-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* --- TOC List --- */ +.toc-list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.toc-empty { + padding: 24px 16px; + text-align: center; + opacity: 0.6; + font-size: 0.9rem; +} + +.toc-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 16px; + background: transparent; + border: none; + color: var(--reader-toolbar-text); + font-size: 0.9rem; + text-align: left; + cursor: pointer; + transition: background 0.15s; + font-family: inherit; +} + +.toc-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.toc-item--active { + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +.toc-item-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + font-size: 0.75rem; + font-weight: 700; + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); + padding: 0 4px; +} + +.toc-item-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Settings Panel --- */ +.settings-body { + flex: 1; + overflow-y: auto; + padding: 12px 16px 32px; +} + +.settings-section { + margin-bottom: 20px; +} + +.settings-section-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* Theme grid */ +.theme-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.theme-btn { + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: transparent; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.theme-btn:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.theme-btn--active { + border-color: var(--reader-accent); + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +/* Font grid */ +.font-grid, +.orientation-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.font-btn, +.orientation-btn { + padding: 10px 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: transparent; + color: var(--reader-toolbar-text); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} + +.font-btn:hover, +.orientation-btn:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.font-btn--active, +.orientation-btn--active { + border-color: var(--reader-accent); + background: rgba(108, 140, 255, 0.15); + color: var(--reader-accent); + font-weight: 600; +} + +/* Sliders */ +.settings-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.settings-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--reader-accent); + border: 2px solid white; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.settings-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--reader-accent); + border: 2px solid white; + cursor: pointer; +} + +/* --- Start Reading Button (in book detail) --- */ +.start-reading-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--reader-accent); + border: none; + border-radius: 8px; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-top: 16px; + margin-bottom: 16px; + font-family: inherit; +} + +.start-reading-btn:hover { + background: #5a7ae8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 140, 255, 0.3); +} + +/* --- Loading State --- */ +.reader-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + color: var(--reader-text); +} + +.reader-error { + color: #ef4444; + text-align: center; +} + +/* --- Responsive --- */ +@media (max-width: 480px) { + .reader-chapter { + max-width: 100%; + } + + .reader-nav-btn { + padding: 8px 10px; + font-size: 0.8rem; + } + + .reader-nav-btn span { + display: none; + } + + .toc-drawer, + .settings-drawer { + max-width: 100vw; + } + + .reader-chapter-title { + font-size: 1.3em; + } +} + +@media (min-width: 768px) { + .reader-chapter { + max-width: 720px; + } + + .reader-chapter-body { + font-size: 1.05em; + } +} + +/* --- Landscape on Mobile --- */ +@media (orientation: landscape) and (max-height: 500px) { + .reader-top-bar { + height: 44px; + } + + .reader-content { + padding-top: 60px; + padding-bottom: 60px; + } + + .reader-chapter-title { + font-size: 1.2em; + } +} + +/* --- Dark mode theme override when reader uses light theme but system is dark --- */ +@media (prefers-color-scheme: dark) { + .reader-container[data-theme="light"] { + --reader-toolbar-bg: rgba(255, 255, 255, 0.95); + --reader-toolbar-text: #1a1a1a; + } +} \ No newline at end of file diff --git a/frontend/src/types/reader.ts b/frontend/src/types/reader.ts new file mode 100644 index 0000000..d478d9a --- /dev/null +++ b/frontend/src/types/reader.ts @@ -0,0 +1,44 @@ +/** + * TypeScript interfaces for the Cloud Reader reader module. + * Reading view, settings, chapters, and progress types. + */ + +export interface ChapterSummary { + id: number; + book: number; + title: string; + number: number; +} + +export interface ChapterDetail extends ChapterSummary { + content: string; + created_at: string; + updated_at: string; +} + +export interface ReadingSettings { + font_family: "sans-serif" | "serif" | "monospace"; + font_size: number; + line_height: number; + margin_width: number; + background_color: string; + text_color: string; + brightness: number; + orientation_lock: "auto" | "portrait" | "landscape"; + theme: "sepia" | "dark" | "light" | "paper"; + created_at: string; + updated_at: string; +} + +export type ThemePreset = ReadingSettings["theme"]; +export type FontFamily = ReadingSettings["font_family"]; +export type OrientationLock = ReadingSettings["orientation_lock"]; + +export interface ReadingProgress { + id: number; + book: number; + current_chapter: number; + current_position: number; + percentage: number; + updated_at: string; +} \ No newline at end of file