Archived
- 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
111 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
} |