feat: uv config other feats

- add uv configuration for the backend
- update frontend to make auth work
- add new auth endpoints
- add bookmars feat
- add reader feat
This commit is contained in:
2026-06-03 22:06:01 -05:00
parent 730c748f5f
commit 6b4c0c43f8
137 changed files with 20319 additions and 2340 deletions
+42 -180
View File
@@ -3,130 +3,76 @@ import {
useContext,
useReducer,
useCallback,
useMemo,
type ReactNode,
} from "react";
import type { Bookmark, Note, AnnotationEntry } from "@/types";
import type { Bookmark, CreateMarkerPayload, MarkerEntry, MarkersByBook } from "@/types";
import * as annotationsApi from "@/api/annotations";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
import { bookmarkToMarkerEntry, groupMarkersByBook, sortMarkers } from "@/utils/markers";
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
// ---------------------------------------------------------------------------
| { type: "ADD_BOOKMARK"; payload: Bookmark };
function annotationsReducer(
state: AnnotationsState,
action: AnnotationsAction
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 "FETCH_BOOKMARKS_SUCCESS": {
const sorted = [...action.payload].sort((a, b) => {
if (a.chapter_index !== b.chapter_index) return a.chapter_index - b.chapter_index;
return a.epub_cfi.localeCompare(b.epub_cfi);
});
return { ...state, bookmarks: sorted, bookmarksLoading: false };
}
case "SET_ERROR":
return { ...state, error: action.payload, bookmarksLoading: false, notesLoading: false };
case "CLEAR_ERROR":
return { ...state, error: null };
return { ...state, error: action.payload, bookmarksLoading: false };
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 };
case "ADD_BOOKMARK": {
const next = [...state.bookmarks, action.payload].sort((a, b) => {
if (a.chapter_index !== b.chapter_index) return a.chapter_index - b.chapter_index;
return a.epub_cfi.localeCompare(b.epub_cfi);
});
return { ...state, bookmarks: next };
}
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>;
markers: MarkerEntry[];
markersByBook: MarkersByBook[];
loadBookmarks: (ebookId?: string | number) => Promise<void>;
addMarker: (data: CreateMarkerPayload) => Promise<Bookmark>;
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,
}: {
@@ -134,10 +80,10 @@ export function AnnotationsProvider({
}): React.ReactElement {
const [state, dispatch] = useReducer(annotationsReducer, initialState);
const loadBookmarks = useCallback(async (bookId?: string) => {
const loadBookmarks = useCallback(async (ebookId?: string | number) => {
dispatch({ type: "FETCH_BOOKMARKS_START" });
try {
const response = await annotationsApi.fetchBookmarks(bookId);
const response = await annotationsApi.fetchBookmarks(ebookId);
dispatch({ type: "FETCH_BOOKMARKS_SUCCESS", payload: response.results });
} catch (err) {
dispatch({
@@ -147,112 +93,34 @@ export function AnnotationsProvider({
}
}, []);
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 addMarker = useCallback(async (data: CreateMarkerPayload): Promise<Bookmark> => {
const bookmark = await annotationsApi.createMarker(data);
dispatch({ type: "ADD_BOOKMARK", payload: bookmark });
return bookmark;
}, []);
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 markers = useMemo(
() => state.bookmarks.map(bookmarkToMarkerEntry).sort(sortMarkers),
[state.bookmarks],
);
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 markersByBook = useMemo(
() => groupMarkersByBook(state.bookmarks),
[state.bookmarks],
);
const value: AnnotationsContextValue = {
state,
markers,
markersByBook,
loadBookmarks,
loadNotes,
addBookmark,
addNote,
editNote,
addMarker,
removeBookmark,
removeNote,
setSelectedBook,
mergedAnnotations,
};
return (
@@ -262,16 +130,10 @@ export function AnnotationsProvider({
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAnnotations(): AnnotationsContextValue {
const context = useContext(AnnotationsContext);
if (!context) {
throw new Error(
"useAnnotations must be used within an AnnotationsProvider"
);
throw new Error("useAnnotations must be used within an AnnotationsProvider");
}
return context;
}
}