This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/web/src/components/BookList.tsx
T
Marko (Hermes Implementer) 3b5b301e42 feat: bookmarks and notes management
- Backend: Django REST Framework API with Bookmark and Note models
  - ViewSets with user-scoped querysets and select_related for N+1 prevention
  - Create/List/Detail/Update/Delete endpoints
  - Batch delete operations
  - Unique constraint on user+book+page for bookmarks
  - IsOwner permission class for object-level access control
  - Full serializer validation (page > 0, non-empty content, duplicate check)
  - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases

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

- Infrastructure: Django project with custom User model, JWT auth, CORS
  - PostgreSQL database models with proper FK and indexes
  - Django admin configuration for all models
2026-05-26 00:50:06 +00:00

111 lines
3.1 KiB
TypeScript

/**
* 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>
);
}