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,277 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Bookmark, Note, AnnotationEntry } from "@/types";
|
||||
import * as annotationsApi from "@/api/annotations";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnnotationsState {
|
||||
bookmarks: Bookmark[];
|
||||
notes: Note[];
|
||||
bookmarksLoading: boolean;
|
||||
notesLoading: boolean;
|
||||
error: string | null;
|
||||
selectedBookId: string | null;
|
||||
}
|
||||
|
||||
const initialState: AnnotationsState = {
|
||||
bookmarks: [],
|
||||
notes: [],
|
||||
bookmarksLoading: false,
|
||||
notesLoading: false,
|
||||
error: null,
|
||||
selectedBookId: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AnnotationsAction =
|
||||
| { type: "FETCH_BOOKMARKS_START" }
|
||||
| { type: "FETCH_BOOKMARKS_SUCCESS"; payload: Bookmark[] }
|
||||
| { type: "FETCH_NOTES_START" }
|
||||
| { type: "FETCH_NOTES_SUCCESS"; payload: Note[] }
|
||||
| { type: "SET_ERROR"; payload: string }
|
||||
| { type: "CLEAR_ERROR" }
|
||||
| { type: "REMOVE_BOOKMARK"; payload: string }
|
||||
| { type: "REMOVE_NOTE"; payload: string }
|
||||
| { type: "UPDATE_NOTE"; payload: Note }
|
||||
| { type: "ADD_BOOKMARK"; payload: Bookmark }
|
||||
| { type: "ADD_NOTE"; payload: Note }
|
||||
| { type: "SET_SELECTED_BOOK"; payload: string | null };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function annotationsReducer(
|
||||
state: AnnotationsState,
|
||||
action: AnnotationsAction
|
||||
): AnnotationsState {
|
||||
switch (action.type) {
|
||||
case "FETCH_BOOKMARKS_START":
|
||||
return { ...state, bookmarksLoading: true, error: null };
|
||||
case "FETCH_BOOKMARKS_SUCCESS":
|
||||
return { ...state, bookmarks: action.payload, bookmarksLoading: false };
|
||||
case "FETCH_NOTES_START":
|
||||
return { ...state, notesLoading: true, error: null };
|
||||
case "FETCH_NOTES_SUCCESS":
|
||||
return { ...state, notes: action.payload, notesLoading: false };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, bookmarksLoading: false, notesLoading: false };
|
||||
case "CLEAR_ERROR":
|
||||
return { ...state, error: null };
|
||||
case "REMOVE_BOOKMARK":
|
||||
return {
|
||||
...state,
|
||||
bookmarks: state.bookmarks.filter((b) => b.id !== action.payload),
|
||||
};
|
||||
case "REMOVE_NOTE":
|
||||
return {
|
||||
...state,
|
||||
notes: state.notes.filter((n) => n.id !== action.payload),
|
||||
};
|
||||
case "UPDATE_NOTE":
|
||||
return {
|
||||
...state,
|
||||
notes: state.notes.map((n) =>
|
||||
n.id === action.payload.id ? action.payload : n
|
||||
),
|
||||
};
|
||||
case "ADD_BOOKMARK":
|
||||
return {
|
||||
...state,
|
||||
bookmarks: [action.payload, ...state.bookmarks],
|
||||
};
|
||||
case "ADD_NOTE":
|
||||
return {
|
||||
...state,
|
||||
notes: [action.payload, ...state.notes],
|
||||
};
|
||||
case "SET_SELECTED_BOOK":
|
||||
return { ...state, selectedBookId: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnnotationsContextValue {
|
||||
state: AnnotationsState;
|
||||
loadBookmarks: (bookId?: string) => Promise<void>;
|
||||
loadNotes: (bookId?: string) => Promise<void>;
|
||||
addBookmark: (data: { book: string; page: number; location_text?: string }) => Promise<Bookmark>;
|
||||
addNote: (data: { book: string; page: number; location_text?: string; content: string }) => Promise<Note>;
|
||||
editNote: (id: string, content: string) => Promise<Note>;
|
||||
removeBookmark: (id: string) => Promise<void>;
|
||||
removeNote: (id: string) => Promise<void>;
|
||||
setSelectedBook: (bookId: string | null) => void;
|
||||
/** Merged list of bookmarks + notes, sorted by created_at desc */
|
||||
mergedAnnotations: AnnotationEntry[];
|
||||
}
|
||||
|
||||
const AnnotationsContext = createContext<AnnotationsContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AnnotationsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
const [state, dispatch] = useReducer(annotationsReducer, initialState);
|
||||
|
||||
const loadBookmarks = useCallback(async (bookId?: string) => {
|
||||
dispatch({ type: "FETCH_BOOKMARKS_START" });
|
||||
try {
|
||||
const response = await annotationsApi.fetchBookmarks(bookId);
|
||||
dispatch({ type: "FETCH_BOOKMARKS_SUCCESS", payload: response.results });
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: err instanceof Error ? err.message : "Failed to load bookmarks",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNotes = useCallback(async (bookId?: string) => {
|
||||
dispatch({ type: "FETCH_NOTES_START" });
|
||||
try {
|
||||
const response = await annotationsApi.fetchNotes(bookId);
|
||||
dispatch({ type: "FETCH_NOTES_SUCCESS", payload: response.results });
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: err instanceof Error ? err.message : "Failed to load notes",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addBookmark = useCallback(
|
||||
async (data: {
|
||||
book: string;
|
||||
page: number;
|
||||
location_text?: string;
|
||||
}): Promise<Bookmark> => {
|
||||
const bookmark = await annotationsApi.createBookmark(data);
|
||||
dispatch({ type: "ADD_BOOKMARK", payload: bookmark });
|
||||
return bookmark;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addNote = useCallback(
|
||||
async (data: {
|
||||
book: string;
|
||||
page: number;
|
||||
location_text?: string;
|
||||
content: string;
|
||||
}): Promise<Note> => {
|
||||
const note = await annotationsApi.createNote(data);
|
||||
dispatch({ type: "ADD_NOTE", payload: note });
|
||||
return note;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const editNote = useCallback(
|
||||
async (id: string, content: string): Promise<Note> => {
|
||||
const updated = await annotationsApi.updateNote(id, { content });
|
||||
dispatch({ type: "UPDATE_NOTE", payload: updated });
|
||||
return updated;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeBookmark = useCallback(async (id: string) => {
|
||||
await annotationsApi.deleteBookmark(id);
|
||||
dispatch({ type: "REMOVE_BOOKMARK", payload: id });
|
||||
}, []);
|
||||
|
||||
const removeNote = useCallback(async (id: string) => {
|
||||
await annotationsApi.deleteNote(id);
|
||||
dispatch({ type: "REMOVE_NOTE", payload: id });
|
||||
}, []);
|
||||
|
||||
const setSelectedBook = useCallback((bookId: string | null) => {
|
||||
dispatch({ type: "SET_SELECTED_BOOK", payload: bookId });
|
||||
}, []);
|
||||
|
||||
// Build merged annotations list sorted by created_at desc
|
||||
const mergedAnnotations: AnnotationEntry[] = [
|
||||
...state.bookmarks.map(
|
||||
(b): AnnotationEntry => ({
|
||||
id: b.id,
|
||||
kind: "bookmark",
|
||||
book_title: b.book_title,
|
||||
book_id: b.book,
|
||||
page: b.page,
|
||||
location_text: b.location_text,
|
||||
created_at: b.created_at,
|
||||
updated_at: b.updated_at,
|
||||
})
|
||||
),
|
||||
...state.notes.map(
|
||||
(n): AnnotationEntry => ({
|
||||
id: n.id,
|
||||
kind: "note",
|
||||
book_title: n.book_title,
|
||||
book_id: n.book,
|
||||
page: n.page,
|
||||
location_text: n.location_text,
|
||||
content: n.content,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at,
|
||||
})
|
||||
),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
|
||||
const value: AnnotationsContextValue = {
|
||||
state,
|
||||
loadBookmarks,
|
||||
loadNotes,
|
||||
addBookmark,
|
||||
addNote,
|
||||
editNote,
|
||||
removeBookmark,
|
||||
removeNote,
|
||||
setSelectedBook,
|
||||
mergedAnnotations,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnnotationsContext.Provider value={value}>
|
||||
{children}
|
||||
</AnnotationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAnnotations(): AnnotationsContextValue {
|
||||
const context = useContext(AnnotationsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAnnotations must be used within an AnnotationsProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user