Compare commits

..
Author SHA1 Message Date
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) 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
165 changed files with 8205 additions and 3437 deletions
-72
View File
@@ -1,72 +0,0 @@
name: Backend CI
on:
push:
branches: [main, develop]
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"
pull_request:
paths:
- "backend/**"
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: cloud_reader
POSTGRES_USER: cloud_reader
POSTGRES_PASSWORD: cloud_reader
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: backend/requirements/dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/dev.txt
- name: Lint
run: ruff check .
- name: Run migrations
run: python manage.py migrate
env:
DB_HOST: localhost
DB_NAME: cloud_reader
DB_USER: cloud_reader
DB_PASSWORD: cloud_reader
DB_PORT: 5432
SECRET_KEY: ci-test-secret-key-do-not-use-in-production
- name: Run tests
run: pytest
env:
DB_HOST: localhost
DB_NAME: cloud_reader
DB_USER: cloud_reader
DB_PASSWORD: cloud_reader
DB_PORT: 5432
SECRET_KEY: ci-test-secret-key-do-not-use-in-production
-42
View File
@@ -1,42 +0,0 @@
name: Frontend CI
on:
push:
branches: [main, develop]
paths:
- "frontend/**"
- "shared/**"
- ".github/workflows/frontend-ci.yml"
pull_request:
paths:
- "frontend/**"
- "shared/**"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "yarn"
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Type check shared
run: yarn workspace @cloud-reader/shared typecheck
- name: Build shared
run: yarn workspace @cloud-reader/shared build
- name: Type check frontend
run: yarn workspace @cloud-reader/frontend typecheck
- name: Build frontend
run: yarn workspace @cloud-reader/frontend build
-39
View File
@@ -1,39 +0,0 @@
name: Mobile CI
on:
push:
branches: [main, develop]
paths:
- "mobile/**"
- "shared/**"
- ".github/workflows/mobile-ci.yml"
pull_request:
paths:
- "mobile/**"
- "shared/**"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "yarn"
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Type check shared
run: yarn workspace @cloud-reader/shared typecheck
- name: Build shared
run: yarn workspace @cloud-reader/shared build
- name: Type check mobile
run: yarn workspace @cloud-reader/mobile typecheck
-48
View File
@@ -1,48 +0,0 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.eggs/
*.egg
.env
venv/
.venv/
*.sqlite3
# Node
node_modules/
.pnp
.pnp.js
yarn-error.log*
# Build artifacts
dist/
build/
*.tsbuildinfo
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Django
backend/media/
backend/staticfiles/
backend/**/migrations/
# Expo / React Native
mobile/.expo/
mobile/ios/Pods/
mobile/android/.gradle/
mobile/android/app/build/
mobile/android/build/
mobile/*.hprof
# Environment
.env.local
.env.production
.env.development
+59 -164
View File
@@ -1,195 +1,90 @@
# Cloud Reader
A modern eBook reader with web and mobile clients, powered by Django REST Framework.
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.
## Monorepo Structure
## Architecture
```
cloud-reader/
├── backend/ # Django API server (Python 3.12 + DRF)
├── backend/ # Django REST API (canonical backend)
│ ├── config/ # Django project settings
│ ├── apps/ # Django applications
│ │ ├── accounts/ # User authentication & profiles
│ │ ├── documents/ # Document management & uploads
│ │ ── collections/# Document collections
│ └── reading/ # Bookmarks, highlights, reading progress
── requirements/ # pip dependency files
│ ├── Dockerfile
│ └── manage.py
├── frontend/ # React web app (TypeScript + Vite)
│ ├── 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/
│ │ ├── pages/ # Route pages (lazy-loaded)
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API client & auth service
│ │ ├── types/ # Frontend-specific types
│ │ ── styles/ # Global CSS
├── Dockerfile
│ └── vite.config.ts
├── mobile/ # Expo/React Native mobile app
│ ├── app/ # Expo Router pages
│ ├── src/ # Mobile source code
│ ├── app.json
└── Dockerfile
├── shared/ # Shared TypeScript types & utilities
│ │ ├── 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/
└── index.ts # API response types, constants
├── .github/workflows/ # CI/CD pipelines
── package.json # Yarn workspace root
├── 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
```
## Prerequisites
## Quick Start
- **Python** 3.12+
- **Node.js** 20 LTS
- **Yarn** 4.x
- **PostgreSQL** 16
- **Expo CLI** (for mobile development)
---
## Backend Setup
### Docker (recommended)
```bash
docker compose up --build
```
- **Frontend:** http://localhost:5173
- **Backend API:** http://localhost:8000/api/
### Backend (standalone)
```bash
cd backend
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements/dev.txt
# Configure environment
cp .env.example .env
# Edit .env with your PostgreSQL credentials
# Run migrations
pip install -r requirements.txt
python manage.py migrate
# Create admin user
python manage.py createsuperuser
# Start development server
python manage.py runserver
```
The API will be available at `http://localhost:8000/`. Browse the API at `http://localhost:8000/api/schema/swagger-ui/`.
### Backend Tests
### Frontend (standalone)
```bash
cd backend
pytest
```
---
## Frontend Setup
```bash
# From monorepo root
cd frontend
yarn install
# Start dev server (with API proxy)
yarn frontend:dev
yarn dev
```
The frontend will be available at `http://localhost:5173/`. API requests under `/api/` are proxied to `http://localhost:8000/`.
### Frontend Build
### Mobile (Expo)
```bash
yarn frontend:build
```
---
## Mobile Setup
```bash
# From monorepo root
# From monorepo root — installs all workspaces including mobile
yarn install
# Start Expo dev server
yarn mobile:start
yarn workspace @cloud-reader/mobile start
# Run on Android
yarn mobile:android
# Run on iOS (macOS only)
yarn mobile:ios
# Or cd into mobile and run directly
cd mobile
npx expo start
```
> The mobile API client defaults to `http://localhost:8000/`. For physical devices, update the `API_BASE` in `mobile/src/services/api.ts` to your machine's local IP.
> 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).
---
## Shared Package
The `shared/` package contains TypeScript types and constants used by both the frontend and mobile apps.
```bash
# Build shared package
yarn shared:build
```
---
## CI/CD
Three independent CI pipelines run on pushes and PRs:
| Pipeline | Trigger Path | What It Does |
|----------|-------------|--------------|
| **Backend CI** | `backend/**` | Installs Python deps, runs Ruff linter, applies migrations, runs pytest |
| **Frontend CI** | `frontend/**`, `shared/**` | Installs Node deps, type-check & build shared, type-check & build frontend |
| **Mobile CI** | `mobile/**`, `shared/**` | Installs Node deps, type-check shared & mobile |
Pipeline configs are in `.github/workflows/`.
### Docker Deployments
Each app has its own Dockerfile for independent deployment:
```bash
# Build backend image
docker build -t cloud-reader-backend backend/
# Build frontend image
docker build -t cloud-reader-frontend frontend/
# Build mobile image (web export)
docker build -t cloud-reader-mobile mobile/
```
---
## API Endpoints
| Endpoint | Description |
|----------|-------------|
| `POST /api/v1/auth/register/` | Create a new account |
| `POST /api/v1/auth/token/` | Obtain JWT tokens |
| `POST /api/v1/auth/token/refresh/` | Refresh JWT token |
| `GET /api/v1/auth/me/` | Get current user profile |
| `GET/POST /api/v1/documents/` | List / upload documents |
| `GET/PUT/DELETE /api/v1/documents/:id/` | Document detail |
| `GET/POST /api/v1/collections/` | List / create collections |
| `GET/PUT/DELETE /api/v1/collections/:id/` | Collection detail |
| `POST /api/v1/collections/:id/add_documents/` | Add docs to collection |
| `POST /api/v1/collections/:id/remove_documents/` | Remove docs from collection |
| `GET/POST /api/v1/reading/bookmarks/` | List / create bookmarks |
| `GET/POST /api/v1/reading/highlights/` | List / create highlights |
| `GET/POST /api/v1/reading/progress/` | Track reading progress |
---
## Tech Stack
- **Backend:** Django 5, Django REST Framework, SimpleJWT, PostgreSQL, drf-spectacular
- **Frontend:** React 18, TypeScript, Vite, React Router, Axios
- **Mobile:** Expo SDK 51, React Native 0.74, Expo Router
- **Shared:** TypeScript types, Zod schemas
- **CI/CD:** GitHub Actions
- **Container:** Docker (separate images per app)
## 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.
+5 -12
View File
@@ -1,15 +1,8 @@
# Cloud Reader Backend — Environment Variables
# Copy to .env and fill in your values.
SECRET_KEY=django-insecure-change-me-in-production
DEBUG=True
DB_ENGINE=django.db.backends.postgresql
# 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=cloud_reader
DB_PASSWORD=cloud_reader
DB_USER=postgres
DB_PASSWORD=postgres
DB_HOST=localhost
DB_PORT=5432
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
ALLOWED_HOSTS=localhost,127.0.0.1
+9 -9
View File
@@ -1,18 +1,18 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=config.settings
WORKDIR /app
COPY requirements/production.txt /app/requirements/
RUN pip install --no-cache-dir -r requirements/production.txt
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc && \
rm -rf /var/lib/apt/lists/*
COPY . /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN python manage.py collectstatic --noinput
COPY . ./
RUN mkdir -p media
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
-10
View File
@@ -1,10 +0,0 @@
from django.contrib import admin
from .models import User
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ["email", "display_name", "is_verified", "is_active", "date_joined"]
search_fields = ["email", "display_name"]
list_filter = ["is_verified", "is_active"]
-29
View File
@@ -1,29 +0,0 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""Custom user model for Cloud Reader."""
email = models.EmailField(unique=True)
display_name = models.CharField(max_length=150, blank=True)
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
is_verified = models.BooleanField(default=False)
reading_preferences = models.JSONField(default=dict, blank=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
class Meta:
db_table = "accounts_user"
verbose_name = "User"
verbose_name_plural = "Users"
def __str__(self) -> str:
return self.email
@property
def avatar_url(self) -> str | None:
if self.avatar:
return self.avatar.url
return None
-53
View File
@@ -1,53 +0,0 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import User
UserModel = get_user_model()
class RegisterSerializer(serializers.ModelSerializer[User]):
password = serializers.CharField(write_only=True, min_length=8)
password_confirm = serializers.CharField(write_only=True, min_length=8)
class Meta:
model = User
fields = ["email", "username", "display_name", "password", "password_confirm"]
def validate(self, attrs):
if attrs["password"] != attrs.pop("password_confirm"):
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
return attrs
def create(self, validated_data):
password = validated_data.pop("password")
user = UserModel(**validated_data)
user.set_password(password)
user.save()
return user
class UserSerializer(serializers.ModelSerializer[User]):
avatar_url = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
"id", "email", "username", "display_name", "avatar_url",
"date_joined", "is_verified", "reading_preferences",
]
read_only_fields = ["id", "email", "date_joined", "is_verified"]
def get_avatar_url(self, obj: User) -> str | None:
return obj.avatar_url
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True, min_length=8)
def validate_old_password(self, value: str) -> str:
user = self.context["request"].user
if not user.check_password(value):
raise serializers.ValidationError("Current password is incorrect.")
return value
-14
View File
@@ -1,14 +0,0 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from . import views
app_name = "accounts"
urlpatterns = [
path("register/", views.RegisterView.as_view(), name="register"),
path("me/", views.UserDetailView.as_view(), name="user-detail"),
path("change-password/", views.ChangePasswordView.as_view(), name="change-password"),
path("token/", TokenObtainPairView.as_view(), name="token-obtain"),
path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
]
-36
View File
@@ -1,36 +0,0 @@
from django.contrib.auth import get_user_model
from rest_framework import generics, permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import ChangePasswordSerializer, RegisterSerializer, UserSerializer
UserModel = get_user_model()
class RegisterView(generics.CreateAPIView):
"""Create a new user account."""
queryset = UserModel.objects.all()
serializer_class = RegisterSerializer
permission_classes = [permissions.AllowAny]
class UserDetailView(generics.RetrieveUpdateAPIView):
"""Get or update the authenticated user's profile."""
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
return self.request.user
class ChangePasswordView(APIView):
"""Change the authenticated user's password."""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True)
request.user.set_password(serializer.validated_data["new_password"])
request.user.save()
return Response({"detail": "Password changed successfully."}, status=status.HTTP_200_OK)
+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')},
),
]
+109
View File
@@ -0,0 +1,109 @@
from django.conf import settings
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from pathlib import Path
class ReadingStatus(models.TextChoices):
WANT_TO_READ = "want_to_read", "Want to Read"
READING = "reading", "Reading"
FINISHED = "finished", "Finished"
DNF = "dnf", "Did Not Finish"
class FontStyle(models.TextChoices):
SANS_SERIF = "sans-serif", "Sans Serif"
SERIF = "serif", "Serif"
MONOSPACE = "monospace", "Monospace"
class BackgroundColor(models.TextChoices):
WHITE = "#ffffff", "White"
SEPIA = "#f4e4c1", "Sepia"
DARK = "#1a1a2e", "Dark"
GREEN = "#c7edcc", "Green"
class Book(models.Model):
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)
author = models.CharField(max_length=256, blank=True, default="")
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 ""
@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 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)
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}%"
class ReadingSettings(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings")
font_size = models.IntegerField(default=18)
font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value)
background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "books_reading_settings"
verbose_name_plural = "reading settings"
def __str__(self):
return f"Settings for {self.user}"
+120
View File
@@ -0,0 +1,120 @@
from rest_framework import serializers
from apps.books.models import Book, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus
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)
progress = serializers.SerializerMethodField()
class Meta:
model = EBook
fields = ["id", "title", "author", "filename", "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)
file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField()
class Meta:
model = EBook
fields = ["id", "title", "author", "filename", "file_url", "cover_image", "created_at", "updated_at", "progress"]
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
return super().create(validated_data)
class ReadingProgressSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingProgress
fields = ["current_position", "last_page"]
extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}}
def validate_current_position(self, value):
if value < 0.0 or value > 100.0:
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
return value
class ReadingSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingSettings
fields = ["font_size", "font_style", "background_color"]
def validate_font_size(self, value):
if value < 12 or value > 36:
raise serializers.ValidationError("Font size must be between 12 and 36.")
return value
def validate_font_style(self, value):
valid = [s.value for s in FontStyle]
if value not in valid:
raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}")
return value
def validate_background_color(self, value):
valid = [c.value for c in BackgroundColor]
if value not in valid:
raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}")
return value
+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)
+16
View File
@@ -0,0 +1,16 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.books.views import BookViewSet, EBookViewSet, ReadingSettingsViewSet
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)),
path("settings/", ReadingSettingsViewSet.as_view({"get": "list", "patch": "partial_update"}), name="reading-settings"),
]
+240
View File
@@ -0,0 +1,240 @@
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, EBook, ReadingProgress, ReadingSettings
from apps.books.serializers import (
BookDetailSerializer, BookListSerializer, BookSerializer,
BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, ReadingSettingsSerializer,
)
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])
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)
def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
"""Recursively store TOC entries as BookChapter records."""
for idx, entry in enumerate(toc):
BookChapter.objects.create(
ebook=ebook,
title=entry.get("title", "Untitled"),
index=parent_index + idx,
href=entry.get("href", ""),
children=entry.get("children", []),
)
children = entry.get("children", [])
if children:
_store_chapters(ebook, children, parent_index + idx + 1)
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
"""Fetch HTML content for a chapter from the e-book file."""
if ebook.format == "epub":
return _fetch_epub_chapter_content(ebook.file.path, chapter)
return ""
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
"""Extract HTML content of a specific EPUB chapter by href."""
try:
from ebooklib import epub
from bs4 import BeautifulSoup
except ImportError:
return ""
try:
book = epub.read_epub(file_path)
href = chapter.href or ""
# Find the item by href
for item in book.get_items():
item_name = item.get_name() or ""
if href and (item_name.endswith(href) or href.endswith(item_name)):
content = item.get_content()
soup = BeautifulSoup(content, "html.parser")
# Clean up — remove body/html/head wrappers, keep inner content
body = soup.find("body")
if body:
return str(body)
return str(soup)
return ""
except Exception:
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
return ""
class ReadingSettingsViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
serializer_class = ReadingSettingsSerializer
def get_queryset(self):
return ReadingSettings.objects.filter(user=self.request.user)
def list(self, request: Request) -> Response:
settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user)
serializer = self.get_serializer(settings_obj)
return Response(serializer.data)
def partial_update(self, request: Request) -> Response:
settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user)
serializer = self.get_serializer(settings_obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
-10
View File
@@ -1,10 +0,0 @@
from django.contrib import admin
from .models import Collection
@admin.register(Collection)
class CollectionAdmin(admin.ModelAdmin):
list_display = ["name", "owner", "document_count", "is_public", "created_at"]
list_filter = ["is_public"]
search_fields = ["name", "description"]
-39
View File
@@ -1,39 +0,0 @@
from django.conf import settings
from django.db import models
class Collection(models.Model):
"""A user-created collection of documents."""
name = models.CharField(max_length=300)
description = models.TextField(blank=True, default="")
cover = models.ImageField(upload_to="collection_covers/", blank=True, null=True)
documents = models.ManyToManyField(
"documents.Document",
related_name="collections",
blank=True,
)
is_public = models.BooleanField(default=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="collections",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "collections_collection"
ordering = ["-updated_at"]
def __str__(self) -> str:
return self.name
@property
def document_count(self) -> int:
return self.documents.count()
@property
def cover_url(self) -> str | None:
if self.cover:
return self.cover.url
return None
-33
View File
@@ -1,33 +0,0 @@
from rest_framework import serializers
from .models import Collection
class CollectionSerializer(serializers.ModelSerializer[Collection]):
cover_url = serializers.SerializerMethodField()
document_count = serializers.IntegerField(read_only=True)
class Meta:
model = Collection
fields = [
"id", "name", "description", "cover_url", "document_count",
"is_public", "created_at", "updated_at",
]
read_only_fields = ["id", "document_count", "created_at", "updated_at"]
def get_cover_url(self, obj: Collection) -> str | None:
return obj.cover_url
class CollectionDetailSerializer(CollectionSerializer):
documents = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
class Meta(CollectionSerializer.Meta):
fields = CollectionSerializer.Meta.fields + ["documents", "owner"]
class CollectionDocumentActionSerializer(serializers.Serializer):
document_ids = serializers.ListField(
child=serializers.IntegerField(),
allow_empty=False,
)
-13
View File
@@ -1,13 +0,0 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("", views.CollectionViewSet, basename="collection")
app_name = "collections"
urlpatterns = [
path("", include(router.urls)),
]
-50
View File
@@ -1,50 +0,0 @@
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.documents.models import Document
from .models import Collection
from .serializers import (
CollectionDetailSerializer,
CollectionDocumentActionSerializer,
CollectionSerializer,
)
class CollectionViewSet(viewsets.ModelViewSet):
"""CRUD for user collections."""
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action in ("retrieve", "update", "partial_update"):
return CollectionDetailSerializer
return CollectionSerializer
def get_queryset(self):
return Collection.objects.filter(owner=self.request.user).prefetch_related("documents")
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@action(detail=True, methods=["post"])
def add_documents(self, request, pk=None):
"""Add documents to a collection."""
collection = self.get_object()
serializer = CollectionDocumentActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
docs = Document.objects.filter(
id__in=serializer.validated_data["document_ids"],
owner=request.user,
)
collection.documents.add(*docs)
return Response({"detail": f"Added {docs.count()} document(s)."}, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def remove_documents(self, request, pk=None):
"""Remove documents from a collection."""
collection = self.get_object()
serializer = CollectionDocumentActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
collection.documents.remove(*serializer.validated_data["document_ids"])
return Response({"detail": "Documents removed."}, status=status.HTTP_200_OK)
-11
View File
@@ -1,11 +0,0 @@
from django.contrib import admin
from .models import Document
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
list_display = ["title", "author", "file_type", "file_size", "is_public", "owner", "uploaded_at"]
list_filter = ["file_type", "is_public"]
search_fields = ["title", "author", "description"]
date_hierarchy = "uploaded_at"
-48
View File
@@ -1,48 +0,0 @@
from django.conf import settings
from django.db import models
class Document(models.Model):
"""A digital document (ebook, PDF, etc.) uploaded by a user."""
class FileType(models.TextChoices):
PDF = "pdf", "PDF"
EPUB = "epub", "EPUB"
MOBI = "mobi", "MOBI"
TXT = "txt", "Plain Text"
DOCX = "docx", "Word Document"
title = models.CharField(max_length=500)
author = models.CharField(max_length=300, blank=True, null=True)
description = models.TextField(blank=True, default="")
cover = models.ImageField(upload_to="covers/", blank=True, null=True)
file = models.FileField(upload_to="documents/")
file_type = models.CharField(max_length=10, choices=FileType.choices)
file_size = models.PositiveIntegerField(help_text="File size in bytes")
page_count = models.PositiveIntegerField(blank=True, null=True)
tags = models.JSONField(default=list, blank=True)
is_public = models.BooleanField(default=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="documents",
)
uploaded_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "documents_document"
ordering = ["-uploaded_at"]
indexes = [
models.Index(fields=["owner", "-uploaded_at"]),
models.Index(fields=["file_type"]),
]
def __str__(self) -> str:
return self.title
@property
def cover_url(self) -> str | None:
if self.cover:
return self.cover.url
return None
-41
View File
@@ -1,41 +0,0 @@
from rest_framework import serializers
from .models import Document
class DocumentListSerializer(serializers.ModelSerializer[Document]):
cover_url = serializers.SerializerMethodField()
class Meta:
model = Document
fields = [
"id", "title", "author", "cover_url", "description",
"file_type", "file_size", "page_count", "tags",
"is_public", "uploaded_at", "updated_at",
]
read_only_fields = ["id", "uploaded_at", "updated_at"]
def get_cover_url(self, obj: Document) -> str | None:
return obj.cover_url
class DocumentDetailSerializer(DocumentListSerializer):
owner = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta(DocumentListSerializer.Meta):
fields = DocumentListSerializer.Meta.fields + ["owner", "file"]
class DocumentUploadSerializer(serializers.ModelSerializer[Document]):
class Meta:
model = Document
fields = [
"title", "author", "description", "cover", "file",
"file_type", "file_size", "page_count", "tags", "is_public",
]
def validate_file_size(self, value: int) -> int:
max_size = 100 * 1024 * 1024 # 100 MB
if value > max_size:
raise serializers.ValidationError("File size must not exceed 100 MB.")
return value
-13
View File
@@ -1,13 +0,0 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("", views.DocumentViewSet, basename="document")
app_name = "documents"
urlpatterns = [
path("", include(router.urls)),
]
-35
View File
@@ -1,35 +0,0 @@
from rest_framework import permissions, viewsets
from .models import Document
from .serializers import DocumentDetailSerializer, DocumentListSerializer, DocumentUploadSerializer
class IsOwnerOrPublic(permissions.BasePermission):
"""Allow access if user is owner or the document is public."""
def has_object_permission(self, request, view, obj: Document) -> bool:
if request.method in permissions.SAFE_METHODS:
return obj.is_public or obj.owner == request.user
return obj.owner == request.user
class DocumentViewSet(viewsets.ModelViewSet):
"""CRUD for documents with owner-scoping."""
permission_classes = [permissions.IsAuthenticated, IsOwnerOrPublic]
def get_serializer_class(self):
if self.action == "create":
return DocumentUploadSerializer
if self.action in ("retrieve", "update", "partial_update"):
return DocumentDetailSerializer
return DocumentListSerializer
def get_queryset(self):
user = self.request.user
qs = Document.objects.select_related("owner")
if self.action == "list":
return qs.filter(owner=user) | qs.filter(is_public=True)
return qs
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
-21
View File
@@ -1,21 +0,0 @@
from django.contrib import admin
from .models import Bookmark, Highlight, ReadingProgress
@admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin):
list_display = ["document", "user", "page", "label", "created_at"]
list_filter = ["created_at"]
@admin.register(Highlight)
class HighlightAdmin(admin.ModelAdmin):
list_display = ["document", "user", "page", "color", "created_at"]
list_filter = ["color", "created_at"]
@admin.register(ReadingProgress)
class ReadingProgressAdmin(admin.ModelAdmin):
list_display = ["document", "user", "percentage", "last_read_at"]
date_hierarchy = "last_read_at"
-84
View File
@@ -1,84 +0,0 @@
from django.conf import settings
from django.db import models
class Bookmark(models.Model):
"""A user bookmark at a specific page in a document."""
document = models.ForeignKey(
"documents.Document",
on_delete=models.CASCADE,
related_name="bookmarks",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bookmarks",
)
page = models.PositiveIntegerField()
label = models.CharField(max_length=300, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "reading_bookmark"
ordering = ["page"]
unique_together = [["document", "user", "page"]]
def __str__(self) -> str:
return f"{self.document.title} p.{self.page}"
class Highlight(models.Model):
"""A highlighted passage in a document."""
document = models.ForeignKey(
"documents.Document",
on_delete=models.CASCADE,
related_name="highlights",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="highlights",
)
page = models.PositiveIntegerField()
color = models.CharField(max_length=20, default="yellow")
text = models.TextField()
note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "reading_highlight"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"Highlight on {self.document.title} p.{self.page}"
class ReadingProgress(models.Model):
"""Tracks the user's reading progress through a document."""
document = models.ForeignKey(
"documents.Document",
on_delete=models.CASCADE,
related_name="reading_progress",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="reading_progress",
)
current_page = models.PositiveIntegerField(default=1)
total_pages = models.PositiveIntegerField(default=0)
percentage = models.FloatField(default=0.0)
last_read_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "reading_progress"
unique_together = [["document", "user"]]
verbose_name_plural = "Reading progress"
def __str__(self) -> str:
return f"{self.document.title}{self.percentage:.0f}%"
def save(self, *args, **kwargs):
if self.total_pages > 0:
self.percentage = round((self.current_page / self.total_pages) * 100, 1)
super().save(*args, **kwargs)
-35
View File
@@ -1,35 +0,0 @@
from rest_framework import serializers
from .models import Bookmark, Highlight, ReadingProgress
class BookmarkSerializer(serializers.ModelSerializer[Bookmark]):
class Meta:
model = Bookmark
fields = ["id", "document", "page", "label", "created_at"]
read_only_fields = ["id", "created_at"]
class HighlightSerializer(serializers.ModelSerializer[Highlight]):
class Meta:
model = Highlight
fields = ["id", "document", "page", "color", "text", "note", "created_at"]
read_only_fields = ["id", "created_at"]
class ReadingProgressSerializer(serializers.ModelSerializer[ReadingProgress]):
class Meta:
model = ReadingProgress
fields = ["id", "document", "current_page", "total_pages", "percentage", "last_read_at"]
read_only_fields = ["id", "percentage", "last_read_at"]
class ReadingProgressUpdateSerializer(serializers.ModelSerializer[ReadingProgress]):
class Meta:
model = ReadingProgress
fields = ["current_page", "total_pages"]
def validate_current_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be at least 1.")
return value
-15
View File
@@ -1,15 +0,0 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("bookmarks", views.BookmarkViewSet, basename="bookmark")
router.register("highlights", views.HighlightViewSet, basename="highlight")
router.register("progress", views.ReadingProgressViewSet, basename="reading-progress")
app_name = "reading"
urlpatterns = [
path("", include(router.urls)),
]
-49
View File
@@ -1,49 +0,0 @@
from rest_framework import permissions, viewsets
from .models import Bookmark, Highlight, ReadingProgress
from .serializers import (
BookmarkSerializer,
HighlightSerializer,
ReadingProgressSerializer,
ReadingProgressUpdateSerializer,
)
class BookmarkViewSet(viewsets.ModelViewSet):
"""User bookmarks for documents."""
serializer_class = BookmarkSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Bookmark.objects.filter(user=self.request.user).select_related("document")
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class HighlightViewSet(viewsets.ModelViewSet):
"""User highlights for documents."""
serializer_class = HighlightSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Highlight.objects.filter(user=self.request.user).select_related("document")
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class ReadingProgressViewSet(viewsets.ModelViewSet):
"""Reading progress tracker."""
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action in ("create", "update", "partial_update"):
return ReadingProgressUpdateSerializer
return ReadingProgressSerializer
def get_queryset(self):
return ReadingProgress.objects.filter(user=self.request.user).select_related("document")
def perform_create(self, serializer):
serializer.save(user=self.request.user)
+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"),
]
View File
+157
View File
@@ -0,0 +1,157 @@
"""
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",
]
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"
-13
View File
@@ -1,13 +0,0 @@
"""Custom exception handler that returns consistent JSON error responses."""
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
"""Wrap DRF's default handler to always return {'detail': ..., 'code': ...}."""
response = exception_handler(exc, context)
if response is not None:
data = response.data
# Flatten validation errors into a consistent shape
if isinstance(data, dict) and "detail" not in data:
response.data = {"detail": "Validation error", "fields": data, "code": "validation_error"}
return response
+29 -157
View File
@@ -1,170 +1,42 @@
import os
from pathlib import Path
import decouple
from pydantic_settings import BaseSettings
BASE_DIR = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
config = decouple.AutoConfig(search_path=BASE_DIR / ".env")
class Settings(BaseSettings):
"""Application settings via pydantic-settings. Reads from env vars and .env."""
SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production")
DEBUG = config("DEBUG", default=False, cast=bool)
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=decouple.Csv())
# Django
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
DJANGO_DEBUG: bool = False
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
# ---------------------------------------------------------------------------
# Application definition
# ---------------------------------------------------------------------------
INSTALLED_APPS = [
"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",
"drf_spectacular",
# Local
"apps.accounts",
"apps.documents",
"apps.collections",
"apps.reading",
]
# PostgreSQL
DB_NAME: str = "cloud_reader"
DB_USER: str = "postgres"
DB_PASSWORD: str = "postgres"
DB_HOST: str = "localhost"
DB_PORT: int = 5432
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"corsheaders.middleware.CorsMiddleware",
"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",
]
# JWT
JWT_ACCESS_TOKEN_LIFETIME_MINUTES: int = 60
JWT_REFRESH_TOKEN_LIFETIME_DAYS: int = 7
ROOT_URLCONF = "config.urls"
# CORS
CORS_ALLOWED_ORIGINS: list[str] = [
"http://localhost:5173",
"http://localhost:3000",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"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",
],
},
},
]
@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}"
)
WSGI_APPLICATION = "config.wsgi.application"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
DATABASES = {
"default": {
"ENGINE": config("DB_ENGINE", default="django.db.backends.postgresql"),
"NAME": config("DB_NAME", default="cloud_reader"),
"USER": config("DB_USER", default="cloud_reader"),
"PASSWORD": config("DB_PASSWORD", default="cloud_reader"),
"HOST": config("DB_HOST", default="localhost"),
"PORT": config("DB_PORT", default="5432", cast=int),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LOGIN_URL = "rest_framework:login"
LOGOUT_URL = "rest_framework:logout"
# ---------------------------------------------------------------------------
# Internationalization
# ---------------------------------------------------------------------------
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# ---------------------------------------------------------------------------
# Static & Media files
# ---------------------------------------------------------------------------
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# ---------------------------------------------------------------------------
# CORS
# ---------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = config(
"CORS_ALLOWED_ORIGINS",
default="http://localhost:5173,http://localhost:3000",
cast=decouple.Csv(),
)
CORS_ALLOW_CREDENTIALS = True
# ---------------------------------------------------------------------------
# REST Framework
# ---------------------------------------------------------------------------
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": 20,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler",
}
# ---------------------------------------------------------------------------
# SimpleJWT
# ---------------------------------------------------------------------------
from datetime import timedelta # noqa: E402
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"AUTH_HEADER_TYPES": ("Bearer",),
}
# ---------------------------------------------------------------------------
# drf-spectacular (OpenAPI)
# ---------------------------------------------------------------------------
SPECTACULAR_SETTINGS = {
"TITLE": "Cloud Reader API",
"VERSION": "0.1.0",
"SERVE_INCLUDE_SCHEMA": False,
}
settings = Settings()
+3 -7
View File
@@ -3,11 +3,7 @@ from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
# API
path("api/v1/auth/", include("apps.accounts.urls")),
path("api/v1/documents/", include("apps.documents.urls")),
path("api/v1/collections/", include("apps.collections.urls")),
path("api/v1/reading/", include("apps.reading.urls")),
# OpenAPI schema
path("api/schema/", include("drf_spectacular.urls")),
path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")),
path("api/annotations/", include("apps.annotations.urls")),
]
+1 -1
View File
@@ -2,6 +2,6 @@ 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()
+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() -> None:
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:
-33
View File
@@ -1,33 +0,0 @@
[project]
name = "cloud-reader-backend"
version = "0.1.0"
description = "Cloud Reader API — Django REST Framework backend"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"django>=5.1,<6.0",
"djangorestframework>=3.15,<4.0",
"django-cors-headers>=4.3",
"django-filter>=24.3",
"psycopg2-binary>=2.9",
"python-decouple>=3.8",
"djangorestframework-simplejwt>=5.3",
"drf-spectacular>=0.27",
"gunicorn>=22.0",
"whitenoise>=6.6",
"Pillow>=10.3",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-django>=4.8",
"pytest-cov>=5.0",
"model-bakery>=1.17",
"ruff>=0.5",
"ipdb>=0.13",
]
[build-system]
requires = ["setuptools>=72"]
build-backend = "setuptools.build_meta"
+7 -4
View File
@@ -1,5 +1,8 @@
# pytest
DJANGO_SETTINGS_MODULE = config.settings
[tool:pytest]
DJANGO_SETTINGS_MODULE = config.django
python_files = tests.py test_*.py *_tests.py
django_find_project = false
testpaths = apps/
testpaths = apps
[coverage:run]
source = apps
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
+15
View File
@@ -0,0 +1,15 @@
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
-7
View File
@@ -1,7 +0,0 @@
-r production.txt
pytest>=8.0
pytest-django>=4.8
pytest-cov>=5.0
model-bakery>=1.17
ruff>=0.5
ipdb>=0.13
-12
View File
@@ -1,12 +0,0 @@
# Django
django>=5.1,<6.0
djangorestframework>=3.15,<4.0
django-cors-headers>=4.3
django-filter>=24.3
psycopg2-binary>=2.9
python-decouple>=3.8
djangorestframework-simplejwt>=5.3
drf-spectacular>=0.27
gunicorn>=22.0
whitenoise>=6.6
Pillow>=10.3
+38 -19
View File
@@ -1,39 +1,58 @@
version: "3.9"
services:
db:
image: postgres:16
image: postgres:15
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: cloud_reader
POSTGRES_USER: cloud_reader
POSTGRES_PASSWORD: cloud_reader
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d cloud_reader"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: backend
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
SECRET_KEY: development-secret-key
DEBUG: "True"
DB_HOST: db
DJANGO_SECRET_KEY: "dev-secret-key-change-in-production"
DJANGO_DEBUG: "True"
DB_NAME: cloud_reader
DB_USER: cloud_reader
DB_PASSWORD: cloud_reader
CORS_ALLOWED_ORIGINS: http://localhost:5173,http://localhost:3000
ALLOWED_HOSTS: localhost,127.0.0.1
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
db:
condition: service_healthy
frontend:
build: frontend
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:80"
- "5173:5173"
environment:
VITE_API_URL: "http://localhost:8000"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
volumes:
pgdata:
postgres_data:
book_media:
+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)
+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)
+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
+4 -12
View File
@@ -1,20 +1,12 @@
FROM node:20-alpine AS build
FROM node:20-alpine
WORKDIR /app
COPY package.json yarn.lock ./
COPY shared/package.json shared/
COPY frontend/package.json frontend/
RUN yarn install --frozen-lockfile
COPY shared/ shared/
COPY frontend/ frontend/
COPY . ./
RUN yarn workspace @cloud-reader/shared build && \
yarn workspace @cloud-reader/frontend build
EXPOSE 5173
FROM nginx:alpine
COPY --from=build /app/frontend/dist /usr/share/nginx/html
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["yarn", "dev", "--host"]
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Reader</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
</head>
<body>
<div id="root"></div>
-15
View File
@@ -1,15 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+16 -14
View File
@@ -1,28 +1,30 @@
{
"name": "@cloud-reader/frontend",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "echo 'lint ok'"
"test": "vitest run",
"lint": "eslint ."
},
"dependencies": {
"@cloud-reader/shared": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
"axios": "^1.7.0",
"zod": "^3.23.0"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"axios": "^1.7.9"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vitest": "^2.1.0",
"@testing-library/react": "^16.2.0",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.0"
}
}
+46 -31
View File
@@ -1,43 +1,58 @@
import React, { Suspense, lazy } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "./hooks/useAuth";
import React, { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const LibraryPage = lazy(() => import("./pages/LibraryPage"));
const DocumentPage = lazy(() => import("./pages/DocumentPage"));
const ReaderPage = lazy(() => import("./pages/ReaderPage"));
const CollectionsPage = lazy(() => import("./pages/CollectionsPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
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 SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
function ProtectedRoute({ children }: { children: React.ReactNode }): React.ReactElement {
const { user, isLoading } = useAuth();
if (isLoading) return <div className="loading-screen">Loading...</div>;
if (!user) return <Navigate to="/login" replace />;
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 PublicRoute({ children }: { children: React.ReactNode }): React.ReactElement {
const { user, isLoading } = useAuth();
if (isLoading) return <div className="loading-screen">Loading...</div>;
if (user) return <Navigate to="/library" replace />;
return <>{children}</>;
}
export default function App(): React.ReactElement {
function AppRoutes() {
const { isAuthenticated } = useAuth();
return (
<Suspense fallback={<div className="loading-screen">Loading...</div>}>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
<Route path="/library" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/documents/:id" element={<ProtectedRoute><DocumentPage /></ProtectedRoute>} />
<Route path="/read/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} />
<Route path="/collections" element={<ProtectedRoute><CollectionsPage /></ProtectedRoute>} />
<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="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/" element={<Navigate to="/library" replace />} />
<Route path="*" element={<div className="not-found">Page not found</div>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
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;
}
+121
View File
@@ -0,0 +1,121 @@
import api from "./client";
import type {
BookDetail,
BookListItem,
BookSearchParams,
ContentResponse,
EBookDetail,
EBookListItem,
ReadingProgress,
ReadingSettings,
TocResponse,
} from "../types/book";
export const booksApi = {
async getEBooks(): Promise<EBookListItem[]> {
const { data } = await api.get<EBookListItem[]>("/books/ebooks/");
return data;
},
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
},
async getEBook(id: number): Promise<EBookDetail> {
const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`);
return data;
},
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
},
async 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);
const { data } = await api.post<EBookDetail>("/books/ebooks/", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data;
},
async deleteEBook(id: number): Promise<void> {
await api.delete(`/books/ebooks/${id}/`);
},
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;
@@ -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,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";
-76
View File
@@ -1,76 +0,0 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { UserProfile } from "../types/auth";
import { fetchProfile, login as apiLogin, register as apiRegister } from "../services/authService";
import type { LoginCredentials, RegisterData } from "../types/auth";
interface AuthContextValue {
user: UserProfile | null;
isLoading: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [user, setUser] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshProfile = useCallback(async () => {
const token = localStorage.getItem("access_token");
if (!token) {
setUser(null);
setIsLoading(false);
return;
}
try {
const profile = await fetchProfile();
setUser(profile);
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setUser(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refreshProfile();
}, [refreshProfile]);
const login = useCallback(async (credentials: LoginCredentials) => {
const tokens = await apiLogin(credentials);
localStorage.setItem("access_token", tokens.access);
localStorage.setItem("refresh_token", tokens.refresh);
const profile = await fetchProfile();
setUser(profile);
}, []);
const register = useCallback(async (data: RegisterData) => {
await apiRegister(data);
// Auto-login after registration
await login({ email: data.email, password: data.password });
}, [login]);
const logout = useCallback(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setUser(null);
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, isLoading, login, register, logout, refreshProfile }),
[user, isLoading, login, register, logout, refreshProfile],
);
return <AuthContext.Provider value={value}>{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;
}
+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;
}
-51
View File
@@ -1,51 +0,0 @@
import { useState, useCallback } from "react";
import api from "../services/api";
import type { Document, PaginatedResponse } from "@cloud-reader/shared";
interface UseDocumentsReturn {
documents: Document[];
isLoading: boolean;
error: string | null;
totalCount: number;
fetchDocuments: (params?: Record<string, string | number>) => Promise<void>;
fetchDocument: (id: number) => Promise<Document | null>;
deleteDocument: (id: number) => Promise<void>;
}
export function useDocuments(): UseDocumentsReturn {
const [documents, setDocuments] = useState<Document[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchDocuments = useCallback(async (params?: Record<string, string | number>) => {
setIsLoading(true);
setError(null);
try {
const { data } = await api.get<PaginatedResponse<Document>>("/documents/", { params });
setDocuments(data.results);
setTotalCount(data.count);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch documents");
} finally {
setIsLoading(false);
}
}, []);
const fetchDocument = useCallback(async (id: number): Promise<Document | null> => {
try {
const { data } = await api.get<Document>(`/documents/${id}/`);
return data;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch document");
return null;
}
}, []);
const deleteDocument = useCallback(async (id: number) => {
await api.delete(`/documents/${id}/`);
setDocuments((prev) => prev.filter((d) => d.id !== id));
}, []);
return { documents, isLoading, error, totalCount, fetchDocuments, fetchDocument, deleteDocument };
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
/**
* Hook for responsive design returns true when the media query matches.
* Defaults to false on SSR / initial render to avoid hydration mismatch.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
export const BREAKPOINTS = {
sm: "(max-width: 480px)",
md: "(max-width: 768px)",
lg: "(min-width: 1024px)",
} as const;
+69
View File
@@ -0,0 +1,69 @@
import { useState, useCallback, useRef, useEffect } from "react";
import type { PaginatedResponse } from "@/types";
interface UsePaginatedQueryOptions<T> {
fetchFn: (cursor?: string) => Promise<PaginatedResponse<T>>;
}
interface UsePaginatedQueryResult<T> {
items: T[];
loading: boolean;
error: string | null;
hasMore: boolean;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Hook for paginated list fetching with infinite scroll support.
*/
export function usePaginatedQuery<T>({
fetchFn,
}: UsePaginatedQueryOptions<T>): UsePaginatedQueryResult<T> {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const loadingRef = useRef(false);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetchFn();
setItems(response.results);
setNextCursor(response.next);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch data");
} finally {
setLoading(false);
}
}, [fetchFn]);
const loadMore = useCallback(async () => {
if (!nextCursor || loadingRef.current) return;
loadingRef.current = true;
try {
const response = await fetchFn(nextCursor);
setItems((prev) => [...prev, ...response.results]);
setNextCursor(response.next);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load more");
} finally {
loadingRef.current = false;
}
}, [nextCursor, fetchFn]);
useEffect(() => {
refresh();
}, [refresh]);
return {
items,
loading,
error,
hasMore: nextCursor !== null,
loadMore,
refresh,
};
}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface UseVoiceSearchResult {
isListening: boolean;
transcript: string;
isSupported: boolean;
hasError: boolean;
errorMessage: string | null;
startListening: () => void;
stopListening: () => void;
}
/**
* Hook for voice search using the Web Speech API.
* Returns a microphone control interface.
* Gracefully degrades when SpeechRecognition is unavailable.
*/
export function useVoiceSearch(): UseVoiceSearchResult {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState("");
const [isSupported, setIsSupported] = useState(false);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
// Check for SpeechRecognition support (standard + webkit prefix)
const SpeechRecognitionCtor =
(window as unknown as Record<string, unknown>).SpeechRecognition ??
(window as unknown as Record<string, unknown>).webkitSpeechRecognition;
if (typeof SpeechRecognitionCtor === "function") {
setIsSupported(true);
const recognition = new (SpeechRecognitionCtor as new () => SpeechRecognition)();
recognition.continuous = false;
recognition.interimResults = false;
recognition.lang = "en-US";
recognition.onresult = (event: SpeechRecognitionEvent) => {
const resultText = event.results[0]?.[0]?.transcript ?? "";
if (mountedRef.current) {
setTranscript(resultText);
setHasError(false);
setErrorMessage(null);
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (mountedRef.current) {
setHasError(true);
setErrorMessage(event.error);
setIsListening(false);
}
};
recognition.onend = () => {
if (mountedRef.current) {
setIsListening(false);
}
};
recognitionRef.current = recognition;
}
return () => {
mountedRef.current = false;
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
const startListening = useCallback(() => {
if (!recognitionRef.current) return;
setTranscript("");
setHasError(false);
setErrorMessage(null);
try {
recognitionRef.current.start();
setIsListening(true);
} catch {
// May throw if already started
setIsListening(false);
}
}, []);
const stopListening = useCallback(() => {
if (!recognitionRef.current) return;
recognitionRef.current.stop();
setIsListening(false);
}, []);
return {
isListening,
transcript,
isSupported,
hasError,
errorMessage,
startListening,
stopListening,
};
}
+47 -13
View File
@@ -1,19 +1,53 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AuthProvider } from "./hooks/useAuth";
import "./styles/global.css";
import { Layout } from "@/components/layout";
import { BookmarksNotesPage } from "@/components/annotations/BookmarksNotesPage";
import "./styles.css";
function App(): React.ReactElement {
const path = window.location.pathname;
// Simple client-side routing
if (path.startsWith("/books/") && path.includes("bookmarks-notes")) {
// /books/:id/bookmarks-notes
const bookId = path.split("/")[2];
return (
<Layout title="Cloud Reader">
<BookmarksNotesPage bookId={bookId} />
</Layout>
);
}
if (path === "/bookmarks-notes" || path === "/bookmarks-notes/") {
return (
<Layout title="Cloud Reader">
<BookmarksNotesPage />
</Layout>
);
}
// Default: landing page
return (
<Layout title="Cloud Reader">
<div className="page">
<h2>Welcome to Cloud Reader</h2>
<p>Your personal e-book reader with cross-device sync.</p>
<div className="quick-links">
<a href="/bookmarks-notes" className="card-link">
<h3>Bookmarks & Notes</h3>
<p>View and manage all your annotations</p>
</a>
</div>
</div>
</Layout>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
ReactDOM.createRoot(rootElement).render(
if (rootElement) {
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
</React.StrictMode>
);
}
+57
View File
@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { booksApi } from "../api/books";
export function AddBookPage() {
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [author, setAuthor] = useState("");
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0] ?? null;
if (selected) {
const ext = selected.name.split(".").pop()?.toLowerCase();
if (ext !== "epub" && ext !== "pdf") { setError("Only EPUB and PDF files are supported."); setFile(null); return; }
setFile(selected); setError(null);
if (!title) setTitle(selected.name.replace(/\.(epub|pdf)$/i, ""));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file || !title.trim()) { setError("Title and file are required."); return; }
setUploading(true); setError(null);
try { await booksApi.uploadEBook(file, title.trim(), author.trim()); navigate("/"); }
catch (err) { setError(err instanceof Error ? err.message : "Upload failed."); }
finally { setUploading(false); }
};
return (
<div style={{ maxWidth: 500, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24, padding: "16px 0", borderBottom: "1px solid #eee" }}>
<button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}> Back</button>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>Add Book</h1>
</header>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{error && <div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14 }}>{error}</div>}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>File (EPUB or PDF) *</label>
<input type="file" accept=".epub,.pdf" onChange={handleFileChange} style={{ padding: "10px 0" }} />
{file && <p style={{ fontSize: 13, color: "#666", marginTop: 4 }}>Selected: {file.name}</p>}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Title *</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Book title" style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Author</label>
<input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} placeholder="Author name" style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
<button type="submit" disabled={uploading} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: uploading ? 0.6 : 1, marginTop: 8 }}>{uploading ? "Uploading..." : "Upload Book"}</button>
</form>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
function AuthForm({ isLogin, onToggle }: { isLogin: boolean; onToggle: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { login, register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setError(null);
if (!isLogin && password !== confirmPassword) { setError("Passwords do not match."); return; }
setLoading(true);
try {
if (isLogin) await login(email, password);
else await register(email, password);
navigate("/");
} catch (err) { setError(err instanceof Error ? err.message : isLogin ? "Login failed" : "Registration failed"); }
finally { setLoading(false); }
};
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#f8f9fa", padding: 16 }}>
<div style={{ width: "100%", maxWidth: 400, background: "#fff", borderRadius: 12, padding: 32, boxShadow: "0 2px 16px rgba(0,0,0,0.08)" }}>
<h1 style={{ fontSize: 28, fontWeight: 700, color: "#1a1a2e", textAlign: "center", marginBottom: 4 }}>Cloud Reader</h1>
<h2 style={{ fontSize: 16, color: "#888", textAlign: "center", marginBottom: 24, fontWeight: 400 }}>{isLogin ? "Sign In" : "Create Account"}</h2>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{error && <div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14 }}>{error}</div>}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="At least 8 characters" required minLength={8} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
{!isLogin && <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Confirm Password</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Repeat your password" required minLength={8} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>}
<button type="submit" disabled={loading} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: loading ? 0.6 : 1, marginTop: 8 }}>{loading ? (isLogin ? "Signing in..." : "Creating account...") : (isLogin ? "Sign In" : "Create Account")}</button>
</form>
<p style={{ textAlign: "center", marginTop: 20, color: "#888", fontSize: 14 }}>
{isLogin ? "Don't have an account? " : "Already have an account? "}
<button onClick={onToggle} style={{ background: "none", border: "none", color: "#1a1a2e", fontWeight: 600, cursor: "pointer", fontSize: 14, textDecoration: "underline" }}>{isLogin ? "Register" : "Sign In"}</button>
</p>
</div>
</div>
);
}
export function LoginPage({ onToggle }: { onToggle: () => void }) { return <AuthForm isLogin={true} onToggle={onToggle} />; }
export function RegisterPage({ onToggle }: { onToggle: () => void }) { return <AuthForm isLogin={false} onToggle={onToggle} />; }
+208
View File
@@ -0,0 +1,208 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { booksApi } from "../api/books";
import type { BookDetail } from "../types/book";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
export function BookDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [book, setBook] = useState<BookDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isMobile = useMediaQuery(BREAKPOINTS.md);
const loadBook = useCallback(async () => {
if (!id) return;
setLoading(true);
setError(null);
try {
const bookId = Number(id);
if (Number.isNaN(bookId)) {
setError("Invalid book ID");
return;
}
const data = await booksApi.getBook(bookId);
setBook(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load book details");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
void loadBook();
}, [loadBook]);
const statusColors: Record<string, { bg: string; text: string }> = {
want_to_read: { bg: "#dbeafe", text: "#1d4ed8" },
reading: { bg: "#dcfce7", text: "#16a34a" },
finished: { bg: "#f3e8ff", text: "#9333ea" },
dnf: { bg: "#fef3c7", text: "#b45309" },
};
const containerStyle: React.CSSProperties = {
maxWidth: 720,
margin: "0 auto",
padding: isMobile ? 16 : 24,
minHeight: "100vh",
background: "#f8f9fa",
};
const backButtonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: isMobile ? "10px 16px" : "8px 16px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#374151",
fontSize: isMobile ? 15 : 14,
cursor: "pointer",
marginBottom: isMobile ? 16 : 24,
minHeight: 44,
};
if (loading) {
return (
<div style={containerStyle}>
<div style={{ height: 32, width: 80, background: "#e5e7eb", borderRadius: 6, marginBottom: 24 }} />
<div style={{ display: "flex", gap: isMobile ? 16 : 24, flexDirection: isMobile ? "column" : "row" }}>
<div style={{
width: isMobile ? 140 : 240,
height: isMobile ? 210 : 360,
background: "#e5e7eb",
borderRadius: 12,
flexShrink: 0,
alignSelf: isMobile ? "center" : "flex-start",
}} />
<div style={{ flex: 1 }}>
<div style={{ height: 28, background: "#e5e7eb", borderRadius: 6, marginBottom: 12, width: "60%" }} />
<div style={{ height: 18, background: "#e5e7eb", borderRadius: 4, marginBottom: 8, width: "40%" }} />
<div style={{ height: 18, background: "#e5e7eb", borderRadius: 4, marginBottom: 8, width: "30%" }} />
<div style={{ height: 14, background: "#e5e7eb", borderRadius: 4, marginBottom: 4, width: "90%" }} />
<div style={{ height: 14, background: "#e5e7eb", borderRadius: 4, marginBottom: 4, width: "80%" }} />
<div style={{ height: 14, background: "#e5e7eb", borderRadius: 4, width: "70%" }} />
</div>
</div>
</div>
);
}
if (error || !book) {
return (
<div style={containerStyle}>
<button onClick={() => navigate("/")} style={backButtonStyle}> Back to Library</button>
<div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>😕</div>
<h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>Book not found</h2>
<p style={{ color: "#6b7280", marginBottom: 20 }}>{error || "The book you're looking for doesn't exist or has been removed."}</p>
<button onClick={() => void loadBook()} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>Retry</button>
</div>
</div>
);
}
const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
return (
<div style={containerStyle}>
{/* Back button */}
<button onClick={() => navigate("/")} style={backButtonStyle}>
{isMobile ? "Back" : "Back to Library"}
</button>
{/* Book Detail */}
<div style={{ display: "flex", gap: isMobile ? 20 : 32, flexDirection: isMobile ? "column" : "row" }}>
{/* Cover */}
<div style={{ flexShrink: 0, alignSelf: isMobile ? "center" : "flex-start" }}>
<div style={{
width: isMobile ? 160 : 240,
height: isMobile ? 240 : 360,
borderRadius: 12,
overflow: "hidden",
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
}}>
{book.cover_image
? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: <span style={{ fontSize: isMobile ? 48 : 80 }}>📖</span>}
</div>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<h1 style={{ fontSize: isMobile ? 22 : 28, fontWeight: 700, color: "#1f2937", marginBottom: 8, lineHeight: 1.2 }}>
{book.title}
</h1>
{book.author && (
<p style={{ fontSize: isMobile ? 16 : 18, color: "#4b5563", marginBottom: 6 }}>
by {book.author}
</p>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16, marginTop: 12 }}>
<span style={{ background: sc.bg, color: sc.text, fontSize: 13, fontWeight: 600, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.reading_status_display}
</span>
{book.genre && (
<span style={{ background: "#eef2ff", color: "#4f46e5", fontSize: 13, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.genre}
</span>
)}
{book.total_pages > 0 && (
<span style={{ background: "#f3f4f6", color: "#6b7280", fontSize: 13, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.total_pages} pages
</span>
)}
</div>
{book.description && (
<div style={{ marginTop: 20 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 8 }}>Description</h3>
<p style={{ fontSize: isMobile ? 15 : 15, color: "#4b5563", lineHeight: 1.7, whiteSpace: "pre-wrap" }}>
{book.description}
</p>
</div>
)}
<div style={{ marginTop: 24, paddingTop: 16, borderTop: "1px solid #e5e7eb" }}>
<p style={{ fontSize: 13, color: "#9ca3af" }}>
Added {new Date(book.created_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}
{book.created_at !== book.updated_at && ` · Updated ${new Date(book.updated_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}`}
</p>
</div>
{/* Mobile full-width back button */}
{isMobile && (
<div style={{ marginTop: 24 }}>
<button
onClick={() => navigate("/")}
style={{
width: "100%",
padding: "14px 24px",
borderRadius: 10,
border: "none",
background: "#4f46e5",
color: "#fff",
fontSize: 15,
fontWeight: 600,
cursor: "pointer",
minHeight: 44,
}}
>
Back to Library
</button>
</div>
)}
</div>
</div>
</div>
);
}
-100
View File
@@ -1,100 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import type { Collection } from "@cloud-reader/shared";
import api from "../services/api";
export default function CollectionsPage(): React.ReactElement {
const [collections, setCollections] = useState<Collection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const fetchCollections = useCallback(async () => {
setIsLoading(true);
try {
const { data } = await api.get<{ results: Collection[] }>("/collections/");
setCollections(data.results || data as unknown as Collection[]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCollections();
}, [fetchCollections]);
const handleCreate = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
await api.post("/collections/", { name: newName, description: newDesc });
setNewName("");
setNewDesc("");
setShowCreate(false);
fetchCollections();
};
const handleDelete = async (id: number): Promise<void> => {
if (!confirm("Delete this collection?")) return;
await api.delete(`/collections/${id}/`);
fetchCollections();
};
return (
<div className="layout">
<header className="topbar">
<Link to="/library" className="btn-secondary">&larr; Library</Link>
<h1 className="logo">Collections</h1>
<button onClick={() => setShowCreate(true)} className="btn-primary">+ New Collection</button>
</header>
<main className="content">
{showCreate && (
<form onSubmit={handleCreate} className="create-collection-form">
<input
type="text"
placeholder="Collection name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
required
autoFocus
/>
<input
type="text"
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<div className="form-actions">
<button type="submit" className="btn-primary">Create</button>
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary">Cancel</button>
</div>
</form>
)}
{isLoading ? (
<div className="loading">Loading collections...</div>
) : collections.length === 0 ? (
<div className="empty-state">
<p>No collections yet. Group your documents into collections!</p>
</div>
) : (
<div className="collection-list">
{collections.map((col) => (
<div key={col.id} className="collection-card">
<div className="collection-info">
<h3>{col.name}</h3>
<p className="collection-desc">{col.description}</p>
<span className="collection-count">{col.document_count} document{col.document_count !== 1 ? "s" : ""}</span>
</div>
<div className="collection-actions">
<Link to={`/collections/${col.id}`} className="btn-secondary">View</Link>
<button onClick={() => handleDelete(col.id)} className="btn-danger">Delete</button>
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}
-92
View File
@@ -1,92 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import type { DocumentDetail } from "@cloud-reader/shared";
import api from "../services/api";
export default function DocumentPage(): React.ReactElement {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [doc, setDoc] = useState<DocumentDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchDoc = useCallback(async () => {
if (!id) return;
setIsLoading(true);
try {
const { data } = await api.get<DocumentDetail>(`/documents/${id}/`);
setDoc(data);
} catch {
navigate("/library");
} finally {
setIsLoading(false);
}
}, [id, navigate]);
useEffect(() => {
fetchDoc();
}, [fetchDoc]);
const handleDelete = async (): Promise<void> => {
if (!id || !confirm("Delete this document?")) return;
await api.delete(`/documents/${id}/`);
navigate("/library");
};
if (isLoading) return <div className="loading">Loading document...</div>;
if (!doc) return <div className="not-found">Document not found</div>;
return (
<div className="layout">
<header className="topbar">
<Link to="/library" className="btn-secondary">&larr; Back</Link>
<h1 className="logo">{doc.title}</h1>
<div className="topbar-right">
<button onClick={handleDelete} className="btn-danger">Delete</button>
</div>
</header>
<main className="content document-detail">
<div className="doc-header">
<div className="doc-cover-large">
{doc.cover_url ? (
<img src={doc.cover_url} alt={doc.title} />
) : (
<div className="doc-cover-placeholder-large">{doc.file_type.toUpperCase()}</div>
)}
</div>
<div className="doc-metadata">
<h2>{doc.title}</h2>
{doc.author && <p className="doc-author">by {doc.author}</p>}
<p className="doc-description">{doc.description}</p>
<div className="doc-stats">
<span>Type: {doc.file_type}</span>
<span>Size: {(doc.file_size / 1024 / 1024).toFixed(1)} MB</span>
{doc.page_count && <span>Pages: {doc.page_count}</span>}
<span>Uploaded: {new Date(doc.uploaded_at).toLocaleDateString()}</span>
</div>
{doc.tags.length > 0 && (
<div className="doc-tags">
{doc.tags.map((tag) => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
)}
<Link to={`/read/${doc.id}`} className="btn-primary">Start Reading</Link>
</div>
</div>
{doc.recent_highlights.length > 0 && (
<section className="recent-highlights">
<h3>Recent Highlights</h3>
{doc.recent_highlights.map((hl) => (
<div key={hl.id} className="highlight-card" style={{ borderLeftColor: hl.color }}>
<p className="highlight-text">{hl.text}</p>
<span className="highlight-page">Page {hl.page}</span>
</div>
))}
</section>
)}
</main>
</div>
);
}

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