diff --git a/backend/apps/books/models.py b/backend/apps/books/models.py index 5e13293..dedd71b 100644 --- a/backend/apps/books/models.py +++ b/backend/apps/books/models.py @@ -1,11 +1,36 @@ +from django.conf import settings from django.db import models +from django.db.models.signals import post_delete +from django.dispatch import receiver +from pathlib import Path + + +class ReadingStatus(models.TextChoices): + WANT_TO_READ = "want_to_read", "Want to Read" + READING = "reading", "Reading" + FINISHED = "finished", "Finished" + 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): - """Represents a book in the user's library.""" - - 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) + genre = models.CharField(max_length=128, blank=True, default="", db_index=True) + description = models.TextField(blank=True, default="") + reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True) total_pages = models.PositiveIntegerField(default=0) cover_image = models.URLField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) @@ -16,13 +41,154 @@ class Book(models.Model): verbose_name = "Book" verbose_name_plural = "Books" ordering = ["title"] + indexes = [models.Index(fields=["title", "author", "genre"])] - def __str__(self) -> str: + def __str__(self): return self.title +class EBook(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks") + 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) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "books_ebook" + verbose_name = "E-Book" + verbose_name_plural = "E-Books" + ordering = ["-created_at"] + indexes = [models.Index(fields=["user", "-created_at"])] + + def __str__(self): + return self.title + + def filename(self): + 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: + instance.file.delete(save=False) + if instance.cover_image: + 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: + db_table = "books_reading_progress" + verbose_name_plural = "reading progress" + unique_together = [("user", "ebook")] + + 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). + + 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 + + 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 + + +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) + + class Meta: + db_table = "books_reading_settings" + verbose_name_plural = "reading settings" + + def __str__(self): + return f"Settings for {self.user}" + + class Chapter(models.Model): - """A chapter within a book, containing the text/markdown content.""" + """A chapter within a book, containing the text/markdown content. + + This is distinct from BookChapter (which tracks EPUB TOC entries). + Chapter represents user-authored chapter content for a Book. + """ book = models.ForeignKey( Book, @@ -48,23 +214,27 @@ class Chapter(models.Model): ) ] - def __str__(self) -> str: + def __str__(self): return f"{self.book.title} — Ch. {self.number}: {self.title}" -class ReadingProgress(models.Model): - """Tracks a user's reading progress within a book.""" +class BookReadingProgress(models.Model): + """Tracks a user's reading progress within a book's chapters. + + This is distinct from ReadingProgress (which tracks ebook progress). + BookReadingProgress tracks chapter-based reading for Books. + """ user = models.ForeignKey( - "users.User", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="reading_progress", + related_name="book_reading_progress", db_index=True, ) book = models.ForeignKey( Book, on_delete=models.CASCADE, - related_name="reading_progress", + related_name="book_reading_progress", db_index=True, ) current_chapter = models.PositiveIntegerField(default=1) @@ -73,16 +243,16 @@ class ReadingProgress(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - db_table = "books_reading_progress" - verbose_name = "Reading Progress" - verbose_name_plural = "Reading Progress" + db_table = "books_book_reading_progress" + verbose_name = "Book Reading Progress" + verbose_name_plural = "Book Reading Progress" ordering = ["-updated_at"] constraints = [ models.UniqueConstraint( fields=["user", "book"], - name="uq_reading_progress_user_book", + name="uq_book_reading_progress_user_book", ) ] - def __str__(self) -> str: + def __str__(self): return f"{self.user} — {self.book.title} ({self.percentage:.0f}%)" \ No newline at end of file diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index 589b623..2a0c6a3 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -1,31 +1,192 @@ from rest_framework import serializers -from apps.books.models import Book, Chapter, ReadingProgress +from apps.books.models import Book, BookChapter, BookReadingProgress, Chapter, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus, DownloadRecord -class BookSerializer(serializers.ModelSerializer): - """Serialize Book data.""" - +class BookChapterSerializer(serializers.ModelSerializer): class Meta: - model = Book - fields = [ - "id", - "title", - "author", - "total_pages", - "cover_image", - "created_at", - "updated_at", - ] - read_only_fields = ["id", "created_at", "updated_at"] + 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): - """Lightweight serializer for list views (excludes heavy fields).""" + reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True) class Meta: model = Book - fields = ["id", "title", "author", "total_pages", "cover_image"] + fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"] + + +class BookDetailSerializer(serializers.ModelSerializer): + reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True) + + class Meta: + model = Book + fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class BookSerializer(serializers.ModelSerializer): + class Meta: + model = Book + fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +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", "format", "page_count", "file_size", "cover_image", "created_at", "progress"] + + def get_progress(self, obj): + try: + return obj.reading_progress.current_position + except ReadingProgress.DoesNotExist: + return None + + +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", "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") + if request and obj.file: + return request.build_absolute_uri(obj.file.url) + return "" + + def get_progress(self, obj): + try: + rp = obj.reading_progress + return {"current_position": rp.current_position, "last_page": rp.last_page} + except ReadingProgress.DoesNotExist: + return None + + +class EBookUploadSerializer(serializers.ModelSerializer): + class Meta: + model = EBook + fields = ["title", "author", "file", "cover_image"] + extra_kwargs = {"title": {"required": True}, "file": {"required": True}} + + def validate_file(self, value): + import os + if value is None: + return value + ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower() + if ext not in (".epub", ".pdf"): + raise serializers.ValidationError("Only EPUB and PDF files are supported.") + return value + + 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", "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): + if value < 0.0 or value > 100.0: + raise serializers.ValidationError("Position must be between 0.0 and 100.0.") + return value + + +class ReadingSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = ReadingSettings + fields = ["font_size", "font_style", "background_color"] + + 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 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 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 + + +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 = DownloadRecord + fields = [ + "id", "ebook_id", "ebook_title", "author", "filename", "file_url", + "file_size", "cover_image", "format", "downloaded_at", "progress", + ] + + def get_filename(self, obj): + return obj.ebook.filename() + + 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 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()) class ChapterSummarySerializer(serializers.ModelSerializer): @@ -45,27 +206,23 @@ class ChapterDetailSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at", "updated_at"] -class ReadingProgressSerializer(serializers.ModelSerializer): - """Serialize reading progress for a book.""" +class BookReadingProgressSerializer(serializers.ModelSerializer): + """Serialize chapter-based reading progress for a book.""" class Meta: - model = ReadingProgress + model = BookReadingProgress fields = [ - "id", - "book", - "current_chapter", - "current_position", - "percentage", - "updated_at", + "id", "book", "current_chapter", "current_position", + "percentage", "updated_at", ] read_only_fields = ["id", "updated_at"] - def validate_percentage(self, value: float) -> float: + def validate_percentage(self, value): 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: + def validate_current_chapter(self, value): if value < 1: raise serializers.ValidationError("Chapter number must be positive.") return value \ No newline at end of file diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index 816fbdb..232c08c 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -1,53 +1,94 @@ +from __future__ import annotations + +import logging +from typing import Any + +from django.db.models import QuerySet, Q from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import status, viewsets +from rest_framework import parsers, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.filters import OrderingFilter, SearchFilter -from rest_framework.permissions import IsAuthenticated +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, Chapter, ReadingProgress +from apps.books.models import Book, BookChapter, BookReadingProgress, DownloadRecord, EBook, Chapter, ReadingProgress, ReadingSettings from apps.books.serializers import ( - BookListSerializer, - BookSerializer, - ChapterDetailSerializer, - ChapterSummarySerializer, - ReadingProgressSerializer, + BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer, + BookReadingProgressSerializer, ChapterDetailSerializer, ChapterSummarySerializer, + DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer, + EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, + ReadingProgressSerializer, ReadingSettingsSerializer, StorageSummarySerializer, ) +logger = logging.getLogger(__name__) + class BookViewSet(viewsets.ModelViewSet): - """CRUD for books. Includes nested chapter and progress endpoints.""" - - queryset = Book.objects.all().prefetch_related("chapters") + queryset = Book.objects.all() permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] - filterset_fields = ["author"] - search_fields = ["title", "author"] - ordering_fields = ["title", "author", "created_at"] + filterset_fields = ["author", "genre", "reading_status"] + search_fields = ["title", "author", "genre"] + ordering_fields = ["title", "author", "genre", "created_at"] ordering = ["title"] def get_serializer_class(self): + if self.action == "retrieve": + return BookDetailSerializer if self.action == "list": return BookListSerializer return BookSerializer + def get_queryset(self) -> QuerySet[Book]: + qs = super().get_queryset() + query = self.request.query_params.get("q", "").strip() + if query: + qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query)) + return qs + + @action(detail=False, methods=["get"], permission_classes=[AllowAny]) + def genres(self, request: Request) -> Response: + genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre") + return Response([g for g in genre_list if g]) + + @action(detail=False, methods=["get"], permission_classes=[AllowAny]) + def authors(self, request: Request) -> Response: + 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) + + @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) + chapter_qs = Chapter.objects.filter(book=book).order_by("number") + serializer = ChapterSummarySerializer(chapter_qs, many=True) return Response(serializer.data) - @action(detail=True, methods=["get"], url_path="chapters/(?P[0-9]+)") - def chapter_detail( - self, request: Request, pk: int | None = None, chapter_number: str | None = None - ) -> Response: + @action(detail=True, methods=["get"], url_path=r"chapters/(?P[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)) + chapter = Chapter.objects.get(book=book, number=int(chapter_number or "0")) except Chapter.DoesNotExist: return Response( {"detail": "Chapter not found."}, status=status.HTTP_404_NOT_FOUND @@ -57,19 +98,244 @@ class BookViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get", "put"]) def progress(self, request: Request, pk: int | None = None) -> Response: - """Get or update reading progress for this book.""" + """Get or update chapter-based reading progress for this book.""" book = self.get_object() - progress, created = ReadingProgress.objects.get_or_create( + progress_obj, _created = BookReadingProgress.objects.get_or_create( user=request.user, book=book ) if request.method == "GET": - serializer = ReadingProgressSerializer(progress) + serializer = BookReadingProgressSerializer(progress_obj) return Response(serializer.data) - serializer = ReadingProgressSerializer( - progress, data=request.data, partial=True + serializer = BookReadingProgressSerializer( + progress_obj, 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 + return Response(serializer.data, status=status.HTTP_200_OK) + + +class IsEBookOwner(permissions.BasePermission): + def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: + return obj.user == request.user + + +class EBookViewSet(viewsets.ModelViewSet): + parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser] + permission_classes = [IsAuthenticated, IsEBookOwner] + + def get_serializer_class(self): + if self.action == "create": + return EBookUploadSerializer + if self.action in ("list",): + return EBookListSerializer + if self.action in ("toc",): + return BookChapterSerializer + return EBookDetailSerializer + + def get_queryset(self): + return EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user") + + @action(detail=True, methods=["get", "patch"]) + def progress(self, request: Request, pk: int | None = None) -> Response: + ebook = self.get_object() + progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook) + if request.method == "GET": + serializer = ReadingProgressSerializer(progress_obj) + return Response(serializer.data) + serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + @action(detail=True, methods=["post"]) + def process(self, request: Request, pk: int | None = None) -> Response: + """Trigger e-book processing: metadata extraction, TOC building, page counting.""" + ebook = self.get_object() + if not ebook.file: + return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) + + try: + from apps.books.services import process_ebook + + file_path = ebook.file.path + result = process_ebook(file_path, original_filename=ebook.filename()) + + # Update ebook with extracted data + ebook.format = result.get("format", ebook.format) + ebook.page_count = result.get("page_count", 0) + ebook.metadata_json = result.get("metadata", {}) + ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"]) + + # Store chapters in DB + raw_toc: list[dict[str, Any]] = result.get("toc", []) + BookChapter.objects.filter(ebook=ebook).delete() + _store_chapters(ebook, raw_toc) + + return Response({ + "format": ebook.format, + "page_count": ebook.page_count, + "metadata": ebook.metadata_json, + "toc_count": len(raw_toc), + "status": "processed", + }) + except Exception as exc: + logger.exception("Failed to process ebook %s", ebook.id) + return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=["get"]) + def toc(self, request: Request, pk: int | None = None) -> Response: + """Return hierarchical table of contents.""" + ebook = self.get_object() + chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook") + serializer = BookChapterSerializer(chapters, many=True) + return Response({ + "chapters": serializer.data, + "format": ebook.format, + "page_count": ebook.page_count, + }) + + @action(detail=True, methods=["get"]) + def content(self, request: Request, pk: int | None = None) -> Response: + """Return paginated content for a given page number. + + Query params: + page (int): page/chapter index to fetch (1-indexed, default: 1) + """ + ebook = self.get_object() + page = max(1, int(request.query_params.get("page", 1))) + + chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")) + total_pages = len(chapters) or ebook.page_count or 1 + + chapter: BookChapter | None = None + chapter_title = "" + content_html = "" + + if chapters and 0 <= page - 1 < len(chapters): + ch = chapters[page - 1] + chapter_title = ch.title + content_html = _fetch_chapter_content(ebook, ch) + + serializer = EBookContentSerializer(data={ + "page": page, + "total_pages": total_pages, + "content": content_html, + "chapter_title": chapter_title, + "format": ebook.format, + }) + 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.""" + for idx, entry in enumerate(toc): + BookChapter.objects.create( + ebook=ebook, + title=entry.get("title", "Untitled"), + index=parent_index + idx, + href=entry.get("href", ""), + children=entry.get("children", []), + ) + children = entry.get("children", []) + if children: + _store_chapters(ebook, children, parent_index + idx + 1) + + +def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str: + """Fetch HTML content for a chapter from the e-book file.""" + if ebook.format == "epub": + return _fetch_epub_chapter_content(ebook.file.path, chapter) + return "" + + +def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str: + """Extract HTML content of a specific EPUB chapter by href.""" + try: + from ebooklib import epub + from bs4 import BeautifulSoup + except ImportError: + return "" + + try: + book = epub.read_epub(file_path) + href = chapter.href or "" + # Find the item by href + for item in book.get_items(): + item_name = item.get_name() or "" + if href and (item_name.endswith(href) or href.endswith(item_name)): + content = item.get_content() + soup = BeautifulSoup(content, "html.parser") + # Clean up — remove body/html/head wrappers, keep inner content + body = soup.find("body") + if body: + return str(body) + return str(soup) + 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7f639f5 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,62 @@ +import React, { lazy, Suspense, useState } from "react"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider, useAuth } from "./context/AuthContext"; + +const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage }))); +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) => ({ + default: () => { + const [isLogin, setIsLogin] = useState(true); + return isLogin ? setIsLogin(false)} /> : setIsLogin(true)} />; + }, + })), +); + +function LoadingFallback() { + return

Loading...

; +} + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, loading } = useAuth(); + if (loading) return ; + if (!isAuthenticated) return ; + return <>{children}; +} + +function AppRoutes() { + const { isAuthenticated } = useAuth(); + return ( + }> + + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default function App() { + return ( + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/api/reader.ts b/frontend/src/api/reader.ts new file mode 100644 index 0000000..cbf73e7 --- /dev/null +++ b/frontend/src/api/reader.ts @@ -0,0 +1,112 @@ +/** + * 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. + */ +export async function getChapters(bookId: number): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/chapters/`); + if (!response.ok) { + throw new Error( + `Failed to fetch chapters: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch a specific chapter with full content for reading. + */ +export async function getChapterContent( + bookId: number, + chapterNumber: number +): Promise { + const response = await fetch( + `${API_BASE}/books/${bookId}/chapters/${chapterNumber}/` + ); + if (!response.ok) { + throw new Error( + `Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch reading progress for a book. + */ +export async function getReadingProgress( + bookId: number +): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/progress/`); + if (!response.ok) { + throw new Error( + `Failed to fetch reading progress: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Update reading progress for a book. + */ +export async function updateReadingProgress( + bookId: number, + progress: Partial +): Promise { + const response = await fetch(`${API_BASE}/books/${bookId}/progress/`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(progress), + }); + if (!response.ok) { + throw new Error( + `Failed to update reading progress: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} \ 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..2f9def6 --- /dev/null +++ b/frontend/src/pages/ReadingPage.tsx @@ -0,0 +1,213 @@ +/** + * ReadingPage — full-screen reading view for ebooks. + * Integrates TOC drawer, settings panel, chapter navigation, and progress tracking. + */ + +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +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]); + + // 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..2bf74a9 --- /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: landascape) { + .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