Compare commits

..
Author SHA1 Message Date
crisleo94 730c748f5f Merge pull request 'Implement: Customizable Mobile Reading Experience' (#25) from feature/customizable-mobile-reading-experience into main
Reviewed-on: #25
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 13:48:05 +00:00
Marko (Hermes Implementer) 7cdc3d0969 Remove stale web/ directory — content already ported to frontend/
Reid's second review flagged that the old web/ files still had:
- XSS vulnerability (no DOMPurify)
- CSS typo (landascape)
- API endpoint mismatches

All fix: delete web/ entirely. The correct, sanitized, fixed versions
already live in frontend/src/.
2026-05-29 07:04:53 +00:00
Marko (Hermes Implementer) a7864bf5cd Address Reid's PR #25 review comments
🔴 Blocking fixes:
- Add reader app migration (apps/reader/migrations/0001_initial.py)
- Remove duplicate ReadingSettings from apps.books to resolve model clash
  with apps.reader.ReadingSettings (keep richer reader version)
- Redirect books serializers/views to use reader app's ReadingSettings
- Update frontend API endpoints to match main's backend routes:
  /api/books/ebooks/{id}/toc/ (chapters)
  /api/books/ebooks/{id}/content/?page=N (chapter content)
  /api/books/ebooks/{id}/progress/ (GET/PATCH progress)
- Add DOMPurify sanitization for dangerouslySetInnerHTML (XSS fix)

🟡 Non-blocking fixes:
- Fix CSS typo: landascape -> landscape in reader.css
- Add null-safe fallback for book.title in ReadingPage
2026-05-29 06:37:58 +00:00
Marko (Hermes Implementer) edac7cb08a Merge branch 'main' into feature/customizable-mobile-reading-experience
Resolve merge conflicts:
- backend/apps/books/: Keep main's models (EBook, BookChapter, etc.)
- frontend/src/App.tsx: Keep /read/:id route + main's all routes
- web/ files: Accept deletion (content ported to frontend/)
2026-05-29 05:59:10 +00:00
Marko (Hermes Implementer) 74fdae61db Resolve merge conflicts for PR #25: customizable mobile reading experience
- Backend: Replace PR's Book-only models with main's full models
  (EBook, BookChapter, DownloadRecord, ReadingProgress, ReadingSettings)
- Backend: Add reader app (ReadingSettings model, serializer, view, URL)
- Frontend: Port reader feature files from web/ to frontend/ (api, hooks,
  components/reader/, pages/ReadingPage, types/reader, reader.css)
- Frontend: Add /read/:id route to App.tsx for chapter-based reading view
- All imports adjusted for frontend/src directory structure
2026-05-29 05:53:14 +00:00
crisleo94 3f626259e8 Merge pull request 'Implement: US: Mobile Book Search and Discovery' (#24) from feature/mobile-search-discovery into main
Reviewed-on: #24
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 05:13:02 +00:00
Marko (Hermes Implementer) ade810a013 fix: address PR #24 review comments - SpeechRecognition types, CSS hover over direct DOM, shared useDebounce 2026-05-29 05:10:06 +00:00
crisleo94 6a223d7237 Merge branch 'main' into feature/mobile-search-discovery 2026-05-29 04:50:59 +00:00
crisleo94 656d20879f Merge pull request 'Implement: US: Integrate Expo Mobile Application into Monorepo (#16)' (#23) from feature/expo-integration into main
Reviewed-on: #23
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 04:50:49 +00:00
crisleo94 2eeb3fb7d6 Merge pull request 'Fix: Missing API methods for Book Search and Discovery (re-opened #5)' (#14) from fix/missing-api-methods into main
Reviewed-on: #14
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 04:25:37 +00:00
Marko (Hermes Implementer) 658f77a746 feat: mobile book search and discovery with voice search, suggestions, and responsive layout 2026-05-29 02:55:50 +00:00
Marko (Hermes Implementer) b29ea8211c feat: customizable mobile reading experience
- Backend: Chapter, ReadingProgress, ReadingSettings models
- Backend: Chapter API (TOC + content), progress tracking, settings CRUD
- Frontend: ReadingPage with chapter navigation
- Frontend: TableOfContents drawer
- Frontend: ReadingSettingsPanel (theme, font, size, orientation)
- Frontend: Custom hooks for settings, chapters, progress tracking
- CSS: Mobile-first reading view with sepia/dark/light/paper themes
- Route: /reader/:bookId reading view from book detail page
- Docs: 001-customizable-mobile-reading-experience.md
2026-05-29 02:55:36 +00:00
Marko (Hermes Implementer) fa82fab44a feat: Integrate Expo mobile application into monorepo (#16)
- Add mobile/ directory with Expo React Native project
- Create API client with JWT auth and token refresh using AsyncStorage
- Implement AuthContext for login/register/logout flow
- Add screens: Login, Register, Library, Search, Settings
- Set up React Navigation with AuthStack and MainTabs
- Create packages/shared/ with shared types and utilities
- Add shared validation utilities (email, password strength)
- Update root package.json workspaces to include mobile + shared
- Add spec document docs/backend/009-expo-integration.md
2026-05-29 02:50:09 +00:00
75 changed files with 5382 additions and 202 deletions
+35 -2
View File
@@ -14,7 +14,7 @@ cloud-reader/
│ │ └── annotations/ # Bookmarks and notes │ │ └── annotations/ # Bookmarks and notes
│ ├── manage.py │ ├── manage.py
│ └── requirements.txt │ └── requirements.txt
├── frontend/ # React + Vite + TypeScript (canonical frontend) ├── frontend/ # React + Vite + TypeScript (web frontend)
│ ├── src/ │ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh) │ │ ├── api/ # API client (axios with JWT refresh)
│ │ ├── components/ # Reusable components │ │ ├── components/ # Reusable components
@@ -23,6 +23,23 @@ cloud-reader/
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings) │ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
│ │ └── types/ # TypeScript type definitions │ │ └── types/ # TypeScript type definitions
│ └── package.json │ └── package.json
├── mobile/ # Expo React Native app (mobile frontend)
│ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
│ │ ├── components/ # Reusable UI components
│ │ ├── context/ # Auth context
│ │ ├── hooks/ # Custom hooks
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
│ │ └── types/ # Mobile-specific types
│ ├── App.tsx
│ └── app.json
├── packages/
│ └── shared/ # @cloud-reader/shared — domain types & utilities
│ └── src/
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.)
│ └── utils.ts # Date formatting, validation, API endpoint constants
├── package.json # Root — yarn workspaces config
└── docker-compose.yml └── docker-compose.yml
``` ```
@@ -50,8 +67,24 @@ yarn install
yarn dev yarn dev
``` ```
### Mobile (Expo)
```bash
# From monorepo root — installs all workspaces including mobile
yarn install
# Start Expo dev server
yarn workspace @cloud-reader/mobile start
# Or cd into mobile and run directly
cd mobile
npx expo start
```
> The mobile app requires the backend to be running. Set `EXPO_PUBLIC_API_URL` environment variable in your shell or `.env` file to point to the backend (defaults to `http://10.0.2.2:8000` for Android emulator).
## Migration Notes ## Migration Notes
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure. Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure.
- `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in. - `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in.
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in. - `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
- `api/` and `web/` directories removed. - `api/` and `web/` directories removed.
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
+73 -27
View File
@@ -12,19 +12,6 @@ class ReadingStatus(models.TextChoices):
DNF = "dnf", "Did Not Finish" 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): class Book(models.Model):
title = models.CharField(max_length=512, db_index=True) title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="", 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): class EBook(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
title = models.CharField(max_length=512) title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="") 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/") file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True) cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=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 "" 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) @receiver(post_delete, sender=EBook)
def _auto_delete_ebook_file(sender, instance, **kwargs): def _auto_delete_ebook_file(sender, instance, **kwargs):
if instance.file: if instance.file:
@@ -78,11 +87,33 @@ def _auto_delete_ebook_file(sender, instance, **kwargs):
instance.cover_image.delete(save=False) 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): class ReadingProgress(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress") 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") ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
current_position = models.FloatField(default=0.0) current_position = models.FloatField(default=0.0)
last_page = models.IntegerField(default=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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
@@ -93,17 +124,32 @@ class ReadingProgress(models.Model):
def __str__(self): def __str__(self):
return f"{self.ebook.title} - {self.current_position:.1f}%" 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): Returns (instance, applied) where applied is True if the update was applied.
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings") """
font_size = models.IntegerField(default=18) if client_updated_at and self.updated_at:
font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value) try:
background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value) from django.utils.timezone import is_naive, make_aware
updated_at = models.DateTimeField(auto_now=True) 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: self.current_position = position
db_table = "books_reading_settings" self.last_page = last_page
verbose_name_plural = "reading settings" self.device_id = device_id
self.device_name = device_name
def __str__(self): self.version += 1
return f"Settings for {self.user}" self.save(update_fields=[
"current_position", "last_page",
"device_id", "device_name", "version", "updated_at",
])
return self, True
+67 -21
View File
@@ -1,6 +1,26 @@
from rest_framework import serializers 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): class BookListSerializer(serializers.ModelSerializer):
@@ -29,11 +49,12 @@ class BookSerializer(serializers.ModelSerializer):
class EBookListSerializer(serializers.ModelSerializer): class EBookListSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True) filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
progress = serializers.SerializerMethodField() progress = serializers.SerializerMethodField()
class Meta: class Meta:
model = EBook 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): def get_progress(self, obj):
try: try:
@@ -44,12 +65,13 @@ class EBookListSerializer(serializers.ModelSerializer):
class EBookDetailSerializer(serializers.ModelSerializer): class EBookDetailSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True) filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
file_url = serializers.SerializerMethodField() file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField() progress = serializers.SerializerMethodField()
class Meta: class Meta:
model = EBook 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): def get_file_url(self, obj):
request = self.context.get("request") request = self.context.get("request")
@@ -82,13 +104,20 @@ class EBookUploadSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["request"].user 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) return super().create(validated_data)
class ReadingProgressSerializer(serializers.ModelSerializer): class ReadingProgressSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ReadingProgress 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}} extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}}
def validate_current_position(self, value): def validate_current_position(self, value):
@@ -97,24 +126,41 @@ class ReadingProgressSerializer(serializers.ModelSerializer):
return value 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: class Meta:
model = ReadingSettings model = DownloadRecord
fields = ["font_size", "font_style", "background_color"] fields = [
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
"file_size", "cover_image", "format", "downloaded_at", "progress",
]
def validate_font_size(self, value): def get_filename(self, obj):
if value < 12 or value > 36: return obj.ebook.filename()
raise serializers.ValidationError("Font size must be between 12 and 36.")
return value
def validate_font_style(self, value): def get_file_url(self, obj):
valid = [s.value for s in FontStyle] request = self.context.get("request")
if value not in valid: if request and obj.ebook.file:
raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}") return request.build_absolute_uri(obj.ebook.file.url)
return value return ""
def validate_background_color(self, value): def get_progress(self, obj):
valid = [c.value for c in BackgroundColor] try:
if value not in valid: rp = obj.ebook.reading_progress
raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}") return {"current_position": rp.current_position, "last_page": rp.last_page}
return value except ReadingProgress.DoesNotExist:
return None
class StorageSummarySerializer(serializers.Serializer):
total_downloads = serializers.IntegerField()
total_size_bytes = serializers.IntegerField()
ebooks = serializers.ListField(child=serializers.DictField())
+1 -2
View File
@@ -1,7 +1,7 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.books.views import BookViewSet, EBookViewSet, ReadingSettingsViewSet from apps.books.views import BookViewSet, EBookViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r"", BookViewSet, basename="book") router.register(r"", BookViewSet, basename="book")
@@ -12,5 +12,4 @@ ebook_router.register(r"ebooks", EBookViewSet, basename="ebook")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
path("", include(ebook_router.urls)), path("", include(ebook_router.urls)),
path("settings/", ReadingSettingsViewSet.as_view({"get": "list", "patch": "partial_update"}), name="reading-settings"),
] ]
+64 -25
View File
@@ -12,12 +12,12 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response 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 ( from apps.books.serializers import (
BookDetailSerializer, BookListSerializer, BookSerializer, BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer,
BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, ReadingSettingsSerializer, ReadingProgressSerializer, StorageSummarySerializer,
) )
logger = logging.getLogger(__name__) 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") author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
return Response([a for a in author_list if a]) 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): class IsEBookOwner(permissions.BasePermission):
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: 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) serializer.is_valid(raise_exception=True)
return Response(serializer.data) 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<download_pk>[^/.]+)")
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: def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
"""Recursively store TOC entries as BookChapter records.""" """Recursively store TOC entries as BookChapter records."""
@@ -217,24 +276,4 @@ def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
return "" return ""
except Exception: except Exception:
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href) logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
return "" 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)
View File
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReaderConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reader"
verbose_name = "Reader Settings"
@@ -0,0 +1,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',
},
),
]
+53
View File
@@ -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)"
+63
View File
@@ -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
+7
View File
@@ -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"),
]
+33
View File
@@ -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)
+1
View File
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
"apps.users", "apps.users",
"apps.books", "apps.books",
"apps.annotations", "apps.annotations",
"apps.reader",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
+1
View File
@@ -6,4 +6,5 @@ urlpatterns = [
path("api/auth/", include("apps.users.urls")), path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")), path("api/books/", include("apps.books.urls")),
path("api/annotations/", include("apps.annotations.urls")), path("api/annotations/", include("apps.annotations.urls")),
path("api/reader/", include("apps.reader.urls")),
] ]
@@ -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
+96
View File
@@ -0,0 +1,96 @@
# 009 — Expo Mobile Application Integration
**Issue:** #16
**Status:** Draft
**Created:** 2026-05-29
## Objective
Integrate an Expo-based React Native mobile application into the `cloud-reader` monorepo, sharing types, API client patterns, and configuration with the existing web frontend.
## Directory Structure
```
cloud-reader/
├── mobile/ # Expo React Native app
│ ├── package.json
│ ├── app.json
│ ├── tsconfig.json
│ ├── babel.config.js
│ ├── App.tsx # Root component
│ ├── src/
│ │ ├── api/ # API client (mirrors frontend/src/api/ pattern)
│ │ │ ├── client.ts # Axios instance + JWT interceptor
│ │ │ ├── books.ts # Book API calls
│ │ │ └── annotations.ts
│ │ ├── screens/ # Screen-level components
│ │ ├── components/ # Reusable UI components
│ │ ├── navigation/ # React Navigation setup
│ │ ├── context/ # Auth context, etc.
│ │ ├── hooks/ # Custom hooks
│ │ └── types/ # Mobile-specific types
│ └── assets/
├── packages/
│ └── shared/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note)
│ └── utils.ts # Shared utility functions
└── package.json # Root — updated workspace config
```
## Monorepo Workspace Config
Root `package.json` workspaces array updated to include `"mobile"`, `"packages/shared"` alongside existing `"frontend"` and `"backend"`.
## Shared `packages/shared`
- `@cloud-reader/shared` package published within the monorepo
- Exports:
- All domain types (`Book`, `BookSummary`, `Bookmark`, `Note`, `User`, `AnnotationEntry`, `PaginatedResponse`, `TokenResponse`)
- API endpoint constants
- Date formatting helpers
- Validation utilities (email regex, password strength check)
## Mobile App Structure
### API Client (`mobile/src/api/client.ts`)
- Axios instance configured with:
- Base URL from environment variable (`EXPO_PUBLIC_API_URL`)
- JWT token attachment via request interceptor
- Token refresh response interceptor on 401
- Uses `AsyncStorage` for token persistence (instead of `localStorage`)
### Navigation (`mobile/src/navigation/`)
- React Navigation stack:
1. `AuthStack` — Login, Register screens
2. `MainTabs` — Library, Search, Settings tabs
3. `BookReader` — Full-screen reading view
### Key Screens
| Screen | Route | Purpose |
|--------|-------|---------|
| Login | `Auth/Login` | Email/password login |
| Register | `Auth/Register` | User registration |
| Library | `Main/Library` | Book list with filtering |
| BookDetail | `Main/BookDetail` | Book metadata + actions |
| Reader | `Reader/View` | EPUB/PDF rendering |
| Search | `Main/Search` | Book discovery |
| Settings | `Main/Settings` | Profile, theme, download mgmt |
## Backend Changes Required
None. The existing Django REST API already serves all endpoints needed by the mobile app. The mobile app communicates with the same backend via the shared API base URL.
## Docker
No changes to `docker-compose.yml` needed — the mobile app runs on-device or via Expo Go, not inside Docker.
## CI/CD Considerations
The monorepo structure supports a single pipeline that can:
- `yarn install` at root (installs all workspaces)
- `yarn workspace @cloud-reader/shared build`
- `yarn workspace @cloud-reader/mobile build` (Expo EAS for mobile builds)
- `yarn workspace @cloud-reader/frontend build` (Vite for web builds)
+99
View File
@@ -0,0 +1,99 @@
# Mobile Book Search & Discovery — Spec
## Overview
Enhance the existing book search experience with mobile-first features: voice search via the Web Speech API, real-time autocomplete suggestions, and touch-optimized responsive layout.
## Prerequisites
- Backend endpoints already exist (from `docs/backend/search-discovery-spec.md`):
- `GET /api/books/?q=...&genre=...&author=...&reading_status=...` — paginated search
- `GET /api/books/{id}/` — book detail
- `GET /api/books/genres/` — genre discovery
- `GET /api/books/authors/` — author discovery
- Frontend `LibraryPage` and `BookDetailPage` components exist but lacked API client methods and types (fixed in this PR).
## Frontend API Client Additions
### `frontend/src/types/book.ts` — New exports
```typescript
export interface BookSearchParams {
q?: string;
genre?: string;
author?: string;
reading_status?: string;
ordering?: string;
page?: number;
page_size?: number;
}
export const READING_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "All Statuses" },
{ value: "want_to_read", label: "Want to Read" },
{ value: "reading", label: "Reading" },
{ value: "finished", label: "Finished" },
{ value: "dnf", label: "Did Not Finish" },
];
```
### `frontend/src/api/books.ts` — New methods on `booksApi`
| Method | Endpoint | Returns |
|--------|----------|---------|
| `searchBooks(params)` | `GET /api/books/` | `{ count, results: BookListItem[] }` |
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
## Mobile Features
### 1. Voice Search
- **Hook**: `useVoiceSearch` in `frontend/src/hooks/useVoiceSearch.ts`
- Uses the Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`)
- Returns: `{ isListening, transcript, isSupported, startListening, stopListening, hasError }`
- Renders a microphone icon button next to the search input
- On mobile, tapping the mic icon triggers the native speech recognition prompt
- On success, populates the search input with the transcript and triggers a search
- Graceful degradation: if SpeechRecognition API is unavailable, the mic button is hidden
### 2. Real-Time Suggestions (Autocomplete)
- Component: `SearchSuggestions` rendered as a dropdown below the search input
- On each keystroke (debounced 200ms), fetches `GET /api/books/?q=...&page_size=5` for suggestions
- Shows up to 5 book title/author suggestions in a styled dropdown list
- Clicking a suggestion navigates directly to `/books/{id}`
- Clicking outside or pressing Escape dismisses the dropdown
- Combines with existing full search results — suggestions are fast previews, not the main result list
### 3. Mobile-Responsive Enhancements
- Filters panel is **collapsed by default** on mobile, toggleable via a "Filters" button
- Touch targets minimum 44px (WCAG 2.1)
- Results grid switches to **single column** below 600px viewport width
- Search input and filters panel stack vertically on small screens
- Add CSS breakpoints via inline styles and a `useMediaQuery` hook
- Bottom navigation-style action buttons on mobile (Add Book, Bookmarks, Settings become icon-only)
## Component Hierarchy
```
LibraryPage
├── Header (title, count, action buttons)
├── SearchInput
│ ├── TextInput (debounced 300ms)
│ ├── VoiceSearchButton (microphone icon)
│ └── SearchSuggestions (dropdown, debounced 200ms)
├── FiltersButton (mobile: toggle; desktop: always visible)
├── FiltersPanel (collapsible on mobile)
│ ├── GenreSelect
│ ├── AuthorSelect
│ ├── StatusSelect
│ └── ClearFiltersButton
├── LoadingState (skeleton grid)
├── ErrorState (message + retry button)
├── EmptyState (no results / no books)
└── ResultsGrid (responsive: auto-fill vs single column)
```
## Mobile-First CSS Strategy
- Use inline styles with `@media` queries in a shared `breakpoints.ts` utility
- Breakpoints: sm = 480px, md = 768px, lg = 1024px
- Base styles are mobile-first (single column, full width)
- Media queries expand to multi-column grid and horizontal layout on larger screens
+8 -7
View File
@@ -11,20 +11,21 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"dompurify": "^3.4.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.0", "react-router-dom": "^7.1.0"
"axios": "^1.7.9"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.0",
"typescript": "~5.7.0", "typescript": "~5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^2.1.0", "vitest": "^2.1.0"
"@testing-library/react": "^16.2.0",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.0"
} }
} }
+4
View File
@@ -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 BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage })));
const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage }))); const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage })));
const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage }))); 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 SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); 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(() => const AuthPage = lazy(() =>
import("./pages/AuthPage").then((m) => ({ import("./pages/AuthPage").then((m) => ({
@@ -38,7 +40,9 @@ function AppRoutes() {
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} /> <Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} /> <Route path="/reader/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} />
<Route path="/read/:id" element={<ProtectedRoute><ReadingPage /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} /> <Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/edit/:id" element={<ProtectedRoute><EditEBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} /> <Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+20
View File
@@ -17,6 +17,26 @@ export const booksApi = {
return data; return data;
}, },
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
},
async getEBook(id: number): Promise<EBookDetail> { async getEBook(id: number): Promise<EBookDetail> {
const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`); const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`);
return data; return data;
+148
View File
@@ -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<ReadingSettings> {
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<ReadingSettings>;
}
/**
* Update (full or partial) the user's reading settings.
*/
export async function updateReadingSettings(
settings: Partial<ReadingSettings>
): Promise<ReadingSettings> {
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<ReadingSettings>;
}
/**
* 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<ChapterSummary[]> {
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<ChapterDetail> {
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<ReadingProgress> {
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<ReadingProgress>
): Promise<ReadingProgress> {
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;
}
@@ -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 */}
<header className="reader-top-bar">
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label="Table of contents"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<div className="reader-bar-title">
<span className="reader-bar-book">{bookTitle}</span>
<span className="reader-bar-chapter">{chapterTitle}</span>
</div>
<button
type="button"
className="reader-bar-btn"
onClick={onToggleSettings}
aria-label="Reading settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
</header>
{/* Bottom progress bar */}
<div className="reader-progress-bar">
<div
className="reader-progress-fill"
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</>
);
}
@@ -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<ReadingSettings>) => Promise<void>;
}
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<Record<string, boolean>>({});
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 && (
<div
className="settings-overlay"
onClick={onClose}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Escape") onClose();
}}
role="presentation"
/>
)}
{/* Drawer */}
<aside
className={`settings-drawer ${isOpen ? "settings-drawer--open" : ""}`}
>
<div className="settings-header">
<h2 className="settings-title">Reading Settings</h2>
<button
type="button"
className="settings-close-btn"
onClick={onClose}
aria-label="Close settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="settings-body">
{/* Theme Presets */}
<section className="settings-section">
<h3 className="settings-section-title">Theme</h3>
<div className="theme-grid">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`theme-btn ${
settings.theme === opt.value ? "theme-btn--active" : ""
}`}
onClick={() => handleChange("theme", opt.value)}
disabled={saving.theme}
>
{opt.label}
</button>
))}
</div>
</section>
{/* Font Family */}
<section className="settings-section">
<h3 className="settings-section-title">Font</h3>
<div className="font-grid">
{FONT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`font-btn ${
settings.font_family === opt.value ? "font-btn--active" : ""
}`}
onClick={() => handleChange("font_family", opt.value)}
disabled={saving.font_family}
>
{opt.label}
</button>
))}
</div>
</section>
{/* Font Size Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Font Size: {settings.font_size}px
</h3>
<input
type="range"
min="12"
max="32"
value={settings.font_size}
onChange={(e) =>
handleChange("font_size", Number(e.target.value))
}
className="settings-slider"
aria-label="Font size"
/>
</section>
{/* Line Height Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Line Height: {settings.line_height.toFixed(1)}
</h3>
<input
type="range"
min="1.2"
max="2.0"
step="0.1"
value={settings.line_height}
onChange={(e) =>
handleChange("line_height", Number(e.target.value))
}
className="settings-slider"
aria-label="Line height"
/>
</section>
{/* Margin Width Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Margins: {settings.margin_width}px
</h3>
<input
type="range"
min="8"
max="48"
value={settings.margin_width}
onChange={(e) =>
handleChange("margin_width", Number(e.target.value))
}
className="settings-slider"
aria-label="Margin width"
/>
</section>
{/* Brightness Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Brightness: {settings.brightness}%
</h3>
<input
type="range"
min="0"
max="100"
value={settings.brightness}
onChange={(e) =>
handleChange("brightness", Number(e.target.value))
}
className="settings-slider"
aria-label="Brightness"
/>
</section>
{/* Orientation Lock */}
<section className="settings-section">
<h3 className="settings-section-title">Orientation</h3>
<div className="orientation-grid">
{ORIENTATION_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`orientation-btn ${
settings.orientation_lock === opt.value
? "orientation-btn--active"
: ""
}`}
onClick={() => handleChange("orientation_lock", opt.value)}
disabled={saving.orientation_lock}
>
{opt.label}
</button>
))}
</div>
</section>
</div>
</aside>
</>
);
}
@@ -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 && (
<div
className="toc-overlay"
onClick={onClose}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Escape") onClose();
}}
role="presentation"
/>
)}
{/* Drawer */}
<aside className={`toc-drawer ${isOpen ? "toc-drawer--open" : ""}`}>
<div className="toc-header">
<h2 className="toc-title">Contents</h2>
<button
type="button"
className="toc-close-btn"
onClick={onClose}
aria-label="Close table of contents"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<nav className="toc-list">
{chapters.length === 0 && (
<p className="toc-empty">No chapters available.</p>
)}
{chapters.map((chapter) => (
<button
key={chapter.number}
type="button"
className={`toc-item ${
chapter.number === currentChapterNumber ? "toc-item--active" : ""
}`}
onClick={() => handleChapterClick(chapter.number)}
>
<span className="toc-item-number">{chapter.number}</span>
<span className="toc-item-title">{chapter.title}</span>
</button>
))}
</nav>
</aside>
</>
);
}
@@ -0,0 +1,65 @@
.container {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: #fff;
border: 1px solid #e5e7eb;
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 320px;
overflow-y: auto;
}
.infoText {
padding: 12px 16px;
color: #9ca3af;
font-size: 13px;
}
.suggestionItem {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
min-height: 44px;
}
.suggestionItem:hover {
background: #f9fafb;
}
.coverImage {
width: 32px;
height: 48px;
object-fit: cover;
border-radius: 4px;
}
.coverPlaceholder {
font-size: 20px;
flex-shrink: 0;
}
.bookInfo {
min-width: 0;
}
.bookTitle {
font-size: 14px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bookAuthor {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { booksApi } from "../../api/books";
import { useDebounce } from "../../hooks/useDebounce";
import type { BookListItem } from "../../types/book";
import styles from "./SearchSuggestions.module.css";
interface SearchSuggestionsProps {
query: string;
visible: boolean;
onClose: () => void;
onSelectSuggestion: () => void;
}
export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion }: SearchSuggestionsProps) {
const navigate = useNavigate();
const [suggestions, setSuggestions] = useState<BookListItem[]>([]);
const [loading, setLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const debouncedQuery = useDebounce(query, 200);
useEffect(() => {
if (!debouncedQuery.trim()) {
setSuggestions([]);
return;
}
let cancelled = false;
setLoading(true);
void booksApi.searchBooks({ q: debouncedQuery.trim(), page_size: 5 }).then(
(res) => {
if (!cancelled) {
setSuggestions(res.results);
setLoading(false);
}
},
() => {
if (!cancelled) {
setSuggestions([]);
setLoading(false);
}
},
);
return () => {
cancelled = true;
};
}, [debouncedQuery]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay attachment to avoid the click that opened us from immediately closing
const timer = setTimeout(() => document.addEventListener("click", handler), 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handler);
};
}, [visible, onClose]);
// Close on Escape
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [visible, onClose]);
if (!visible || !query.trim()) return null;
return (
<div ref={containerRef} className={styles.container}>
{loading && (
<div className={styles.infoText}>
Searching...
</div>
)}
{!loading && suggestions.length === 0 && debouncedQuery.trim() && (
<div className={styles.infoText}>
No quick suggestions
</div>
)}
{suggestions.map((book) => (
<div
key={book.id}
className={styles.suggestionItem}
onClick={() => {
onSelectSuggestion();
navigate(`/books/${book.id}`);
}}
>
<span className={styles.coverPlaceholder}>
{book.cover_image ? (
<img
src={book.cover_image}
alt=""
className={styles.coverImage}
/>
) : (
"📖"
)}
</span>
<div className={styles.bookInfo}>
<div className={styles.bookTitle}>
{book.title}
</div>
<div className={styles.bookAuthor}>
{book.author || "Unknown Author"}
</div>
</div>
</div>
))}
</div>
);
}
+4 -1
View File
@@ -1 +1,4 @@
export { usePaginatedQuery } from "./usePaginatedQuery"; export { usePaginatedQuery } from "./usePaginatedQuery";
export { useDebounce } from "./useDebounce";
export { useVoiceSearch } from "./useVoiceSearch";
export { useMediaQuery } from "./useMediaQuery";
+129
View File
@@ -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<void>;
goToNextChapter: () => Promise<void>;
goToPreviousChapter: () => Promise<void>;
hasNext: boolean;
hasPrevious: boolean;
}
export function useChapters(
bookId: number,
initialChapter: number = 1
): UseChaptersReturn {
const [chapters, setChapters] = useState<ChapterSummary[]>([]);
const [currentChapter, setCurrentChapter] = useState<ChapterDetail | null>(
null
);
const [currentChapterNumber, setCurrentChapterNumber] =
useState<number>(initialChapter);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
/**
* A hook that debounces a value by the specified delay.
* @param value - The value to debounce
* @param delay - The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
/**
* Hook for responsive design — returns true when the media query matches.
* Defaults to false on SSR / initial render to avoid hydration mismatch.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
export const BREAKPOINTS = {
sm: "(max-width: 480px)",
md: "(max-width: 768px)",
lg: "(min-width: 1024px)",
} as const;
+99
View File
@@ -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<void>;
/** 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<ReadingProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | 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 };
}
+96
View File
@@ -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<ReadingSettings>) => Promise<void>;
}
export function useReadingSettings(): UseReadingSettingsReturn {
const [settings, setSettings] = useState<ReadingSettings>(DEFAULT_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<ReadingSettings>) => {
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 };
}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface UseVoiceSearchResult {
isListening: boolean;
transcript: string;
isSupported: boolean;
hasError: boolean;
errorMessage: string | null;
startListening: () => void;
stopListening: () => void;
}
/**
* Hook for voice search using the Web Speech API.
* Returns a microphone control interface.
* Gracefully degrades when SpeechRecognition is unavailable.
*/
export function useVoiceSearch(): UseVoiceSearchResult {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState("");
const [isSupported, setIsSupported] = useState(false);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
// Check for SpeechRecognition support (standard + webkit prefix)
const SpeechRecognitionCtor =
(window as unknown as Record<string, unknown>).SpeechRecognition ??
(window as unknown as Record<string, unknown>).webkitSpeechRecognition;
if (typeof SpeechRecognitionCtor === "function") {
setIsSupported(true);
const recognition = new (SpeechRecognitionCtor as new () => SpeechRecognition)();
recognition.continuous = false;
recognition.interimResults = false;
recognition.lang = "en-US";
recognition.onresult = (event: SpeechRecognitionEvent) => {
const resultText = event.results[0]?.[0]?.transcript ?? "";
if (mountedRef.current) {
setTranscript(resultText);
setHasError(false);
setErrorMessage(null);
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (mountedRef.current) {
setHasError(true);
setErrorMessage(event.error);
setIsListening(false);
}
};
recognition.onend = () => {
if (mountedRef.current) {
setIsListening(false);
}
};
recognitionRef.current = recognition;
}
return () => {
mountedRef.current = false;
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
const startListening = useCallback(() => {
if (!recognitionRef.current) return;
setTranscript("");
setHasError(false);
setErrorMessage(null);
try {
recognitionRef.current.start();
setIsListening(true);
} catch {
// May throw if already started
setIsListening(false);
}
}, []);
const stopListening = useCallback(() => {
if (!recognitionRef.current) return;
recognitionRef.current.stop();
setIsListening(false);
}, []);
return {
isListening,
transcript,
isSupported,
hasError,
errorMessage,
startListening,
stopListening,
};
}
+84 -35
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { booksApi } from "../api/books"; import { booksApi } from "../api/books";
import type { BookDetail } from "../types/book"; import type { BookDetail } from "../types/book";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
export function BookDetailPage() { export function BookDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -9,6 +10,7 @@ export function BookDetailPage() {
const [book, setBook] = useState<BookDetail | null>(null); const [book, setBook] = useState<BookDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isMobile = useMediaQuery(BREAKPOINTS.md);
const loadBook = useCallback(async () => { const loadBook = useCallback(async () => {
if (!id) return; if (!id) return;
@@ -40,12 +42,42 @@ export function BookDetailPage() {
dnf: { bg: "#fef3c7", text: "#b45309" }, dnf: { bg: "#fef3c7", text: "#b45309" },
}; };
const containerStyle: React.CSSProperties = {
maxWidth: 720,
margin: "0 auto",
padding: isMobile ? 16 : 24,
minHeight: "100vh",
background: "#f8f9fa",
};
const backButtonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: isMobile ? "10px 16px" : "8px 16px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#374151",
fontSize: isMobile ? 15 : 14,
cursor: "pointer",
marginBottom: isMobile ? 16 : 24,
minHeight: 44,
};
if (loading) { if (loading) {
return ( return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: 24, minHeight: "100vh", background: "#f8f9fa" }}> <div style={containerStyle}>
<div style={{ height: 32, width: 80, background: "#e5e7eb", borderRadius: 6, marginBottom: 24 }} /> <div style={{ height: 32, width: 80, background: "#e5e7eb", borderRadius: 6, marginBottom: 24 }} />
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: isMobile ? 16 : 24, flexDirection: isMobile ? "column" : "row" }}>
<div style={{ width: 240, height: 360, background: "#e5e7eb", borderRadius: 12, flexShrink: 0 }} /> <div style={{
width: isMobile ? 140 : 240,
height: isMobile ? 210 : 360,
background: "#e5e7eb",
borderRadius: 12,
flexShrink: 0,
alignSelf: isMobile ? "center" : "flex-start",
}} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ height: 28, background: "#e5e7eb", borderRadius: 6, marginBottom: 12, width: "60%" }} /> <div style={{ height: 28, background: "#e5e7eb", borderRadius: 6, marginBottom: 12, width: "60%" }} />
<div style={{ height: 18, background: "#e5e7eb", borderRadius: 4, marginBottom: 8, width: "40%" }} /> <div style={{ height: 18, background: "#e5e7eb", borderRadius: 4, marginBottom: 8, width: "40%" }} />
@@ -61,13 +93,13 @@ export function BookDetailPage() {
if (error || !book) { if (error || !book) {
return ( return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: 24, minHeight: "100vh", background: "#f8f9fa" }}> <div style={containerStyle}>
<button onClick={() => navigate("/")} className="btn btn-secondary" style={{ marginBottom: 24 }}> Back to Library</button> <button onClick={() => navigate("/")} style={backButtonStyle}> Back to Library</button>
<div style={{ textAlign: "center", padding: "80px 20px" }}> <div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: 64, marginBottom: 16 }}>😕</div> <div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>😕</div>
<h2 style={{ fontSize: 20, color: "#1f2937", marginBottom: 8 }}>Book not found</h2> <h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>Book not found</h2>
<p style={{ color: "#6b7280", marginBottom: 20 }}>{error || "The book you're looking for doesn't exist or has been removed."}</p> <p style={{ color: "#6b7280", marginBottom: 20 }}>{error || "The book you're looking for doesn't exist or has been removed."}</p>
<button onClick={() => void loadBook()} className="btn" style={{ padding: "10px 24px" }}>Retry</button> <button onClick={() => void loadBook()} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>Retry</button>
</div> </div>
</div> </div>
); );
@@ -76,57 +108,56 @@ export function BookDetailPage() {
const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" }; const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
return ( return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: 24, minHeight: "100vh", background: "#f8f9fa" }}> <div style={containerStyle}>
{/* Back button */} {/* Back button */}
<button <button onClick={() => navigate("/")} style={backButtonStyle}>
onClick={() => navigate("/")} {isMobile ? "Back" : "Back to Library"}
style={{
display: "inline-flex", alignItems: "center", gap: 4, padding: "8px 16px",
borderRadius: 8, border: "1px solid #e5e7eb", background: "#fff",
color: "#374151", fontSize: 14, cursor: "pointer", marginBottom: 24,
}}
>
Back to Library
</button> </button>
{/* Book Detail */} {/* Book Detail */}
<div style={{ display: "flex", gap: 32, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: isMobile ? 20 : 32, flexDirection: isMobile ? "column" : "row" }}>
{/* Cover */} {/* Cover */}
<div style={{ flexShrink: 0 }}> <div style={{ flexShrink: 0, alignSelf: isMobile ? "center" : "flex-start" }}>
<div style={{ <div style={{
width: 240, height: 360, borderRadius: 12, overflow: "hidden", width: isMobile ? 160 : 240,
background: "#f0f0f0", display: "flex", alignItems: "center", justifyContent: "center", height: isMobile ? 240 : 360,
borderRadius: 12,
overflow: "hidden",
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 4px 20px rgba(0,0,0,0.1)", boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
}}> }}>
{book.cover_image {book.cover_image
? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> ? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: <span style={{ fontSize: 80 }}>📖</span>} : <span style={{ fontSize: isMobile ? 48 : 80 }}>📖</span>}
</div> </div>
</div> </div>
{/* Info */} {/* Info */}
<div style={{ flex: 1, minWidth: 240 }}> <div style={{ flex: 1, minWidth: 0 }}>
<h1 style={{ fontSize: 28, fontWeight: 700, color: "#1f2937", marginBottom: 8, lineHeight: 1.2 }}> <h1 style={{ fontSize: isMobile ? 22 : 28, fontWeight: 700, color: "#1f2937", marginBottom: 8, lineHeight: 1.2 }}>
{book.title} {book.title}
</h1> </h1>
{book.author && ( {book.author && (
<p style={{ fontSize: 18, color: "#4b5563", marginBottom: 6 }}> <p style={{ fontSize: isMobile ? 16 : 18, color: "#4b5563", marginBottom: 6 }}>
by {book.author} by {book.author}
</p> </p>
)} )}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16, marginTop: 12 }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16, marginTop: 12 }}>
<span style={{ background: sc.bg, color: sc.text, fontSize: 13, fontWeight: 600, padding: "4px 12px", borderRadius: 999 }}> <span style={{ background: sc.bg, color: sc.text, fontSize: 13, fontWeight: 600, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.reading_status_display} {book.reading_status_display}
</span> </span>
{book.genre && ( {book.genre && (
<span style={{ background: "#eef2ff", color: "#4f46e5", fontSize: 13, padding: "4px 12px", borderRadius: 999 }}> <span style={{ background: "#eef2ff", color: "#4f46e5", fontSize: 13, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.genre} {book.genre}
</span> </span>
)} )}
{book.total_pages > 0 && ( {book.total_pages > 0 && (
<span style={{ background: "#f3f4f6", color: "#6b7280", fontSize: 13, padding: "4px 12px", borderRadius: 999 }}> <span style={{ background: "#f3f4f6", color: "#6b7280", fontSize: 13, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.total_pages} pages {book.total_pages} pages
</span> </span>
)} )}
@@ -135,7 +166,7 @@ export function BookDetailPage() {
{book.description && ( {book.description && (
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 8 }}>Description</h3> <h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 8 }}>Description</h3>
<p style={{ fontSize: 15, color: "#4b5563", lineHeight: 1.7, whiteSpace: "pre-wrap" }}> <p style={{ fontSize: isMobile ? 15 : 15, color: "#4b5563", lineHeight: 1.7, whiteSpace: "pre-wrap" }}>
{book.description} {book.description}
</p> </p>
</div> </div>
@@ -148,10 +179,28 @@ export function BookDetailPage() {
</p> </p>
</div> </div>
{/* Mobile-only: open in reader if it's an ebook, or just navigate back */} {/* Mobile full-width back button */}
<div style={{ marginTop: 24, display: "none" }}> {isMobile && (
<button onClick={() => navigate("/")} className="btn btn-block">Back to Library</button> <div style={{ marginTop: 24 }}>
</div> <button
onClick={() => navigate("/")}
style={{
width: "100%",
padding: "14px 24px",
borderRadius: 10,
border: "none",
background: "#4f46e5",
color: "#fff",
fontSize: 15,
fontWeight: 600,
cursor: "pointer",
minHeight: 44,
}}
>
Back to Library
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+13
View File
@@ -0,0 +1,13 @@
.bookCard {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.bookCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
+320 -80
View File
@@ -4,6 +4,11 @@ import { booksApi } from "../api/books";
import type { BookListItem, BookSearchParams } from "../types/book"; import type { BookListItem, BookSearchParams } from "../types/book";
import { READING_STATUS_OPTIONS } from "../types/book"; import { READING_STATUS_OPTIONS } from "../types/book";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useDebounce } from "../hooks/useDebounce";
import { useVoiceSearch } from "../hooks/useVoiceSearch";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
import { SearchSuggestions } from "../components/search/SearchSuggestions";
import styles from "./Library.module.css";
interface FilterState { interface FilterState {
genre: string; genre: string;
@@ -11,14 +16,14 @@ interface FilterState {
reading_status: string; reading_status: string;
} }
function useDebounce<T>(value: T, delay: number): T { /** WCAG 2.1 minimum touch target */
const [debounced, setDebounced] = useState(value); const TOUCH_TARGET: React.CSSProperties = {
useEffect(() => { minHeight: 44,
const timer = setTimeout(() => setDebounced(value), delay); minWidth: 44,
return () => clearTimeout(timer); display: "inline-flex",
}, [value, delay]); alignItems: "center",
return debounced; justifyContent: "center",
} };
export function LibraryPage() { export function LibraryPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -33,9 +38,23 @@ export function LibraryPage() {
const [authors, setAuthors] = useState<string[]>([]); const [authors, setAuthors] = useState<string[]>([]);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 300); const debouncedSearch = useDebounce(searchQuery, 300);
const loadedRef = useRef(false); const loadedRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const isMobile = useMediaQuery(BREAKPOINTS.md);
// Voice search
const voiceSearch = useVoiceSearch();
// Sync voice transcript into search input
useEffect(() => {
if (voiceSearch.transcript && !voiceSearch.isListening) {
setSearchQuery(voiceSearch.transcript);
setShowSuggestions(false);
}
}, [voiceSearch.transcript, voiceSearch.isListening]);
// Load filter options once // Load filter options once
useEffect(() => { useEffect(() => {
@@ -79,114 +98,303 @@ export function LibraryPage() {
}, [debouncedSearch, filters, loadBooks]); }, [debouncedSearch, filters, loadBooks]);
const handleFilterChange = (key: keyof FilterState, value: string) => { const handleFilterChange = (key: keyof FilterState, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value })); setFilters((prev: FilterState) => ({ ...prev, [key]: value }));
}; };
const clearAllFilters = () => { const clearAllFilters = () => {
setSearchQuery(""); setSearchQuery("");
setFilters({ genre: "", author: "", reading_status: "" }); setFilters({ genre: "", author: "", reading_status: "" });
setShowFilters(false);
}; };
const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status; const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status;
const containerStyle: React.CSSProperties = {
maxWidth: 960,
margin: "0 auto",
padding: isMobile ? 12 : 16,
minHeight: "100vh",
background: "#f8f9fa",
};
const headerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: isMobile ? 12 : 20,
padding: isMobile ? "12px 0" : "16px 0",
borderBottom: "1px solid #e5e7eb",
flexWrap: "wrap",
gap: 8,
};
const searchContainerStyle: React.CSSProperties = {
position: "relative",
flex: 1,
};
return ( return (
<div style={{ maxWidth: 960, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}> <div style={containerStyle}>
{/* Header */} {/* Header */}
<header style={{ <header style={headerStyle}>
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 20, padding: "16px 0", borderBottom: "1px solid #e5e7eb", flexWrap: "wrap", gap: 8,
}}>
<div> <div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>Library</h1> <h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>Library</h1>
{!loading && <p style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>{totalCount} book{totalCount !== 1 ? "s" : ""}</p>} {!loading && (
<p style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>
{totalCount} book{totalCount !== 1 ? "s" : ""}
</p>
)}
</div> </div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: isMobile ? 4 : 8, flexWrap: "wrap", alignItems: "center" }}>
<button onClick={() => navigate("/add")} className="btn">+ Add Book</button> {isMobile ? (
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">Bookmarks</button> <>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">Settings</button> <button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Add Book"></button>
<button onClick={logout} className="btn btn-danger">Logout</button> <button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Bookmarks">🔖</button>
<button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Settings"></button>
<button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Logout">🚪</button>
</>
) : (
<>
<button onClick={() => navigate("/add")} className="btn">+ Add Book</button>
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">Bookmarks</button>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">Settings</button>
<button onClick={logout} className="btn btn-danger">Logout</button>
</>
)}
</div> </div>
</header> </header>
{/* Search Bar */} {/* Search Bar */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={{ flex: 1, position: "relative" }}> <div style={searchContainerStyle}>
<input <input
ref={searchInputRef}
type="text" type="text"
placeholder="Search by title, author, or genre..." placeholder="Search by title, author, or genre..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => {
setSearchQuery(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
style={{ style={{
width: "100%", padding: "12px 16px 12px 44px", borderRadius: 10, width: "100%",
border: "1px solid #e5e7eb", fontSize: 15, background: "#fff", padding: `12px 16px 12px ${voiceSearch.isSupported ? 44 : 44}px`,
outline: "none", boxSizing: "border-box", paddingRight: voiceSearch.isSupported ? 48 : 16,
borderRadius: 10,
border: "1px solid #e5e7eb",
fontSize: isMobile ? 16 : 15,
background: "#fff",
outline: "none",
boxSizing: "border-box",
minHeight: 44,
}} }}
/> />
<span style={{ <span style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)", position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
fontSize: 18, color: "#9ca3af", pointerEvents: "none", fontSize: 18, color: "#9ca3af", pointerEvents: "none",
}}>🔍</span> }}>🔍</span>
{/* Voice Search Button */}
{voiceSearch.isSupported && (
<button
onClick={() => {
if (voiceSearch.isListening) {
voiceSearch.stopListening();
} else {
voiceSearch.startListening();
}
}}
style={{
position: "absolute",
right: 8,
top: "50%",
transform: "translateY(-50%)",
background: voiceSearch.isListening ? "#dc2626" : "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
fontSize: 20,
padding: "8px 8px",
minWidth: 36,
minHeight: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: voiceSearch.isListening ? "#fff" : "#6b7280",
transition: "background 0.15s",
}}
title={voiceSearch.isListening ? "Stop listening" : "Search with voice"}
>
🎤
</button>
)}
{/* Real-time Suggestions */}
<SearchSuggestions
query={searchQuery}
visible={showSuggestions && !voiceSearch.isListening}
onClose={() => setShowSuggestions(false)}
onSelectSuggestion={() => setShowSuggestions(false)}
/>
</div> </div>
<button <button
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className={`btn ${showFilters ? "" : "btn-secondary"}`} style={{
title="Toggle filters" ...TOUCH_TARGET,
padding: "0 12px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: showFilters ? "#4f46e5" : "#fff",
color: showFilters ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
gap: 4,
}}
> >
{showFilters ? "▲ Filters" : "▼ Filters"} {isMobile ? "⚙️" : showFilters ? "▲ Filters" : "▼ Filters"}
</button> </button>
</div> </div>
</div> </div>
{/* Voice search listening indicator */}
{voiceSearch.isListening && (
<div style={{
background: "#fef2f2",
padding: "10px 16px",
borderRadius: 8,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 14,
color: "#dc2626",
}}>
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: "#dc2626", animation: "pulse 1s infinite" }} />
Listening... speak now
<button
onClick={voiceSearch.stopListening}
style={{
marginLeft: "auto",
background: "#dc2626",
color: "#fff",
border: "none",
borderRadius: 4,
padding: "4px 12px",
cursor: "pointer",
fontSize: 12,
}}
>
Stop
</button>
</div>
)}
{/* Voice search error */}
{voiceSearch.hasError && !voiceSearch.isListening && (
<div style={{
background: "#fef3c7",
padding: "8px 12px",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "#92400e",
}}>
Voice search: {voiceSearch.errorMessage === "no-speech" ? "No speech detected. Try again." : voiceSearch.errorMessage}
</div>
)}
{/* Filters Panel */} {/* Filters Panel */}
{showFilters && ( {showFilters && (
<div style={{ <div style={{
background: "#fff", borderRadius: 10, padding: 16, marginBottom: 16, background: "#fff",
border: "1px solid #e5e7eb", display: "flex", gap: 12, flexWrap: "wrap", alignItems: "end", borderRadius: 10,
padding: isMobile ? 12 : 16,
marginBottom: 16,
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 12,
alignItems: isMobile ? "stretch" : "end",
}}> }}>
<div style={{ minWidth: 160, flex: 1 }}> <div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Genre</label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Genre</label>
<select <select
value={filters.genre} value={filters.genre}
onChange={(e) => handleFilterChange("genre", e.target.value)} onChange={(e) => handleFilterChange("genre", e.target.value)}
style={{ style={{
width: "100%", padding: "8px 12px", borderRadius: 6, border: "1px solid #e5e7eb", width: "100%",
fontSize: 14, background: "#fff", cursor: "pointer", padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}} }}
> >
<option value="">All Genres</option> <option value="">All Genres</option>
{genres.map((g) => <option key={g} value={g}>{g}</option>)} {genres.map((g) => <option key={g} value={g}>{g}</option>)}
</select> </select>
</div> </div>
<div style={{ minWidth: 160, flex: 1 }}> <div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Author</label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Author</label>
<select <select
value={filters.author} value={filters.author}
onChange={(e) => handleFilterChange("author", e.target.value)} onChange={(e) => handleFilterChange("author", e.target.value)}
style={{ style={{
width: "100%", padding: "8px 12px", borderRadius: 6, border: "1px solid #e5e7eb", width: "100%",
fontSize: 14, background: "#fff", cursor: "pointer", padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}} }}
> >
<option value="">All Authors</option> <option value="">All Authors</option>
{authors.map((a) => <option key={a} value={a}>{a}</option>)} {authors.map((a) => <option key={a} value={a}>{a}</option>)}
</select> </select>
</div> </div>
<div style={{ minWidth: 160, flex: 1 }}> <div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Status</label> <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Status</label>
<select <select
value={filters.reading_status} value={filters.reading_status}
onChange={(e) => handleFilterChange("reading_status", e.target.value)} onChange={(e) => handleFilterChange("reading_status", e.target.value)}
style={{ style={{
width: "100%", padding: "8px 12px", borderRadius: 6, border: "1px solid #e5e7eb", width: "100%",
fontSize: 14, background: "#fff", cursor: "pointer", padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}} }}
> >
{READING_STATUS_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)} {READING_STATUS_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select> </select>
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
<button onClick={clearAllFilters} className="btn btn-secondary" style={{ whiteSpace: "nowrap" }}> <button
onClick={clearAllFilters}
style={{
...TOUCH_TARGET,
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#6b7280",
fontSize: 13,
cursor: "pointer",
whiteSpace: "nowrap",
width: isMobile ? "100%" : "auto",
}}
>
Clear Clear
</button> </button>
)} )}
@@ -197,16 +405,21 @@ export function LibraryPage() {
{error && ( {error && (
<div style={{ background: "#fef2f2", padding: 16, borderRadius: 8, marginBottom: 16, textAlign: "center" }}> <div style={{ background: "#fef2f2", padding: 16, borderRadius: 8, marginBottom: 16, textAlign: "center" }}>
<p style={{ color: "#dc2626", fontSize: 14 }}>{error}</p> <p style={{ color: "#dc2626", fontSize: 14 }}>{error}</p>
<button onClick={() => void loadBooks({})} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#dc2626", color: "#fff", cursor: "pointer", fontSize: 13 }}>Retry</button> <button onClick={() => void loadBooks({})} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#dc2626", color: "#fff", cursor: "pointer", fontSize: 13, minHeight: 36 }}>Retry</button>
</div> </div>
)} )}
{/* Loading State */} {/* Loading State */}
{loading && ( {loading && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 16, opacity: 0.6 }}> <div style={{
{Array.from({ length: 8 }).map((_, i) => ( display: "grid",
<div key={i} style={{ background: "#fff", borderRadius: 12, overflow: "hidden", boxShadow: "0 2px 8px rgba(0,0,0,0.06)" }}> gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
<div style={{ height: 180, background: "#f0f0f0" }} /> gap: isMobile ? 12 : 16,
opacity: 0.6,
}}>
{Array.from({ length: isMobile ? 4 : 8 }).map((_, i) => (
<div key={i} style={{ background: "#fff", borderRadius: 12, overflow: "hidden", boxShadow: "0 2px 8px rgba(0,0,0,0.06)", display: isMobile ? "flex" : "block" }}>
<div style={{ width: isMobile ? 80 : "100%", height: isMobile ? 120 : 180, background: "#f0f0f0", flexShrink: 0 }} />
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
<div style={{ height: 14, background: "#f0f0f0", borderRadius: 4, marginBottom: 6, width: "70%" }} /> <div style={{ height: 14, background: "#f0f0f0", borderRadius: 4, marginBottom: 6, width: "70%" }} />
<div style={{ height: 12, background: "#f0f0f0", borderRadius: 4, width: "40%" }} /> <div style={{ height: 12, background: "#f0f0f0", borderRadius: 4, width: "40%" }} />
@@ -218,9 +431,9 @@ export function LibraryPage() {
{/* Empty State */} {/* Empty State */}
{!loading && !error && books.length === 0 && ( {!loading && !error && books.length === 0 && (
<div style={{ textAlign: "center", padding: "80px 20px" }}> <div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: 64, marginBottom: 16 }}>{hasActiveFilters ? "🔍" : "📚"}</div> <div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>{hasActiveFilters ? "🔍" : "📚"}</div>
<h2 style={{ fontSize: 20, color: "#1f2937", marginBottom: 8 }}> <h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>
{hasActiveFilters ? "No books found" : "Your library is empty"} {hasActiveFilters ? "No books found" : "Your library is empty"}
</h2> </h2>
<p style={{ color: "#6b7280", marginBottom: 20, fontSize: 15, lineHeight: 1.5 }}> <p style={{ color: "#6b7280", marginBottom: 20, fontSize: 15, lineHeight: 1.5 }}>
@@ -229,11 +442,11 @@ export function LibraryPage() {
: "Add a book to get started building your collection."} : "Add a book to get started building your collection."}
</p> </p>
{hasActiveFilters ? ( {hasActiveFilters ? (
<button onClick={clearAllFilters} className="btn" style={{ padding: "10px 24px" }}> <button onClick={clearAllFilters} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
Clear All Filters Clear All Filters
</button> </button>
) : ( ) : (
<button onClick={() => navigate("/add")} className="btn" style={{ padding: "10px 24px" }}> <button onClick={() => navigate("/add")} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
Add Your First Book Add Your First Book
</button> </button>
)} )}
@@ -242,7 +455,11 @@ export function LibraryPage() {
{/* Results Grid */} {/* Results Grid */}
{!loading && books.length > 0 && ( {!loading && books.length > 0 && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 16 }}> <div style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
}}>
{books.map((book) => { {books.map((book) => {
const statusColors: Record<string, { bg: string; text: string }> = { const statusColors: Record<string, { bg: string; text: string }> = {
want_to_read: { bg: "#dbeafe", text: "#1d4ed8" }, want_to_read: { bg: "#dbeafe", text: "#1d4ed8" },
@@ -256,41 +473,64 @@ export function LibraryPage() {
<div <div
key={book.id} key={book.id}
onClick={() => navigate(`/books/${book.id}`)} onClick={() => navigate(`/books/${book.id}`)}
className={styles.bookCard}
style={{ style={{
background: "#fff", borderRadius: 12, overflow: "hidden", display: isMobile ? "flex" : "block",
boxShadow: "0 2px 8px rgba(0,0,0,0.06)", cursor: "pointer",
transition: "transform 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = "translateY(-2px)";
(e.currentTarget as HTMLElement).style.boxShadow = "0 4px 16px rgba(0,0,0,0.1)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = "";
(e.currentTarget as HTMLElement).style.boxShadow = "0 2px 8px rgba(0,0,0,0.06)";
}} }}
> >
<div style={{ height: 180, background: "#f0f0f0", display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}> <div style={{
width: isMobile ? 80 : "100%",
height: isMobile ? 120 : 180,
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}>
{book.cover_image {book.cover_image
? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> ? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: <span style={{ fontSize: 48 }}>📖</span>} : <span style={{ fontSize: isMobile ? 32 : 48 }}>📖</span>}
<span style={{ {!isMobile && (
position: "absolute", top: 8, right: 8, <span style={{
background: sc.bg, color: sc.text, fontSize: 11, fontWeight: 600, position: "absolute", top: 8, right: 8,
padding: "2px 8px", borderRadius: 999, lineHeight: "18px", background: sc.bg, color: sc.text, fontSize: 11, fontWeight: 600,
}}> padding: "2px 8px", borderRadius: 999, lineHeight: "18px",
{book.reading_status_display} }}>
</span> {book.reading_status_display}
</span>
)}
</div> </div>
<div style={{ padding: 12 }}> <div style={{ padding: isMobile ? "8px 12px" : 12, flex: 1 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: "#1f2937", marginBottom: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
{book.title} <div style={{ minWidth: 0, flex: 1 }}>
</h3> <h3 style={{
<p style={{ fontSize: 12, color: "#6b7280", marginBottom: 4 }}> fontSize: isMobile ? 14 : 14,
{book.author || "Unknown Author"} fontWeight: 600,
</p> color: "#1f2937",
marginBottom: 2,
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{book.title}
</h3>
<p style={{ fontSize: 12, color: "#6b7280", marginBottom: 4, margin: "2px 0" }}>
{book.author || "Unknown Author"}
</p>
</div>
{isMobile && (
<span style={{
background: sc.bg, color: sc.text, fontSize: 10, fontWeight: 600,
padding: "2px 6px", borderRadius: 999, whiteSpace: "nowrap", flexShrink: 0,
}}>
{book.reading_status_display}
</span>
)}
</div>
{book.genre && ( {book.genre && (
<span style={{ fontSize: 11, color: "#4f46e5", background: "#eef2ff", padding: "1px 6px", borderRadius: 4 }}> <span style={{ fontSize: 11, color: "#4f46e5", background: "#eef2ff", padding: "1px 6px", borderRadius: 4, display: "inline-block", marginTop: 4 }}>
{book.genre} {book.genre}
</span> </span>
)} )}
+220
View File
@@ -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<BookDetail | null>(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 (
<div className="reader-loading">
<div className="spinner" />
<p>Loading reader...</p>
</div>
);
}
if (error) {
return (
<div className="reader-loading">
<p className="reader-error">{error}</p>
<button type="button" className="back-button" onClick={() => navigate(`/books/${bookId}`)}>
Back to book
</button>
</div>
);
}
return (
<Suspense
fallback={
<div className="reader-loading">
<div className="spinner" />
</div>
}
>
<div className="reader-container">
<ReaderToolbar
bookTitle={book?.title ?? "Loading..."}
chapterTitle={currentChapter?.title ?? "Loading..."}
progress={progress}
onToggleToc={() => setTocOpen((v) => !v)}
onToggleSettings={() => setSettingsOpen((v) => !v)}
/>
<TableOfContents
chapters={chapters}
currentChapterNumber={currentChapterNumber}
isOpen={tocOpen}
onClose={() => setTocOpen(false)}
onNavigate={navigateToChapter}
/>
<ReadingSettingsPanel
settings={settings}
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
onUpdate={updateSettings}
/>
{/* Main reading area */}
<main className="reader-content">
{isLoading ? (
<div className="reader-loading">
<div className="spinner" />
<p>Loading chapter...</p>
</div>
) : currentChapter ? (
<article className="reader-chapter">
<h1 className="reader-chapter-title">{currentChapter.title}</h1>
<div
className="reader-chapter-body"
dangerouslySetInnerHTML={{
__html: sanitizedContent,
}}
/>
</article>
) : (
<div className="reader-loading">
<p>Select a chapter from the table of contents to start reading.</p>
</div>
)}
</main>
{/* Bottom navigation bar */}
<nav className="reader-nav">
<button
type="button"
className={`reader-nav-btn ${!hasPrevious ? "reader-nav-btn--disabled" : ""}`}
onClick={goToPreviousChapter}
disabled={!hasPrevious}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Previous
</button>
<button
type="button"
className="reader-nav-btn reader-nav-btn--back"
onClick={() => navigate("/")}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H4M10 18l-6-6 6-6" />
</svg>
Library
</button>
<button
type="button"
className={`reader-nav-btn ${!hasNext ? "reader-nav-btn--disabled" : ""}`}
onClick={goToNextChapter}
disabled={!hasNext}
>
Next
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</nav>
</div>
</Suspense>
);
}
+621
View File
@@ -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;
}
}
+44
View File
@@ -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;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Type declarations for the Web Speech API (SpeechRecognition).
* These are not part of the standard TypeScript DOM lib types.
* Install @types/dom-speech-recognition for full coverage.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
readonly isFinal: boolean;
readonly length: number;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
readonly transcript: string;
readonly confidence: number;
}
interface SpeechRecognitionErrorEvent extends Event {
readonly error: string;
readonly message: string;
}
interface SpeechRecognitionConstructor {
new (): SpeechRecognition;
}
interface Window {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { StatusBar } from "expo-status-bar";
import { AuthProvider } from "./src/context/AuthContext";
import { RootNavigator } from "./src/navigation/RootNavigator";
export default function App() {
return (
<AuthProvider>
<NavigationContainer>
<StatusBar style="auto" />
<RootNavigator />
</NavigationContainer>
</AuthProvider>
);
}
+28
View File
@@ -0,0 +1,28 @@
{
"expo": {
"name": "Cloud Reader",
"slug": "cloud-reader",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"backgroundColor": "#1a1a2e"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.cloudreader.app"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#1a1a2e"
},
"package": "com.cloudreader.app"
},
"plugins": [
"expo-document-picker",
"expo-file-system"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["react-native-reanimated/plugin"],
};
};
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@cloud-reader/mobile",
"version": "1.0.0",
"private": true,
"main": "expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint ."
},
"dependencies": {
"expo": "~52.0.0",
"expo-status-bar": "~2.0.0",
"react": "^19.0.0",
"react-native": "0.76.6",
"react-native-safe-area-context": "4.14.1",
"react-native-screens": "~4.4.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@react-navigation/bottom-tabs": "^7.0.0",
"axios": "^1.7.9",
"@react-native-async-storage/async-storage": "2.1.0",
"expo-document-picker": "~13.0.0",
"expo-file-system": "~18.0.0",
"react-native-gesture-handler": "~2.20.0",
"react-native-reanimated": "~3.16.0",
"@cloud-reader/shared": "*"
},
"devDependencies": {
"@types/react": "~19.0.0",
"typescript": "~5.7.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
import api from "./client";
import type {
Bookmark,
Note,
CreateBookmarkPayload,
CreateNotePayload,
PaginatedResponse,
} from "@cloud-reader/shared";
export function fetchBookmarks(
bookId?: string,
): Promise<PaginatedResponse<Bookmark>> {
const params = bookId ? { book: bookId } : {};
return api
.get<PaginatedResponse<Bookmark>>("/api/annotations/bookmarks/", { params })
.then((res) => res.data);
}
export function createBookmark(
payload: CreateBookmarkPayload,
): Promise<Bookmark> {
return api
.post<Bookmark>("/api/annotations/bookmarks/", payload)
.then((res) => res.data);
}
export function deleteBookmark(id: string): Promise<void> {
return api.delete(`/api/annotations/bookmarks/${id}/`).then(() => {});
}
export function fetchNotes(bookId?: string): Promise<PaginatedResponse<Note>> {
const params = bookId ? { book: bookId } : {};
return api
.get<PaginatedResponse<Note>>("/api/annotations/notes/", { params })
.then((res) => res.data);
}
export function createNote(payload: CreateNotePayload): Promise<Note> {
return api
.post<Note>("/api/annotations/notes/", payload)
.then((res) => res.data);
}
export function updateNote(
id: string,
content: string,
): Promise<Note> {
return api
.patch<Note>(`/api/annotations/notes/${id}/`, { content })
.then((res) => res.data);
}
export function deleteNote(id: string): Promise<void> {
return api.delete(`/api/annotations/notes/${id}/`).then(() => {});
}
+31
View File
@@ -0,0 +1,31 @@
import api from "./client";
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
export function fetchBooks(
page = 1,
pageSize = 20,
): Promise<PaginatedResponse<Book>> {
return api
.get<PaginatedResponse<Book>>("/api/books/", {
params: { page, page_size: pageSize },
})
.then((res) => res.data);
}
export function fetchBook(id: string): Promise<Book> {
return api.get<Book>(`/api/books/${id}/`).then((res) => res.data);
}
export function searchBooks(
query: string,
): Promise<PaginatedResponse<Book>> {
return api
.get<PaginatedResponse<Book>>("/api/books/search/", {
params: { q: query },
})
.then((res) => res.data);
}
export function deleteBook(id: string): Promise<void> {
return api.delete(`/api/books/${id}/`).then(() => {});
}
+140
View File
@@ -0,0 +1,140 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEYS = {
ACCESS_TOKEN: "access_token",
REFRESH_TOKEN: "refresh_token",
} as const;
interface RetryConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
const api = axios.create({
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:8000",
headers: {
"Content-Type": "application/json",
},
});
// ── Token helpers ────────────────────────────────────────────────────
async function getAccessToken(): Promise<string | null> {
return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
}
async function getRefreshToken(): Promise<string | null> {
return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
}
async function setTokens(access: string, refresh: string): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access);
await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh);
}
async function clearTokens(): Promise<void> {
await AsyncStorage.multiRemove([
STORAGE_KEYS.ACCESS_TOKEN,
STORAGE_KEYS.REFRESH_TOKEN,
]);
}
// ── Request interceptor ─────────────────────────────────────────────
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = await getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ── Response interceptor: auto-refresh on 401 ───────────────────────
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null = null): void {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else if (token) {
prom.resolve(token);
}
});
failedQueue = [];
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig | undefined;
if (!originalRequest) {
return Promise.reject(error);
}
if (
error.response?.status !== 401 ||
originalRequest._retry ||
originalRequest.url?.includes("/api/auth/token/refresh/") ||
originalRequest.url?.includes("/api/auth/login/") ||
originalRequest.url?.includes("/api/auth/register/") ||
originalRequest.url?.includes("/api/auth/logout/")
) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = await getRefreshToken();
if (!refreshToken) {
isRefreshing = false;
await clearTokens();
return Promise.reject(error);
}
try {
const response = await axios.post(
`${api.defaults.baseURL}/api/auth/token/refresh/`,
{ refresh: refreshToken },
);
const newAccess = response.data.access as string;
const newRefresh = response.data.refresh as string;
await setTokens(newAccess, newRefresh);
processQueue(null, newAccess);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
}
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
await clearTokens();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
export { getAccessToken, getRefreshToken, setTokens, clearTokens };
export default api;
+54
View File
@@ -0,0 +1,54 @@
import { apiClient } from "./client";
import type {
EBookListItem,
EBookDetail,
ReadingProgress,
ReadingSettings,
TocResponse,
ContentResponse,
PaginatedResponse,
} from "@cloud-reader/shared";
export const ebooksApi = {
/** List uploaded e-books */
list() {
return apiClient.get<PaginatedResponse<EBookListItem>>("/api/ebooks/");
},
/** Get e-book detail */
get(id: number) {
return apiClient.get<EBookDetail>(`/api/ebooks/${id}/`);
},
/** Get table of contents */
getToc(id: number) {
return apiClient.get<TocResponse>(`/api/ebooks/${id}/toc/`);
},
/** Get page content */
getContent(id: number, page: number) {
return apiClient.get<ContentResponse>(
`/api/ebooks/${id}/content/?page=${page}`,
);
},
/** Update reading progress */
updateProgress(id: number, data: Partial<ReadingProgress>) {
return apiClient.patch<ReadingProgress>(
`/api/ebooks/${id}/progress/`,
data,
);
},
/** Get or update reading settings */
getSettings(id: number) {
return apiClient.get<ReadingSettings>(`/api/ebooks/${id}/settings/`);
},
updateSettings(id: number, data: Partial<ReadingSettings>) {
return apiClient.patch<ReadingSettings>(
`/api/ebooks/${id}/settings/`,
data,
);
},
};
+4
View File
@@ -0,0 +1,4 @@
export { apiClient, saveTokens, loadTokens, clearTokens } from "./client";
export { booksApi } from "./books";
export { ebooksApi } from "./ebooks";
export { annotationsApi } from "./annotations";
+104
View File
@@ -0,0 +1,104 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import type { User, TokenResponse } from "@cloud-reader/shared";
import { apiClient, saveTokens, loadTokens, clearTokens } from "../api/client";
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
}
interface AuthContextValue extends AuthState {
login: (email: string, password: string) => Promise<void>;
register: (
email: string,
username: string,
password: string,
) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
isLoading: true,
isAuthenticated: false,
});
// Restore session on mount
useEffect(() => {
(async () => {
try {
const tokens = await loadTokens();
if (tokens?.access) {
const response = await apiClient.get<User>("/api/auth/profile/");
setState({
user: response.data,
isLoading: false,
isAuthenticated: true,
});
return;
}
} catch {
await clearTokens();
}
setState({ user: null, isLoading: false, isAuthenticated: false });
})();
}, []);
const login = useCallback(async (email: string, password: string) => {
const response = await apiClient.post<TokenResponse>(
"/api/auth/login/",
{ email, password },
);
await saveTokens(response.data);
const profile = await apiClient.get<User>("/api/auth/profile/");
setState({
user: profile.data,
isLoading: false,
isAuthenticated: true,
});
}, []);
const register = useCallback(
async (email: string, username: string, password: string) => {
await apiClient.post("/api/auth/register/", {
email,
username,
password,
password2: password,
});
// Auto-login after registration
await login(email, password);
},
[login],
);
const logout = useCallback(async () => {
await clearTokens();
setState({ user: null, isLoading: false, isAuthenticated: false });
}, []);
return (
<AuthContext.Provider value={{ ...state, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}
+2
View File
@@ -0,0 +1,2 @@
export { useAuth } from "../context/AuthContext";
export { useAsyncData } from "./useAsyncData";
+33
View File
@@ -0,0 +1,33 @@
import { useState, useEffect, useCallback } from "react";
/**
* Generic async data fetching hook for mobile screens.
*/
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: unknown[] = [],
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetcher();
setData(result);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
execute();
}, [execute]);
return { data, loading, error, refetch: execute };
}
+45
View File
@@ -0,0 +1,45 @@
import { type ReactNode } from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { useAuth } from "../context/AuthContext";
import LoginScreen from "../screens/LoginScreen";
import RegisterScreen from "../screens/RegisterScreen";
import MainTabs from "./MainTabs";
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
};
const RootStack = createNativeStackNavigator<RootStackParamList>();
const AuthStack = createNativeStackNavigator<AuthStackParamList>();
function AuthNavigator(): ReactNode {
return (
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="Register" component={RegisterScreen} />
</AuthStack.Navigator>
);
}
export default function AppNavigator(): ReactNode {
const { state } = useAuth();
return (
<NavigationContainer>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
{state.isAuthenticated ? (
<RootStack.Screen name="Main" component={MainTabs} />
) : (
<RootStack.Screen name="Auth" component={AuthNavigator} />
)}
</RootStack.Navigator>
</NavigationContainer>
);
}
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import LoginScreen from "../screens/LoginScreen";
import RegisterScreen from "../screens/RegisterScreen";
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
const Stack = createNativeStackNavigator<AuthStackParamList>();
export function AuthNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
}
+68
View File
@@ -0,0 +1,68 @@
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Text } from "react-native";
import LibraryScreen from "../screens/LibraryScreen";
import SearchScreen from "../screens/SearchScreen";
import SettingsScreen from "../screens/SettingsScreen";
export type MainTabParamList = {
Library: undefined;
Search: undefined;
Settings: undefined;
};
const Tab = createBottomTabNavigator<MainTabParamList>();
function TabIcon({ label, focused }: { label: string; focused: boolean }) {
const icons: Record<string, string> = {
Library: "📚",
Search: "🔍",
Settings: "⚙️",
};
return (
<Text style={{ fontSize: 22, opacity: focused ? 1 : 0.5 }}>
{icons[label] ?? "●"}
</Text>
);
}
export function MainNavigator() {
return (
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: "#fff" },
headerTitleStyle: { fontWeight: "600", color: "#1a1a2e" },
tabBarActiveTintColor: "#4a6cf7",
tabBarInactiveTintColor: "#999",
}}
>
<Tab.Screen
name="Library"
component={LibraryScreen}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="Library" focused={focused} />
),
}}
/>
<Tab.Screen
name="Search"
component={SearchScreen}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="Search" focused={focused} />
),
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon label="Settings" focused={focused} />
),
}}
/>
</Tab.Navigator>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { type ReactNode } from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Text } from "react-native";
import LibraryScreen from "../screens/LibraryScreen";
import SearchScreen from "../screens/SearchScreen";
import SettingsScreen from "../screens/SettingsScreen";
export type MainTabParamList = {
Library: undefined;
Search: undefined;
Settings: undefined;
};
const Tab = createBottomTabNavigator<MainTabParamList>();
function TabIcon({ label, focused }: { label: string; focused: boolean }) {
return (
<Text style={{ fontSize: 22, opacity: focused ? 1 : 0.5 }}>
{label === "Library" ? "📚" : label === "Search" ? "🔍" : "⚙️"}
</Text>
);
}
export default function MainTabs(): ReactNode {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused }: { focused: boolean }) => (
<TabIcon label={route.name} focused={focused} />
),
tabBarActiveTintColor: "#4f8ef7",
tabBarInactiveTintColor: "#888",
headerStyle: { backgroundColor: "#1a1a2e" },
headerTintColor: "#fff",
tabBarStyle: { backgroundColor: "#1a1a2e", borderTopColor: "#333" },
})}
>
<Tab.Screen
name="Library"
component={LibraryScreen}
options={{ title: "My Library" }}
/>
<Tab.Screen
name="Search"
component={SearchScreen}
options={{ title: "Search" }}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{ title: "Settings" }}
/>
</Tab.Navigator>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import { ActivityIndicator, View } from "react-native";
import { useAuth } from "../context/AuthContext";
import { AuthNavigator } from "./AuthNavigator";
import { MainNavigator } from "./MainNavigator";
export function RootNavigator() {
const { isLoading, isAuthenticated } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" color="#4a6cf7" />
</View>
);
}
if (!isAuthenticated) {
return <AuthNavigator />;
}
return <MainNavigator />;
}
+181
View File
@@ -0,0 +1,181 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { fetchBooks } from "../api/books";
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
export default function LibraryScreen({ navigation }: { navigation: any }): ReactNode {
const [books, setBooks] = useState<Book[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadBooks = useCallback(async (pageNum: number, isRefresh = false) => {
try {
const data: PaginatedResponse<Book> = await fetchBooks(pageNum);
if (isRefresh) {
setBooks(data.results);
} else {
setBooks((prev) => [...prev, ...data.results]);
}
setHasMore(data.next !== null);
setPage(pageNum);
} catch {
// Silent error for now
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadBooks(1, true);
}, [loadBooks]);
const onRefresh = () => {
setRefreshing(true);
loadBooks(1, true);
};
const loadMore = () => {
if (hasMore && !loading) {
loadBooks(page + 1);
}
};
const renderBook = ({ item }: { item: Book }) => (
<TouchableOpacity
style={styles.bookCard}
onPress={() =>
navigation.navigate("BookDetail", { bookId: item.id })
}
>
<View style={styles.bookCover}>
<Text style={styles.coverText}>
{item.title.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.bookInfo}>
<Text style={styles.bookTitle} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.bookAuthor} numberOfLines={1}>
{item.author}
</Text>
<Text style={styles.bookPages}>{item.total_pages} pages</Text>
</View>
</TouchableOpacity>
);
if (loading && books.length === 0) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={books}
renderItem={renderBook}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#4f8ef7"
/>
}
ListEmptyComponent={
<View style={styles.centered}>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
Add books to get started
</Text>
</View>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
},
list: {
padding: 16,
},
bookCard: {
flexDirection: "row",
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 12,
marginBottom: 12,
borderWidth: 1,
borderColor: "#333",
},
bookCover: {
width: 60,
height: 80,
backgroundColor: "#2a2a4e",
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
coverText: {
fontSize: 24,
fontWeight: "bold",
color: "#4f8ef7",
},
bookInfo: {
flex: 1,
marginLeft: 12,
justifyContent: "center",
},
bookTitle: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
marginBottom: 4,
},
bookAuthor: {
fontSize: 14,
color: "#888",
marginBottom: 4,
},
bookPages: {
fontSize: 12,
color: "#666",
},
emptyText: {
fontSize: 18,
fontWeight: "600",
color: "#888",
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: "#666",
},
});
+173
View File
@@ -0,0 +1,173 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { isValidEmail } from "@cloud-reader/shared";
export default function LoginScreen({ navigation }: { navigation: any }): ReactNode {
const { state, login, clearError } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleLogin = async () => {
clearError();
if (!email.trim()) {
Alert.alert("Validation Error", "Please enter your email.");
return;
}
if (!isValidEmail(email.trim())) {
Alert.alert("Validation Error", "Please enter a valid email address.");
return;
}
if (!password) {
Alert.alert("Validation Error", "Please enter your password.");
return;
}
try {
await login(email.trim(), password);
} catch {
// Error handled in context
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View style={styles.inner}>
<Text style={styles.title}>Cloud Reader</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
{state.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, state.isLoading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={state.isLoading}
>
{state.isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Register")}>
<Text style={styles.linkText}>
Don't have an account?{" "}
<Text style={styles.linkBold}>Sign Up</Text>
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
inner: {
flex: 1,
justifyContent: "center",
paddingHorizontal: 24,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
textAlign: "center",
marginBottom: 32,
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 8,
padding: 16,
fontSize: 16,
color: "#fff",
marginBottom: 12,
borderWidth: 1,
borderColor: "#333",
},
button: {
backgroundColor: "#4f8ef7",
borderRadius: 8,
padding: 16,
alignItems: "center",
marginTop: 8,
marginBottom: 24,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
errorBox: {
backgroundColor: "rgba(255, 69, 58, 0.15)",
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: "rgba(255, 69, 58, 0.3)",
},
errorText: {
color: "#ff453a",
fontSize: 14,
textAlign: "center",
},
linkText: {
color: "#888",
textAlign: "center",
fontSize: 14,
},
linkBold: {
color: "#4f8ef7",
fontWeight: "600",
},
});
+212
View File
@@ -0,0 +1,212 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import {
isValidEmail,
validatePasswordStrength,
} from "@cloud-reader/shared";
export default function RegisterScreen({
navigation,
}: {
navigation: any;
}): ReactNode {
const { state, register, clearError } = useAuth();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const handleRegister = async () => {
clearError();
if (!username.trim()) {
Alert.alert("Validation Error", "Please enter a username.");
return;
}
if (!email.trim()) {
Alert.alert("Validation Error", "Please enter your email.");
return;
}
if (!isValidEmail(email.trim())) {
Alert.alert("Validation Error", "Please enter a valid email address.");
return;
}
const passwordError = validatePasswordStrength(password);
if (passwordError) {
Alert.alert("Validation Error", passwordError);
return;
}
if (password !== confirmPassword) {
Alert.alert("Validation Error", "Passwords do not match.");
return;
}
try {
await register(email.trim(), password, username.trim());
} catch {
// Error handled in context
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView contentContainerStyle={styles.inner}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join Cloud Reader</Text>
{state.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Username"
placeholderTextColor="#666"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TextInput
style={styles.input}
placeholder="Confirm Password"
placeholderTextColor="#666"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, state.isLoading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={state.isLoading}
>
{state.isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Create Account</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.linkText}>
Already have an account?{" "}
<Text style={styles.linkBold}>Sign In</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
inner: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 48,
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
textAlign: "center",
marginBottom: 32,
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 8,
padding: 16,
fontSize: 16,
color: "#fff",
marginBottom: 12,
borderWidth: 1,
borderColor: "#333",
},
button: {
backgroundColor: "#4f8ef7",
borderRadius: 8,
padding: 16,
alignItems: "center",
marginTop: 8,
marginBottom: 24,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
errorBox: {
backgroundColor: "rgba(255, 69, 58, 0.15)",
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: "rgba(255, 69, 58, 0.3)",
},
errorText: {
color: "#ff453a",
fontSize: 14,
textAlign: "center",
},
linkText: {
color: "#888",
textAlign: "center",
fontSize: 14,
},
linkBold: {
color: "#4f8ef7",
fontWeight: "600",
},
});
+130
View File
@@ -0,0 +1,130 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from "react-native";
import { searchBooks } from "../api/books";
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
export default function SearchScreen(): ReactNode {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Book[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const handleSearch = async () => {
if (!query.trim()) return;
setLoading(true);
setSearched(true);
try {
const data: PaginatedResponse<Book> = await searchBooks(query.trim());
setResults(data.results);
} catch {
setResults([]);
} finally {
setLoading(false);
}
};
const renderBook = ({ item }: { item: Book }) => (
<View style={styles.resultCard}>
<Text style={styles.resultTitle}>{item.title}</Text>
<Text style={styles.resultAuthor}>{item.author}</Text>
</View>
);
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<TextInput
style={styles.input}
placeholder="Search books by title, author..."
placeholderTextColor="#666"
value={query}
onChangeText={setQuery}
onSubmitEditing={handleSearch}
returnKeyType="search"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{loading && (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
)}
{!loading && searched && results.length === 0 && (
<View style={styles.centered}>
<Text style={styles.noResults}>No books found for "{query}"</Text>
</View>
)}
<FlatList
data={results}
renderItem={renderBook}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
searchBar: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 8,
padding: 14,
fontSize: 16,
color: "#fff",
borderWidth: 1,
borderColor: "#333",
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
},
list: {
padding: 16,
},
resultCard: {
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 16,
marginBottom: 8,
borderWidth: 1,
borderColor: "#333",
},
resultTitle: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
marginBottom: 4,
},
resultAuthor: {
fontSize: 14,
color: "#888",
},
noResults: {
fontSize: 16,
color: "#888",
textAlign: "center",
},
});
+91
View File
@@ -0,0 +1,91 @@
import { type ReactNode } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function SettingsScreen(): ReactNode {
const { state, logout } = useAuth();
const handleLogout = () => {
Alert.alert("Logout", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: logout },
]);
};
return (
<View style={styles.container}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<View style={styles.infoRow}>
<Text style={styles.label}>Username</Text>
<Text style={styles.value}>{state.user?.username ?? "—"}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.label}>Email</Text>
<Text style={styles.value}>{state.user?.email ?? "—"}</Text>
</View>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
padding: 16,
},
section: {
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 16,
marginBottom: 24,
borderWidth: 1,
borderColor: "#333",
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
label: {
fontSize: 14,
color: "#888",
},
value: {
fontSize: 14,
color: "#fff",
fontWeight: "500",
},
logoutButton: {
backgroundColor: "rgba(255, 69, 58, 0.15)",
borderRadius: 8,
padding: 16,
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 69, 58, 0.3)",
},
logoutText: {
color: "#ff453a",
fontSize: 16,
fontWeight: "600",
},
});
+8
View File
@@ -0,0 +1,8 @@
// Mobile-specific type aliases and extensions not covered by shared types
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
BookReader: { bookId: number };
BookDetail: { bookId: number };
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+11 -2
View File
@@ -4,6 +4,15 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"frontend", "frontend",
"backend" "backend",
] "mobile",
"packages/shared"
],
"scripts": {
"dev:frontend": "yarn workspace @cloud-reader/frontend dev",
"dev:backend": "cd backend && python manage.py runserver",
"start:mobile": "yarn workspace @cloud-reader/mobile start",
"build:shared": "yarn workspace @cloud-reader/shared build",
"install:all": "yarn install"
}
} }
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@cloud-reader/shared",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"typescript": "~5.7.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./utils";
+218
View File
@@ -0,0 +1,218 @@
/** Core domain types for Cloud Reader — shared across web and mobile */
// ---- User & Auth ----
export interface User {
id: number;
email: string;
username: string;
}
export interface TokenResponse {
access: string;
refresh: string;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload {
email: string;
username: string;
password: string;
password2: string;
}
// ---- Books ----
export interface Book {
id: string;
title: string;
author: string;
total_pages: number;
cover_image: string;
created_at: string;
updated_at: string;
}
export interface BookSummary {
id: string;
title: string;
author: string;
total_pages: number;
cover_image: string;
}
export interface BookListItem {
id: number;
title: string;
author: string;
genre: string;
reading_status: string;
reading_status_display: string;
cover_image: string | null;
}
export interface BookDetail {
id: number;
title: string;
author: string;
genre: string;
description: string;
reading_status: string;
reading_status_display: string;
cover_image: string | null;
total_pages: number;
created_at: string;
updated_at: string;
}
export interface BookSearchParams {
q?: string;
genre?: string;
author?: string;
reading_status?: string;
ordering?: string;
page?: number;
page_size?: number;
}
export const READING_STATUS_OPTIONS = [
"to_read",
"reading",
"finished",
"dnf",
] as const;
// ---- E-Books ----
export interface EBookListItem {
id: number;
title: string;
author: string;
filename: string;
format: string;
page_count: number;
cover_image: string | null;
created_at: string;
progress: number | null;
}
export interface EBookDetail {
id: number;
title: string;
author: string;
filename: string;
file_url: string;
format: string;
page_count: number;
file_size: number;
metadata_json: Record<string, unknown>;
cover_image: string | null;
created_at: string;
updated_at: string;
progress: ReadingProgress | null;
}
export interface ReadingProgress {
current_position: number;
last_page: number;
}
export interface ReadingSettings {
font_size: number;
font_style: "sans-serif" | "serif" | "monospace";
background_color: string;
}
export interface BookChapter {
id: number;
title: string;
index: number;
href: string;
children: BookChapter[];
}
export interface TocResponse {
chapters: BookChapter[];
format: string;
page_count: number;
}
export interface ContentResponse {
page: number;
total_pages: number;
content: string;
chapter_title: string;
format: string;
}
// ---- Bookmarks & Notes ----
export interface Bookmark {
id: string;
book: string;
book_title: string;
page: number;
location_text: string;
created_at: string;
updated_at: string;
}
export interface Note {
id: string;
book: string;
book_title: string;
page: number;
location_text: string;
content: string;
created_at: string;
updated_at: string;
}
export interface CreateBookmarkPayload {
book: string;
page: number;
location_text?: string;
}
export interface CreateNotePayload {
book: string;
page: number;
location_text?: string;
content: string;
}
export interface UpdateNotePayload {
content: string;
}
export type AnnotationKind = "bookmark" | "note";
export interface AnnotationEntry {
id: string;
kind: AnnotationKind;
book_title: string;
book_id: string;
page: number;
location_text: string;
content?: string;
created_at: string;
updated_at: string;
}
// ---- Generic API shapes ----
export interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export interface ApiError {
detail?: string;
[key: string]: unknown;
}
+117
View File
@@ -0,0 +1,117 @@
/** Shared utility functions for Cloud Reader */
/**
* Format an ISO date string to a human-readable date.
*/
export function formatDate(iso: string): string {
const date = new Date(iso);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
/**
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
*/
export function formatRelativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return "just now";
}
/**
* Validate an email address format.
*/
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* Password strength check:
* - At least 8 characters
* - At least one uppercase letter
* - At least one lowercase letter
* - At least one digit
*/
export function isStrongPassword(password: string): boolean {
return (
password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/\d/.test(password)
);
}
/**
* Check if two passwords match.
*/
export function doPasswordsMatch(password: string, confirm: string): boolean {
return password === confirm;
}
/**
* Get a user-friendly reading status label.
*/
export function readingStatusLabel(status: string): string {
const labels: Record<string, string> = {
to_read: "To Read",
reading: "Reading",
finished: "Finished",
dnf: "Did Not Finish",
};
return labels[status] ?? status;
}
/**
* Format file size in bytes to a human-readable string.
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ---- API endpoint constants ----
export const API_ENDPOINTS = {
auth: {
login: "/api/auth/login/",
register: "/api/auth/register/",
tokenRefresh: "/api/auth/token/refresh/",
profile: "/api/auth/profile/",
},
books: {
list: "/api/books/",
detail: (id: number | string) => `/api/books/${id}/`,
genres: "/api/books/genres/",
authors: "/api/books/authors/",
},
ebooks: {
list: "/api/ebooks/",
detail: (id: number) => `/api/ebooks/${id}/`,
upload: "/api/ebooks/upload/",
toc: (id: number) => `/api/ebooks/${id}/toc/`,
content: (id: number, page: number) =>
`/api/ebooks/${id}/content/?page=${page}`,
progress: (id: number) => `/api/ebooks/${id}/progress/`,
settings: (id: number) => `/api/ebooks/${id}/settings/`,
},
annotations: {
list: "/api/annotations/",
bookmarks: "/api/annotations/bookmarks/",
notes: "/api/annotations/notes/",
bookmarkDetail: (id: string) => `/api/annotations/bookmarks/${id}/`,
noteDetail: (id: string) => `/api/annotations/notes/${id}/`,
},
} as const;
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}