Archived
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:
@@ -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>
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user