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
@@ -1,120 +0,0 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
interface AddAnnotationFormProps {
bookId: string;
page: number;
locationText?: string;
onClose?: () => void;
}
export function AddAnnotationForm({
bookId,
page,
locationText,
onClose,
}: AddAnnotationFormProps): React.ReactElement {
const { addBookmark, addNote } = useAnnotations();
const [mode, setMode] = useState<"bookmark" | "note" | null>(null);
const [noteContent, setNoteContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (): Promise<void> => {
setSubmitting(true);
setError(null);
try {
if (mode === "bookmark") {
await addBookmark({ book: bookId, page, location_text: locationText });
} else if (mode === "note") {
if (!noteContent.trim()) {
setError("Note content cannot be empty.");
setSubmitting(false);
return;
}
await addNote({
book: bookId,
page,
location_text: locationText,
content: noteContent.trim(),
});
}
onClose?.();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save annotation."
);
} finally {
setSubmitting(false);
}
};
return (
<div className="add-annotation-overlay">
<div className="add-annotation-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
<h3>Add to Page {page}</h3>
{locationText && (
<blockquote className="annotation-quote">
&ldquo;{locationText}&rdquo;
</blockquote>
)}
{!mode ? (
<div className="mode-selector">
<button
className="btn btn-block"
onClick={() => setMode("bookmark")}
>
Add Bookmark
</button>
<button
className="btn btn-block btn-secondary"
onClick={() => setMode("note")}
>
Add Note
</button>
</div>
) : (
<div className="annotation-form">
{mode === "note" && (
<div className="form-group">
<label htmlFor="note-content">Note:</label>
<textarea
id="note-content"
className="form-textarea"
value={noteContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setNoteContent(e.target.value)
}
rows={5}
placeholder="Write your note here..."
autoFocus
/>
</div>
)}
{error && <div className="form-error">{error}</div>}
<div className="form-actions">
<button
className="btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</button>
<button
className="btn btn-secondary"
onClick={() => setMode(null)}
disabled={submitting}
>
Back
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,109 +0,0 @@
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">&middot;</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">
&ldquo;{entry.location_text}&rdquo;
</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>
);
}
@@ -1,87 +0,0 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { Bookmark } from "@/types";
interface BookmarkListProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function BookmarkList({
bookId,
onNavigateToPage,
}: BookmarkListProps): React.ReactElement {
const { state, loadBookmarks, removeBookmark } = useAnnotations();
const [deletingId, setDeletingId] = useState<string | null>(null);
React.useEffect(() => {
loadBookmarks(bookId);
}, [loadBookmarks, bookId]);
const handleDelete = async (id: string): Promise<void> => {
setDeletingId(id);
try {
await removeBookmark(id);
} catch {
// error handled by context
} finally {
setDeletingId(null);
}
};
if (state.bookmarksLoading) {
return <div className="annotations-loading">Loading bookmarks...</div>;
}
if (state.bookmarks.length === 0) {
return (
<div className="annotations-empty">
No bookmarks yet. Select a passage and add a bookmark while reading.
</div>
);
}
return (
<div className="annotations-list">
{state.bookmarks.map((bookmark: Bookmark) => (
<div key={bookmark.id} className="annotation-card">
<div className="annotation-card-header">
<span className="annotation-kind-badge bookmark-badge">
Bookmark
</span>
<span className="annotation-page">
Page {bookmark.page}
</span>
<span className="annotation-date">
{new Date(bookmark.created_at).toLocaleDateString()}
</span>
</div>
{bookmark.location_text && (
<blockquote className="annotation-quote">
&ldquo;{bookmark.location_text}&rdquo;
</blockquote>
)}
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(bookmark.book, bookmark.page)
}
>
Go to page
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(bookmark.id)}
disabled={deletingId === bookmark.id}
>
{deletingId === bookmark.id ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
);
}
@@ -1,23 +1,38 @@
import React from "react";
import { AnnotationsDashboard } from "@/components/annotations";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { MarkerThreadsView } from "./MarkerThreadsView";
interface BookmarksNotesPageProps {
bookId?: string;
}
export function BookmarksNotesPage(): React.ReactElement {
const { t } = useTranslation();
const navigate = useNavigate();
const { bookId } = useParams<{ bookId?: string }>();
export function BookmarksNotesPage({
bookId,
}: BookmarksNotesPageProps): React.ReactElement {
return (
<div className="page">
<h2>Bookmarks & Notes</h2>
<AnnotationsDashboard
bookId={bookId}
onNavigateToPage={(bookId, page) => {
// Navigate to the book reader page at the specific page
window.location.href = `/books/${bookId}?page=${page}`;
<div className="page bookmarks-notes-page" style={{ maxWidth: 720, margin: "0 auto", padding: 16 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24 }}>
<button
type="button"
onClick={() => navigate("/")}
style={{
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
fontSize: 14,
}}
>
{t("common.back")}
</button>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>{t("annotations.title")}</h2>
</header>
<MarkerThreadsView
ebookIdFilter={bookId}
onGoToPassage={(ebookId, epubCfi) => {
navigate(`/read/${ebookId}`, { state: { epubLocation: epubCfi } });
}}
/>
</div>
);
}
}
@@ -0,0 +1,90 @@
import { useState } from "react";
import { useTranslation } from "react-i18n-lite";
import {
highlightBackgroundStyle,
markerPassageStyle,
normalizeHighlightColor,
} from "@/constants/bookmarkHighlightColors";
const PASSAGE_COLLAPSE_CHARS = 200;
const THOUGHT_COLLAPSE_CHARS = 120;
interface CollapsibleMarkerPassageProps {
text: string;
highlightColor?: string | null;
className?: string;
}
export function CollapsibleMarkerPassage({
text,
highlightColor,
className = "annotation-quote marker-thread-passage",
}: CollapsibleMarkerPassageProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const collapsible = text.length > PASSAGE_COLLAPSE_CHARS;
const color = normalizeHighlightColor(highlightColor);
return (
<blockquote className={className} style={markerPassageStyle(color)}>
<span
className={
collapsible && !expanded
? "marker-passage-text marker-passage-text--clamped"
: "marker-passage-text"
}
style={highlightBackgroundStyle(color)}
>
&ldquo;{text}&rdquo;
</span>
{collapsible && (
<button
type="button"
className="marker-text-toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? t("annotations.showLess") : t("annotations.showMore")}
</button>
)}
</blockquote>
);
}
interface CollapsibleMarkerThoughtProps {
text: string;
className?: string;
}
export function CollapsibleMarkerThought({
text,
className = "marker-thread-thought",
}: CollapsibleMarkerThoughtProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const collapsible = text.length > THOUGHT_COLLAPSE_CHARS;
return (
<div className="marker-thought-wrap">
<p
className={
collapsible && !expanded
? `${className} marker-thought-text marker-thought-text--clamped`
: `${className} marker-thought-text`
}
>
{text}
</p>
{collapsible && (
<button
type="button"
className="marker-text-toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? t("annotations.showLess") : t("annotations.showMore")}
</button>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { MarkerEntry } from "@/types";
import {
CollapsibleMarkerPassage,
CollapsibleMarkerThought,
} from "@/components/annotations/CollapsibleMarkerText";
interface MarkerThreadsViewProps {
ebookIdFilter?: string;
onGoToPassage: (ebookId: number, epubCfi: string) => void;
}
export function MarkerThreadsView({
ebookIdFilter,
onGoToPassage,
}: MarkerThreadsViewProps) {
const { t } = useTranslation();
const { markersByBook, loadBookmarks, removeBookmark, state } = useAnnotations();
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
useEffect(() => {
void loadBookmarks(ebookIdFilter);
}, [loadBookmarks, ebookIdFilter]);
const groups = useMemo(() => {
if (!ebookIdFilter) return markersByBook;
const id = Number(ebookIdFilter);
return markersByBook.filter((g) => g.ebookId === id);
}, [markersByBook, ebookIdFilter]);
if (state.bookmarksLoading) {
return <div className="annotations-loading">{t("annotations.loading")}</div>;
}
if (groups.length === 0) {
return (
<div className="annotations-empty">
<p>{t("annotations.empty")}</p>
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 8 }}>{t("annotations.selectTextHint")}</p>
</div>
);
}
const toggleBook = (ebookId: number) => {
setCollapsed((prev) => ({ ...prev, [ebookId]: !prev[ebookId] }));
};
return (
<div className="marker-books-list">
{groups.map((group) => {
const isCollapsed = collapsed[group.ebookId] ?? false;
return (
<section key={group.ebookId} className="marker-book-group">
<button
type="button"
className="marker-book-header"
onClick={() => toggleBook(group.ebookId)}
>
<span className="marker-book-title">{group.ebookTitle}</span>
<span className="marker-book-count">
{t("annotations.markerCount", { count: String(group.markers.length) })}
</span>
<span className="marker-book-chevron">{isCollapsed ? "▸" : "▾"}</span>
</button>
{!isCollapsed && (
<ul className="marker-thread-list">
{group.markers.map((m: MarkerEntry) => (
<li key={m.id} className="marker-thread">
<div className="marker-thread-meta">
{m.chapter_title ? (
<span className="marker-thread-chapter">{m.chapter_title}</span>
) : (
<span className="marker-thread-chapter">
{t("annotations.chapterIndex", { index: String(m.chapter_index + 1) })}
</span>
)}
</div>
{m.location_text && (
<CollapsibleMarkerPassage
text={m.location_text}
highlightColor={m.highlight_color}
/>
)}
{m.content ? (
<CollapsibleMarkerThought
text={m.content}
className="marker-thread-thought marker-thread-reply"
/>
) : (
<span className="annotation-kind-badge bookmark-badge">{t("annotations.bookmarkOnly")}</span>
)}
<div className="annotation-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onGoToPassage(m.ebook_id, m.epub_cfi)}
>
{t("annotations.goToPassage")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => removeBookmark(m.id)}
>
{t("common.delete")}
</button>
</div>
</li>
))}
</ul>
)}
</section>
);
})}
</div>
);
}
@@ -1,148 +0,0 @@
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">
&ldquo;{note.location_text}&rdquo;
</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>
);
}
+2 -4
View File
@@ -1,4 +1,2 @@
export { BookmarkList } from "./BookmarkList";
export { NoteList } from "./NoteList";
export { AddAnnotationForm } from "./AddAnnotationForm";
export { AnnotationsDashboard } from "./AnnotationsDashboard";
export { BookmarksNotesPage } from "./BookmarksNotesPage";
export { MarkerThreadsView } from "./MarkerThreadsView";