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
@@ -0,0 +1,51 @@
.menu {
position: fixed;
z-index: 9999;
min-width: 180px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
padding: 4px 0;
overflow: hidden;
}
.item {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: transparent;
text-align: left;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.item:hover:not(:disabled) {
background: #f3f4f6;
}
.item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.itemDanger {
color: #dc2626;
}
.itemDanger:hover:not(:disabled) {
background: #fef2f2;
}
.itemLoading {
color: #6b7280;
}
.separator {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import { getApiErrorMessage } from "../api/errors";
import { useToast } from "../hooks/useToast";
import type { BookListItem } from "../types/book";
import styles from "./BookContextMenu.module.css";
type LibraryBook = BookListItem & { format: string; progressPercent: number | null };
interface BookContextMenuProps {
book: LibraryBook;
x: number;
y: number;
onClose: () => void;
onBookUpdated: (book: LibraryBook) => void;
onBookRemoved: (id: number) => void;
}
type ActionKind = "sync" | "remove" | null;
function clampPosition(x: number, y: number, width: number, height: number) {
const padding = 8;
const maxX = window.innerWidth - width - padding;
const maxY = window.innerHeight - height - padding;
return {
left: Math.max(padding, Math.min(x, maxX)),
top: Math.max(padding, Math.min(y, maxY)),
};
}
export function BookContextMenu({
book,
x,
y,
onClose,
onBookUpdated,
onBookRemoved,
}: BookContextMenuProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y });
const [activeAction, setActiveAction] = useState<ActionKind>(null);
useLayoutEffect(() => {
const el = menuRef.current;
if (!el) return;
setPosition(clampPosition(x, y, el.offsetWidth, el.offsetHeight));
}, [x, y]);
useEffect(() => {
const handlePointerDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const handleScroll = () => onClose();
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
const handleSyncMetadata = async () => {
setActiveAction("sync");
try {
const updated = await booksApi.enrichEBookMetadata(book.id);
onBookUpdated({
...book,
id: updated.id,
title: updated.title,
author: updated.author,
genre: updated.format ? updated.format.toUpperCase() : "",
format: updated.format,
cover_image: updated.cover_image,
progressPercent: book.progressPercent,
});
const status = updated.metadata?.match_status;
if (status === "matched") {
showToast({ message: t("toast.metadataSynced", { title: updated.title }), variant: "success" });
} else if (status === "not_found") {
showToast({ message: t("toast.noMatch"), variant: "warning" });
} else {
showToast({ message: t("toast.refreshDone"), variant: "success" });
}
} catch (err) {
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
} finally {
setActiveAction(null);
onClose();
}
};
const handleRemove = async () => {
const confirmed = window.confirm(t("contextMenu.confirmRemove", { title: book.title }));
if (!confirmed) return;
setActiveAction("remove");
try {
await booksApi.deleteEBook(book.id);
onBookRemoved(book.id);
showToast({ message: t("toast.removed", { title: book.title }), variant: "success" });
} catch (err) {
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
} finally {
setActiveAction(null);
onClose();
}
};
const busy = activeAction !== null;
return (
<div
ref={menuRef}
className={styles.menu}
style={{ left: position.left, top: position.top }}
role="menu"
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button
type="button"
className={`${styles.item} ${activeAction === "sync" ? styles.itemLoading : ""}`}
role="menuitem"
disabled={busy}
onClick={() => void handleSyncMetadata()}
>
{activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")}
</button>
<div className={styles.separator} role="separator" />
<button
type="button"
className={`${styles.item} ${styles.itemDanger} ${activeAction === "remove" ? styles.itemLoading : ""}`}
role="menuitem"
disabled={busy}
onClick={() => void handleRemove()}
>
{activeAction === "remove" ? t("contextMenu.removing") : t("contextMenu.remove")}
</button>
</div>
);
}
@@ -0,0 +1,50 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: min(360px, calc(100vw - 32px));
}
.toast {
pointer-events: auto;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
animation: slideIn 0.2s ease-out;
}
.success {
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.warning {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,20 @@
import type { ToastItem } from "../hooks/useToast";
import styles from "./ToastContainer.module.css";
interface ToastContainerProps {
toasts: ToastItem[];
}
export function ToastContainer({ toasts }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className={styles.container} role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`${styles.toast} ${styles[toast.variant]}`}>
{toast.message}
</div>
))}
</div>
);
}
@@ -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";
+16 -15
View File
@@ -1,5 +1,5 @@
import React from "react";
import { AnnotationsProvider } from "@/context/AnnotationsContext";
import { useTranslation } from "react-i18n-lite";
interface LayoutProps {
children: React.ReactNode;
@@ -8,20 +8,21 @@ interface LayoutProps {
export function Layout({
children,
title = "Cloud Reader",
title,
}: LayoutProps): React.ReactElement {
const { t } = useTranslation();
const pageTitle = title ?? t("common.appName");
return (
<AnnotationsProvider>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">{title}</h1>
<nav className="app-nav">
<a href="/" className="nav-link">Home</a>
<a href="/bookmarks-notes" className="nav-link">Bookmarks & Notes</a>
</nav>
</header>
<main className="app-main">{children}</main>
</div>
</AnnotationsProvider>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">{pageTitle}</h1>
<nav className="app-nav">
<a href="/" className="nav-link">{t("annotations.home")}</a>
<a href="/bookmarks-notes" className="nav-link">{t("annotations.bookmarksNotes")}</a>
</nav>
</header>
<main className="app-main">{children}</main>
</div>
);
}
}
@@ -0,0 +1,140 @@
.shelf {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
background: #fff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08);
transition: max-height 0.25s ease;
}
.shelfCollapsed {
max-height: 52px;
}
.shelfExpanded {
max-height: min(42vh, 320px);
}
.shelfHandle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: none;
background: #fafafa;
cursor: pointer;
font: inherit;
text-align: left;
}
.shelfHandle:hover {
background: #f3f4f6;
}
.shelfHandleTitle {
font-weight: 700;
font-size: 14px;
color: #1f2937;
}
.shelfHandleCount {
font-size: 13px;
color: #6b7280;
flex: 1;
}
.shelfChevron {
font-size: 14px;
color: #9333ea;
font-weight: 700;
}
.shelfBody {
overflow-y: auto;
max-height: calc(min(42vh, 320px) - 52px);
padding: 12px 16px 16px;
border-top: 1px solid #f3f4f6;
}
.shelfGrid {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.shelfCard {
flex: 0 0 120px;
scroll-snap-align: start;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
transition: transform 0.15s, box-shadow 0.15s;
}
.shelfCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.shelfCover {
position: relative;
height: 160px;
background: #f0f0f0;
}
.shelfCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.shelfCoverPlaceholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 32px;
}
.shelfBadge {
position: absolute;
top: 6px;
right: 6px;
background: #f3e8ff;
color: #9333ea;
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
}
.shelfMeta {
padding: 8px;
}
.shelfTitle {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shelfAuthor {
margin: 2px 0 0;
font-size: 11px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import styles from "./FinishedBooksShelf.module.css";
export interface ShelfBook {
id: number;
title: string;
author: string;
cover_image: string | null;
format: string;
genre: string;
}
interface FinishedBooksShelfProps {
books: ShelfBook[];
defaultExpanded?: boolean;
onOpenBook: (book: ShelfBook) => void;
onContextMenu: (e: React.MouseEvent, book: ShelfBook) => void;
}
export function FinishedBooksShelf({
books,
defaultExpanded = false,
onOpenBook,
onContextMenu,
}: FinishedBooksShelfProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => {
if (defaultExpanded) setExpanded(true);
}, [defaultExpanded]);
if (books.length === 0) return null;
return (
<div
className={`${styles.shelf} ${expanded ? styles.shelfExpanded : styles.shelfCollapsed}`}
aria-label={t("library.finishedSection")}
>
<button
type="button"
className={styles.shelfHandle}
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<span className={styles.shelfHandleTitle}>{t("library.finishedSection")}</span>
<span className={styles.shelfHandleCount}>
{t("library.finishedCount", { count: String(books.length) })}
</span>
<span className={styles.shelfChevron} aria-hidden>
{expanded ? "▾" : "▴"}
</span>
</button>
{expanded && (
<div className={styles.shelfBody}>
<div className={styles.shelfGrid}>
{books.map((book) => (
<div
key={book.id}
role="button"
tabIndex={0}
className={styles.shelfCard}
onClick={() => onOpenBook(book)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenBook(book);
}
}}
onContextMenu={(e) => onContextMenu(e, book)}
>
<div className={styles.shelfCover}>
{book.cover_image ? (
<img src={book.cover_image} alt="" />
) : (
<span className={styles.shelfCoverPlaceholder}>📖</span>
)}
<span className={styles.shelfBadge}>{t("library.readingStatus.finished")}</span>
</div>
<div className={styles.shelfMeta}>
<p className={styles.shelfTitle}>{book.title}</p>
<p className={styles.shelfAuthor}>{book.author}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
import { useTranslation } from "react-i18n-lite";
import { readingStatusKey } from "@/locales";
import styles from "../../pages/Library.module.css";
export const LIBRARY_STATUS_COLORS: Record<string, { bg: string; text: string }> = {
want_to_read: { bg: "#dbeafe", text: "#1d4ed8" },
reading: { bg: "#dcfce7", text: "#16a34a" },
finished: { bg: "#f3e8ff", text: "#9333ea" },
dnf: { bg: "#fef3c7", text: "#b45309" },
};
export interface LibraryBookCardData {
id: number;
title: string;
author: string;
genre: string;
format: string;
cover_image: string | null;
reading_status: string;
progressPercent: number | null;
}
function badgeLabel(
book: LibraryBookCardData,
t: (key: string, params?: Record<string, string>) => string,
): string {
if (book.reading_status === "reading" && book.progressPercent != null && book.progressPercent > 0) {
return t("library.readingStatus.readingWithProgress", {
percent: String(book.progressPercent),
});
}
return t(readingStatusKey(book.reading_status));
}
interface LibraryBookCardProps {
book: LibraryBookCardData;
isMobile: boolean;
onOpen: () => void;
onContextMenu: (e: React.MouseEvent) => void;
}
export function LibraryBookCard({
book,
isMobile,
onOpen,
onContextMenu,
}: LibraryBookCardProps) {
const { t } = useTranslation();
const sc = LIBRARY_STATUS_COLORS[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
const showProgressBar =
book.reading_status === "reading" &&
book.progressPercent != null &&
book.progressPercent > 0;
return (
<div
onClick={onOpen}
onContextMenu={onContextMenu}
className={styles.bookCard}
style={{ display: isMobile ? "flex" : "block" }}
>
<div
style={{
width: isMobile ? 80 : "100%",
height: isMobile ? 120 : 180,
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}
>
{book.cover_image ? (
<img
src={book.cover_image}
alt={book.title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span style={{ fontSize: isMobile ? 32 : 48 }}>📖</span>
)}
{showProgressBar && (
<div className={styles.coverProgressBar}>
<div
className={styles.coverProgressFill}
style={{ width: `${book.progressPercent}%` }}
/>
</div>
)}
{!isMobile && (
<span
style={{
position: "absolute",
top: 8,
right: 8,
background: sc.bg,
color: sc.text,
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 999,
lineHeight: "18px",
maxWidth: "calc(100% - 16px)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{badgeLabel(book, t)}
</span>
)}
</div>
<div style={{ padding: isMobile ? "8px 12px" : 12, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<h3
style={{
fontSize: 14,
fontWeight: 600,
color: "#1f2937",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{book.title}
</h3>
<p style={{ fontSize: 12, color: "#6b7280", margin: "2px 0" }}>
{book.author || t("common.unknownAuthor")}
</p>
</div>
{isMobile && (
<span
style={{
background: sc.bg,
color: sc.text,
fontSize: 10,
fontWeight: 600,
padding: "2px 6px",
borderRadius: 999,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{badgeLabel(book, t)}
</span>
)}
</div>
{book.genre && (
<span
style={{
fontSize: 11,
color: "#4f46e5",
background: "#eef2ff",
padding: "1px 6px",
borderRadius: 4,
display: "inline-block",
marginTop: 4,
}}
>
{book.genre}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useEffect } 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 BookMarkersPanelProps {
ebookId: number;
isOpen: boolean;
onClose: () => void;
onGoToPassage: (marker: MarkerEntry) => void;
}
export function BookMarkersPanel({
ebookId,
isOpen,
onClose,
onGoToPassage,
}: BookMarkersPanelProps) {
const { t } = useTranslation();
const { markers, loadBookmarks, removeBookmark, state } = useAnnotations();
useEffect(() => {
if (isOpen) void loadBookmarks(ebookId);
}, [isOpen, ebookId, loadBookmarks]);
if (!isOpen) return null;
return (
<>
<div className="reader-panel-backdrop" onClick={onClose} aria-hidden />
<aside className="reader-side-panel reader-markers-panel" role="dialog" aria-label={t("annotations.inBookPanel")}>
<header className="reader-panel-header">
<h2>{t("annotations.inBookPanel")}</h2>
<button type="button" className="reader-panel-close" onClick={onClose} aria-label={t("annotations.close")}>
×
</button>
</header>
<div className="reader-panel-body">
{state.bookmarksLoading ? (
<p className="annotations-loading">{t("annotations.loading")}</p>
) : markers.length === 0 ? (
<p className="annotations-empty">{t("annotations.selectTextHint")}</p>
) : (
<ul className="marker-thread-list">
{markers.map((m) => (
<li key={m.id} className="marker-thread">
{m.chapter_title && (
<span className="marker-thread-chapter">{m.chapter_title}</span>
)}
{m.location_text && (
<CollapsibleMarkerPassage
text={m.location_text}
highlightColor={m.highlight_color}
className="annotation-quote"
/>
)}
{m.content ? (
<CollapsibleMarkerThought text={m.content} />
) : (
<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)}>
{t("annotations.goToPassage")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => removeBookmark(m.id)}
>
{t("common.delete")}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</aside>
</>
);
}
@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18n-lite";
import {
BOOKMARK_HIGHLIGHT_PRESETS,
normalizeHighlightColor,
} from "@/constants/bookmarkHighlightColors";
interface BookmarkColorPickerProps {
value: string;
onChange: (color: string) => void;
}
export function BookmarkColorPicker({ value, onChange }: BookmarkColorPickerProps) {
const { t } = useTranslation();
const normalized = normalizeHighlightColor(value);
const isPreset = BOOKMARK_HIGHLIGHT_PRESETS.some((p) => p.value === normalized);
return (
<div className="bookmark-color-picker" role="group" aria-label={t("annotations.highlightColor")}>
<span className="bookmark-color-picker-label">{t("annotations.highlightColor")}</span>
<div className="bookmark-color-picker-swatches">
{BOOKMARK_HIGHLIGHT_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
className={`bookmark-color-swatch${normalized === preset.value ? " bookmark-color-swatch--active" : ""}`}
style={{ backgroundColor: preset.value }}
title={t(preset.labelKey)}
aria-label={t(preset.labelKey)}
aria-pressed={normalized === preset.value}
onClick={() => onChange(preset.value)}
/>
))}
<label className="bookmark-color-custom" title={t("annotations.highlightCustom")}>
<input
type="color"
className="bookmark-color-custom-input"
value={normalized}
onChange={(e) => onChange(e.target.value)}
aria-label={t("annotations.highlightCustom")}
/>
<span
className={`bookmark-color-swatch bookmark-color-swatch--custom${!isPreset ? " bookmark-color-swatch--active" : ""}`}
style={{ backgroundColor: normalized }}
aria-hidden
/>
</label>
</div>
</div>
);
}
@@ -1,61 +1,105 @@
/**
* ReaderToolbar — fixed bottom toolbar for the reading view.
* Provides TOC toggle, settings toggle, and progress indicator.
*/
import { useTranslation } from "react-i18n-lite";
import type { ReadingProgress } from "../../types/reader";
interface ReaderToolbarProps {
bookTitle: string;
chapterTitle: string;
progress: ReadingProgress | null;
onBack?: () => void;
onToggleToc: () => void;
onToggleSettings: () => void;
onToggleMarkers?: () => void;
}
export default function ReaderToolbar({
bookTitle,
chapterTitle,
progress,
onBack,
onToggleToc,
onToggleSettings,
onToggleMarkers,
}: ReaderToolbarProps) {
const { t } = useTranslation();
const percentage = progress?.percentage ?? 0;
return (
<>
{/* Top bar */}
<header className="reader-top-bar">
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label="Table of contents"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{onBack ? (
<button
type="button"
className="reader-bar-btn"
onClick={onBack}
aria-label={t("reader.backToLibraryAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H4M10 18l-6-6 6-6" />
</svg>
</button>
) : (
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label={t("reader.tocAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<div className="reader-bar-title">
<span className="reader-bar-book">{bookTitle}</span>
<span className="reader-bar-chapter">{chapterTitle}</span>
</div>
<div className="reader-bar-actions">
{onBack && (
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label={t("reader.tocAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
{onToggleMarkers && (
<button
type="button"
className="reader-bar-btn"
onClick={onToggleMarkers}
aria-label={t("annotations.inBookPanel")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
<button
type="button"
className="reader-bar-btn"
onClick={onToggleSettings}
aria-label="Reading settings"
aria-label={t("reader.settingsAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
</div>
</header>
{/* Bottom progress bar */}
<div className="reader-progress-bar">
<div
className="reader-progress-fill"
@@ -64,4 +108,4 @@ export default function ReaderToolbar({
</div>
</>
);
}
}
@@ -3,38 +3,44 @@
* the reading experience: theme, font, sizing, orientation.
*/
import { useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18n-lite";
import type {
FontFamily,
OrientationLock,
ReadingSettings,
ThemePreset,
} from "../../types/reader";
import type { SettingsPersistMode } from "../../hooks/useReadingSettings";
interface ReadingSettingsPanelProps {
settings: ReadingSettings;
isOpen: boolean;
onClose: () => void;
onUpdate: (partial: Partial<ReadingSettings>) => Promise<void>;
onUpdate: (
partial: Partial<ReadingSettings>,
persist?: SettingsPersistMode,
) => void;
onFlush: () => Promise<void>;
}
const THEME_OPTIONS: { value: ThemePreset; label: string }[] = [
{ value: "sepia", label: "Sepia" },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
{ value: "paper", label: "Paper" },
const THEME_OPTIONS: { value: ThemePreset; labelKey: string }[] = [
{ value: "sepia", labelKey: "reader.themeSepia" },
{ value: "dark", labelKey: "reader.themeDark" },
{ value: "light", labelKey: "reader.themeLight" },
{ value: "paper", labelKey: "reader.themePaper" },
];
const FONT_OPTIONS: { value: FontFamily; label: string }[] = [
{ value: "sans-serif", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "monospace", label: "Monospace" },
const FONT_OPTIONS: { value: FontFamily; labelKey: string }[] = [
{ value: "sans-serif", labelKey: "reader.fontSans" },
{ value: "serif", labelKey: "reader.fontSerif" },
{ value: "monospace", labelKey: "reader.fontMonospace" },
];
const ORIENTATION_OPTIONS: { value: OrientationLock; label: string }[] = [
{ value: "auto", label: "Auto" },
{ value: "portrait", label: "Portrait" },
{ value: "landscape", label: "Landscape" },
const ORIENTATION_OPTIONS: { value: OrientationLock; labelKey: string }[] = [
{ value: "auto", labelKey: "reader.orientationAuto" },
{ value: "portrait", labelKey: "reader.orientationPortrait" },
{ value: "landscape", labelKey: "reader.orientationLandscape" },
];
export default function ReadingSettingsPanel({
@@ -42,46 +48,46 @@ export default function ReadingSettingsPanel({
isOpen,
onClose,
onUpdate,
onFlush,
}: ReadingSettingsPanelProps) {
const [saving, setSaving] = useState<Record<string, boolean>>({});
const { t } = useTranslation();
const handleChange = async (
key: keyof ReadingSettings,
value: string | number
) => {
setSaving((prev) => ({ ...prev, [key]: true }));
try {
await onUpdate({ [key]: value as never });
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
const handleClose = () => {
void onFlush();
onClose();
};
const handleSlider = (key: keyof ReadingSettings, value: number) => {
onUpdate({ [key]: value } as Partial<ReadingSettings>, "debounced");
};
const handleDiscrete = (key: keyof ReadingSettings, value: string | number) => {
onUpdate({ [key]: value } as Partial<ReadingSettings>, "immediate");
};
return (
<>
{/* Overlay */}
{isOpen && (
<div
className="settings-overlay"
onClick={onClose}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Escape") onClose();
onClick={handleClose}
onKeyDown={(e: ReactKeyboardEvent) => {
if (e.key === "Escape") handleClose();
}}
role="presentation"
/>
)}
{/* Drawer */}
<aside
className={`settings-drawer ${isOpen ? "settings-drawer--open" : ""}`}
>
<div className="settings-header">
<h2 className="settings-title">Reading Settings</h2>
<h2 className="settings-title">{t("reader.settingsTitle")}</h2>
<button
type="button"
className="settings-close-btn"
onClick={onClose}
aria-label="Close settings"
onClick={handleClose}
aria-label={t("reader.closeSettingsAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
@@ -91,9 +97,8 @@ export default function ReadingSettingsPanel({
</div>
<div className="settings-body">
{/* Theme Presets */}
<section className="settings-section">
<h3 className="settings-section-title">Theme</h3>
<h3 className="settings-section-title">{t("reader.theme")}</h3>
<div className="theme-grid">
{THEME_OPTIONS.map((opt) => (
<button
@@ -102,18 +107,16 @@ export default function ReadingSettingsPanel({
className={`theme-btn ${
settings.theme === opt.value ? "theme-btn--active" : ""
}`}
onClick={() => handleChange("theme", opt.value)}
disabled={saving.theme}
onClick={() => handleDiscrete("theme", opt.value)}
>
{opt.label}
{t(opt.labelKey)}
</button>
))}
</div>
</section>
{/* Font Family */}
<section className="settings-section">
<h3 className="settings-section-title">Font</h3>
<h3 className="settings-section-title">{t("reader.font")}</h3>
<div className="font-grid">
{FONT_OPTIONS.map((opt) => (
<button
@@ -122,19 +125,17 @@ export default function ReadingSettingsPanel({
className={`font-btn ${
settings.font_family === opt.value ? "font-btn--active" : ""
}`}
onClick={() => handleChange("font_family", opt.value)}
disabled={saving.font_family}
onClick={() => handleDiscrete("font_family", opt.value)}
>
{opt.label}
{t(opt.labelKey)}
</button>
))}
</div>
</section>
{/* Font Size Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Font Size: {settings.font_size}px
{t("reader.fontSize", { size: String(settings.font_size) })}
</h3>
<input
type="range"
@@ -142,17 +143,16 @@ export default function ReadingSettingsPanel({
max="32"
value={settings.font_size}
onChange={(e) =>
handleChange("font_size", Number(e.target.value))
handleSlider("font_size", Number(e.target.value))
}
className="settings-slider"
aria-label="Font size"
aria-label={t("reader.fontSizeAria")}
/>
</section>
{/* Line Height Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Line Height: {settings.line_height.toFixed(1)}
{t("reader.lineHeight", { value: settings.line_height.toFixed(1) })}
</h3>
<input
type="range"
@@ -161,17 +161,16 @@ export default function ReadingSettingsPanel({
step="0.1"
value={settings.line_height}
onChange={(e) =>
handleChange("line_height", Number(e.target.value))
handleSlider("line_height", Number(e.target.value))
}
className="settings-slider"
aria-label="Line height"
aria-label={t("reader.lineHeightAria")}
/>
</section>
{/* Margin Width Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Margins: {settings.margin_width}px
{t("reader.margins", { value: String(settings.margin_width) })}
</h3>
<input
type="range"
@@ -179,17 +178,16 @@ export default function ReadingSettingsPanel({
max="48"
value={settings.margin_width}
onChange={(e) =>
handleChange("margin_width", Number(e.target.value))
handleSlider("margin_width", Number(e.target.value))
}
className="settings-slider"
aria-label="Margin width"
aria-label={t("reader.marginWidthAria")}
/>
</section>
{/* Brightness Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Brightness: {settings.brightness}%
{t("reader.brightness", { value: String(settings.brightness) })}
</h3>
<input
type="range"
@@ -197,16 +195,15 @@ export default function ReadingSettingsPanel({
max="100"
value={settings.brightness}
onChange={(e) =>
handleChange("brightness", Number(e.target.value))
handleSlider("brightness", Number(e.target.value))
}
className="settings-slider"
aria-label="Brightness"
aria-label={t("reader.brightnessAria")}
/>
</section>
{/* Orientation Lock */}
<section className="settings-section">
<h3 className="settings-section-title">Orientation</h3>
<h3 className="settings-section-title">{t("reader.orientation")}</h3>
<div className="orientation-grid">
{ORIENTATION_OPTIONS.map((opt) => (
<button
@@ -217,10 +214,9 @@ export default function ReadingSettingsPanel({
? "orientation-btn--active"
: ""
}`}
onClick={() => handleChange("orientation_lock", opt.value)}
disabled={saving.orientation_lock}
onClick={() => handleDiscrete("orientation_lock", opt.value)}
>
{opt.label}
{t(opt.labelKey)}
</button>
))}
</div>
@@ -229,4 +225,4 @@ export default function ReadingSettingsPanel({
</aside>
</>
);
}
}
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18n-lite";
interface ResumeReadingButtonProps {
visible: boolean;
onResume: () => void;
}
export function ResumeReadingButton({ visible, onResume }: ResumeReadingButtonProps) {
const { t } = useTranslation();
if (!visible) return null;
return (
<button
type="button"
className="reader-resume-reading"
onClick={onResume}
aria-label={t("reader.resumeReadingAria")}
>
<span className="reader-resume-reading-icon" aria-hidden>
</span>
<span className="reader-resume-reading-label">{t("reader.resumeReading")}</span>
</button>
);
}
@@ -0,0 +1,117 @@
import { useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import { useToast } from "@/hooks/useToast";
import { getApiErrorMessage } from "@/api/errors";
import type { PendingSelection } from "@/hooks/useEpubSelection";
import { BookmarkColorPicker } from "./BookmarkColorPicker";
import {
highlightBackgroundStyle,
loadLastHighlightColor,
normalizeHighlightColor,
saveLastHighlightColor,
} from "@/constants/bookmarkHighlightColors";
interface SelectionPopoverProps {
ebookId: number;
selection: PendingSelection;
onClose: () => void;
onSaved: () => void;
}
export function SelectionPopover({
ebookId,
selection,
onClose,
onSaved,
}: SelectionPopoverProps) {
const { t } = useTranslation();
const { addMarker } = useAnnotations();
const { showToast } = useToast();
const [thought, setThought] = useState("");
const [highlightColor, setHighlightColor] = useState(loadLastHighlightColor);
const [saving, setSaving] = useState(false);
const quoteText =
selection.locationText.length > 120
? `${selection.locationText.slice(0, 120)}`
: selection.locationText;
const handleColorChange = (color: string) => {
const normalized = normalizeHighlightColor(color);
setHighlightColor(normalized);
saveLastHighlightColor(normalized);
};
const handleSave = async () => {
setSaving(true);
try {
await addMarker({
ebook: ebookId,
epub_cfi: selection.epubCfi,
chapter_index: selection.chapterIndex,
chapter_title: selection.chapterTitle,
location_text: selection.locationText,
content: thought.trim(),
highlight_color: highlightColor,
});
showToast({
message: thought.trim()
? t("annotations.markerSavedWithNote")
: t("annotations.markerSaved"),
variant: "success",
});
onSaved();
onClose();
} catch (err) {
showToast({
message: getApiErrorMessage(err, t("annotations.saveFailed")),
variant: "error",
});
} finally {
setSaving(false);
}
};
return (
<>
<div className="selection-popover-backdrop" onClick={onClose} aria-hidden />
<div
className="selection-popover"
style={{ top: selection.anchorTop, left: selection.anchorLeft }}
role="dialog"
aria-label={t("annotations.saveMarker")}
>
<p className="selection-popover-quote">
<span style={highlightBackgroundStyle(highlightColor)}>&ldquo;{quoteText}&rdquo;</span>
</p>
<BookmarkColorPicker value={highlightColor} onChange={handleColorChange} />
<textarea
className="selection-popover-input"
value={thought}
onChange={(e) => setThought(e.target.value)}
placeholder={t("annotations.thoughtPlaceholder")}
rows={3}
/>
<div className="selection-popover-actions">
<button
type="button"
className="btn btn-sm"
onClick={handleSave}
disabled={saving}
>
{saving ? t("common.saving") : t("annotations.saveMarker")}
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={onClose}
disabled={saving}
>
{t("common.cancel")}
</button>
</div>
</div>
</>
);
}
@@ -1,53 +1,83 @@
/**
* TableOfContents — slide-in drawer listing all chapters.
* Tap a chapter to navigate. Current chapter is highlighted.
* TableOfContents — slide-in drawer for epub.js navigation tree.
*/
import type { ChapterSummary } from "../../types/reader";
import type { KeyboardEvent } from "react";
import { useTranslation } from "react-i18n-lite";
export interface EpubTocItem {
label: string;
href: string;
subitems?: EpubTocItem[];
}
interface TableOfContentsProps {
chapters: ChapterSummary[];
currentChapterNumber: number;
items: EpubTocItem[];
isOpen: boolean;
onClose: () => void;
onNavigate: (number: number) => void;
onNavigate: (href: string) => void;
}
function TocEntry({
item,
depth,
onNavigate,
}: {
item: EpubTocItem;
depth: number;
onNavigate: (href: string) => void;
}) {
return (
<>
<button
type="button"
className="toc-item"
style={{ paddingLeft: `${16 + depth * 14}px` }}
onClick={() => onNavigate(item.href)}
>
<span className="toc-item-title">{item.label}</span>
</button>
{item.subitems?.map((sub) => (
<TocEntry key={sub.href} item={sub} depth={depth + 1} onNavigate={onNavigate} />
))}
</>
);
}
export default function TableOfContents({
chapters,
currentChapterNumber,
items,
isOpen,
onClose,
onNavigate,
}: TableOfContentsProps) {
const handleChapterClick = (number: number) => {
onNavigate(number);
const { t } = useTranslation();
const handleNavigate = (href: string) => {
onNavigate(href);
onClose();
};
return (
<>
{/* Overlay */}
{isOpen && (
<div
className="toc-overlay"
onClick={onClose}
onKeyDown={(e: React.KeyboardEvent) => {
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
}}
role="presentation"
/>
)}
{/* Drawer */}
<aside className={`toc-drawer ${isOpen ? "toc-drawer--open" : ""}`}>
<div className="toc-header">
<h2 className="toc-title">Contents</h2>
<h2 className="toc-title">{t("reader.tocTitle")}</h2>
<button
type="button"
className="toc-close-btn"
onClick={onClose}
aria-label="Close table of contents"
aria-label={t("reader.closeTocAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
@@ -57,24 +87,15 @@ export default function TableOfContents({
</div>
<nav className="toc-list">
{chapters.length === 0 && (
<p className="toc-empty">No chapters available.</p>
{items.length === 0 ? (
<p className="toc-empty">{t("reader.tocEmpty")}</p>
) : (
items.map((item) => (
<TocEntry key={item.href} item={item} depth={0} onNavigate={handleNavigate} />
))
)}
{chapters.map((chapter) => (
<button
key={chapter.number}
type="button"
className={`toc-item ${
chapter.number === currentChapterNumber ? "toc-item--active" : ""
}`}
onClick={() => handleChapterClick(chapter.number)}
>
<span className="toc-item-number">{chapter.number}</span>
<span className="toc-item-title">{chapter.title}</span>
</button>
))}
</nav>
</aside>
</>
);
}
}
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../../api/books";
import { useDebounce } from "../../hooks/useDebounce";
import type { BookListItem } from "../../types/book";
@@ -13,6 +14,7 @@ interface SearchSuggestionsProps {
}
export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion }: SearchSuggestionsProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [suggestions, setSuggestions] = useState<BookListItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -45,7 +47,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
};
}, [debouncedQuery]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handler = (e: MouseEvent) => {
@@ -53,7 +54,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
onClose();
}
};
// Delay attachment to avoid the click that opened us from immediately closing
const timer = setTimeout(() => document.addEventListener("click", handler), 0);
return () => {
clearTimeout(timer);
@@ -61,7 +61,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
};
}, [visible, onClose]);
// Close on Escape
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
@@ -77,12 +76,12 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
<div ref={containerRef} className={styles.container}>
{loading && (
<div className={styles.infoText}>
Searching...
{t("search.searching")}
</div>
)}
{!loading && suggestions.length === 0 && debouncedQuery.trim() && (
<div className={styles.infoText}>
No quick suggestions
{t("search.noSuggestions")}
</div>
)}
{suggestions.map((book) => (
@@ -110,11 +109,11 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
{book.title}
</div>
<div className={styles.bookAuthor}>
{book.author || "Unknown Author"}
{book.author || t("common.unknownAuthor")}
</div>
</div>
</div>
))}
</div>
);
}
}