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
53 changed files with 3897 additions and 43 deletions
+34 -1
View File
@@ -14,7 +14,7 @@ cloud-reader/
│ │ └── annotations/ # Bookmarks and notes │ │ └── annotations/ # Bookmarks and notes
│ ├── manage.py │ ├── manage.py
│ └── requirements.txt │ └── requirements.txt
├── frontend/ # React + Vite + TypeScript (canonical frontend) ├── frontend/ # React + Vite + TypeScript (web frontend)
│ ├── src/ │ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh) │ │ ├── api/ # API client (axios with JWT refresh)
│ │ ├── components/ # Reusable components │ │ ├── components/ # Reusable components
@@ -23,6 +23,23 @@ cloud-reader/
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings) │ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
│ │ └── types/ # TypeScript type definitions │ │ └── types/ # TypeScript type definitions
│ └── package.json │ └── package.json
├── mobile/ # Expo React Native app (mobile frontend)
│ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
│ │ ├── components/ # Reusable UI components
│ │ ├── context/ # Auth context
│ │ ├── hooks/ # Custom hooks
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
│ │ └── types/ # Mobile-specific types
│ ├── App.tsx
│ └── app.json
├── packages/
│ └── shared/ # @cloud-reader/shared — domain types & utilities
│ └── src/
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.)
│ └── utils.ts # Date formatting, validation, API endpoint constants
├── package.json # Root — yarn workspaces config
└── docker-compose.yml └── docker-compose.yml
``` ```
@@ -50,8 +67,24 @@ yarn install
yarn dev yarn dev
``` ```
### Mobile (Expo)
```bash
# From monorepo root — installs all workspaces including mobile
yarn install
# Start Expo dev server
yarn workspace @cloud-reader/mobile start
# Or cd into mobile and run directly
cd mobile
npx expo start
```
> The mobile app requires the backend to be running. Set `EXPO_PUBLIC_API_URL` environment variable in your shell or `.env` file to point to the backend (defaults to `http://10.0.2.2:8000` for Android emulator).
## Migration Notes ## Migration Notes
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure. Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure.
- `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in. - `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in.
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in. - `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
- `api/` and `web/` directories removed. - `api/` and `web/` directories removed.
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
+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)
+140 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
import logging
from typing import Any from typing import Any
from django.db.models import QuerySet, Q from django.db.models import QuerySet, Q
@@ -9,13 +12,16 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.books.models import Book, EBook, ReadingProgress, ReadingSettings from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingSettings
from apps.books.serializers import ( from apps.books.serializers import (
BookDetailSerializer, BookListSerializer, BookSerializer, BookDetailSerializer, BookListSerializer, BookSerializer,
EBookDetailSerializer, EBookListSerializer, EBookUploadSerializer, BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, ReadingSettingsSerializer, ReadingProgressSerializer, ReadingSettingsSerializer,
) )
logger = logging.getLogger(__name__)
class BookViewSet(viewsets.ModelViewSet): class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all() queryset = Book.objects.all()
@@ -63,8 +69,10 @@ class EBookViewSet(viewsets.ModelViewSet):
def get_serializer_class(self): def get_serializer_class(self):
if self.action == "create": if self.action == "create":
return EBookUploadSerializer return EBookUploadSerializer
if self.action == "list": if self.action in ("list",):
return EBookListSerializer return EBookListSerializer
if self.action in ("toc",):
return BookChapterSerializer
return EBookDetailSerializer return EBookDetailSerializer
def get_queryset(self): def get_queryset(self):
@@ -82,6 +90,135 @@ class EBookViewSet(viewsets.ModelViewSet):
serializer.save() serializer.save()
return Response(serializer.data) 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): class ReadingSettingsViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
+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
+2
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext"; import { AuthProvider, useAuth } from "./context/AuthContext";
const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage }))); 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 ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage })));
const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage }))); const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage })));
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
@@ -35,6 +36,7 @@ function AppRoutes() {
<Routes> <Routes>
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} /> <Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} /> <Route path="/reader/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} /> <Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
+66 -1
View File
@@ -1,5 +1,15 @@
import api from "./client"; import api from "./client";
import type { EBookDetail, EBookListItem, ReadingProgress, ReadingSettings } from "../types/book"; import type {
BookDetail,
BookListItem,
BookSearchParams,
ContentResponse,
EBookDetail,
EBookListItem,
ReadingProgress,
ReadingSettings,
TocResponse,
} from "../types/book";
export const booksApi = { export const booksApi = {
async getEBooks(): Promise<EBookListItem[]> { async getEBooks(): Promise<EBookListItem[]> {
@@ -7,11 +17,51 @@ export const booksApi = {
return data; return data;
}, },
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
},
async getEBook(id: number): Promise<EBookDetail> { async getEBook(id: number): Promise<EBookDetail> {
const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`); const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`);
return data; return data;
}, },
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( async uploadEBook(
file: File, file: File,
title: string, title: string,
@@ -34,6 +84,21 @@ export const booksApi = {
await api.delete(`/books/ebooks/${id}/`); 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> { async getProgress(ebookId: number): Promise<ReadingProgress> {
const { data } = await api.get<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`); const { data } = await api.get<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`);
return data; return data;
@@ -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>
);
}
+3
View File
@@ -1 +1,4 @@
export { usePaginatedQuery } from "./usePaginatedQuery"; export { usePaginatedQuery } from "./usePaginatedQuery";
export { useDebounce } from "./useDebounce";
export { useVoiceSearch } from "./useVoiceSearch";
export { useMediaQuery } from "./useMediaQuery";
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
/**
* A hook that debounces a value by the specified delay.
* @param value - The value to debounce
* @param delay - The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
/**
* Hook for responsive design — returns true when the media query matches.
* Defaults to false on SSR / initial render to avoid hydration mismatch.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
export const BREAKPOINTS = {
sm: "(max-width: 480px)",
md: "(max-width: 768px)",
lg: "(min-width: 1024px)",
} as const;
+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,
};
}
+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>
);
}
+13
View File
@@ -0,0 +1,13 @@
.bookCard {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.bookCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
+513 -29
View File
@@ -1,61 +1,545 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { booksApi } from "../api/books"; import { booksApi } from "../api/books";
import type { EBookListItem } from "../types/book"; import type { BookListItem, BookSearchParams } from "../types/book";
import { READING_STATUS_OPTIONS } from "../types/book";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useDebounce } from "../hooks/useDebounce";
import { useVoiceSearch } from "../hooks/useVoiceSearch";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
import { SearchSuggestions } from "../components/search/SearchSuggestions";
import styles from "./Library.module.css";
interface FilterState {
genre: string;
author: string;
reading_status: string;
}
/** WCAG 2.1 minimum touch target */
const TOUCH_TARGET: React.CSSProperties = {
minHeight: 44,
minWidth: 44,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
export function LibraryPage() { export function LibraryPage() {
const [books, setBooks] = useState<EBookListItem[]>([]); const navigate = useNavigate();
const { logout } = useAuth();
const [books, setBooks] = useState<BookListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { logout } = useAuth(); const [searchQuery, setSearchQuery] = useState("");
const navigate = useNavigate(); const [filters, setFilters] = useState<FilterState>({ genre: "", author: "", reading_status: "" });
const [genres, setGenres] = useState<string[]>([]);
const [authors, setAuthors] = useState<string[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const loadBooks = useCallback(async () => { const debouncedSearch = useDebounce(searchQuery, 300);
const loadedRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const isMobile = useMediaQuery(BREAKPOINTS.md);
// Voice search
const voiceSearch = useVoiceSearch();
// Sync voice transcript into search input
useEffect(() => {
if (voiceSearch.transcript && !voiceSearch.isListening) {
setSearchQuery(voiceSearch.transcript);
setShowSuggestions(false);
}
}, [voiceSearch.transcript, voiceSearch.isListening]);
// Load filter options once
useEffect(() => {
if (loadedRef.current) return;
loadedRef.current = true;
void Promise.all([booksApi.getGenres(), booksApi.getAuthors()]).then(
([genreList, authorList]) => {
setGenres(genreList);
setAuthors(authorList);
},
() => {
// Filters degrade gracefully if discovery endpoints fail
},
);
}, []);
const loadBooks = useCallback(async (params: BookSearchParams) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await booksApi.getEBooks(); const response = await booksApi.searchBooks(params);
setBooks(data); setBooks(response.results);
setTotalCount(response.count);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load library"); setError(err instanceof Error ? err.message : "Failed to load books");
setBooks([]);
setTotalCount(0);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
useEffect(() => { void loadBooks(); }, [loadBooks]); // Reload when search or filters change
useEffect(() => {
const params: BookSearchParams = {};
if (debouncedSearch) params.q = debouncedSearch;
if (filters.genre) params.genre = filters.genre;
if (filters.author) params.author = filters.author;
if (filters.reading_status) params.reading_status = filters.reading_status;
void loadBooks(params);
}, [debouncedSearch, filters, loadBooks]);
const handleFilterChange = (key: keyof FilterState, value: string) => {
setFilters((prev: FilterState) => ({ ...prev, [key]: value }));
};
const clearAllFilters = () => {
setSearchQuery("");
setFilters({ genre: "", author: "", reading_status: "" });
setShowFilters(false);
};
const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status;
const containerStyle: React.CSSProperties = {
maxWidth: 960,
margin: "0 auto",
padding: isMobile ? 12 : 16,
minHeight: "100vh",
background: "#f8f9fa",
};
const headerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: isMobile ? 12 : 20,
padding: isMobile ? "12px 0" : "16px 0",
borderBottom: "1px solid #e5e7eb",
flexWrap: "wrap",
gap: 8,
};
const searchContainerStyle: React.CSSProperties = {
position: "relative",
flex: 1,
};
return ( return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}> <div style={containerStyle}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24, padding: "16px 0", borderBottom: "1px solid #eee", flexWrap: "wrap", gap: 8 }}> {/* Header */}
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e" }}>My Library</h1> <header style={headerStyle}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div>
<button onClick={() => navigate("/add")} style={{ padding: "10px 20px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>+ Add Book</button> <h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>Library</h1>
<button onClick={() => navigate("/settings")} style={{ padding: "10px 20px", borderRadius: 8, border: "1px solid #ddd", background: "#fff", color: "#333", fontSize: 14, cursor: "pointer" }}>Settings</button> {!loading && (
<button onClick={() => navigate("/bookmarks-notes")} style={{ padding: "10px 20px", borderRadius: 8, border: "1px solid #ddd", background: "#fff", color: "#333", fontSize: 14, cursor: "pointer" }}>Bookmarks</button> <p style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>
<button onClick={logout} style={{ padding: "10px 20px", borderRadius: 8, border: "1px solid #e74c3c", background: "#fff", color: "#e74c3c", fontSize: 14, cursor: "pointer" }}>Logout</button> {totalCount} book{totalCount !== 1 ? "s" : ""}
</p>
)}
</div>
<div style={{ display: "flex", gap: isMobile ? 4 : 8, flexWrap: "wrap", alignItems: "center" }}>
{isMobile ? (
<>
<button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Add Book"></button>
<button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Bookmarks">🔖</button>
<button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Settings"></button>
<button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Logout">🚪</button>
</>
) : (
<>
<button onClick={() => navigate("/add")} className="btn">+ Add Book</button>
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">Bookmarks</button>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">Settings</button>
<button onClick={logout} className="btn btn-danger">Logout</button>
</>
)}
</div> </div>
</header> </header>
{error && <div style={{ background: "#fde8e8", padding: 16, borderRadius: 8, marginBottom: 16, textAlign: "center" }}><p>{error}</p><button onClick={loadBooks} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#e74c3c", color: "#fff", cursor: "pointer" }}>Retry</button></div>} {/* Search Bar */}
{loading && books.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}><p>Loading your library...</p></div>} <div style={{ marginBottom: 16 }}>
{!loading && !error && books.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}><p style={{ fontSize: 20, color: "#666", marginBottom: 8 }}>Your library is empty</p><p style={{ color: "#999", marginBottom: 20 }}>Add a book to get started</p><button onClick={() => navigate("/add")} style={{ padding: "10px 20px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>Add Your First Book</button></div>} <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={searchContainerStyle}>
<input
ref={searchInputRef}
type="text"
placeholder="Search by title, author, or genre..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
style={{
width: "100%",
padding: `12px 16px 12px ${voiceSearch.isSupported ? 44 : 44}px`,
paddingRight: voiceSearch.isSupported ? 48 : 16,
borderRadius: 10,
border: "1px solid #e5e7eb",
fontSize: isMobile ? 16 : 15,
background: "#fff",
outline: "none",
boxSizing: "border-box",
minHeight: 44,
}}
/>
<span style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
fontSize: 18, color: "#9ca3af", pointerEvents: "none",
}}>🔍</span>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 16 }}> {/* Voice Search Button */}
{books.map((book) => ( {voiceSearch.isSupported && (
<div key={book.id} onClick={() => navigate(`/reader/${book.id}`)} style={{ background: "#fff", borderRadius: 12, overflow: "hidden", boxShadow: "0 2px 8px rgba(0,0,0,0.06)", cursor: "pointer" }}> <button
<div style={{ height: 180, background: "#f0f0f0", display: "flex", alignItems: "center", justifyContent: "center" }}> onClick={() => {
{book.cover_image ? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <span style={{ fontSize: 48 }}>📖</span>} if (voiceSearch.isListening) {
voiceSearch.stopListening();
} else {
voiceSearch.startListening();
}
}}
style={{
position: "absolute",
right: 8,
top: "50%",
transform: "translateY(-50%)",
background: voiceSearch.isListening ? "#dc2626" : "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
fontSize: 20,
padding: "8px 8px",
minWidth: 36,
minHeight: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: voiceSearch.isListening ? "#fff" : "#6b7280",
transition: "background 0.15s",
}}
title={voiceSearch.isListening ? "Stop listening" : "Search with voice"}
>
🎤
</button>
)}
{/* Real-time Suggestions */}
<SearchSuggestions
query={searchQuery}
visible={showSuggestions && !voiceSearch.isListening}
onClose={() => setShowSuggestions(false)}
onSelectSuggestion={() => setShowSuggestions(false)}
/>
</div> </div>
<button
onClick={() => setShowFilters(!showFilters)}
style={{
...TOUCH_TARGET,
padding: "0 12px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: showFilters ? "#4f46e5" : "#fff",
color: showFilters ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
gap: 4,
}}
>
{isMobile ? "⚙️" : showFilters ? "▲ Filters" : "▼ Filters"}
</button>
</div>
</div>
{/* Voice search listening indicator */}
{voiceSearch.isListening && (
<div style={{
background: "#fef2f2",
padding: "10px 16px",
borderRadius: 8,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 14,
color: "#dc2626",
}}>
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: "#dc2626", animation: "pulse 1s infinite" }} />
Listening... speak now
<button
onClick={voiceSearch.stopListening}
style={{
marginLeft: "auto",
background: "#dc2626",
color: "#fff",
border: "none",
borderRadius: 4,
padding: "4px 12px",
cursor: "pointer",
fontSize: 12,
}}
>
Stop
</button>
</div>
)}
{/* Voice search error */}
{voiceSearch.hasError && !voiceSearch.isListening && (
<div style={{
background: "#fef3c7",
padding: "8px 12px",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "#92400e",
}}>
Voice search: {voiceSearch.errorMessage === "no-speech" ? "No speech detected. Try again." : voiceSearch.errorMessage}
</div>
)}
{/* Filters Panel */}
{showFilters && (
<div style={{
background: "#fff",
borderRadius: 10,
padding: isMobile ? 12 : 16,
marginBottom: 16,
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 12,
alignItems: isMobile ? "stretch" : "end",
}}>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Genre</label>
<select
value={filters.genre}
onChange={(e) => handleFilterChange("genre", e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}}
>
<option value="">All Genres</option>
{genres.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
</div>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Author</label>
<select
value={filters.author}
onChange={(e) => handleFilterChange("author", e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}}
>
<option value="">All Authors</option>
{authors.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Status</label>
<select
value={filters.reading_status}
onChange={(e) => handleFilterChange("reading_status", e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}}
>
{READING_STATUS_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
style={{
...TOUCH_TARGET,
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#6b7280",
fontSize: 13,
cursor: "pointer",
whiteSpace: "nowrap",
width: isMobile ? "100%" : "auto",
}}
>
Clear
</button>
)}
</div>
)}
{/* Error State */}
{error && (
<div style={{ background: "#fef2f2", padding: 16, borderRadius: 8, marginBottom: 16, textAlign: "center" }}>
<p style={{ color: "#dc2626", fontSize: 14 }}>{error}</p>
<button onClick={() => void loadBooks({})} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#dc2626", color: "#fff", cursor: "pointer", fontSize: 13, minHeight: 36 }}>Retry</button>
</div>
)}
{/* Loading State */}
{loading && (
<div style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
opacity: 0.6,
}}>
{Array.from({ length: isMobile ? 4 : 8 }).map((_, i) => (
<div key={i} style={{ background: "#fff", borderRadius: 12, overflow: "hidden", boxShadow: "0 2px 8px rgba(0,0,0,0.06)", display: isMobile ? "flex" : "block" }}>
<div style={{ width: isMobile ? 80 : "100%", height: isMobile ? 120 : 180, background: "#f0f0f0", flexShrink: 0 }} />
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
<h3 style={{ fontSize: 14, fontWeight: 600, color: "#1a1a2e", marginBottom: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{book.title}</h3> <div style={{ height: 14, background: "#f0f0f0", borderRadius: 4, marginBottom: 6, width: "70%" }} />
<p style={{ fontSize: 12, color: "#888", marginBottom: 8 }}>{book.author || "Unknown Author"}</p> <div style={{ height: 12, background: "#f0f0f0", borderRadius: 4, width: "40%" }} />
{book.progress !== null && <div style={{ width: "100%", height: 4, background: "#eee", borderRadius: 2, overflow: "hidden" }}><div style={{ height: "100%", background: "#1a1a2e", borderRadius: 2, width: `${Math.min(book.progress, 100)}%` }} /></div>}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)}
{/* Empty State */}
{!loading && !error && books.length === 0 && (
<div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>{hasActiveFilters ? "🔍" : "📚"}</div>
<h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>
{hasActiveFilters ? "No books found" : "Your library is empty"}
</h2>
<p style={{ color: "#6b7280", marginBottom: 20, fontSize: 15, lineHeight: 1.5 }}>
{hasActiveFilters
? "Try adjusting your search query or filters to discover more books."
: "Add a book to get started building your collection."}
</p>
{hasActiveFilters ? (
<button onClick={clearAllFilters} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
Clear All Filters
</button>
) : (
<button onClick={() => navigate("/add")} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
Add Your First Book
</button>
)}
</div>
)}
{/* Results Grid */}
{!loading && books.length > 0 && (
<div style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
}}>
{books.map((book) => {
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 sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
return (
<div
key={book.id}
onClick={() => navigate(`/books/${book.id}`)}
className={styles.bookCard}
style={{
display: isMobile ? "flex" : "block",
}}
>
<div style={{
width: isMobile ? 80 : "100%",
height: isMobile ? 120 : 180,
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}>
{book.cover_image
? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: <span style={{ fontSize: isMobile ? 32 : 48 }}>📖</span>}
{!isMobile && (
<span style={{
position: "absolute", top: 8, right: 8,
background: sc.bg, color: sc.text, fontSize: 11, fontWeight: 600,
padding: "2px 8px", borderRadius: 999, lineHeight: "18px",
}}>
{book.reading_status_display}
</span>
)}
</div>
<div style={{ padding: isMobile ? "8px 12px" : 12, flex: 1 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{
fontSize: isMobile ? 14 : 14,
fontWeight: 600,
color: "#1f2937",
marginBottom: 2,
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{book.title}
</h3>
<p style={{ fontSize: 12, color: "#6b7280", marginBottom: 4, margin: "2px 0" }}>
{book.author || "Unknown Author"}
</p>
</div>
{isMobile && (
<span style={{
background: sc.bg, color: sc.text, fontSize: 10, fontWeight: 600,
padding: "2px 6px", borderRadius: 999, whiteSpace: "nowrap", flexShrink: 0,
}}>
{book.reading_status_display}
</span>
)}
</div>
{book.genre && (
<span style={{ fontSize: 11, color: "#4f46e5", background: "#eef2ff", padding: "1px 6px", borderRadius: 4, display: "inline-block", marginTop: 4 }}>
{book.genre}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div> </div>
); );
} }
+46
View File
@@ -27,6 +27,8 @@ export interface EBookListItem {
title: string; title: string;
author: string; author: string;
filename: string; filename: string;
format: string;
page_count: number;
cover_image: string | null; cover_image: string | null;
created_at: string; created_at: string;
progress: number | null; progress: number | null;
@@ -38,6 +40,10 @@ export interface EBookDetail {
author: string; author: string;
filename: string; filename: string;
file_url: string; file_url: string;
format: string;
page_count: number;
file_size: number;
metadata_json: Record<string, unknown>;
cover_image: string | null; cover_image: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -54,3 +60,43 @@ export interface ReadingSettings {
font_style: "sans-serif" | "serif" | "monospace"; font_style: "sans-serif" | "serif" | "monospace";
background_color: string; background_color: string;
} }
export interface BookChapter {
id: number;
title: string;
index: number;
href: string;
children: BookChapter[];
}
export interface TocResponse {
chapters: BookChapter[];
format: string;
page_count: number;
}
export interface ContentResponse {
page: number;
total_pages: number;
content: string;
chapter_title: string;
format: string;
}
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" },
];
+54
View File
@@ -0,0 +1,54 @@
/**
* Type declarations for the Web Speech API (SpeechRecognition).
* These are not part of the standard TypeScript DOM lib types.
* Install @types/dom-speech-recognition for full coverage.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
readonly isFinal: boolean;
readonly length: number;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
readonly transcript: string;
readonly confidence: number;
}
interface SpeechRecognitionErrorEvent extends Event {
readonly error: string;
readonly message: string;
}
interface SpeechRecognitionConstructor {
new (): SpeechRecognition;
}
interface Window {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { StatusBar } from "expo-status-bar";
import { AuthProvider } from "./src/context/AuthContext";
import { RootNavigator } from "./src/navigation/RootNavigator";
export default function App() {
return (
<AuthProvider>
<NavigationContainer>
<StatusBar style="auto" />
<RootNavigator />
</NavigationContainer>
</AuthProvider>
);
}
+28
View File
@@ -0,0 +1,28 @@
{
"expo": {
"name": "Cloud Reader",
"slug": "cloud-reader",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"backgroundColor": "#1a1a2e"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.cloudreader.app"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#1a1a2e"
},
"package": "com.cloudreader.app"
},
"plugins": [
"expo-document-picker",
"expo-file-system"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

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