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
109 lines
3.2 KiB
TypeScript
109 lines
3.2 KiB
TypeScript
import React from "react";
|
|
import { useAnnotations } from "@/context/AnnotationsContext";
|
|
import type { AnnotationEntry } from "@/types";
|
|
|
|
interface AnnotationsDashboardProps {
|
|
bookId?: string;
|
|
onNavigateToPage?: (bookId: string, page: number) => void;
|
|
}
|
|
|
|
export function AnnotationsDashboard({
|
|
bookId,
|
|
onNavigateToPage,
|
|
}: AnnotationsDashboardProps): React.ReactElement {
|
|
const {
|
|
mergedAnnotations,
|
|
loadBookmarks,
|
|
loadNotes,
|
|
removeBookmark,
|
|
removeNote,
|
|
state,
|
|
} = useAnnotations();
|
|
|
|
React.useEffect(() => {
|
|
loadBookmarks(bookId);
|
|
loadNotes(bookId);
|
|
}, [loadBookmarks, loadNotes, bookId]);
|
|
|
|
const handleDelete = async (entry: AnnotationEntry): Promise<void> => {
|
|
if (entry.kind === "bookmark") {
|
|
await removeBookmark(entry.id);
|
|
} else {
|
|
await removeNote(entry.id);
|
|
}
|
|
};
|
|
|
|
if (state.bookmarksLoading || state.notesLoading) {
|
|
return <div className="annotations-loading">Loading annotations...</div>;
|
|
}
|
|
|
|
if (mergedAnnotations.length === 0) {
|
|
return (
|
|
<div className="annotations-empty">
|
|
No bookmarks or notes yet.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="annotations-dashboard">
|
|
<div className="annotations-summary">
|
|
<span className="summary-count">
|
|
{state.bookmarks.length} bookmarks
|
|
</span>
|
|
<span className="summary-separator">·</span>
|
|
<span className="summary-count">
|
|
{state.notes.length} notes
|
|
</span>
|
|
</div>
|
|
<div className="annotations-list">
|
|
{mergedAnnotations.map((entry: AnnotationEntry) => (
|
|
<div key={`${entry.kind}-${entry.id}`} className="annotation-card">
|
|
<div className="annotation-card-header">
|
|
<span
|
|
className={`annotation-kind-badge ${
|
|
entry.kind === "bookmark" ? "bookmark-badge" : "note-badge"
|
|
}`}
|
|
>
|
|
{entry.kind === "bookmark" ? "Bookmark" : "Note"}
|
|
</span>
|
|
<span className="annotation-book-title">
|
|
{entry.book_title}
|
|
</span>
|
|
<span className="annotation-page">p.{entry.page}</span>
|
|
<span className="annotation-date">
|
|
{new Date(entry.created_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
{entry.location_text && (
|
|
<blockquote className="annotation-quote">
|
|
“{entry.location_text}”
|
|
</blockquote>
|
|
)}
|
|
{entry.kind === "note" && entry.content && (
|
|
<p className="note-content-text">{entry.content}</p>
|
|
)}
|
|
<div className="annotation-actions">
|
|
{onNavigateToPage && (
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() =>
|
|
onNavigateToPage(entry.book_id, entry.page)
|
|
}
|
|
>
|
|
Go to page
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-sm btn-danger"
|
|
onClick={() => handleDelete(entry)}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |