feat: bookmarks and notes management

- Backend: Django REST Framework API with Bookmark and Note models
  - ViewSets with user-scoped querysets and select_related for N+1 prevention
  - Create/List/Detail/Update/Delete endpoints
  - Batch delete operations
  - Unique constraint on user+book+page for bookmarks
  - IsOwner permission class for object-level access control
  - Full serializer validation (page > 0, non-empty content, duplicate check)
  - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases

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

- Infrastructure: Django project with custom User model, JWT auth, CORS
  - PostgreSQL database models with proper FK and indexes
  - Django admin configuration for all models
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 00:50:06 +00:00
commit 3b5b301e42
94 changed files with 6086 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Reader</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
+515
View File
@@ -0,0 +1,515 @@
/* ============================================================
Cloud Reader — Book Search & Discovery Styles
============================================================ */
/* --- Reset & Base --- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #0f1117;
--color-surface: #1a1d27;
--color-surface-hover: #242736;
--color-border: #2a2d3a;
--color-text: #e1e4ed;
--color-text-secondary: #8b8fa3;
--color-primary: #6c8cff;
--color-primary-hover: #8ba3ff;
--color-accent-green: #34d399;
--color-accent-yellow: #fbbf24;
--color-accent-blue: #60a5fa;
--color-accent-gray: #6b7280;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
min-height: 100vh;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px;
}
/* --- Library Header --- */
.library-header {
margin-bottom: 24px;
}
.library-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: 4px;
}
.library-header p {
color: var(--color-text-secondary);
font-size: 0.95rem;
}
/* --- Search Bar --- */
.search-bar {
position: relative;
margin-bottom: 16px;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 12px 16px 12px 44px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-text);
font-size: 1rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: var(--color-primary);
}
.search-input::placeholder {
color: var(--color-text-secondary);
}
/* --- Filters --- */
.book-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
align-items: flex-end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-group label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.filter-group select {
padding: 8px 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
cursor: pointer;
min-width: 140px;
}
.filter-group select:focus {
border-color: var(--color-primary);
}
.clear-filters-btn {
padding: 8px 16px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text-secondary);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* --- Book Grid --- */
.book-list {
margin-top: 8px;
}
.result-count {
color: var(--color-text-secondary);
font-size: 0.9rem;
margin-bottom: 12px;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.book-card {
display: flex;
gap: 14px;
padding: 14px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
color: inherit;
font-family: inherit;
transition: all 0.2s;
width: 100%;
}
.book-card:hover {
background: var(--color-surface-hover);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.book-card-cover {
flex-shrink: 0;
width: 60px;
height: 90px;
border-radius: 4px;
overflow: hidden;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
}
.book-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
color: var(--color-text-secondary);
opacity: 0.5;
display: flex;
align-items: center;
justify-content: center;
}
.book-card-info {
flex: 1;
min-width: 0;
}
.book-card-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-card-author {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.book-card-genre {
font-size: 0.8rem;
color: var(--color-primary);
margin-bottom: 6px;
}
/* --- Status Badges --- */
.status-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 100px;
text-transform: capitalize;
}
.status-want_to_read {
background: rgba(96, 165, 250, 0.15);
color: var(--color-accent-blue);
}
.status-reading {
background: rgba(52, 211, 153, 0.15);
color: var(--color-accent-green);
}
.status-finished {
background: rgba(107, 114, 128, 0.15);
color: var(--color-accent-gray);
}
.status-dnf {
background: rgba(251, 191, 36, 0.15);
color: var(--color-accent-yellow);
}
/* --- Empty State --- */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--color-text-secondary);
}
.empty-state svg {
margin-bottom: 16px;
opacity: 0.4;
}
.empty-state h3 {
font-size: 1.2rem;
color: var(--color-text);
margin-bottom: 8px;
}
.empty-state p {
max-width: 360px;
margin: 0 auto;
line-height: 1.5;
}
/* --- Loading --- */
.loading-spinner {
text-align: center;
padding: 48px 24px;
color: var(--color-text-secondary);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* --- Pagination --- */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.pagination-btn {
padding: 8px 18px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.pagination-btn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pagination-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-indicator {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
/* --- Book Detail --- */
.book-detail {
max-width: 720px;
margin: 0 auto;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text-secondary);
font-size: 0.9rem;
cursor: pointer;
margin-bottom: 24px;
transition: all 0.2s;
font-family: inherit;
}
.back-button:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.book-detail-content {
display: flex;
gap: 28px;
}
.book-detail-cover {
flex-shrink: 0;
width: 180px;
border-radius: var(--radius);
overflow: hidden;
background: var(--color-surface);
}
.book-detail-cover img {
width: 100%;
display: block;
}
.cover-placeholder.large {
width: 180px;
height: 270px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
opacity: 0.3;
}
.book-detail-info {
flex: 1;
}
.book-detail-title {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 4px;
line-height: 1.3;
}
.book-detail-author {
font-size: 1.1rem;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
.book-detail-genre {
display: inline-block;
font-size: 0.85rem;
color: var(--color-primary);
margin-bottom: 12px;
}
.book-detail-status {
margin-bottom: 20px;
font-size: 0.95rem;
color: var(--color-text-secondary);
}
.book-detail-status .status-badge {
margin-left: 6px;
}
.book-detail-description {
margin-bottom: 20px;
}
.book-detail-description h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 8px;
}
.book-detail-description p {
color: var(--color-text-secondary);
line-height: 1.7;
}
.book-detail-meta {
font-size: 0.85rem;
color: var(--color-text-secondary);
opacity: 0.7;
}
.book-detail-meta p {
margin-bottom: 4px;
}
/* --- Error State --- */
.error-state {
text-align: center;
padding: 48px 24px;
color: #ef4444;
}
.error-state p {
margin-bottom: 16px;
}
/* --- Responsive --- */
@media (max-width: 640px) {
.book-grid {
grid-template-columns: 1fr;
}
.book-detail-content {
flex-direction: column;
}
.book-detail-cover {
width: 140px;
}
.cover-placeholder.large {
width: 140px;
height: 210px;
}
.book-filters {
flex-direction: column;
}
.filter-group select {
width: 100%;
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* App — root component with simple page navigation (library ↔ book detail).
*/
import { useState, lazy, Suspense } from "react";
import "./App.css";
const LibraryPage = lazy(() => import("./pages/LibraryPage"));
const BookDetailPage = lazy(() => import("./pages/BookDetailPage"));
type View =
| { kind: "library" }
| { kind: "detail"; bookId: number };
function LoadingFallback() {
return (
<div className="loading-spinner">
<div className="spinner" />
<p>Loading...</p>
</div>
);
}
export default function App() {
const [view, setView] = useState<View>({ kind: "library" });
const handleBookSelect = (id: number) => {
setView({ kind: "detail", bookId: id });
};
const handleBack = () => {
setView({ kind: "library" });
};
return (
<div className="app">
<Suspense fallback={<LoadingFallback />}>
{view.kind === "library" ? (
<LibraryPage onBookSelect={handleBookSelect} />
) : (
<BookDetailPage bookId={view.bookId} onBack={handleBack} />
)}
</Suspense>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* API client for the Cloud Reader backend.
* All requests are proxied through Vite's dev server in development.
*/
import type {
BookDetail,
BookSummary,
PaginatedResponse,
SearchParams,
} from "../types";
const API_BASE = "/api";
function buildSearchQuery(params: SearchParams): string {
const searchParams = new URLSearchParams();
if (params.q) searchParams.set("q", params.q);
if (params.genre) searchParams.set("genre", params.genre);
if (params.author) searchParams.set("author", params.author);
if (params.reading_status) searchParams.set("reading_status", params.reading_status);
if (params.page && params.page > 1) searchParams.set("page", String(params.page));
return searchParams.toString();
}
export async function searchBooks(
params: SearchParams,
): Promise<PaginatedResponse<BookSummary>> {
const query = buildSearchQuery(params);
const response = await fetch(`${API_BASE}/books/?${query}`);
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<PaginatedResponse<BookSummary>>;
}
export async function getBook(id: number): Promise<BookDetail> {
const response = await fetch(`${API_BASE}/books/${id}/`);
if (!response.ok) {
throw new Error(`Failed to fetch book: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<BookDetail>;
}
export async function getGenres(): Promise<string[]> {
const response = await fetch(`${API_BASE}/books/genres/`);
if (!response.ok) {
throw new Error(`Failed to fetch genres: ${response.status}`);
}
return response.json() as Promise<string[]>;
}
export async function getAuthors(): Promise<string[]> {
const response = await fetch(`${API_BASE}/books/authors/`);
if (!response.ok) {
throw new Error(`Failed to fetch authors: ${response.status}`);
}
return response.json() as Promise<string[]>;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* BookDetail component — full book information display.
*/
import type { BookDetail as BookDetailType } from "../types";
import { READING_STATUS_LABELS } from "../types";
interface BookDetailProps {
book: BookDetailType;
onBack: () => void;
}
export default function BookDetail({ book, onBack }: BookDetailProps) {
return (
<div className="book-detail">
<button
type="button"
className="back-button"
onClick={onBack}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
Back to search
</button>
<div className="book-detail-content">
<div className="book-detail-cover">
{book.cover_url ? (
<img src={book.cover_url} alt={`${book.title} cover`} />
) : (
<div className="cover-placeholder large">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
)}
</div>
<div className="book-detail-info">
<h1 className="book-detail-title">{book.title}</h1>
<p className="book-detail-author">by {book.author}</p>
<p className="book-detail-genre">{book.genre}</p>
<p className="book-detail-status">
Status:{" "}
<span className={`status-badge status-${book.reading_status}`}>
{READING_STATUS_LABELS[book.reading_status]}
</span>
</p>
{book.description && (
<div className="book-detail-description">
<h2>Description</h2>
<p>{book.description}</p>
</div>
)}
<div className="book-detail-meta">
<p>
<strong>Added:</strong>{" "}
{new Date(book.created_at).toLocaleDateString()}
</p>
<p>
<strong>Updated:</strong>{" "}
{new Date(book.updated_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* BookFilters component — dropdown filters for genre, author, and reading status.
*/
import type { ReadingStatus } from "../types";
import { READING_STATUS_LABELS } from "../types";
interface BookFiltersProps {
genres: string[];
authors: string[];
selectedGenre: string;
selectedAuthor: string;
selectedStatus: ReadingStatus | "";
onGenreChange: (genre: string) => void;
onAuthorChange: (author: string) => void;
onStatusChange: (status: ReadingStatus | "") => void;
}
export default function BookFilters({
genres,
authors,
selectedGenre,
selectedAuthor,
selectedStatus,
onGenreChange,
onAuthorChange,
onStatusChange,
}: BookFiltersProps) {
const handleGenreChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onGenreChange(e.target.value);
};
const handleAuthorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onAuthorChange(e.target.value);
};
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onStatusChange(e.target.value as ReadingStatus | "");
};
const handleClearFilters = () => {
onGenreChange("");
onAuthorChange("");
onStatusChange("");
};
const hasActiveFilters = selectedGenre || selectedAuthor || selectedStatus;
return (
<div className="book-filters">
<div className="filter-group">
<label htmlFor="filter-genre">Genre</label>
<select
id="filter-genre"
value={selectedGenre}
onChange={handleGenreChange}
>
<option value="">All Genres</option>
{genres.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="filter-author">Author</label>
<select
id="filter-author"
value={selectedAuthor}
onChange={handleAuthorChange}
>
<option value="">All Authors</option>
{authors.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="filter-status">Status</label>
<select
id="filter-status"
value={selectedStatus}
onChange={handleStatusChange}
>
<option value="">All Statuses</option>
{(Object.keys(READING_STATUS_LABELS) as ReadingStatus[]).map((s) => (
<option key={s} value={s}>
{READING_STATUS_LABELS[s]}
</option>
))}
</select>
</div>
{hasActiveFilters && (
<button
type="button"
className="clear-filters-btn"
onClick={handleClearFilters}
>
Clear Filters
</button>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
/**
* BookList component — displays search results with click-to-detail navigation.
*/
import type { BookSummary } from "../types";
import { READING_STATUS_LABELS } from "../types";
interface BookListProps {
books: BookSummary[];
isLoading: boolean;
totalCount: number;
onBookClick: (id: number) => void;
}
function StatusBadge({ status }: { status: BookSummary["reading_status"] }) {
const className = `status-badge status-${status}`;
return <span className={className}>{READING_STATUS_LABELS[status]}</span>;
}
function EmptyState() {
return (
<div className="empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="8" y1="7" x2="16" y2="7" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
<h3>No books found</h3>
<p>
Try adjusting your search query or filters to find what you're looking
for.
</p>
</div>
);
}
function LoadingSpinner() {
return (
<div className="loading-spinner">
<div className="spinner" />
<p>Searching books...</p>
</div>
);
}
export default function BookList({
books,
isLoading,
totalCount,
onBookClick,
}: BookListProps) {
if (isLoading) return <LoadingSpinner />;
if (totalCount === 0) return <EmptyState />;
return (
<div className="book-list">
<p className="result-count">
{totalCount} book{totalCount !== 1 ? "s" : ""} found
</p>
<div className="book-grid">
{books.map((book) => (
<button
key={book.id}
type="button"
className="book-card"
onClick={() => onBookClick(book.id)}
>
<div className="book-card-cover">
{book.cover_url ? (
<img src={book.cover_url} alt={`${book.title} cover`} />
) : (
<div className="cover-placeholder">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
)}
</div>
<div className="book-card-info">
<h3 className="book-card-title">{book.title}</h3>
<p className="book-card-author">{book.author}</p>
<p className="book-card-genre">{book.genre}</p>
<StatusBadge status={book.reading_status} />
</div>
</button>
))}
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* SearchBar component — real-time text input for search queries.
*/
import { useCallback, useRef } from "react";
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export default function SearchBar({
value,
onChange,
placeholder = "Search by title, author, or genre...",
}: SearchBarProps) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onChange(newValue);
}, 300);
},
[onChange],
);
return (
<div className="search-bar">
<svg
className="search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
defaultValue={value}
onChange={handleChange}
placeholder={placeholder}
className="search-input"
aria-label="Search books"
/>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+63
View File
@@ -0,0 +1,63 @@
/**
* BookDetailPage — fetches and displays a single book's details.
*/
import { useEffect, useState } from "react";
import { getBook } from "../api/books";
import type { BookDetail as BookDetailType } from "../types";
import BookDetailComponent from "../components/BookDetail";
interface BookDetailPageProps {
bookId: number;
onBack: () => void;
}
export default function BookDetailPage({ bookId, onBack }: BookDetailPageProps) {
const [book, setBook] = useState<BookDetailType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
getBook(bookId)
.then(setBook)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load book");
})
.finally(() => setIsLoading(false));
}, [bookId]);
if (isLoading) {
return (
<div className="loading-spinner">
<div className="spinner" />
<p>Loading book details...</p>
</div>
);
}
if (error) {
return (
<div className="error-state">
<p>{error}</p>
<button type="button" className="back-button" onClick={onBack}>
Back to library
</button>
</div>
);
}
if (!book) {
return (
<div className="error-state">
<p>Book not found.</p>
<button type="button" className="back-button" onClick={onBack}>
Back to library
</button>
</div>
);
}
return <BookDetailComponent book={book} onBack={onBack} />;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* LibraryPage — main page for searching and discovering books.
* Manages search state, filters, and pagination.
*/
import { useCallback, useEffect, useState } from "react";
import { searchBooks, getGenres, getAuthors } from "../api/books";
import type { BookSummary, ReadingStatus } from "../types";
import SearchBar from "../components/SearchBar";
import BookFilters from "../components/BookFilters";
import BookList from "../components/BookList";
interface LibraryPageProps {
onBookSelect: (id: number) => void;
}
export default function LibraryPage({ onBookSelect }: LibraryPageProps) {
const [query, setQuery] = useState("");
const [books, setBooks] = useState<BookSummary[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
// Filter state
const [genres, setGenres] = useState<string[]>([]);
const [authors, setAuthors] = useState<string[]>([]);
const [selectedGenre, setSelectedGenre] = useState("");
const [selectedAuthor, setSelectedAuthor] = useState("");
const [selectedStatus, setSelectedStatus] = useState<ReadingStatus | "">("");
// Load filter options on mount
useEffect(() => {
getGenres().then(setGenres).catch(() => {});
getAuthors().then(setAuthors).catch(() => {});
}, []);
// Perform search whenever any parameter changes
const performSearch = useCallback(
async (
q: string,
genre: string,
author: string,
status: ReadingStatus | "",
pageNum: number,
) => {
setIsLoading(true);
try {
const result = await searchBooks({
q: q || undefined,
genre: genre || undefined,
author: author || undefined,
reading_status: status || undefined,
page: pageNum,
});
setBooks(result.results);
setTotalCount(result.count);
setHasNext(result.next !== null);
setHasPrev(result.previous !== null);
} catch {
setBooks([]);
setTotalCount(0);
} finally {
setIsLoading(false);
}
},
[],
);
// Trigger search when state changes
useEffect(() => {
performSearch(query, selectedGenre, selectedAuthor, selectedStatus, page);
}, [query, selectedGenre, selectedAuthor, selectedStatus, page, performSearch]);
const handleSearchChange = useCallback((q: string) => {
setQuery(q);
setPage(1);
}, []);
const handleGenreChange = useCallback((genre: string) => {
setSelectedGenre(genre);
setPage(1);
}, []);
const handleAuthorChange = useCallback((author: string) => {
setSelectedAuthor(author);
setPage(1);
}, []);
const handleStatusChange = useCallback((status: ReadingStatus | "") => {
setSelectedStatus(status);
setPage(1);
}, []);
return (
<div className="library-page">
<header className="library-header">
<h1>Book Library</h1>
<p>Search and discover books in your collection</p>
</header>
<SearchBar value={query} onChange={handleSearchChange} />
<BookFilters
genres={genres}
authors={authors}
selectedGenre={selectedGenre}
selectedAuthor={selectedAuthor}
selectedStatus={selectedStatus}
onGenreChange={handleGenreChange}
onAuthorChange={handleAuthorChange}
onStatusChange={handleStatusChange}
/>
<BookList
books={books}
isLoading={isLoading}
totalCount={totalCount}
onBookClick={onBookSelect}
/>
{/* Pagination */}
{(hasPrev || hasNext) && (
<div className="pagination">
<button
type="button"
className="pagination-btn"
disabled={!hasPrev || isLoading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="page-indicator">Page {page}</span>
<button
type="button"
className="pagination-btn"
disabled={!hasNext || isLoading}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
/** Shared TypeScript interfaces for the Cloud Reader API. */
export type ReadingStatus =
| "want_to_read"
| "reading"
| "finished"
| "dnf";
export const READING_STATUS_LABELS: Record<ReadingStatus, string> = {
want_to_read: "Want to Read",
reading: "Reading",
finished: "Finished",
dnf: "Did Not Finish",
};
export interface BookSummary {
id: number;
title: string;
author: string;
genre: string;
reading_status: ReadingStatus;
reading_status_display: string;
cover_url: string;
}
export interface BookDetail {
id: number;
title: string;
author: string;
genre: string;
description: string;
reading_status: ReadingStatus;
reading_status_display: string;
cover_url: string;
created_at: string;
updated_at: string;
}
export interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export interface SearchParams {
q?: string;
genre?: string;
author?: string;
reading_status?: ReadingStatus | "";
page?: number;
}
+36
View File
@@ -0,0 +1,36 @@
export interface BookListItem {
id: number;
title: string;
author: string;
filename: string;
cover_image: string | null;
created_at: string;
progress: number | null;
}
export interface BookDetail {
id: number;
title: string;
author: string;
filename: string;
file_url: string;
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 AuthToken {
token: string;
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 3000,
watch: { usePolling: true },
},
});