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
148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
import React, { useState } from "react";
|
|
import { useAnnotations } from "@/context/AnnotationsContext";
|
|
import type { Note } from "@/types";
|
|
|
|
interface NoteListProps {
|
|
bookId?: string;
|
|
onNavigateToPage?: (bookId: string, page: number) => void;
|
|
}
|
|
|
|
export function NoteList({
|
|
bookId,
|
|
onNavigateToPage,
|
|
}: NoteListProps): React.ReactElement {
|
|
const { state, loadNotes, editNote, removeNote } = useAnnotations();
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editContent, setEditContent] = useState<string>("");
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [savingId, setSavingId] = useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
loadNotes(bookId);
|
|
}, [loadNotes, bookId]);
|
|
|
|
const handleEdit = (note: Note): void => {
|
|
setEditingId(note.id);
|
|
setEditContent(note.content);
|
|
};
|
|
|
|
const handleSave = async (id: string): Promise<void> => {
|
|
setSavingId(id);
|
|
try {
|
|
await editNote(id, editContent.trim());
|
|
setEditingId(null);
|
|
} catch {
|
|
// error handled by context
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
};
|
|
|
|
const handleCancelEdit = (): void => {
|
|
setEditingId(null);
|
|
setEditContent("");
|
|
};
|
|
|
|
const handleDelete = async (id: string): Promise<void> => {
|
|
setDeletingId(id);
|
|
try {
|
|
await removeNote(id);
|
|
} catch {
|
|
// error handled by context
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
if (state.notesLoading) {
|
|
return <div className="annotations-loading">Loading notes...</div>;
|
|
}
|
|
|
|
if (state.notes.length === 0) {
|
|
return (
|
|
<div className="annotations-empty">
|
|
No notes yet. Select a passage and add a note while reading.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="annotations-list">
|
|
{state.notes.map((note: Note) => (
|
|
<div key={note.id} className="annotation-card">
|
|
<div className="annotation-card-header">
|
|
<span className="annotation-kind-badge note-badge">Note</span>
|
|
<span className="annotation-page">Page {note.page}</span>
|
|
<span className="annotation-date">
|
|
{new Date(note.created_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
{note.location_text && (
|
|
<blockquote className="annotation-quote">
|
|
“{note.location_text}”
|
|
</blockquote>
|
|
)}
|
|
<div className="annotation-note-content">
|
|
{editingId === note.id ? (
|
|
<div className="note-edit-form">
|
|
<textarea
|
|
className="note-edit-textarea"
|
|
value={editContent}
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
setEditContent(e.target.value)
|
|
}
|
|
rows={4}
|
|
autoFocus
|
|
/>
|
|
<div className="note-edit-actions">
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() => handleSave(note.id)}
|
|
disabled={savingId === note.id || !editContent.trim()}
|
|
>
|
|
{savingId === note.id ? "Saving..." : "Save"}
|
|
</button>
|
|
<button
|
|
className="btn btn-sm btn-secondary"
|
|
onClick={handleCancelEdit}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="note-content-text">{note.content}</p>
|
|
)}
|
|
</div>
|
|
<div className="annotation-actions">
|
|
{onNavigateToPage && (
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() =>
|
|
onNavigateToPage(note.book, note.page)
|
|
}
|
|
>
|
|
Go to page
|
|
</button>
|
|
)}
|
|
{editingId !== note.id && (
|
|
<button
|
|
className="btn btn-sm btn-secondary"
|
|
onClick={() => handleEdit(note)}
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-sm btn-danger"
|
|
onClick={() => handleDelete(note.id)}
|
|
disabled={deletingId === note.id}
|
|
>
|
|
{deletingId === note.id ? "Deleting..." : "Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
} |