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
Marko (Hermes Implementer) 670101b61e fix: add missing book API methods and types (searchBooks, getBook, getGenres, getAuthors) 2026-05-29 00:08:32 +00:00
markoandreid 332b539880 Implement: US: Book Search and Discovery (#13)
Reviewed and merged by Reid (Hermes Reviewer)

Co-authored-by: crisleo-hermes <hermes@codescripters.org>
Co-committed-by: crisleo-hermes <hermes@codescripters.org>
2026-05-26 06:21:32 +00:00
markoandreid f84a593c5d Implement: Refactor: Consolidate Duplicate Backend and Frontend Implementations (#11)
Reviewed and merged by Reid (Hermes Reviewer)

Co-authored-by: crisleo-hermes <hermes@codescripters.org>
Co-committed-by: crisleo-hermes <hermes@codescripters.org>
2026-05-26 03:54:25 +00:00
Marko (Hermes Implementer) 3b5b301e42 feat: bookmarks and notes management
- Backend: Django REST Framework API with Bookmark and Note models
  - ViewSets with user-scoped querysets and select_related for N+1 prevention
  - Create/List/Detail/Update/Delete endpoints
  - Batch delete operations
  - Unique constraint on user+book+page for bookmarks
  - IsOwner permission class for object-level access control
  - Full serializer validation (page > 0, non-empty content, duplicate check)
  - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases

- Frontend: React TypeScript components
  - AnnotationsContext with useReducer for state management
  - BookmarkList, NoteList, AddAnnotationForm, AnnotationsDashboard
  - Inline note editing with immediate save
  - Batch delete support
  - API client with JWT auto-refresh interceptors
  - Paginated query hook for infinite scroll support
  - Responsive CSS with loading/empty states

- Infrastructure: Django project with custom User model, JWT auth, CORS
  - PostgreSQL database models with proper FK and indexes
  - Django admin configuration for all models
2026-05-26 00:50:06 +00:00
164 changed files with 10319 additions and 3718 deletions
-34
View File
@@ -1,34 +0,0 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
*.egg
.venv/
venv/
*.sqlite3
# Node
node_modules/
dist/
build/
.yarn/
yarn-error.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.production
# Build artifacts (generated)
frontend/dist/
+90
View File
@@ -0,0 +1,90 @@
# Cloud Reader
A full-stack e-book reader application with cross-device sync. Upload EPUB/PDF files, track reading progress, bookmark passages, take notes, and customize your reading experience.
## Architecture
```
cloud-reader/
├── backend/ # Django REST API (canonical backend)
│ ├── config/ # Django project settings
│ ├── apps/
│ │ ├── users/ # User auth (JWT)
│ │ ├── books/ # Books, e-books, reading progress, settings
│ │ └── annotations/ # Bookmarks and notes
│ ├── manage.py
│ └── requirements.txt
├── frontend/ # React + Vite + TypeScript (web frontend)
│ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh)
│ │ ├── components/ # Reusable components
│ │ ├── context/ # Auth and annotations context
│ │ ├── hooks/ # Custom hooks
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
│ │ └── types/ # TypeScript type definitions
│ └── 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
```
## Quick Start
### Docker (recommended)
```bash
docker compose up --build
```
- **Frontend:** http://localhost:5173
- **Backend API:** http://localhost:8000/api/
### Backend (standalone)
```bash
cd backend
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
### Frontend (standalone)
```bash
cd frontend
yarn install
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
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.
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
- `api/` and `web/` directories removed.
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
+8
View File
@@ -0,0 +1,8 @@
# Backend environment (example never commit real secrets)
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
DJANGO_DEBUG=True
DB_NAME=cloud_reader
DB_USER=postgres
DB_PASSWORD=postgres
DB_HOST=localhost
DB_PORT=5432
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . ./
RUN mkdir -p media
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+19
View File
@@ -0,0 +1,19 @@
from django.contrib import admin
from apps.annotations.models import Bookmark, Note
@admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin):
list_display = ("user", "book", "page", "created_at")
list_select_related = ("user", "book")
search_fields = ("user__email", "book__title", "location_text")
list_filter = ("created_at",)
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ("user", "book", "page", "created_at", "updated_at")
list_select_related = ("user", "book")
search_fields = ("user__email", "book__title", "content", "location_text")
list_filter = ("created_at",)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AnnotationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.annotations"
label = "annotations"
+84
View File
@@ -0,0 +1,84 @@
import uuid
from django.conf import settings
from django.db import models
class Bookmark(models.Model):
"""A saved location in a book that the user can return to."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bookmarks",
db_index=True,
)
book = models.ForeignKey(
"books.Book",
on_delete=models.CASCADE,
related_name="bookmarks",
db_index=True,
)
page = models.PositiveIntegerField()
location_text = models.TextField(
blank=True,
default="",
help_text="The selected passage text at this location",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "annotations_bookmark"
verbose_name = "Bookmark"
verbose_name_plural = "Bookmarks"
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "book", "page"],
name="uq_bookmark_user_book_page",
)
]
def __str__(self) -> str:
return f"{self.user} @ {self.book} p.{self.page}"
class Note(models.Model):
"""A user-written note attached to a specific location in a book."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notes",
db_index=True,
)
book = models.ForeignKey(
"books.Book",
on_delete=models.CASCADE,
related_name="notes",
db_index=True,
)
page = models.PositiveIntegerField()
location_text = models.TextField(
blank=True,
default="",
help_text="The selected passage text this note refers to",
)
content = models.TextField(
help_text="The note body content"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "annotations_note"
verbose_name = "Note"
verbose_name_plural = "Notes"
ordering = ["-created_at"]
def __str__(self) -> str:
preview = self.content[:50]
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
+8
View File
@@ -0,0 +1,8 @@
from rest_framework import permissions
class IsOwner(permissions.BasePermission):
"""Grant access only if the requesting user owns the object."""
def has_object_permission(self, request, view, obj) -> bool:
return obj.user == request.user
+108
View File
@@ -0,0 +1,108 @@
from rest_framework import serializers
from apps.annotations.models import Bookmark, Note
class BookmarkSerializer(serializers.ModelSerializer):
"""Serialize Bookmark data with full details."""
book_title = serializers.CharField(source="book.title", read_only=True)
class Meta:
model = Bookmark
fields = [
"id",
"book",
"book_title",
"page",
"location_text",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
def validate_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be a positive integer.")
return value
class BookmarkCreateSerializer(serializers.ModelSerializer):
"""Serializer used for creating bookmarks. Sets user from request context."""
class Meta:
model = Bookmark
fields = ["book", "page", "location_text"]
def validate_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be a positive integer.")
return value
def validate(self, attrs):
user = self.context["request"].user
if Bookmark.objects.filter(
user=user, book=attrs["book"], page=attrs["page"]
).exists():
raise serializers.ValidationError(
{"page": "A bookmark already exists at this page for this book."}
)
return attrs
def create(self, validated_data):
validated_data["user"] = self.context["request"].user
return super().create(validated_data)
class NoteSerializer(serializers.ModelSerializer):
"""Serialize Note data with full details."""
book_title = serializers.CharField(source="book.title", read_only=True)
class Meta:
model = Note
fields = [
"id",
"book",
"book_title",
"page",
"location_text",
"content",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
def validate_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be a positive integer.")
return value
def validate_content(self, value: str) -> str:
stripped = value.strip()
if not stripped:
raise serializers.ValidationError("Note content cannot be empty.")
return stripped
class NoteCreateSerializer(serializers.ModelSerializer):
"""Serializer used for creating notes. Sets user from request context."""
class Meta:
model = Note
fields = ["book", "page", "location_text", "content"]
def validate_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be a positive integer.")
return value
def validate_content(self, value: str) -> str:
stripped = value.strip()
if not stripped:
raise serializers.ValidationError("Note content cannot be empty.")
return stripped
def create(self, validated_data):
validated_data["user"] = self.context["request"].user
return super().create(validated_data)
+331
View File
@@ -0,0 +1,331 @@
"""Tests for the annotations app Bookmarks & Notes API."""
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.annotations.models import Bookmark, Note
from apps.books.models import Book
from apps.users.models import User
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def api_client() -> APIClient:
return APIClient()
@pytest.fixture
def user() -> User:
return User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123",
)
@pytest.fixture
def other_user() -> User:
return User.objects.create_user(
username="other",
email="other@example.com",
password="testpass123",
)
@pytest.fixture
def auth_client(api_client: APIClient, user: User) -> APIClient:
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def book() -> Book:
return Book.objects.create(
title="Test Book",
author="Test Author",
total_pages=300,
)
@pytest.fixture
def bookmark(auth_client, user: User, book: Book) -> Bookmark:
return Bookmark.objects.create(
user=user,
book=book,
page=42,
location_text="important passage",
)
@pytest.fixture
def note(auth_client, user: User, book: Book) -> Note:
return Note.objects.create(
user=user,
book=book,
page=15,
location_text="highlighted section",
content="This is my note about this section.",
)
# ---------------------------------------------------------------------------
# Bookmark tests
# ---------------------------------------------------------------------------
class TestBookmarkList:
url = reverse("bookmark-list")
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
response = api_client.get(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_returns_user_bookmarks_only(
self, auth_client: APIClient, user: User, other_user: User, book: Book
):
Bookmark.objects.create(user=user, book=book, page=1)
Bookmark.objects.create(user=other_user, book=book, page=2)
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
results = response.data["results"]
assert len(results) == 1
assert results[0]["page"] == 1
def test_list_returns_empty_when_no_bookmarks(
self, auth_client: APIClient
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 0
def test_list_orders_by_newest_first(
self, auth_client: APIClient, user: User, book: Book
):
b1 = Bookmark.objects.create(user=user, book=book, page=1)
b2 = Bookmark.objects.create(user=user, book=book, page=2)
response = auth_client.get(self.url)
results = response.data["results"]
assert results[0]["page"] == 2
assert results[1]["page"] == 1
def test_list_includes_book_title(
self, auth_client: APIClient, bookmark: Bookmark
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["book_title"] == "Test Book"
class TestBookmarkCreate:
url = reverse("bookmark-list")
def test_create_bookmark(self, auth_client: APIClient, book: Book):
data = {"book": str(book.id), "page": 10, "location_text": "key insight"}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["page"] == 10
def test_create_bookmark_without_location_text(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 5}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["page"] == 5
def test_duplicate_bookmark_page_is_rejected(
self, auth_client: APIClient, bookmark: Bookmark
):
data = {"book": str(bookmark.book.id), "page": bookmark.page}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_user_cannot_create(
self, api_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 10}
response = api_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_invalid_page_rejected(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 0}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
class TestBookmarkDetail:
def test_get_bookmark(
self, auth_client: APIClient, bookmark: Bookmark
):
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["page"] == bookmark.page
def test_cannot_access_other_users_bookmark(
self, api_client: APIClient, other_user: User, bookmark: Bookmark
):
api_client.force_authenticate(user=other_user)
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = api_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestBookmarkDelete:
def test_delete_bookmark(
self, auth_client: APIClient, bookmark: Bookmark
):
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = auth_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert Bookmark.objects.count() == 0
def test_cannot_delete_other_users_bookmark(
self, api_client: APIClient, other_user: User, bookmark: Bookmark
):
api_client.force_authenticate(user=other_user)
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestBookmarkFilterByBook:
def test_filter_by_book(
self, auth_client: APIClient, user: User, book: Book
):
other_book = Book.objects.create(title="Other", author="Other")
Bookmark.objects.create(user=user, book=book, page=1)
Bookmark.objects.create(user=user, book=other_book, page=2)
url = reverse("bookmark-list")
response = auth_client.get(url, {"book": str(book.id)})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["page"] == 1
# ---------------------------------------------------------------------------
# Note tests
# ---------------------------------------------------------------------------
class TestNoteList:
url = reverse("note-list")
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
response = api_client.get(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_returns_user_notes_only(
self, auth_client: APIClient, user: User, other_user: User, book: Book
):
Note.objects.create(user=user, book=book, page=1, content="My note")
Note.objects.create(user=other_user, book=book, page=2, content="Other's note")
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
results = response.data["results"]
assert len(results) == 1
assert results[0]["content"] == "My note"
def test_list_includes_book_title(
self, auth_client: APIClient, note: Note
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["book_title"] == "Test Book"
class TestNoteCreate:
url = reverse("note-list")
def test_create_note(self, auth_client: APIClient, book: Book):
data = {
"book": str(book.id),
"page": 20,
"location_text": "interesting part",
"content": "This is a thoughtful note.",
}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["content"] == "This is a thoughtful note."
def test_create_note_without_location_text(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": "A note."}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_empty_content_rejected(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": " "}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_user_cannot_create(
self, api_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": "Note"}
response = api_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestNoteUpdate:
def test_update_note_content(
self, auth_client: APIClient, note: Note
):
url = reverse("note-detail", args=[str(note.id)])
data = {"content": "Updated note content."}
response = auth_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["content"] == "Updated note content."
def test_cannot_update_other_users_note(
self, api_client: APIClient, other_user: User, note: Note
):
api_client.force_authenticate(user=other_user)
url = reverse("note-detail", args=[str(note.id)])
data = {"content": "Hacked!"}
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestNoteDelete:
def test_delete_note(self, auth_client: APIClient, note: Note):
url = reverse("note-detail", args=[str(note.id)])
response = auth_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert Note.objects.count() == 0
def test_batch_delete_notes(
self, auth_client: APIClient, user: User, book: Book
):
n1 = Note.objects.create(user=user, book=book, page=1, content="A")
n2 = Note.objects.create(user=user, book=book, page=2, content="B")
url = reverse("note-batch-delete")
response = auth_client.delete(url, {"ids": [str(n1.id), str(n2.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["deleted"] == 2
class TestNoteFilterByBook:
def test_filter_by_book(
self, auth_client: APIClient, user: User, book: Book
):
other_book = Book.objects.create(title="Other", author="Other")
Note.objects.create(user=user, book=book, page=1, content="In book")
Note.objects.create(user=user, book=other_book, page=2, content="In other")
url = reverse("note-list")
response = auth_client.get(url, {"book": str(book.id)})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["content"] == "In book"
+12
View File
@@ -0,0 +1,12 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.annotations.views import BookmarkViewSet, NoteViewSet
router = DefaultRouter()
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
router.register(r"notes", NoteViewSet, basename="note")
urlpatterns = [
path("", include(router.urls)),
]
+93
View File
@@ -0,0 +1,93 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.annotations.models import Bookmark, Note
from apps.annotations.permissions import IsOwner
from apps.annotations.serializers import (
BookmarkCreateSerializer,
BookmarkSerializer,
NoteCreateSerializer,
NoteSerializer,
)
class BookmarkViewSet(viewsets.ModelViewSet):
"""CRUD for user bookmarks. Users can only manage their own bookmarks."""
permission_classes = [IsAuthenticated, IsOwner]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["book"]
search_fields = ["location_text"]
ordering_fields = ["created_at", "page"]
ordering = ["-created_at"]
def get_serializer_class(self):
if self.action == "create":
return BookmarkCreateSerializer
return BookmarkSerializer
def get_queryset(self):
return Bookmark.objects.filter(user=self.request.user).select_related(
"book"
)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
@action(detail=False, methods=["delete"], url_path="batch-delete")
def batch_delete(self, request):
"""Delete multiple bookmarks by id list."""
ids = request.data.get("ids", [])
if not ids:
return Response(
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
)
deleted, _ = Bookmark.objects.filter(
id__in=ids, user=request.user
).delete()
return Response(
{"deleted": deleted}, status=status.HTTP_200_OK
)
class NoteViewSet(viewsets.ModelViewSet):
"""CRUD for user notes. Users can only manage their own notes."""
permission_classes = [IsAuthenticated, IsOwner]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["book"]
search_fields = ["content", "location_text"]
ordering_fields = ["created_at", "page"]
ordering = ["-created_at"]
def get_serializer_class(self):
if self.action == "create":
return NoteCreateSerializer
return NoteSerializer
def get_queryset(self):
return Note.objects.filter(user=self.request.user).select_related(
"book"
)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
@action(detail=False, methods=["delete"], url_path="batch-delete")
def batch_delete(self, request):
"""Delete multiple notes by id list."""
ids = request.data.get("ids", [])
if not ids:
return Response(
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
)
deleted, _ = Note.objects.filter(
id__in=ids, user=request.user
).delete()
return Response(
{"deleted": deleted}, status=status.HTTP_200_OK
)
+9
View File
@@ -0,0 +1,9 @@
from django.contrib import admin
from apps.books.models import Book
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "author", "total_pages", "created_at")
search_fields = ("title", "author")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.books"
label = "books"
@@ -0,0 +1,96 @@
# Generated by Django 5.1.7 on 2026-05-26 03:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(db_index=True, max_length=512)),
('author', models.CharField(blank=True, db_index=True, default='', max_length=256)),
('genre', models.CharField(blank=True, db_index=True, default='', max_length=128)),
('description', models.TextField(blank=True, default='')),
('reading_status', models.CharField(choices=[('want_to_read', 'Want to Read'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], db_index=True, default='want_to_read', max_length=20)),
('total_pages', models.PositiveIntegerField(default=0)),
('cover_image', models.URLField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Book',
'verbose_name_plural': 'Books',
'db_table': 'books_book',
'ordering': ['title'],
'indexes': [models.Index(fields=['title', 'author', 'genre'], name='books_book_title_9fddc2_idx')],
},
),
migrations.CreateModel(
name='EBook',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('author', models.CharField(blank=True, default='', max_length=256)),
('file', models.FileField(upload_to='ebooks/%Y/%m/%d/')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='ebook_covers/%Y/%m/%d/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'E-Book',
'verbose_name_plural': 'E-Books',
'db_table': 'books_ebook',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ReadingProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('current_position', models.FloatField(default=0.0)),
('last_page', models.IntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('ebook', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'reading progress',
'db_table': 'books_reading_progress',
},
),
migrations.CreateModel(
name='ReadingSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('font_size', models.IntegerField(default=18)),
('font_style', models.CharField(choices=[('sans-serif', 'Sans Serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='sans-serif', max_length=20)),
('background_color', models.CharField(choices=[('#ffffff', 'White'), ('#f4e4c1', 'Sepia'), ('#1a1a2e', 'Dark'), ('#c7edcc', 'Green')], default='#ffffff', max_length=7)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_settings', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'reading settings',
'db_table': 'books_reading_settings',
},
),
migrations.AddIndex(
model_name='ebook',
index=models.Index(fields=['user', '-created_at'], name='books_ebook_user_id_0b6bdb_idx'),
),
migrations.AlterUniqueTogether(
name='readingprogress',
unique_together={('user', 'ebook')},
),
]
+155
View File
@@ -0,0 +1,155 @@
from django.conf import settings
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from pathlib import Path
class ReadingStatus(models.TextChoices):
WANT_TO_READ = "want_to_read", "Want to Read"
READING = "reading", "Reading"
FINISHED = "finished", "Finished"
DNF = "dnf", "Did Not Finish"
class Book(models.Model):
title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
genre = models.CharField(max_length=128, blank=True, default="", db_index=True)
description = models.TextField(blank=True, default="")
reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True)
total_pages = models.PositiveIntegerField(default=0)
cover_image = models.URLField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "books_book"
verbose_name = "Book"
verbose_name_plural = "Books"
ordering = ["title"]
indexes = [models.Index(fields=["title", "author", "genre"])]
def __str__(self):
return self.title
class EBook(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
format = models.CharField(max_length=20, blank=True, default="", editable=False)
page_count = models.PositiveIntegerField(default=0)
file_size = models.BigIntegerField(default=0)
metadata_json = models.JSONField(blank=True, default=dict)
file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "books_ebook"
verbose_name = "E-Book"
verbose_name_plural = "E-Books"
ordering = ["-created_at"]
indexes = [models.Index(fields=["user", "-created_at"])]
def __str__(self):
return self.title
def filename(self):
return Path(self.file.name).name if self.file else ""
class BookChapter(models.Model):
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters")
title = models.CharField(max_length=512)
index = models.IntegerField(default=0)
href = models.CharField(max_length=1024, blank=True, default="")
children = models.JSONField(blank=True, default=list)
class Meta:
db_table = "books_book_chapter"
verbose_name = "Book Chapter"
verbose_name_plural = "Book Chapters"
ordering = ["index"]
indexes = [models.Index(fields=["ebook", "index"])]
def __str__(self):
return f"{self.ebook.title} - {self.title}"
@receiver(post_delete, sender=EBook)
def _auto_delete_ebook_file(sender, instance, **kwargs):
if instance.file:
instance.file.delete(save=False)
if instance.cover_image:
instance.cover_image.delete(save=False)
class DownloadRecord(models.Model):
"""Tracks book downloads for offline access management."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records")
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records")
file_size = models.BigIntegerField(default=0)
downloaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "books_download_record"
verbose_name = "Download Record"
verbose_name_plural = "Download Records"
ordering = ["-downloaded_at"]
unique_together = [("user", "ebook")]
def __str__(self):
return f"{self.user} - {self.ebook.title}"
class ReadingProgress(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress")
ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
current_position = models.FloatField(default=0.0)
last_page = models.IntegerField(default=0)
device_id = models.CharField(max_length=128, blank=True, default="")
device_name = models.CharField(max_length=128, blank=True, default="")
version = models.PositiveIntegerField(default=1)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "books_reading_progress"
verbose_name_plural = "reading progress"
unique_together = [("user", "ebook")]
def __str__(self):
return f"{self.ebook.title} - {self.current_position:.1f}%"
def update_with_sync(self, position: float, last_page: int,
device_id: str, device_name: str,
client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]:
"""Update progress with conflict resolution (last-write-wins by timestamp).
Returns (instance, applied) where applied is True if the update was applied.
"""
if client_updated_at and self.updated_at:
try:
from django.utils.timezone import is_naive, make_aware
from datetime import datetime
client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00"))
if is_naive(client_dt):
client_dt = make_aware(client_dt)
if client_dt <= self.updated_at:
return self, False
except (ValueError, TypeError):
pass
self.current_position = position
self.last_page = last_page
self.device_id = device_id
self.device_name = device_name
self.version += 1
self.save(update_fields=[
"current_position", "last_page",
"device_id", "device_name", "version", "updated_at",
])
return self, True
+166
View File
@@ -0,0 +1,166 @@
from rest_framework import serializers
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):
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
class Meta:
model = Book
fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"]
class BookDetailSerializer(serializers.ModelSerializer):
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
class Meta:
model = Book
fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class EBookListSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
progress = serializers.SerializerMethodField()
class Meta:
model = EBook
fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "cover_image", "created_at", "progress"]
def get_progress(self, obj):
try:
return obj.reading_progress.current_position
except ReadingProgress.DoesNotExist:
return None
class EBookDetailSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField()
class Meta:
model = EBook
fields = ["id", "title", "author", "filename", "format", "page_count", "file_size", "file_url", "cover_image", "created_at", "updated_at", "progress"]
def get_file_url(self, obj):
request = self.context.get("request")
if request and obj.file:
return request.build_absolute_uri(obj.file.url)
return ""
def get_progress(self, obj):
try:
rp = obj.reading_progress
return {"current_position": rp.current_position, "last_page": rp.last_page}
except ReadingProgress.DoesNotExist:
return None
class EBookUploadSerializer(serializers.ModelSerializer):
class Meta:
model = EBook
fields = ["title", "author", "file", "cover_image"]
extra_kwargs = {"title": {"required": True}, "file": {"required": True}}
def validate_file(self, value):
import os
if value is None:
return value
ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower()
if ext not in (".epub", ".pdf"):
raise serializers.ValidationError("Only EPUB and PDF files are supported.")
return value
def create(self, validated_data):
validated_data["user"] = self.context["request"].user
# Auto-detect format from file extension
import os
name = str(getattr(validated_data.get("file"), "name", ""))
ext = os.path.splitext(name)[1].lower().lstrip(".")
if ext:
validated_data["format"] = ext
return super().create(validated_data)
class ReadingProgressSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingProgress
fields = ["current_position", "last_page", "device_id", "device_name", "version", "updated_at"]
read_only_fields = ["version", "updated_at"]
extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}}
def validate_current_position(self, value):
if value < 0.0 or value > 100.0:
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
return value
class DownloadRecordSerializer(serializers.ModelSerializer):
ebook_id = serializers.IntegerField(source="ebook.id", read_only=True)
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
author = serializers.CharField(source="ebook.author", read_only=True)
filename = serializers.SerializerMethodField()
cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True)
format = serializers.CharField(source="ebook.format", read_only=True)
progress = serializers.SerializerMethodField()
file_url = serializers.SerializerMethodField()
class Meta:
model = DownloadRecord
fields = [
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
"file_size", "cover_image", "format", "downloaded_at", "progress",
]
def get_filename(self, obj):
return obj.ebook.filename()
def get_file_url(self, obj):
request = self.context.get("request")
if request and obj.ebook.file:
return request.build_absolute_uri(obj.ebook.file.url)
return ""
def get_progress(self, obj):
try:
rp = obj.ebook.reading_progress
return {"current_position": rp.current_position, "last_page": rp.last_page}
except ReadingProgress.DoesNotExist:
return None
class StorageSummarySerializer(serializers.Serializer):
total_downloads = serializers.IntegerField()
total_size_bytes = serializers.IntegerField()
ebooks = serializers.ListField(child=serializers.DictField())
+255
View File
@@ -0,0 +1,255 @@
"""Tests for the Book search & discovery endpoints."""
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.books.models import Book, ReadingStatus
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user(django_user_model):
return django_user_model.objects.create_user(
email="reader@example.com",
password="testpass123",
)
@pytest.fixture
def auth_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def books():
books_data = [
Book.objects.create(
title="Dune",
author="Frank Herbert",
genre="Science Fiction",
reading_status=ReadingStatus.FINISHED,
total_pages=688,
description="A desert planet saga.",
),
Book.objects.create(
title="Neuromancer",
author="William Gibson",
genre="Science Fiction",
reading_status=ReadingStatus.READING,
total_pages=271,
description="Cyberpunk classic.",
),
Book.objects.create(
title="The Hobbit",
author="J.R.R. Tolkien",
genre="Fantasy",
reading_status=ReadingStatus.WANT_TO_READ,
total_pages=310,
description="A hobbit's adventure.",
),
Book.objects.create(
title="1984",
author="George Orwell",
genre="Dystopian",
reading_status=ReadingStatus.FINISHED,
total_pages=328,
description="Big Brother is watching.",
),
]
return books_data
# ---------------------------------------------------------------------------
# Search tests
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookSearch:
"""Verify the search endpoint returns correct results."""
def test_search_by_title(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Dune"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
assert "Neuromancer" not in titles
def test_search_by_author(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Tolkien"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "The Hobbit" in titles
def test_search_by_genre(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Fantasy"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "The Hobbit" in titles
def test_search_case_insensitive(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "dune"})
assert response.status_code == status.HTTP_200_OK
assert any(b["title"] == "Dune" for b in response.data["results"])
def test_search_partial_match(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Neu"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Neuromancer" in titles
def test_search_empty_query_returns_all(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": ""})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 4
def test_search_no_results(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "zzzznotfound"})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
# ---------------------------------------------------------------------------
# Filter tests
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookFilters:
"""Verify filters for genre, author, and reading_status."""
def test_filter_by_genre(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"genre": "Science Fiction"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
assert "Neuromancer" in titles
assert "The Hobbit" not in titles
def test_filter_by_author(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"author": "George Orwell"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "1984" in titles
assert "Dune" not in titles
def test_filter_by_reading_status(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
assert "1984" in titles
assert "Neuromancer" not in titles
assert "The Hobbit" not in titles
def test_filter_combined_with_search(self, auth_client, books):
"""Search + filter should intersect results."""
url = reverse("book-list")
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
# 1984 matches reading_status but not search
assert "1984" not in titles
# ---------------------------------------------------------------------------
# Discovery endpoints
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookDiscovery:
"""Verify genre and author discovery endpoints."""
def test_genres_endpoint(self, auth_client, books):
url = reverse("book-genres")
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.data, list)
assert "Science Fiction" in response.data
assert "Fantasy" in response.data
assert "Dystopian" in response.data
# No duplicate genres
assert response.data.count("Science Fiction") == 1
def test_authors_endpoint(self, auth_client, books):
url = reverse("book-authors")
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.data, list)
assert "Frank Herbert" in response.data
assert "J.R.R. Tolkien" in response.data
def test_genres_requires_auth(self, api_client, books):
url = reverse("book-genres")
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_authors_requires_auth(self, api_client, books):
url = reverse("book-authors")
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ---------------------------------------------------------------------------
# Detail view
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookDetail:
"""Verify the book detail endpoint."""
def test_retrieve_book(self, auth_client, books):
book = books[0]
url = reverse("book-detail", kwargs={"pk": book.pk})
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["title"] == "Dune"
assert response.data["author"] == "Frank Herbert"
assert response.data["description"] == "A desert planet saga."
assert response.data["total_pages"] == 688
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
url = reverse("book-detail", kwargs={"pk": 99999})
response = auth_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_pagination(self, auth_client, books):
"""List with small page size should paginate."""
url = reverse("book-list")
response = auth_client.get(f"{url}?page_size=2")
assert response.status_code == status.HTTP_200_OK
assert "count" in response.data
assert "results" in response.data
assert response.data["count"] == 4
def test_ordering(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"ordering": "title"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert titles == sorted(titles)
+15
View File
@@ -0,0 +1,15 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.books.views import BookViewSet, EBookViewSet
router = DefaultRouter()
router.register(r"", BookViewSet, basename="book")
ebook_router = DefaultRouter()
ebook_router.register(r"ebooks", EBookViewSet, basename="ebook")
urlpatterns = [
path("", include(router.urls)),
path("", include(ebook_router.urls)),
]
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
import logging
from typing import Any
from django.db.models import QuerySet, Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import parsers, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
from apps.books.serializers import (
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookSerializer,
DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, StorageSummarySerializer,
)
logger = logging.getLogger(__name__)
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["author", "genre", "reading_status"]
search_fields = ["title", "author", "genre"]
ordering_fields = ["title", "author", "genre", "created_at"]
ordering = ["title"]
def get_serializer_class(self):
if self.action == "retrieve":
return BookDetailSerializer
if self.action == "list":
return BookListSerializer
return BookSerializer
def get_queryset(self) -> QuerySet[Book]:
qs = super().get_queryset()
query = self.request.query_params.get("q", "").strip()
if query:
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
return qs
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
def genres(self, request: Request) -> Response:
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
return Response([g for g in genre_list if g])
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
def authors(self, request: Request) -> Response:
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
return Response([a for a in author_list if a])
@action(detail=False, methods=["get"])
def storage(self, request: Request) -> Response:
"""Return storage usage summary for the current user."""
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
total_size = sum(r.file_size for r in download_records)
ebook_list = [
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
for r in download_records
]
serializer = StorageSummarySerializer(data={
"total_downloads": download_records.count(),
"total_size_bytes": total_size,
"ebooks": ebook_list,
})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
class IsEBookOwner(permissions.BasePermission):
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
return obj.user == request.user
class EBookViewSet(viewsets.ModelViewSet):
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
permission_classes = [IsAuthenticated, IsEBookOwner]
def get_serializer_class(self):
if self.action == "create":
return EBookUploadSerializer
if self.action in ("list",):
return EBookListSerializer
if self.action in ("toc",):
return BookChapterSerializer
return EBookDetailSerializer
def get_queryset(self):
return EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
@action(detail=True, methods=["get", "patch"])
def progress(self, request: Request, pk: int | None = None) -> Response:
ebook = self.get_object()
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
if request.method == "GET":
serializer = ReadingProgressSerializer(progress_obj)
return Response(serializer.data)
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=["post"])
def process(self, request: Request, pk: int | None = None) -> Response:
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
ebook = self.get_object()
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
try:
from apps.books.services import process_ebook
file_path = ebook.file.path
result = process_ebook(file_path, original_filename=ebook.filename())
# Update ebook with extracted data
ebook.format = result.get("format", ebook.format)
ebook.page_count = result.get("page_count", 0)
ebook.metadata_json = result.get("metadata", {})
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
# Store chapters in DB
raw_toc: list[dict[str, Any]] = result.get("toc", [])
BookChapter.objects.filter(ebook=ebook).delete()
_store_chapters(ebook, raw_toc)
return Response({
"format": ebook.format,
"page_count": ebook.page_count,
"metadata": ebook.metadata_json,
"toc_count": len(raw_toc),
"status": "processed",
})
except Exception as exc:
logger.exception("Failed to process ebook %s", ebook.id)
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=["get"])
def toc(self, request: Request, pk: int | None = None) -> Response:
"""Return hierarchical table of contents."""
ebook = self.get_object()
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
serializer = BookChapterSerializer(chapters, many=True)
return Response({
"chapters": serializer.data,
"format": ebook.format,
"page_count": ebook.page_count,
})
@action(detail=True, methods=["get"])
def content(self, request: Request, pk: int | None = None) -> Response:
"""Return paginated content for a given page number.
Query params:
page (int): page/chapter index to fetch (1-indexed, default: 1)
"""
ebook = self.get_object()
page = max(1, int(request.query_params.get("page", 1)))
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
total_pages = len(chapters) or ebook.page_count or 1
chapter: BookChapter | None = None
chapter_title = ""
content_html = ""
if chapters and 0 <= page - 1 < len(chapters):
ch = chapters[page - 1]
chapter_title = ch.title
content_html = _fetch_chapter_content(ebook, ch)
serializer = EBookContentSerializer(data={
"page": page,
"total_pages": total_pages,
"content": content_html,
"chapter_title": chapter_title,
"format": ebook.format,
})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def download(self, request: Request, pk: int | None = None) -> Response:
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
ebook = self.get_object()
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
download, created = DownloadRecord.objects.get_or_create(
user=request.user,
ebook=ebook,
defaults={"file_size": ebook.file.size if ebook.file else 0},
)
if not created:
download.file_size = ebook.file.size if ebook.file else 0
download.save(update_fields=["file_size"])
serializer = DownloadRecordSerializer(download, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"])
def downloads(self, request: Request) -> Response:
"""List all e-books the current user has downloaded."""
records = DownloadRecord.objects.filter(user=request.user).select_related(
"ebook", "ebook__reading_progress"
).prefetch_related("ebook__chapters")
page = self.paginate_queryset(records)
if page is not None:
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
return self.get_paginated_response(serializer.data)
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
return Response(serializer.data)
@action(detail=False, methods=["delete"], url_path="downloads/(?P<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:
"""Recursively store TOC entries as BookChapter records."""
for idx, entry in enumerate(toc):
BookChapter.objects.create(
ebook=ebook,
title=entry.get("title", "Untitled"),
index=parent_index + idx,
href=entry.get("href", ""),
children=entry.get("children", []),
)
children = entry.get("children", [])
if children:
_store_chapters(ebook, children, parent_index + idx + 1)
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
"""Fetch HTML content for a chapter from the e-book file."""
if ebook.format == "epub":
return _fetch_epub_chapter_content(ebook.file.path, chapter)
return ""
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
"""Extract HTML content of a specific EPUB chapter by href."""
try:
from ebooklib import epub
from bs4 import BeautifulSoup
except ImportError:
return ""
try:
book = epub.read_epub(file_path)
href = chapter.href or ""
# Find the item by href
for item in book.get_items():
item_name = item.get_name() or ""
if href and (item_name.endswith(href) or href.endswith(item_name)):
content = item.get_content()
soup = BeautifulSoup(content, "html.parser")
# Clean up — remove body/html/head wrappers, keep inner content
body = soup.find("body")
if body:
return str(body)
return str(soup)
return ""
except Exception:
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
return ""
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)
View File
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from apps.users.models import User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
"""Admin config for the custom User model."""
list_display = ("email", "username", "is_staff", "is_active", "date_joined")
search_fields = ("email", "username")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.users"
label = "users"
+13
View File
@@ -0,0 +1,13 @@
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
"""Custom user model. Uses email as the unique identifier field."""
class Meta:
db_table = "users_user"
verbose_name = "User"
verbose_name_plural = "Users"
def __str__(self) -> str:
return self.email or self.username
+8
View File
@@ -0,0 +1,8 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
from django.contrib import admin
from books.models import Book
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
"""Admin configuration for the Book model."""
list_display = ["title", "author", "genre", "reading_status", "reading_progress", "owner", "updated_at"]
list_filter = ["reading_status", "genre"]
search_fields = ["title", "author"]
readonly_fields = ["reading_progress", "created_at", "updated_at"]
raw_id_fields = ["owner"]
-5
View File
@@ -1,5 +0,0 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
name = 'books'
-41
View File
@@ -1,41 +0,0 @@
# Generated by Django 6.0.5 on 2026-05-26 00:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=500, verbose_name='Title')),
('author', models.CharField(max_length=500, verbose_name='Author')),
('genre', models.CharField(blank=True, default='', max_length=200, verbose_name='Genre')),
('description', models.TextField(blank=True, default='', verbose_name='Description')),
('cover_image_url', models.URLField(blank=True, default='', verbose_name='Cover Image URL')),
('isbn', models.CharField(blank=True, default='', max_length=20, verbose_name='ISBN')),
('total_pages', models.PositiveIntegerField(default=0, verbose_name='Total Pages')),
('current_page', models.PositiveIntegerField(default=0, verbose_name='Current Page')),
('reading_status', models.CharField(choices=[('not_started', 'Not Started'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], default='not_started', max_length=20, verbose_name='Reading Status')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='books', to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
],
options={
'verbose_name': 'Book',
'verbose_name_plural': 'Books',
'ordering': ['-updated_at'],
'indexes': [models.Index(fields=['owner', 'reading_status'], name='books_book_owner_i_424622_idx'), models.Index(fields=['owner', 'title'], name='books_book_owner_i_350794_idx'), models.Index(fields=['owner', 'author'], name='books_book_owner_i_f90626_idx')],
},
),
]
-66
View File
@@ -1,66 +0,0 @@
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
class ReadingStatus(models.TextChoices):
"""Enumeration of possible reading statuses for a book."""
NOT_STARTED = "not_started", _("Not Started")
READING = "reading", _("Reading")
FINISHED = "finished", _("Finished")
DNF = "dnf", _("Did Not Finish")
class Book(models.Model):
"""Represents a book in the user's library."""
title = models.CharField(max_length=500, verbose_name=_("Title"))
author = models.CharField(max_length=500, verbose_name=_("Author"))
genre = models.CharField(max_length=200, blank=True, default="", verbose_name=_("Genre"))
description = models.TextField(blank=True, default="", verbose_name=_("Description"))
cover_image_url = models.URLField(blank=True, default="", verbose_name=_("Cover Image URL"))
isbn = models.CharField(max_length=20, blank=True, default="", verbose_name=_("ISBN"))
total_pages = models.PositiveIntegerField(default=0, verbose_name=_("Total Pages"))
current_page = models.PositiveIntegerField(default=0, verbose_name=_("Current Page"))
reading_status = models.CharField(
max_length=20,
choices=ReadingStatus.choices,
default=ReadingStatus.NOT_STARTED,
verbose_name=_("Reading Status"),
)
owner = models.ForeignKey(
"auth.User",
on_delete=models.CASCADE,
related_name="books",
verbose_name=_("Owner"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
class Meta:
ordering = ["-updated_at"]
verbose_name = _("Book")
verbose_name_plural = _("Books")
indexes = [
models.Index(fields=["owner", "reading_status"]),
models.Index(fields=["owner", "title"]),
models.Index(fields=["owner", "author"]),
]
def __str__(self) -> str:
return f"{self.title} by {self.author}"
@property
def reading_progress(self) -> float:
"""Calculate reading progress as a percentage (0.0 - 100.0)."""
if self.total_pages == 0:
return 0.0
return round((self.current_page / self.total_pages) * 100, 1)
def mark_as_finished(self) -> None:
"""Mark the book as finished, setting progress to 100%."""
self.reading_status = ReadingStatus.FINISHED
self.current_page = self.total_pages
self.save(update_fields=["reading_status", "current_page", "updated_at"])
-95
View File
@@ -1,95 +0,0 @@
from __future__ import annotations
from rest_framework import serializers
from books.models import Book, ReadingStatus
class BookListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for list views — avoids heavy field serialization."""
reading_progress = serializers.FloatField(read_only=True)
class Meta:
model = Book
fields = [
"id",
"title",
"author",
"genre",
"reading_status",
"reading_progress",
"cover_image_url",
"created_at",
"updated_at",
]
class BookDetailSerializer(serializers.ModelSerializer):
"""Full serializer for book detail views including all metadata."""
reading_progress = serializers.FloatField(read_only=True)
owner = serializers.ReadOnlyField(source="owner.username")
class Meta:
model = Book
fields = [
"id",
"title",
"author",
"genre",
"description",
"cover_image_url",
"isbn",
"total_pages",
"current_page",
"reading_status",
"reading_progress",
"owner",
"created_at",
"updated_at",
]
read_only_fields = ["owner", "created_at", "updated_at", "reading_progress"]
def validate_title(self, value: str) -> str:
"""Ensure title is not just whitespace."""
stripped = value.strip()
if not stripped:
msg = "Title cannot be empty."
raise serializers.ValidationError(msg)
return stripped
def validate_author(self, value: str) -> str:
"""Ensure author is not just whitespace."""
stripped = value.strip()
if not stripped:
msg = "Author cannot be empty."
raise serializers.ValidationError(msg)
return stripped
def validate(self, attrs: dict) -> dict:
"""Business rules — handles both full creates and partial updates."""
# Resolve effective values: use provided attrs, fall back to instance
current_total = getattr(self.instance, "total_pages", None)
current_current = getattr(self.instance, "current_page", None)
total_pages = attrs.get("total_pages", current_total) or 0
current_page = attrs.get("current_page", current_current) or 0
reading_status = attrs.get("reading_status", None)
if current_page > total_pages > 0:
msg = "Current page cannot exceed total pages."
raise serializers.ValidationError({"current_page": msg})
if reading_status == ReadingStatus.FINISHED:
if total_pages > 0:
attrs["current_page"] = total_pages
elif self.instance and self.instance.total_pages > 0:
attrs["current_page"] = self.instance.total_pages
return attrs
class BookWriteSerializer(BookDetailSerializer):
"""Alias for detail serializer — used for write operations with full validation."""
pass
-325
View File
@@ -1,325 +0,0 @@
"""Tests for the books app — API endpoints, models, serializers, and permissions."""
from __future__ import annotations
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from books.models import Book, ReadingStatus
class BookModelTests(TestCase):
"""Tests for the Book model."""
def setUp(self) -> None:
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.book = Book.objects.create(
title="Test Book",
author="Test Author",
total_pages=200,
current_page=50,
reading_status=ReadingStatus.READING,
owner=self.user,
)
def test_reading_progress_calculates_correctly(self) -> None:
"""Reading progress should be (current_page / total_pages) * 100."""
assert self.book.reading_progress == 25.0
def test_reading_progress_returns_zero_when_no_pages(self) -> None:
"""When total_pages is 0, reading_progress should be 0.0."""
book = Book.objects.create(
title="No Pages",
author="Author",
total_pages=0,
current_page=50,
owner=self.user,
)
assert book.reading_progress == 0.0
def test_mark_as_finished_sets_progress_to_100(self) -> None:
"""mark_as_finished should set status to FINISHED and progress to 100%."""
self.book.mark_as_finished()
self.book.refresh_from_db()
assert self.book.reading_status == ReadingStatus.FINISHED
assert self.book.current_page == self.book.total_pages
def test_mark_as_finished_with_zero_pages(self) -> None:
"""mark_as_finished with total_pages=0 should set 0/0."""
book = Book.objects.create(
title="Empty",
author="Author",
total_pages=0,
current_page=0,
owner=self.user,
)
book.mark_as_finished()
book.refresh_from_db()
assert book.reading_status == ReadingStatus.FINISHED
assert book.current_page == 0
def test_str_method(self) -> None:
"""__str__ should return 'Title by Author'."""
assert str(self.book) == "Test Book by Test Author"
class BookAPITests(TestCase):
"""Tests for the Book API endpoints."""
def setUp(self) -> None:
self.client = APIClient()
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.other_user = User.objects.create_user(username="other", password="testpass123")
self.client.force_authenticate(user=self.user)
self.book = Book.objects.create(
title="My Book",
author="My Author",
genre="Fiction",
description="A great book",
total_pages=200,
current_page=50,
reading_status=ReadingStatus.READING,
owner=self.user,
)
# Other user's book (should not be visible)
Book.objects.create(
title="Other Book",
author="Other Author",
total_pages=100,
owner=self.other_user,
)
# --- List ---
def test_list_books_returns_owned_books_only(self) -> None:
"""GET /api/books/ should only return books owned by the current user."""
resp = self.client.get("/api/books/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
assert resp.data["results"][0]["title"] == "My Book"
def test_list_books_requires_authentication(self) -> None:
"""GET /api/books/ without auth should return 403."""
self.client.force_authenticate(user=None)
resp = self.client.get("/api/books/")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# --- Create ---
def test_create_book_sets_owner(self) -> None:
"""POST /api/books/ should create a book owned by the current user."""
resp = self.client.post("/api/books/", {
"title": "New Book",
"author": "New Author",
}, format="json")
assert resp.status_code == status.HTTP_201_CREATED
assert resp.data["owner"] == "testuser"
assert Book.objects.filter(title="New Book", owner=self.user).exists()
def test_create_book_validates_required_fields(self) -> None:
"""POST /api/books/ with missing title should fail."""
resp = self.client.post("/api/books/", {
"author": "Author Only",
}, format="json")
assert resp.status_code == status.HTTP_400_BAD_REQUEST
# --- Retrieve ---
def test_retrieve_book(self) -> None:
"""GET /api/books/{id}/ should return full book details."""
resp = self.client.get(f"/api/books/{self.book.id}/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["title"] == "My Book"
assert resp.data["reading_progress"] == 25.0
assert resp.data["owner"] == "testuser"
def test_retrieve_other_users_book_returns_404(self) -> None:
"""GET /api/books/{other_id}/ should return 404 for another user's book."""
other_book = Book.objects.get(title="Other Book")
resp = self.client.get(f"/api/books/{other_book.id}/")
assert resp.status_code == status.HTTP_404_NOT_FOUND
# --- Update ---
def test_update_book(self) -> None:
"""PATCH /api/books/{id}/ should update book fields."""
resp = self.client.patch(f"/api/books/{self.book.id}/", {
"title": "Updated Title",
"current_page": 100,
}, format="json")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["title"] == "Updated Title"
assert resp.data["reading_progress"] == 50.0
def test_update_current_page_exceeds_total(self) -> None:
"""PATCH with current_page > total_pages should fail."""
resp = self.client.patch(f"/api/books/{self.book.id}/", {
"current_page": 999,
}, format="json")
assert resp.status_code == status.HTTP_400_BAD_REQUEST
# --- Delete ---
def test_delete_book(self) -> None:
"""DELETE /api/books/{id}/ should delete the book."""
resp = self.client.delete(f"/api/books/{self.book.id}/")
assert resp.status_code == status.HTTP_204_NO_CONTENT
assert not Book.objects.filter(id=self.book.id).exists()
# --- Mark Finished ---
def test_mark_finished_sets_100_percent(self) -> None:
"""POST /api/books/{id}/mark_finished/ should set progress to 100%."""
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["reading_status"] == ReadingStatus.FINISHED
assert resp.data["reading_progress"] == 100.0
def test_mark_finished_idempotent(self) -> None:
"""Calling mark_finished twice should be safe."""
self.client.post(f"/api/books/{self.book.id}/mark_finished/")
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
assert resp.status_code == status.HTTP_200_OK
def test_mark_finished_other_users_book(self) -> None:
"""POST mark_finished on another user's book should return 404."""
other_book = Book.objects.get(title="Other Book")
resp = self.client.post(f"/api/books/{other_book.id}/mark_finished/")
assert resp.status_code == status.HTTP_404_NOT_FOUND
# --- Stats ---
def test_stats_returns_counts(self) -> None:
"""GET /api/books/stats/ should return aggregate counts."""
resp = self.client.get("/api/books/stats/")
assert resp.status_code == status.HTTP_200_OK
assert resp.data["total_books"] == 1
assert resp.data["finished"] == 0
assert resp.data["reading"] == 1
assert resp.data["not_started"] == 0
# --- Filtering & Sorting ---
def test_filter_by_reading_status(self) -> None:
"""GET /api/books/?reading_status=reading should filter correctly."""
resp = self.client.get("/api/books/", {"reading_status": "reading"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
resp = self.client.get("/api/books/", {"reading_status": "finished"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 0
def test_search_by_title(self) -> None:
"""GET /api/books/?search=My should filter by title."""
resp = self.client.get("/api/books/", {"search": "My"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
resp = self.client.get("/api/books/", {"search": "Nonexistent"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 0
def test_search_by_author(self) -> None:
"""GET /api/books/?search=My should filter by author."""
resp = self.client.get("/api/books/", {"search": "My Author"})
assert resp.status_code == status.HTTP_200_OK
assert resp.data["count"] == 1
def test_sort_by_title(self) -> None:
"""GET /api/books/?sort_by=title should sort alphabetically."""
Book.objects.create(title="Aardvark", author="Author", owner=self.user)
Book.objects.create(title="Zebra", author="Author", owner=self.user)
resp = self.client.get("/api/books/", {"sort_by": "title"})
assert resp.status_code == status.HTTP_200_OK
titles = [b["title"] for b in resp.data["results"]]
assert titles == sorted(titles)
def test_sort_by_author_desc(self) -> None:
"""GET /api/books/?sort_by=-author should sort by author desc."""
resp = self.client.get("/api/books/", {"sort_by": "-author"})
assert resp.status_code == status.HTTP_200_OK
# --- Pagination ---
def test_pagination_default_page_size(self) -> None:
"""Books should be paginated with default page size."""
for i in range(25):
Book.objects.create(
title=f"Book {i}",
author="Author",
owner=self.user,
)
resp = self.client.get("/api/books/")
assert resp.status_code == status.HTTP_200_OK
assert len(resp.data["results"]) == 20 # PAGE_SIZE = 20
assert resp.data["count"] == 26 # 1 original + 25 new
def test_pagination_second_page(self) -> None:
"""GET /api/books/?page=2 should return remaining items."""
for i in range(25):
Book.objects.create(
title=f"Book {i}",
author="Author",
owner=self.user,
)
resp = self.client.get("/api/books/", {"page": 2})
assert resp.status_code == status.HTTP_200_OK
assert len(resp.data["results"]) == 6
class BookSerializerTests(TestCase):
"""Tests for serializers — validation rules."""
def setUp(self) -> None:
self.user = User.objects.create_user(username="testuser", password="testpass123")
def test_current_page_cannot_exceed_total(self) -> None:
"""Serializer should reject current_page > total_pages."""
from books.serializers import BookDetailSerializer
data = {
"title": "Test",
"author": "Author",
"total_pages": 100,
"current_page": 150,
}
serializer = BookDetailSerializer(data=data)
assert not serializer.is_valid()
assert "current_page" in serializer.errors
def test_title_cannot_be_blank(self) -> None:
"""Serializer should reject blank title."""
from books.serializers import BookDetailSerializer
data = {
"title": " ",
"author": "Author",
}
serializer = BookDetailSerializer(data=data)
assert not serializer.is_valid()
assert "title" in serializer.errors
def test_author_cannot_be_blank(self) -> None:
"""Serializer should reject blank author."""
from books.serializers import BookDetailSerializer
data = {
"title": "Test",
"author": "",
}
serializer = BookDetailSerializer(data=data)
assert not serializer.is_valid()
assert "author" in serializer.errors
def test_list_serializer_has_minimal_fields(self) -> None:
"""BookListSerializer should exclude sensitive/fluff fields."""
from books.serializers import BookListSerializer
serializer = BookListSerializer()
fields = set(serializer.fields.keys())
assert "id" in fields
assert "title" in fields
assert "author" in fields
assert "reading_progress" in fields
assert "description" not in fields
assert "isbn" not in fields
-13
View File
@@ -1,13 +0,0 @@
from __future__ import annotations
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from books.views import BookViewSet
router = DefaultRouter()
router.register(r"books", BookViewSet, basename="book")
urlpatterns = [
path("", include(router.urls)),
]
-91
View File
@@ -1,91 +0,0 @@
from __future__ import annotations
from django.db import models
from django.db.models import QuerySet
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from books.models import Book, ReadingStatus
from books.serializers import BookDetailSerializer, BookListSerializer
class BookViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing books in the user's library.
Provides:
- list / retrieve / create / update / partial_update / destroy
- `mark_finished` action to set a book as 100% complete
- `stats` action for library overview counts
"""
permission_classes = [IsAuthenticated]
def get_serializer_class(self) -> type:
if self.action == "list":
return BookListSerializer
return BookDetailSerializer
def get_queryset(self) -> QuerySet[Book]:
"""Return books owned by the current user with optimised queries."""
qs = Book.objects.filter(owner=self.request.user).select_related("owner")
# Sorting
sort_by = self.request.query_params.get("sort_by", "-updated_at")
allowed_sorts = {
"title": "title",
"-title": "-title",
"author": "author",
"-author": "-author",
"created_at": "created_at",
"-created_at": "-created_at",
"updated_at": "updated_at",
"-updated_at": "-updated_at",
"reading_progress": "current_page", # approximate sort by pages read
"-reading_progress": "-current_page",
}
if sort_by in allowed_sorts:
qs = qs.order_by(allowed_sorts[sort_by])
# Filtering
status_filter = self.request.query_params.get("reading_status", None)
if status_filter in ReadingStatus.values:
qs = qs.filter(reading_status=status_filter)
search = self.request.query_params.get("search", "").strip()
if search:
qs = qs.filter(
models.Q(title__icontains=search) | models.Q(author__icontains=search)
)
return qs
def perform_create(self, serializer: BookDetailSerializer) -> None:
"""Set the owner to the current user on creation."""
serializer.save(owner=self.request.user)
@action(detail=True, methods=["post"])
def mark_finished(self, request: Request, pk: int | None = None) -> Response:
"""Mark a book as finished (100% progress)."""
book: Book = self.get_object()
book.mark_as_finished()
serializer = self.get_serializer(book)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
"""Return aggregate stats about the user's library."""
qs = self.get_queryset()
total = qs.count()
finished = qs.filter(reading_status=ReadingStatus.FINISHED).count()
reading = qs.filter(reading_status=ReadingStatus.READING).count()
not_started = qs.filter(reading_status=ReadingStatus.NOT_STARTED).count()
return Response({
"total_books": total,
"finished": finished,
"reading": reading,
"not_started": not_started,
})
-16
View File
@@ -1,16 +0,0 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()
+158
View File
@@ -0,0 +1,158 @@
"""
Django settings for cloud-reader backend.
Generated using Django 5.1. Customised with pydantic-settings integration.
"""
from pathlib import Path
from config.settings import settings
# ---------------------------------------------------------------------------
# Build paths
# ---------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Security
# ---------------------------------------------------------------------------
SECRET_KEY = settings.DJANGO_SECRET_KEY
DEBUG = settings.DJANGO_DEBUG
ALLOWED_HOSTS = settings.DJANGO_ALLOWED_HOSTS
# ---------------------------------------------------------------------------
# Application definition
# ---------------------------------------------------------------------------
INSTALLED_APPS = [
# Django built-in
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"rest_framework_simplejwt",
"corsheaders",
"django_filters",
# Local apps
"apps.users",
"apps.books",
"apps.annotations",
"apps.reader",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": settings.DB_NAME,
"USER": settings.DB_USER,
"PASSWORD": settings.DB_PASSWORD,
"HOST": settings.DB_HOST,
"PORT": settings.DB_PORT,
}
}
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
AUTH_USER_MODEL = "users.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# ---------------------------------------------------------------------------
# DRF
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 50,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
"rest_framework.filters.SearchFilter",
],
}
# ---------------------------------------------------------------------------
# SimpleJWT
# ---------------------------------------------------------------------------
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES),
"REFRESH_TOKEN_LIFETIME": timedelta(days=settings.JWT_REFRESH_TOKEN_LIFETIME_DAYS),
}
# ---------------------------------------------------------------------------
# CORS
# ---------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
# ---------------------------------------------------------------------------
# i18n
# ---------------------------------------------------------------------------
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# ---------------------------------------------------------------------------
# Static / Media / Uploads
# ---------------------------------------------------------------------------
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# Maximum upload size: 50MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
FILE_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
# ---------------------------------------------------------------------------
# Default primary key
# ---------------------------------------------------------------------------
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+30 -87
View File
@@ -1,99 +1,42 @@
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 6.0.5.
"""
import os
from pathlib import Path
from config.settings_app import settings
from pydantic_settings import BaseSettings
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = settings.SECRET_KEY
DEBUG = settings.DEBUG
ALLOWED_HOSTS = settings.ALLOWED_HOSTS
class Settings(BaseSettings):
"""Application settings via pydantic-settings. Reads from env vars and .env."""
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"corsheaders",
# Local apps
"books",
]
# Django
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
DJANGO_DEBUG: bool = False
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
# PostgreSQL
DB_NAME: str = "cloud_reader"
DB_USER: str = "postgres"
DB_PASSWORD: str = "postgres"
DB_HOST: str = "localhost"
DB_PORT: int = 5432
ROOT_URLCONF = "config.urls"
# JWT
JWT_ACCESS_TOKEN_LIFETIME_MINUTES: int = 60
JWT_REFRESH_TOKEN_LIFETIME_DAYS: int = 7
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
# CORS
CORS_ALLOWED_ORIGINS: list[str] = [
"http://localhost:5173",
"http://localhost:3000",
]
WSGI_APPLICATION = "config.wsgi.application"
@property
def DATABASE_URL(self) -> str:
return (
f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# CORS
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
CORS_ALLOW_CREDENTIALS = settings.CORS_ALLOW_CREDENTIALS
# REST Framework
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}
settings = Settings()
-33
View File
@@ -1,33 +0,0 @@
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
DEBUG: bool = True
SECRET_KEY: str = "django-insecure-change-me-in-production"
DATABASE_URL: str = "sqlite:///db.sqlite3"
ALLOWED_HOSTS: list[str] = ["*"]
CORS_ALLOWED_ORIGINS: list[str] = [
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
]
CORS_ALLOW_CREDENTIALS: bool = True
BASE_DIR: Path = Path(__file__).resolve().parent.parent
model_config = SettingsConfigDict(
env_prefix="CR_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()
+4 -2
View File
@@ -3,6 +3,8 @@ from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api-auth/", include("rest_framework.urls")),
path("api/", include("books.urls")),
path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")),
path("api/annotations/", include("apps.annotations.urls")),
path("api/reader/", include("apps.reader.urls")),
]
+1 -10
View File
@@ -1,16 +1,7 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
application = get_wsgi_application()
Executable → Regular
+3 -2
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+7 -2
View File
@@ -1,3 +1,8 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
[tool:pytest]
DJANGO_SETTINGS_MODULE = config.django
python_files = tests.py test_*.py *_tests.py
testpaths = apps
[coverage:run]
source = apps
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
+15 -6
View File
@@ -1,6 +1,15 @@
django>=5.0,<7.0
djangorestframework>=3.15.0
django-cors-headers>=4.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
pytest-django>=4.5.0
Django==5.1.7
djangorestframework==3.15.2
djangorestframework-simplejwt==5.4.0
django-filter==25.1
django-cors-headers==4.6.0
psycopg2-binary==2.9.10
pydantic==2.10.5
pydantic-settings==2.7.1
python-dotenv==1.0.1
gunicorn==23.0.0
Pillow>=11.0.0
pytest==8.3.4
pytest-django==4.9.0
pytest-cov==6.0.0
coverage==7.6.10
+58
View File
@@ -0,0 +1,58 @@
services:
db:
image: postgres:15
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: cloud_reader
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d cloud_reader"]
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
DJANGO_SECRET_KEY: "dev-secret-key-change-in-production"
DJANGO_DEBUG: "True"
DB_NAME: cloud_reader
DB_USER: postgres
DB_PASSWORD: postgres
DB_HOST: db
DB_PORT: "5432"
volumes:
- ./backend:/app
- book_media:/app/media
command: >
sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
depends_on:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:5173"
environment:
VITE_API_URL: "http://localhost:8000"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
volumes:
postgres_data:
book_media:
@@ -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)
-84
View File
@@ -1,84 +0,0 @@
# Backend API Specification — Cloud Reader
## Overview
The Cloud Reader backend provides a RESTful API for managing a user's personal book library. Built with Django 5 + Django REST Framework.
## Models
### Book
| Field | Type | Constraints |
|------------------|--------------------|---------------------------------|
| `title` | `CharField(500)` | Required |
| `author` | `CharField(500)` | Required |
| `genre` | `CharField(200)` | Optional, blank allowed |
| `description` | `TextField` | Optional, blank allowed |
| `cover_image_url`| `URLField` | Optional, blank allowed |
| `isbn` | `CharField(20)` | Optional, blank allowed |
| `total_pages` | `PositiveIntegerField` | Default 0 |
| `current_page` | `PositiveIntegerField` | Default 0, validated ≤ total |
| `reading_status` | `CharField(20)` | Choices: `not_started`, `reading`, `finished`, `dnf` |
| `owner` | `ForeignKey(User)` | Set automatically on create |
| `created_at` | `DateTimeField` | Auto-set on create |
| `updated_at` | `DateTimeField` | Auto-set on update |
**Properties:**
- `reading_progress` — computed `(current_page / total_pages) * 100`, returns `0.0` when `total_pages` is 0.
**Indexes:** Composite indexes on `(owner, reading_status)`, `(owner, title)`, `(owner, author)`.
## API Endpoints
Base URL: `/api/`
Authentication: SessionAuthentication + BasicAuthentication (DRF defaults).
Permissions: All book endpoints require `IsAuthenticated`. Users can only access their own books.
### Books
| Method | URL | Action | Serializer |
|----------|------------------------------------|---------------|--------------------|
| `GET` | `/api/books/` | List books | `BookListSerializer` |
| `POST` | `/api/books/` | Create book | `BookDetailSerializer` |
| `GET` | `/api/books/{id}/` | Retrieve book | `BookDetailSerializer` |
| `PUT` | `/api/books/{id}/` | Full update | `BookDetailSerializer` |
| `PATCH` | `/api/books/{id}/` | Partial update| `BookDetailSerializer` |
| `DELETE` | `/api/books/{id}/` | Delete book | — |
| `POST` | `/api/books/{id}/mark_finished/` | Mark finished | `BookDetailSerializer` |
| `GET` | `/api/books/stats/` | Library stats | — (custom) |
### Query Parameters (List)
| Parameter | Type | Description |
|------------------|----------|--------------------------------------------------|
| `page` | int | Page number for pagination (20 items/page) |
| `sort_by` | string | `title`, `-title`, `author`, `-author`, `-created_at`, `-updated_at`, `-reading_progress`, `reading_progress` |
| `reading_status` | string | Filter by status value |
| `search` | string | Search in title and author fields (icontains) |
### Stats Response
```json
{
"total_books": 10,
"finished": 3,
"reading": 4,
"not_started": 3
}
```
### Mark Finished
`POST /api/books/{id}/mark_finished/` sets `reading_status` to `finished` and `current_page` to `total_pages`.
## Validation Rules
- Title and author cannot be empty or whitespace-only
- `current_page` cannot exceed `total_pages` (when `total_pages > 0`)
- Setting `reading_status` to `finished` automatically sets `current_page = total_pages`
## Admin
Books are registered in Django admin with list display, filters by `reading_status` and `genre`, and search by `title`/`author`.
+107
View File
@@ -0,0 +1,107 @@
# Book Search & Discovery — Spec
## Overview
Enable users to search books within the library and discover new books via filters and a dedicated detail view.
## Backend API Contracts
### Book List & Search
`GET /api/books/`
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `q` | string | Full-text search across title, author, genre |
| `genre` | string | Exact filter by genre |
| `author` | string | Exact filter by author |
| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` |
| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) |
| `page` | int | Page number (default: 1) |
**Response (paginated):**
```json
{
"count": 42,
"next": "http://.../?page=2",
"previous": null,
"results": [
{
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"genre": "Science Fiction",
"reading_status": "finished",
"reading_status_display": "Finished",
"cover_image": "https://..."
}
]
}
```
### Book Detail
`GET /api/books/{id}/`
**Response:**
```json
{
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"genre": "Science Fiction",
"description": "...",
"reading_status": "finished",
"reading_status_display": "Finished",
"cover_image": "https://...",
"total_pages": 688,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-15T00:00:00Z"
}
```
### Genre / Author Discovery
`GET /api/books/genres/``["Fiction", "Science Fiction", ...]`
`GET /api/books/authors/``["Frank Herbert", "Ursula K. Le Guin", ...]`
## Frontend Components
### LibraryPage (enhanced)
- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param
- **Filter row**: genre dropdown, author dropdown, reading status dropdown
- Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/`
- Reading status uses static enum values (`READING_STATUS_OPTIONS`)
- **Results grid**: card layout showing cover, title, author, reading status badge
- **Empty state**: "No books found" with clear message when results are empty
- **Loading state**: spinner/skeleton while fetching
- **Click card → navigate to** `/books/{id}`
### BookDetailPage (new)
- Shows full book info: cover, title, author, genre, description, reading status, total pages
- Back button to return to library
- Clean, mobile-responsive layout
### API Client — `frontend/src/api/books.ts`
| 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[]` |
## Routes (Frontend)
| Path | Component | Auth |
|------|-----------|------|
| `/` | LibraryPage | Protected |
| `/books/:id` | BookDetailPage | Protected |
## Data Flow
1. User types in search bar → 300ms debounce → `GET /api/books/?q=...`
2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...`
3. User clicks result → navigate to `/books/:id`
4. BookDetailPage → `GET /api/books/{id}/`
## Mobile Optimizations
- Filters collapse into a toggleable panel on small screens
- Cards stack in single column on mobile
- Touch-friendly tap targets (min 44px)
-82
View File
@@ -1,82 +0,0 @@
# Frontend Component Specification — Cloud Reader
## Overview
React 19 + TypeScript SPA using Vite for development and production builds. State managed via React hooks and Context API. Routing via `react-router-dom` v7 with `lazy`/`Suspense` for code splitting.
## Components
### App (root)
- **Path:** `/`
- **Layout:** Header with logo + `<Routes>` wrapper
- **Routing:** `/``Library`, `*` → redirect to `/`
- **Code splitting:** `Library` loaded via `React.lazy` + `<Suspense>`
### Library
- **State:** `books[]`, `stats`, `search`, `sortBy`, `statusFilter`, `currentPage`, `view`
- **Views:** `library` (grid), `detail` (selected book), `add` (modal form)
- **Sub-components:** `BookCard`, `BookDetail`, `BookForm`
- **Data flow:** Calls `fetchBooks()` on mount and when filters/page change
### BookCard
- **Props:** `{ book: Book, onClick: (book: Book) => void }`
- **Display:** Cover image (or first-letter placeholder), title, author, genre badge, reading progress bar with status color
- **Interaction:** Click/keyboard-accessible (Enter/Space)
### BookDetail
- **Props:** `{ bookId: number, onBack: () => void, onUpdated: () => void }`
- **Sections:** Cover, metadata (title, author, genre, ISBN), progress bar with page count, action buttons
- **Actions:** Mark as Finished, Edit Details (switches to BookForm), Delete (with confirmation)
- **States:** Loading, error, editing mode
### BookForm
- **Props:** `{ initialData?: Book, onSubmit: (data: BookFormData) => Promise<void>, onCancel: () => void }`
- **Fields:** Title*, Author*, Genre, Description, Cover URL, ISBN, Total Pages, Current Page, Status
- **Client validation:** Title/author required, page ≤ total pages
- **Loading state:** Submit button shows "Saving..." when `isLoading`
## Types
```typescript
interface Book {
id: number; title: string; author: string; genre: string;
description: string; cover_image_url: string; isbn: string;
total_pages: number; current_page: number;
reading_status: ReadingStatus; reading_progress: number;
owner: string; created_at: string; updated_at: string;
}
type ReadingStatus = 'not_started' | 'reading' | 'finished' | 'dnf';
interface BookFormData {
title: string; author: string; genre: string;
description: string; cover_image_url: string; isbn: string;
total_pages: number; current_page: number; reading_status: ReadingStatus;
}
```
## API Client (`api/books.ts`)
| Function | HTTP Call |
|----------------------|------------------------------------|
| `fetchBooks(params)` | `GET /api/books/` |
| `fetchBook(id)` | `GET /api/books/{id}/` |
| `createBook(data)` | `POST /api/books/` |
| `updateBook(id, data)` | `PATCH /api/books/{id}/` |
| `deleteBook(id)` | `DELETE /api/books/{id}/` |
| `markAsFinished(id)` | `POST /api/books/{id}/mark_finished/` |
| `fetchBookStats()` | `GET /api/books/stats/` |
## Styling
Dark theme with CSS custom properties. All styles in `App.css`. Responsive grid layout with breakpoint at 768px.
## Build & Dev
- `yarn workspace frontend dev` — Vite dev server on port 5173 with API proxy to 127.0.0.1:8000
- `yarn workspace frontend build` — TypeScript check + Vite production build
+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
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . ./
EXPOSE 5173
CMD ["yarn", "dev", "--host"]
-73
View File
@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
-22
View File
@@ -1,22 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+2 -3
View File
@@ -1,10 +1,9 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Cloud Reader</title>
</head>
<body>
<div id="root"></div>
+19 -20
View File
@@ -1,32 +1,31 @@
{
"name": "frontend",
"name": "@cloud-reader/frontend",
"version": "1.0.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.16.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1"
"axios": "^1.7.9",
"dompurify": "^3.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.0",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vitest": "^2.1.0"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-577
View File
@@ -1,577 +0,0 @@
/* === Reset & Base === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #0f1117;
--color-surface: #1a1d27;
--color-surface-hover: #242736;
--color-border: #2a2d3a;
--color-text: #e1e3ea;
--color-text-muted: #8b8fa3;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-success: #22c55e;
--color-danger: #ef4444;
--color-danger-hover: #dc2626;
--color-secondary: #374151;
--color-secondary-hover: #4b5563;
--radius: 8px;
--radius-lg: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* === App Layout === */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background-color: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.app-header-inner {
max-width: 1200px;
margin: 0 auto;
height: 56px;
display: flex;
align-items: center;
}
.app-logo {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--color-text);
font-weight: 700;
font-size: 1.125rem;
}
.logo-icon {
font-size: 1.5rem;
}
.app-main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 24px;
}
/* === Library === */
.library-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.library-header h1 {
font-size: 1.75rem;
font-weight: 700;
}
/* === Stats === */
.library-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-label {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* === Controls === */
.library-controls {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9rem;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sort-select,
.filter-select {
padding: 10px 14px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
}
/* === Books Grid === */
.books-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.book-card {
display: flex;
gap: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 16px;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s, transform 0.15s;
}
.book-card:hover {
background: var(--color-surface-hover);
border-color: var(--color-primary);
transform: translateY(-2px);
}
.book-card-cover {
flex-shrink: 0;
width: 80px;
height: 120px;
border-radius: 6px;
overflow: hidden;
}
.book-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.book-card-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-primary), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
color: white;
}
.book-card-placeholder.large {
width: 180px;
height: 260px;
font-size: 3rem;
}
.book-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.book-card-title {
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-card-author {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.book-card-genre {
display: inline-block;
font-size: 0.75rem;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.15);
color: var(--color-primary);
border-radius: 12px;
align-self: flex-start;
}
.book-card-progress {
margin-top: auto;
}
/* === Progress Bar === */
.progress-bar {
width: 100%;
height: 6px;
background: var(--color-border);
border-radius: 3px;
overflow: hidden;
}
.progress-bar.large {
height: 10px;
border-radius: 5px;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-text {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 4px;
}
/* === Buttons === */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-secondary {
background: var(--color-secondary);
color: var(--color-text);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-secondary-hover);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: #16a34a;
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: var(--color-danger-hover);
}
/* === Book Detail === */
.book-detail {
max-width: 800px;
}
.book-detail > .btn {
margin-bottom: 24px;
}
.book-detail-header {
display: flex;
gap: 32px;
margin-bottom: 32px;
}
.book-detail-cover {
flex-shrink: 0;
width: 180px;
height: 260px;
border-radius: var(--radius-lg);
overflow: hidden;
}
.book-detail-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.book-detail-meta {
flex: 1;
}
.book-detail-meta h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 4px;
}
.book-detail-author {
font-size: 1.1rem;
color: var(--color-text-muted);
margin-bottom: 12px;
}
.book-detail-isbn {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-top: 8px;
}
.book-detail-progress {
margin: 20px 0;
}
.book-detail-progress .progress-text {
display: flex;
gap: 16px;
font-size: 0.85rem;
}
.book-detail-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.book-detail-description {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
}
.book-detail-description h3 {
font-size: 1.1rem;
margin-bottom: 12px;
}
.book-detail-description p {
color: var(--color-text-muted);
line-height: 1.7;
}
/* === Form === */
.book-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--color-danger);
color: var(--color-danger);
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.875rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text-muted);
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 10px 14px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--color-primary);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* === Modal === */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
z-index: 200;
}
.modal-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 32px;
width: 100%;
max-width: 560px;
max-height: 80vh;
overflow-y: auto;
}
.modal-content h2 {
margin-bottom: 20px;
}
/* === Pagination === */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 32px;
}
.page-info {
font-size: 0.875rem;
color: var(--color-text-muted);
}
/* === States === */
.loading-spinner {
text-align: center;
padding: 48px;
color: var(--color-text-muted);
font-size: 1rem;
}
.empty-state {
text-align: center;
padding: 64px 24px;
color: var(--color-text-muted);
}
.empty-state p {
margin-bottom: 8px;
}
.error-message {
text-align: center;
padding: 32px;
color: var(--color-danger);
}
/* === Responsive === */
@media (max-width: 768px) {
.books-grid {
grid-template-columns: 1fr;
}
.book-detail-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.book-detail-actions {
justify-content: center;
}
.library-controls {
flex-direction: column;
}
.library-stats {
grid-template-columns: repeat(2, 1fr);
}
.form-row {
grid-template-columns: 1fr;
}
}
+56 -26
View File
@@ -1,32 +1,62 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import './App.css';
import React, { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
const Library = lazy(() => import('./components/Library').then((m) => ({ default: m.Library })));
const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage })));
const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage })));
const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage })));
const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage })));
const EditEBookPage = lazy(() => import("./pages/EditEBook").then((m) => ({ default: m.EditEBookPage })));
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
function App() {
const AuthPage = lazy(() =>
import("./pages/AuthPage").then((m) => ({
default: () => {
const [isLogin, setIsLogin] = useState(true);
return isLogin ? <m.LoginPage onToggle={() => setIsLogin(false)} /> : <m.RegisterPage onToggle={() => setIsLogin(true)} />;
},
})),
);
function LoadingFallback() {
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", color: "#888", fontSize: 16 }}><p>Loading...</p></div>;
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) return <LoadingFallback />;
if (!isAuthenticated) return <Navigate to="/auth" replace />;
return <>{children}</>;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
return (
<BrowserRouter>
<div className="app">
<header className="app-header">
<div className="app-header-inner">
<a href="/" className="app-logo" onClick={(e) => { e.preventDefault(); window.location.href = '/'; }}>
<span className="logo-icon">📚</span>
<span className="logo-text">Cloud Reader</span>
</a>
</div>
</header>
<main className="app-main">
<Suspense fallback={<div className="loading-spinner">Loading...</div>}>
<Routes>
<Route path="/" element={<Library />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</main>
</div>
</BrowserRouter>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></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="/edit/:id" element={<ProtectedRoute><EditEBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
export default App;
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}
+96
View File
@@ -0,0 +1,96 @@
import api from "@/api/client";
import type {
Bookmark,
CreateBookmarkPayload,
Note,
CreateNotePayload,
UpdateNotePayload,
PaginatedResponse,
} from "@/types";
/** Fetch bookmarks for the current user, optionally filtered by book */
export async function fetchBookmarks(
bookId?: string
): Promise<PaginatedResponse<Bookmark>> {
const params: Record<string, string> = {};
if (bookId) params.book = bookId;
const { data } = await api.get<PaginatedResponse<Bookmark>>(
"/annotations/bookmarks/",
{ params }
);
return data;
}
/** Create a new bookmark */
export async function createBookmark(
payload: CreateBookmarkPayload
): Promise<Bookmark> {
const { data } = await api.post<Bookmark>(
"/annotations/bookmarks/",
payload
);
return data;
}
/** Delete a bookmark by id */
export async function deleteBookmark(id: string): Promise<void> {
await api.delete(`/annotations/bookmarks/${id}/`);
}
/** Batch delete bookmarks */
export async function batchDeleteBookmarks(
ids: string[]
): Promise<{ deleted: number }> {
const { data } = await api.delete<{ deleted: number }>(
"/annotations/bookmarks/batch-delete/",
{ data: { ids } }
);
return data;
}
/** Fetch notes for the current user, optionally filtered by book */
export async function fetchNotes(
bookId?: string
): Promise<PaginatedResponse<Note>> {
const params: Record<string, string> = {};
if (bookId) params.book = bookId;
const { data } = await api.get<PaginatedResponse<Note>>(
"/annotations/notes/",
{ params }
);
return data;
}
/** Create a new note */
export async function createNote(payload: CreateNotePayload): Promise<Note> {
const { data } = await api.post<Note>("/annotations/notes/", payload);
return data;
}
/** Update a note's content */
export async function updateNote(
id: string,
payload: UpdateNotePayload
): Promise<Note> {
const { data } = await api.patch<Note>(
`/annotations/notes/${id}/`,
payload
);
return data;
}
/** Delete a note by id */
export async function deleteNote(id: string): Promise<void> {
await api.delete(`/annotations/notes/${id}/`);
}
/** Batch delete notes */
export async function batchDeleteNotes(
ids: string[]
): Promise<{ deleted: number }> {
const { data } = await api.delete<{ deleted: number }>(
"/annotations/notes/batch-delete/",
{ data: { ids } }
);
return data;
}
+105 -74
View File
@@ -1,90 +1,121 @@
import axios from 'axios';
import type { Book, BookFormData, BookListResponse, BookStats } from '../types';
import api from "./client";
import type {
BookDetail,
BookListItem,
BookSearchParams,
ContentResponse,
EBookDetail,
EBookListItem,
ReadingProgress,
ReadingSettings,
TocResponse,
} from "../types/book";
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
export const booksApi = {
async getEBooks(): Promise<EBookListItem[]> {
const { data } = await api.get<EBookListItem[]>("/books/ebooks/");
return data;
},
withCredentials: true,
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
window.location.href = '/login';
}
return Promise.reject(error);
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
);
export async function fetchBooks(params?: {
page?: number;
sort_by?: string;
reading_status?: string;
search?: string;
}): Promise<BookListResponse> {
const response = await api.get<BookListResponse>('/books/', { params });
return response.data;
}
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
export async function fetchBook(id: number): Promise<Book> {
const response = await api.get<Book>(`/books/${id}/`);
return response.data;
}
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
export async function createBook(data: BookFormData): Promise<Book> {
const response = await api.post<Book>('/books/', data);
return response.data;
}
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
},
export async function updateBook(id: number, data: Partial<BookFormData>): Promise<Book> {
const response = await api.patch<Book>(`/books/${id}/`, data);
return response.data;
}
async getEBook(id: number): Promise<EBookDetail> {
const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`);
return data;
},
export async function deleteBook(id: number): Promise<void> {
await api.delete(`/books/${id}/`);
}
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
export async function markBookAsFinished(id: number): Promise<Book> {
const response = await api.post<Book>(`/books/${id}/mark_finished/`);
return response.data;
}
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
export async function fetchBookStats(): Promise<BookStats> {
const response = await api.get<BookStats>('/books/stats/');
return response.data;
}
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
export async function login(username: string, password: string): Promise<void> {
// Get CSRF token from any existing session
const csrfToken = extractCsrfToken();
if (!csrfToken) {
// Make a light request to set the CSRF cookie
await fetch('/api/books/', { credentials: 'include' });
}
const token = extractCsrfToken();
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
},
const resp = await fetch('/api-auth/login/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': token,
},
body: new URLSearchParams({ username, password }),
credentials: 'include',
});
async uploadEBook(
file: File,
title: string,
author: string,
coverImage?: File | null,
): Promise<EBookDetail> {
const formData = new FormData();
formData.append("file", file);
formData.append("title", title);
if (author) formData.append("author", author);
if (coverImage) formData.append("cover_image", coverImage);
if (!resp.ok) {
throw new Error('Login failed');
}
}
const { data } = await api.post<EBookDetail>("/books/ebooks/", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data;
},
function extractCsrfToken(): string {
const match = document.cookie.match(/csrftoken=([\w-]+)/);
return match ? match[1] : '';
}
async deleteEBook(id: number): Promise<void> {
await api.delete(`/books/ebooks/${id}/`);
},
export default api;
async processEBook(id: number): Promise<{ status: string }> {
const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`);
return data;
},
async getToc(id: number): Promise<TocResponse> {
const { data } = await api.get<TocResponse>(`/books/ebooks/${id}/toc/`);
return data;
},
async getContent(id: number, page: number): Promise<ContentResponse> {
const { data } = await api.get<ContentResponse>(`/books/ebooks/${id}/content/?page=${page}`);
return data;
},
async getProgress(ebookId: number): Promise<ReadingProgress> {
const { data } = await api.get<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`);
return data;
},
async updateProgress(ebookId: number, progressData: Partial<ReadingProgress>): Promise<ReadingProgress> {
const { data } = await api.patch<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`, progressData);
return data;
},
async getSettings(): Promise<ReadingSettings> {
const { data } = await api.get<ReadingSettings>("/books/settings/");
return data;
},
async updateSettings(settingsData: Partial<ReadingSettings>): Promise<ReadingSettings> {
const { data } = await api.patch<ReadingSettings>("/books/settings/", settingsData);
return data;
},
};
+67
View File
@@ -0,0 +1,67 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
});
// Attach JWT token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Attempt token refresh on 401
let isRefreshing = false;
let pendingRequests: Array<(token: string) => void> = [];
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
throw new Error("No refresh token");
}
const { data } = await axios.post("/api/auth/token/refresh/", {
refresh: refreshToken,
});
localStorage.setItem("access_token", data.access);
pendingRequests.forEach((cb) => cb(data.access));
pendingRequests = [];
originalRequest.headers.Authorization = `Bearer ${data.access}`;
return api(originalRequest);
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
);
export default api;
+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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-54
View File
@@ -1,54 +0,0 @@
import type { Book } from '../types';
import { READING_STATUS_LABELS, READING_STATUS_COLORS } from '../types';
interface BookCardProps {
book: Book;
onClick: (book: Book) => void;
}
export function BookCard({ book, onClick }: BookCardProps) {
const progress = book.reading_progress;
const statusColor = READING_STATUS_COLORS[book.reading_status];
return (
<article
className="book-card"
onClick={() => onClick(book)}
role="button"
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(book);
}
}}
>
<div className="book-card-cover">
{book.cover_image_url ? (
<img src={book.cover_image_url} alt={`Cover of ${book.title}`} loading="lazy" />
) : (
<div className="book-card-placeholder">
<span>{book.title.charAt(0)}</span>
</div>
)}
</div>
<div className="book-card-info">
<h3 className="book-card-title">{book.title}</h3>
<p className="book-card-author">{book.author}</p>
{book.genre && <span className="book-card-genre">{book.genre}</span>}
<div className="book-card-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${Math.min(progress, 100)}%`, backgroundColor: statusColor }}
/>
</div>
<div className="progress-text">
<span style={{ color: statusColor }}>{READING_STATUS_LABELS[book.reading_status]}</span>
<span>{progress}%</span>
</div>
</div>
</div>
</article>
);
}
-151
View File
@@ -1,151 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import type { Book, BookFormData } from '../types';
import { READING_STATUS_LABELS, READING_STATUS_COLORS } from '../types';
import { fetchBook, updateBook, markBookAsFinished, deleteBook } from '../api/books';
import { BookForm } from './BookForm';
interface BookDetailProps {
bookId: number;
onBack: () => void;
onUpdated: () => void;
}
export function BookDetail({ bookId, onBack, onUpdated }: BookDetailProps) {
const [book, setBook] = useState<Book | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadBook = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchBook(bookId);
setBook(data);
} catch {
setError('Failed to load book details.');
} finally {
setLoading(false);
}
}, [bookId]);
useEffect(() => {
loadBook();
}, [loadBook]);
const handleUpdate = useCallback(
async (formData: BookFormData) => {
const updated = await updateBook(bookId, formData);
setBook(updated);
setEditing(false);
onUpdated();
},
[bookId, onUpdated],
);
const handleMarkFinished = useCallback(async () => {
const updated = await markBookAsFinished(bookId);
setBook(updated);
onUpdated();
}, [bookId, onUpdated]);
const handleDelete = useCallback(async () => {
if (!window.confirm('Are you sure you want to delete this book?')) return;
await deleteBook(bookId);
onBack();
onUpdated();
}, [bookId, onBack, onUpdated]);
if (loading) {
return <div className="loading-spinner">Loading...</div>;
}
if (error || !book) {
return <div className="error-message">{error ?? 'Book not found.'}</div>;
}
if (editing) {
return (
<div className="book-detail">
<button className="btn btn-secondary" onClick={() => setEditing(false)}>
Back to Details
</button>
<h2>Edit Book</h2>
<BookForm
initialData={book}
onSubmit={handleUpdate}
onCancel={() => setEditing(false)}
/>
</div>
);
}
const statusColor = READING_STATUS_COLORS[book.reading_status];
return (
<div className="book-detail">
<button className="btn btn-secondary" onClick={onBack}>
Back to Library
</button>
<div className="book-detail-header">
<div className="book-detail-cover">
{book.cover_image_url ? (
<img src={book.cover_image_url} alt={`Cover of ${book.title}`} />
) : (
<div className="book-card-placeholder large">
<span>{book.title.charAt(0)}</span>
</div>
)}
</div>
<div className="book-detail-meta">
<h1>{book.title}</h1>
<p className="book-detail-author">by {book.author}</p>
{book.genre && <span className="book-card-genre">{book.genre}</span>}
{book.isbn && <p className="book-detail-isbn">ISBN: {book.isbn}</p>}
<div className="book-detail-progress">
<div className="progress-bar large">
<div
className="progress-fill"
style={{ width: `${Math.min(book.reading_progress, 100)}%`, backgroundColor: statusColor }}
/>
</div>
<div className="progress-text">
<span style={{ color: statusColor }}>
{READING_STATUS_LABELS[book.reading_status]}
</span>
<span>{book.reading_progress}% complete</span>
{book.total_pages > 0 && (
<span>
Page {book.current_page} of {book.total_pages}
</span>
)}
</div>
</div>
<div className="book-detail-actions">
{book.reading_status !== 'finished' && (
<button className="btn btn-success" onClick={handleMarkFinished}>
Mark as Finished
</button>
)}
<button className="btn btn-primary" onClick={() => setEditing(true)}>
Edit Details
</button>
<button className="btn btn-danger" onClick={handleDelete}>
Delete
</button>
</div>
</div>
</div>
{book.description && (
<div className="book-detail-description">
<h3>Description</h3>
<p>{book.description}</p>
</div>
)}
</div>
);
}
-209
View File
@@ -1,209 +0,0 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Book, BookFormData, ReadingStatus } from '../types';
import { READING_STATUS_LABELS } from '../types';
interface BookFormProps {
initialData?: Book;
onSubmit: (data: BookFormData) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}
export function BookForm({ initialData, onSubmit, onCancel, isLoading }: BookFormProps) {
const [formData, setFormData] = useState<BookFormData>({
title: initialData?.title ?? '',
author: initialData?.author ?? '',
genre: initialData?.genre ?? '',
description: initialData?.description ?? '',
cover_image_url: initialData?.cover_image_url ?? '',
isbn: initialData?.isbn ?? '',
total_pages: initialData?.total_pages ?? 0,
current_page: initialData?.current_page ?? 0,
reading_status: initialData?.reading_status ?? 'not_started',
});
const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]:
name === 'total_pages' || name === 'current_page'
? Math.max(0, parseInt(value, 10) || 0)
: value,
}));
},
[],
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!formData.title.trim()) {
setError('Title is required.');
return;
}
if (!formData.author.trim()) {
setError('Author is required.');
return;
}
if (formData.current_page > formData.total_pages && formData.total_pages > 0) {
setError('Current page cannot exceed total pages.');
return;
}
try {
await onSubmit(formData);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Failed to save book.');
}
}
},
[formData, onSubmit],
);
return (
<form onSubmit={handleSubmit} className="book-form">
{error && <div className="form-error">{error}</div>}
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
ref={titleRef}
id="title"
name="title"
type="text"
value={formData.title}
onChange={handleChange}
placeholder="Book title"
required
/>
</div>
<div className="form-group">
<label htmlFor="author">Author *</label>
<input
id="author"
name="author"
type="text"
value={formData.author}
onChange={handleChange}
placeholder="Author name"
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="genre">Genre</label>
<input
id="genre"
name="genre"
type="text"
value={formData.genre}
onChange={handleChange}
placeholder="e.g. Fiction, Science"
/>
</div>
<div className="form-group">
<label htmlFor="isbn">ISBN</label>
<input
id="isbn"
name="isbn"
type="text"
value={formData.isbn}
onChange={handleChange}
placeholder="ISBN number"
/>
</div>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Book description"
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="cover_image_url">Cover Image URL</label>
<input
id="cover_image_url"
name="cover_image_url"
type="url"
value={formData.cover_image_url}
onChange={handleChange}
placeholder="https://example.com/cover.jpg"
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="total_pages">Total Pages</label>
<input
id="total_pages"
name="total_pages"
type="number"
min={0}
value={formData.total_pages}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="current_page">Current Page</label>
<input
id="current_page"
name="current_page"
type="number"
min={0}
value={formData.current_page}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="reading_status">Status</label>
<select
id="reading_status"
name="reading_status"
value={formData.reading_status}
onChange={handleChange}
>
{(Object.keys(READING_STATUS_LABELS) as ReadingStatus[]).map((status) => (
<option key={status} value={status}>
{READING_STATUS_LABELS[status]}
</option>
))}
</select>
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={isLoading}>
{isLoading ? 'Saving...' : initialData ? 'Update Book' : 'Add Book'}
</button>
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Cancel
</button>
</div>
</form>
);
}
-227
View File
@@ -1,227 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import type { Book, ReadingStatus } from '../types';
import { READING_STATUS_LABELS } from '../types';
import { fetchBooks, fetchBookStats, createBook } from '../api/books';
import type { BookStats, BookFormData } from '../types';
import { BookCard } from './BookCard';
import { BookDetail } from './BookDetail';
import { BookForm } from './BookForm';
type View = 'library' | 'detail' | 'add';
export function Library() {
const [view, setView] = useState<View>('library');
const [selectedBookId, setSelectedBookId] = useState<number | null>(null);
const [books, setBooks] = useState<Book[]>([]);
const [stats, setStats] = useState<BookStats | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('-updated_at');
const [statusFilter, setStatusFilter] = useState<string>('');
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [showForm, setShowForm] = useState(false);
const loadBooks = useCallback(async () => {
setLoading(true);
try {
const data = await fetchBooks({
page: currentPage,
sort_by: sortBy || undefined,
reading_status: statusFilter || undefined,
search: search || undefined,
});
setBooks(data.results);
setTotalCount(data.count);
} catch {
setBooks([]);
} finally {
setLoading(false);
}
}, [currentPage, sortBy, statusFilter, search]);
const loadStats = useCallback(async () => {
try {
const data = await fetchBookStats();
setStats(data);
} catch {
// Stats are non-critical
}
}, []);
useEffect(() => {
loadBooks();
loadStats();
}, [loadBooks, loadStats]);
const handleBookClick = useCallback((book: Book) => {
setSelectedBookId(book.id);
setView('detail');
}, []);
const handleBackToLibrary = useCallback(() => {
setView('library');
setSelectedBookId(null);
}, []);
const handleUpdated = useCallback(() => {
loadBooks();
loadStats();
}, [loadBooks, loadStats]);
const handleAddBook = useCallback(
async (data: BookFormData) => {
await createBook(data);
setShowForm(false);
await loadBooks();
await loadStats();
},
[loadBooks, loadStats],
);
const totalPages = useMemo(() => Math.max(1, Math.ceil(totalCount / 20)), [totalCount]);
if (view === 'detail' && selectedBookId) {
return (
<BookDetail
bookId={selectedBookId}
onBack={handleBackToLibrary}
onUpdated={handleUpdated}
/>
);
}
return (
<div className="library">
<div className="library-header">
<h1>My Library</h1>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
+ Add Book
</button>
</div>
{stats && (
<div className="library-stats">
<div className="stat-card">
<span className="stat-value">{stats.total_books}</span>
<span className="stat-label">Total</span>
</div>
<div className="stat-card">
<span className="stat-value" style={{ color: '#22c55e' }}>{stats.finished}</span>
<span className="stat-label">Finished</span>
</div>
<div className="stat-card">
<span className="stat-value" style={{ color: '#3b82f6' }}>{stats.reading}</span>
<span className="stat-label">Reading</span>
</div>
<div className="stat-card">
<span className="stat-value" style={{ color: '#6b7280' }}>{stats.not_started}</span>
<span className="stat-label">Unread</span>
</div>
</div>
)}
{showForm && (
<div className="modal-overlay">
<div className="modal-content">
<h2>Add New Book</h2>
<BookForm
onSubmit={handleAddBook}
onCancel={() => setShowForm(false)}
/>
</div>
</div>
)}
<div className="library-controls">
<input
type="search"
className="search-input"
placeholder="Search by title or author..."
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
/>
<select
value={sortBy}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setSortBy(e.target.value);
setCurrentPage(1);
}}
className="sort-select"
>
<option value="-updated_at">Recently Updated</option>
<option value="title">Title (A-Z)</option>
<option value="-title">Title (Z-A)</option>
<option value="author">Author (A-Z)</option>
<option value="-author">Author (Z-A)</option>
<option value="-created_at">Recently Added</option>
<option value="-reading_progress">Most Progress</option>
<option value="reading_progress">Least Progress</option>
</select>
<select
value={statusFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setStatusFilter(e.target.value);
setCurrentPage(1);
}}
className="filter-select"
>
<option value="">All Status</option>
{(Object.keys(READING_STATUS_LABELS) as ReadingStatus[]).map((status) => (
<option key={status} value={status}>
{READING_STATUS_LABELS[status]}
</option>
))}
</select>
</div>
{loading ? (
<div className="loading-spinner">Loading books...</div>
) : books.length === 0 ? (
<div className="empty-state">
<p>No books found.</p>
{search || statusFilter ? (
<p>Try adjusting your search or filters.</p>
) : (
<p>Add your first book to get started!</p>
)}
</div>
) : (
<>
<div className="books-grid">
{books.map((book) => (
<BookCard key={book.id} book={book} onClick={handleBookClick} />
))}
</div>
{totalPages > 1 && (
<div className="pagination">
<button
className="btn btn-secondary"
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="page-info">
Page {currentPage} of {totalPages}
</span>
<button
className="btn btn-secondary"
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,120 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
interface AddAnnotationFormProps {
bookId: string;
page: number;
locationText?: string;
onClose?: () => void;
}
export function AddAnnotationForm({
bookId,
page,
locationText,
onClose,
}: AddAnnotationFormProps): React.ReactElement {
const { addBookmark, addNote } = useAnnotations();
const [mode, setMode] = useState<"bookmark" | "note" | null>(null);
const [noteContent, setNoteContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (): Promise<void> => {
setSubmitting(true);
setError(null);
try {
if (mode === "bookmark") {
await addBookmark({ book: bookId, page, location_text: locationText });
} else if (mode === "note") {
if (!noteContent.trim()) {
setError("Note content cannot be empty.");
setSubmitting(false);
return;
}
await addNote({
book: bookId,
page,
location_text: locationText,
content: noteContent.trim(),
});
}
onClose?.();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save annotation."
);
} finally {
setSubmitting(false);
}
};
return (
<div className="add-annotation-overlay">
<div className="add-annotation-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
<h3>Add to Page {page}</h3>
{locationText && (
<blockquote className="annotation-quote">
&ldquo;{locationText}&rdquo;
</blockquote>
)}
{!mode ? (
<div className="mode-selector">
<button
className="btn btn-block"
onClick={() => setMode("bookmark")}
>
Add Bookmark
</button>
<button
className="btn btn-block btn-secondary"
onClick={() => setMode("note")}
>
Add Note
</button>
</div>
) : (
<div className="annotation-form">
{mode === "note" && (
<div className="form-group">
<label htmlFor="note-content">Note:</label>
<textarea
id="note-content"
className="form-textarea"
value={noteContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setNoteContent(e.target.value)
}
rows={5}
placeholder="Write your note here..."
autoFocus
/>
</div>
)}
{error && <div className="form-error">{error}</div>}
<div className="form-actions">
<button
className="btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</button>
<button
className="btn btn-secondary"
onClick={() => setMode(null)}
disabled={submitting}
>
Back
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import React from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { AnnotationEntry } from "@/types";
interface AnnotationsDashboardProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function AnnotationsDashboard({
bookId,
onNavigateToPage,
}: AnnotationsDashboardProps): React.ReactElement {
const {
mergedAnnotations,
loadBookmarks,
loadNotes,
removeBookmark,
removeNote,
state,
} = useAnnotations();
React.useEffect(() => {
loadBookmarks(bookId);
loadNotes(bookId);
}, [loadBookmarks, loadNotes, bookId]);
const handleDelete = async (entry: AnnotationEntry): Promise<void> => {
if (entry.kind === "bookmark") {
await removeBookmark(entry.id);
} else {
await removeNote(entry.id);
}
};
if (state.bookmarksLoading || state.notesLoading) {
return <div className="annotations-loading">Loading annotations...</div>;
}
if (mergedAnnotations.length === 0) {
return (
<div className="annotations-empty">
No bookmarks or notes yet.
</div>
);
}
return (
<div className="annotations-dashboard">
<div className="annotations-summary">
<span className="summary-count">
{state.bookmarks.length} bookmarks
</span>
<span className="summary-separator">&middot;</span>
<span className="summary-count">
{state.notes.length} notes
</span>
</div>
<div className="annotations-list">
{mergedAnnotations.map((entry: AnnotationEntry) => (
<div key={`${entry.kind}-${entry.id}`} className="annotation-card">
<div className="annotation-card-header">
<span
className={`annotation-kind-badge ${
entry.kind === "bookmark" ? "bookmark-badge" : "note-badge"
}`}
>
{entry.kind === "bookmark" ? "Bookmark" : "Note"}
</span>
<span className="annotation-book-title">
{entry.book_title}
</span>
<span className="annotation-page">p.{entry.page}</span>
<span className="annotation-date">
{new Date(entry.created_at).toLocaleDateString()}
</span>
</div>
{entry.location_text && (
<blockquote className="annotation-quote">
&ldquo;{entry.location_text}&rdquo;
</blockquote>
)}
{entry.kind === "note" && entry.content && (
<p className="note-content-text">{entry.content}</p>
)}
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(entry.book_id, entry.page)
}
>
Go to page
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(entry)}
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { Bookmark } from "@/types";
interface BookmarkListProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function BookmarkList({
bookId,
onNavigateToPage,
}: BookmarkListProps): React.ReactElement {
const { state, loadBookmarks, removeBookmark } = useAnnotations();
const [deletingId, setDeletingId] = useState<string | null>(null);
React.useEffect(() => {
loadBookmarks(bookId);
}, [loadBookmarks, bookId]);
const handleDelete = async (id: string): Promise<void> => {
setDeletingId(id);
try {
await removeBookmark(id);
} catch {
// error handled by context
} finally {
setDeletingId(null);
}
};
if (state.bookmarksLoading) {
return <div className="annotations-loading">Loading bookmarks...</div>;
}
if (state.bookmarks.length === 0) {
return (
<div className="annotations-empty">
No bookmarks yet. Select a passage and add a bookmark while reading.
</div>
);
}
return (
<div className="annotations-list">
{state.bookmarks.map((bookmark: Bookmark) => (
<div key={bookmark.id} className="annotation-card">
<div className="annotation-card-header">
<span className="annotation-kind-badge bookmark-badge">
Bookmark
</span>
<span className="annotation-page">
Page {bookmark.page}
</span>
<span className="annotation-date">
{new Date(bookmark.created_at).toLocaleDateString()}
</span>
</div>
{bookmark.location_text && (
<blockquote className="annotation-quote">
&ldquo;{bookmark.location_text}&rdquo;
</blockquote>
)}
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(bookmark.book, bookmark.page)
}
>
Go to page
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(bookmark.id)}
disabled={deletingId === bookmark.id}
>
{deletingId === bookmark.id ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,23 @@
import React from "react";
import { AnnotationsDashboard } from "@/components/annotations";
interface BookmarksNotesPageProps {
bookId?: string;
}
export function BookmarksNotesPage({
bookId,
}: BookmarksNotesPageProps): React.ReactElement {
return (
<div className="page">
<h2>Bookmarks & Notes</h2>
<AnnotationsDashboard
bookId={bookId}
onNavigateToPage={(bookId, page) => {
// Navigate to the book reader page at the specific page
window.location.href = `/books/${bookId}?page=${page}`;
}}
/>
</div>
);
}
@@ -0,0 +1,148 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { Note } from "@/types";
interface NoteListProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function NoteList({
bookId,
onNavigateToPage,
}: NoteListProps): React.ReactElement {
const { state, loadNotes, editNote, removeNote } = useAnnotations();
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string>("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const [savingId, setSavingId] = useState<string | null>(null);
React.useEffect(() => {
loadNotes(bookId);
}, [loadNotes, bookId]);
const handleEdit = (note: Note): void => {
setEditingId(note.id);
setEditContent(note.content);
};
const handleSave = async (id: string): Promise<void> => {
setSavingId(id);
try {
await editNote(id, editContent.trim());
setEditingId(null);
} catch {
// error handled by context
} finally {
setSavingId(null);
}
};
const handleCancelEdit = (): void => {
setEditingId(null);
setEditContent("");
};
const handleDelete = async (id: string): Promise<void> => {
setDeletingId(id);
try {
await removeNote(id);
} catch {
// error handled by context
} finally {
setDeletingId(null);
}
};
if (state.notesLoading) {
return <div className="annotations-loading">Loading notes...</div>;
}
if (state.notes.length === 0) {
return (
<div className="annotations-empty">
No notes yet. Select a passage and add a note while reading.
</div>
);
}
return (
<div className="annotations-list">
{state.notes.map((note: Note) => (
<div key={note.id} className="annotation-card">
<div className="annotation-card-header">
<span className="annotation-kind-badge note-badge">Note</span>
<span className="annotation-page">Page {note.page}</span>
<span className="annotation-date">
{new Date(note.created_at).toLocaleDateString()}
</span>
</div>
{note.location_text && (
<blockquote className="annotation-quote">
&ldquo;{note.location_text}&rdquo;
</blockquote>
)}
<div className="annotation-note-content">
{editingId === note.id ? (
<div className="note-edit-form">
<textarea
className="note-edit-textarea"
value={editContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setEditContent(e.target.value)
}
rows={4}
autoFocus
/>
<div className="note-edit-actions">
<button
className="btn btn-sm"
onClick={() => handleSave(note.id)}
disabled={savingId === note.id || !editContent.trim()}
>
{savingId === note.id ? "Saving..." : "Save"}
</button>
<button
className="btn btn-sm btn-secondary"
onClick={handleCancelEdit}
>
Cancel
</button>
</div>
</div>
) : (
<p className="note-content-text">{note.content}</p>
)}
</div>
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(note.book, note.page)
}
>
Go to page
</button>
)}
{editingId !== note.id && (
<button
className="btn btn-sm btn-secondary"
onClick={() => handleEdit(note)}
>
Edit
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(note.id)}
disabled={deletingId === note.id}
>
{deletingId === note.id ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,4 @@
export { BookmarkList } from "./BookmarkList";
export { NoteList } from "./NoteList";
export { AddAnnotationForm } from "./AddAnnotationForm";
export { AnnotationsDashboard } from "./AnnotationsDashboard";
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
import { AnnotationsProvider } from "@/context/AnnotationsContext";
interface LayoutProps {
children: React.ReactNode;
title?: string;
}
export function Layout({
children,
title = "Cloud Reader",
}: LayoutProps): React.ReactElement {
return (
<AnnotationsProvider>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">{title}</h1>
<nav className="app-nav">
<a href="/" className="nav-link">Home</a>
<a href="/bookmarks-notes" className="nav-link">Bookmarks & Notes</a>
</nav>
</header>
<main className="app-main">{children}</main>
</div>
</AnnotationsProvider>
);
}
+1
View File
@@ -0,0 +1 @@
export { Layout } from "./Layout";
@@ -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>
);
}
+277
View File
@@ -0,0 +1,277 @@
import {
createContext,
useContext,
useReducer,
useCallback,
type ReactNode,
} from "react";
import type { Bookmark, Note, AnnotationEntry } from "@/types";
import * as annotationsApi from "@/api/annotations";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
interface AnnotationsState {
bookmarks: Bookmark[];
notes: Note[];
bookmarksLoading: boolean;
notesLoading: boolean;
error: string | null;
selectedBookId: string | null;
}
const initialState: AnnotationsState = {
bookmarks: [],
notes: [],
bookmarksLoading: false,
notesLoading: false,
error: null,
selectedBookId: null,
};
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
type AnnotationsAction =
| { type: "FETCH_BOOKMARKS_START" }
| { type: "FETCH_BOOKMARKS_SUCCESS"; payload: Bookmark[] }
| { type: "FETCH_NOTES_START" }
| { type: "FETCH_NOTES_SUCCESS"; payload: Note[] }
| { type: "SET_ERROR"; payload: string }
| { type: "CLEAR_ERROR" }
| { type: "REMOVE_BOOKMARK"; payload: string }
| { type: "REMOVE_NOTE"; payload: string }
| { type: "UPDATE_NOTE"; payload: Note }
| { type: "ADD_BOOKMARK"; payload: Bookmark }
| { type: "ADD_NOTE"; payload: Note }
| { type: "SET_SELECTED_BOOK"; payload: string | null };
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
function annotationsReducer(
state: AnnotationsState,
action: AnnotationsAction
): AnnotationsState {
switch (action.type) {
case "FETCH_BOOKMARKS_START":
return { ...state, bookmarksLoading: true, error: null };
case "FETCH_BOOKMARKS_SUCCESS":
return { ...state, bookmarks: action.payload, bookmarksLoading: false };
case "FETCH_NOTES_START":
return { ...state, notesLoading: true, error: null };
case "FETCH_NOTES_SUCCESS":
return { ...state, notes: action.payload, notesLoading: false };
case "SET_ERROR":
return { ...state, error: action.payload, bookmarksLoading: false, notesLoading: false };
case "CLEAR_ERROR":
return { ...state, error: null };
case "REMOVE_BOOKMARK":
return {
...state,
bookmarks: state.bookmarks.filter((b) => b.id !== action.payload),
};
case "REMOVE_NOTE":
return {
...state,
notes: state.notes.filter((n) => n.id !== action.payload),
};
case "UPDATE_NOTE":
return {
...state,
notes: state.notes.map((n) =>
n.id === action.payload.id ? action.payload : n
),
};
case "ADD_BOOKMARK":
return {
...state,
bookmarks: [action.payload, ...state.bookmarks],
};
case "ADD_NOTE":
return {
...state,
notes: [action.payload, ...state.notes],
};
case "SET_SELECTED_BOOK":
return { ...state, selectedBookId: action.payload };
default:
return state;
}
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface AnnotationsContextValue {
state: AnnotationsState;
loadBookmarks: (bookId?: string) => Promise<void>;
loadNotes: (bookId?: string) => Promise<void>;
addBookmark: (data: { book: string; page: number; location_text?: string }) => Promise<Bookmark>;
addNote: (data: { book: string; page: number; location_text?: string; content: string }) => Promise<Note>;
editNote: (id: string, content: string) => Promise<Note>;
removeBookmark: (id: string) => Promise<void>;
removeNote: (id: string) => Promise<void>;
setSelectedBook: (bookId: string | null) => void;
/** Merged list of bookmarks + notes, sorted by created_at desc */
mergedAnnotations: AnnotationEntry[];
}
const AnnotationsContext = createContext<AnnotationsContextValue | null>(null);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function AnnotationsProvider({
children,
}: {
children: ReactNode;
}): React.ReactElement {
const [state, dispatch] = useReducer(annotationsReducer, initialState);
const loadBookmarks = useCallback(async (bookId?: string) => {
dispatch({ type: "FETCH_BOOKMARKS_START" });
try {
const response = await annotationsApi.fetchBookmarks(bookId);
dispatch({ type: "FETCH_BOOKMARKS_SUCCESS", payload: response.results });
} catch (err) {
dispatch({
type: "SET_ERROR",
payload: err instanceof Error ? err.message : "Failed to load bookmarks",
});
}
}, []);
const loadNotes = useCallback(async (bookId?: string) => {
dispatch({ type: "FETCH_NOTES_START" });
try {
const response = await annotationsApi.fetchNotes(bookId);
dispatch({ type: "FETCH_NOTES_SUCCESS", payload: response.results });
} catch (err) {
dispatch({
type: "SET_ERROR",
payload: err instanceof Error ? err.message : "Failed to load notes",
});
}
}, []);
const addBookmark = useCallback(
async (data: {
book: string;
page: number;
location_text?: string;
}): Promise<Bookmark> => {
const bookmark = await annotationsApi.createBookmark(data);
dispatch({ type: "ADD_BOOKMARK", payload: bookmark });
return bookmark;
},
[]
);
const addNote = useCallback(
async (data: {
book: string;
page: number;
location_text?: string;
content: string;
}): Promise<Note> => {
const note = await annotationsApi.createNote(data);
dispatch({ type: "ADD_NOTE", payload: note });
return note;
},
[]
);
const editNote = useCallback(
async (id: string, content: string): Promise<Note> => {
const updated = await annotationsApi.updateNote(id, { content });
dispatch({ type: "UPDATE_NOTE", payload: updated });
return updated;
},
[]
);
const removeBookmark = useCallback(async (id: string) => {
await annotationsApi.deleteBookmark(id);
dispatch({ type: "REMOVE_BOOKMARK", payload: id });
}, []);
const removeNote = useCallback(async (id: string) => {
await annotationsApi.deleteNote(id);
dispatch({ type: "REMOVE_NOTE", payload: id });
}, []);
const setSelectedBook = useCallback((bookId: string | null) => {
dispatch({ type: "SET_SELECTED_BOOK", payload: bookId });
}, []);
// Build merged annotations list sorted by created_at desc
const mergedAnnotations: AnnotationEntry[] = [
...state.bookmarks.map(
(b): AnnotationEntry => ({
id: b.id,
kind: "bookmark",
book_title: b.book_title,
book_id: b.book,
page: b.page,
location_text: b.location_text,
created_at: b.created_at,
updated_at: b.updated_at,
})
),
...state.notes.map(
(n): AnnotationEntry => ({
id: n.id,
kind: "note",
book_title: n.book_title,
book_id: n.book,
page: n.page,
location_text: n.location_text,
content: n.content,
created_at: n.created_at,
updated_at: n.updated_at,
})
),
].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
const value: AnnotationsContextValue = {
state,
loadBookmarks,
loadNotes,
addBookmark,
addNote,
editNote,
removeBookmark,
removeNote,
setSelectedBook,
mergedAnnotations,
};
return (
<AnnotationsContext.Provider value={value}>
{children}
</AnnotationsContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAnnotations(): AnnotationsContextValue {
const context = useContext(AnnotationsContext);
if (!context) {
throw new Error(
"useAnnotations must be used within an AnnotationsProvider"
);
}
return context;
}
+63
View File
@@ -0,0 +1,63 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
import api from "../api/client";
interface AuthContextValue {
isAuthenticated: boolean;
loading: boolean;
user: { email: string } | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<{ email: string } | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("access_token");
if (token) {
try {
const payload = JSON.parse(atob(token.split(".")[1] ?? ""));
setUser({ email: payload.email ?? payload.sub ?? "user" });
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
}
}
setLoading(false);
}, []);
const login = useCallback(async (email: string, password: string) => {
const { data } = await api.post<{ access: string; refresh: string }>("/auth/token/", { email, password });
localStorage.setItem("access_token", data.access);
localStorage.setItem("refresh_token", data.refresh);
const payload = JSON.parse(atob(data.access.split(".")[1] ?? ""));
setUser({ email: payload.email ?? payload.sub ?? "user" });
}, []);
const register = useCallback(async (email: string, password: string) => {
await api.post("/auth/register/", { email, password });
await login(email, password);
}, [login]);
const logout = useCallback(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ isAuthenticated: !!user, loading, user, 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;
}
+4
View File
@@ -0,0 +1,4 @@
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;
}

Some files were not shown because too many files have changed in this diff Show More