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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user