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
+95
View File
@@ -0,0 +1,95 @@
import type { EpubRendition } from "./epubRendition";
import { normalizeHighlightColor } from "@/constants/bookmarkHighlightColors";
export interface BookmarkHighlight {
id: string;
epub_cfi: string;
highlight_color?: string | null;
}
type EpubAnnotations = {
highlight?: (
cfiRange: string,
data: Record<string, unknown>,
callback?: () => void,
id?: string,
styles?: Record<string, string>,
) => void;
remove?: (cfiRange: string, type: string) => void;
};
export type EpubRenditionWithHighlights = EpubRendition & {
annotations?: EpubAnnotations;
on?: (event: string, handler: () => void) => void;
off?: (event: string, handler: () => void) => void;
};
function annotationId(bookmarkId: string): string {
return `bookmark-hl-${bookmarkId}`;
}
export function applyBookmarkHighlight(
rendition: EpubRenditionWithHighlights,
bookmark: BookmarkHighlight,
): void {
const cfi = (bookmark.epub_cfi || "").trim();
if (!cfi || !rendition.annotations?.highlight) return;
const color = normalizeHighlightColor(bookmark.highlight_color);
try {
rendition.annotations.highlight(
cfi,
{},
undefined,
annotationId(bookmark.id),
{
fill: color,
"fill-opacity": "0.45",
"mix-blend-mode": "multiply",
},
);
} catch {
/* CFI may not be in the current spine slice */
}
}
export function removeBookmarkHighlight(
rendition: EpubRenditionWithHighlights,
bookmark: BookmarkHighlight,
): void {
const cfi = (bookmark.epub_cfi || "").trim();
if (!cfi || !rendition.annotations?.remove) return;
try {
rendition.annotations.remove(cfi, "highlight");
} catch {
/* ignore */
}
}
export function syncBookmarkHighlights(
rendition: EpubRenditionWithHighlights | null,
bookmarks: BookmarkHighlight[],
applied: Map<string, string>,
): void {
if (!rendition?.annotations?.highlight) return;
const nextById = new Map(bookmarks.map((b) => [b.id, b]));
for (const [id, cfi] of applied) {
if (!nextById.has(id)) {
removeBookmarkHighlight(rendition, { id, epub_cfi: cfi });
applied.delete(id);
}
}
for (const bookmark of bookmarks) {
const prevCfi = applied.get(bookmark.id);
if (prevCfi && prevCfi !== bookmark.epub_cfi) {
removeBookmarkHighlight(rendition, { id: bookmark.id, epub_cfi: prevCfi });
applied.delete(bookmark.id);
}
if (applied.has(bookmark.id)) continue;
applyBookmarkHighlight(rendition, bookmark);
applied.set(bookmark.id, bookmark.epub_cfi);
}
}
+151
View File
@@ -0,0 +1,151 @@
import type { ReadingSettings } from "../types/reader";
type EpubSpine = {
get: (target: string | number) => { href: string } | null;
spineItems?: Array<{ href: string }>;
};
type EpubBookForNav = {
ready: Promise<void>;
spine: EpubSpine;
};
type EpubRendition = {
themes: {
register: (name: string, styles: { body: Record<string, string> }) => void;
select: (name: string) => void;
fontSize: (size: string) => void;
override: (property: string, value: string, important?: boolean) => void;
};
book: EpubBookForNav & {
locations: {
generate: (size: number) => Promise<string[]>;
percentageFromCfi: (cfi: string) => number | null;
};
navigation?: {
get: (href: string) => { label?: string } | null;
};
};
currentLocation: () => Promise<{ start?: { href?: string } } | null>;
display: (target: string) => Promise<unknown>;
next: () => void;
prev: () => void;
};
type EpubManager = {
settings: { gap?: number };
isRendered?: () => boolean;
updateLayout?: () => void;
};
type EpubRenditionWithManager = EpubRendition & {
settings?: { gap?: number };
manager?: EpubManager;
};
const FONT_STACKS: Record<ReadingSettings["font_family"], string> = {
serif: 'Georgia, "Times New Roman", serif',
"sans-serif": 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
monospace: 'ui-monospace, "Cascadia Code", monospace',
};
const READER_THEME_KEY = "cloud-reader";
export function isEpubCfi(location: string | number): boolean {
return typeof location === "string" && location.startsWith("epubcfi(");
}
/** Map nav/TOC href to a spine href epub.js can display. */
export function resolveSpineHref(book: EpubBookForNav, href: string): string {
const hashIndex = href.indexOf("#");
const fragment = hashIndex >= 0 ? href.slice(hashIndex) : "";
const path = hashIndex >= 0 ? href.slice(0, hashIndex) : href;
const candidates = [href, path];
try {
candidates.push(decodeURIComponent(path), encodeURI(path));
} catch {
/* ignore malformed URI encoding in nav hrefs */
}
for (const candidate of candidates) {
if (book.spine.get(candidate)) {
return candidate.includes("#") ? candidate : candidate + fragment;
}
}
const normalizedPath = path.replace(/^\/+/, "");
const pathFile = normalizedPath.split("/").pop() ?? normalizedPath;
for (const item of book.spine.spineItems ?? []) {
const spineHref = item.href.replace(/^\/+/, "");
const spineFile = spineHref.split("/").pop() ?? spineHref;
if (
spineHref === normalizedPath ||
spineHref.endsWith(normalizedPath) ||
normalizedPath.endsWith(spineHref) ||
spineFile === pathFile
) {
return item.href + fragment;
}
}
return href;
}
/** epub.js paginated side padding is gap/2; map per-side margin to total gap. */
export function marginWidthToGap(marginWidth: number): number {
return marginWidth * 2;
}
function applyEpubGap(rendition: EpubRendition, marginWidth: number): void {
const gap = marginWidthToGap(marginWidth);
const r = rendition as EpubRenditionWithManager;
if (r.settings) {
r.settings.gap = gap;
}
if (r.manager?.settings) {
r.manager.settings.gap = gap;
}
if (r.manager?.isRendered?.() && r.manager.updateLayout) {
r.manager.updateLayout();
}
}
/** Apply reading preferences inside the epub.js iframe. */
export function applyRenditionSettings(
rendition: EpubRendition,
settings: ReadingSettings,
): void {
const fontStack = FONT_STACKS[settings.font_family] ?? FONT_STACKS.serif;
applyEpubGap(rendition, settings.margin_width);
rendition.themes.register(READER_THEME_KEY, {
body: {
color: `${settings.text_color} !important`,
background: `${settings.background_color} !important`,
"font-family": `${fontStack} !important`,
"line-height": `${settings.line_height} !important`,
},
});
rendition.themes.select(READER_THEME_KEY);
rendition.themes.fontSize(`${settings.font_size}px`);
}
export async function updateChapterTitleFromRendition(
rendition: EpubRendition,
): Promise<string> {
try {
const loc = await rendition.currentLocation();
const href = loc?.start?.href;
if (href && rendition.book.navigation) {
const nav = rendition.book.navigation.get(href);
if (nav?.label) return nav.label;
}
} catch {
/* ignore navigation lookup failures */
}
return "";
}
export type { EpubRendition };
+35
View File
@@ -0,0 +1,35 @@
export const FINISHED_PROGRESS_THRESHOLD = 99;
export type LibraryReadingStatus = "want_to_read" | "reading" | "finished";
/** API may return progress as a number or numeric string. */
export function coerceProgressPercent(progress: unknown): number | null {
if (progress == null || progress === "") return null;
const n = typeof progress === "number" ? progress : Number(progress);
if (!Number.isFinite(n)) return null;
return n;
}
/**
* @param started True when the user has a saved EPUB location (reader was used).
*/
export function deriveReadingStatus(
progress: unknown,
started = false,
): LibraryReadingStatus {
const pct = coerceProgressPercent(progress);
if (pct != null && pct >= FINISHED_PROGRESS_THRESHOLD) return "finished";
if (pct != null && pct > 0) return "reading";
if (started) return "reading";
return "want_to_read";
}
export function normalizeProgressPercent(
progress: unknown,
readingStatus: LibraryReadingStatus,
): number | null {
const pct = coerceProgressPercent(progress);
if (readingStatus !== "reading" && readingStatus !== "finished") return null;
if (pct == null || pct <= 0) return null;
return Math.min(100, Math.max(0, Math.round(pct)));
}
+50
View File
@@ -0,0 +1,50 @@
import { DEFAULT_BOOKMARK_HIGHLIGHT_COLOR } from "@/constants/bookmarkHighlightColors";
import { DEFAULT_BOOKMARK_HIGHLIGHT_COLOR } from "@/constants/bookmarkHighlightColors";
import type { Bookmark, MarkerEntry, MarkersByBook } from "@/types";
export function bookmarkToMarkerEntry(b: Bookmark): MarkerEntry {
return {
id: b.id,
ebook_id: b.ebook,
ebook_title: b.ebook_title,
epub_cfi: b.epub_cfi,
chapter_index: b.chapter_index,
chapter_title: b.chapter_title,
page: b.page,
location_text: b.location_text,
content: b.content,
highlight_color: b.highlight_color ?? DEFAULT_BOOKMARK_HIGHLIGHT_COLOR,
created_at: b.created_at,
updated_at: b.updated_at,
};
}
export function sortMarkers(a: MarkerEntry, b: MarkerEntry): number {
if (a.chapter_index !== b.chapter_index) {
return a.chapter_index - b.chapter_index;
}
return a.epub_cfi.localeCompare(b.epub_cfi);
}
export function groupMarkersByBook(bookmarks: Bookmark[]): MarkersByBook[] {
const map = new Map<number, MarkersByBook>();
for (const b of bookmarks) {
const entry = bookmarkToMarkerEntry(b);
const existing = map.get(b.ebook);
if (existing) {
existing.markers.push(entry);
} else {
map.set(b.ebook, {
ebookId: b.ebook,
ebookTitle: b.ebook_title,
markers: [entry],
});
}
}
const groups = Array.from(map.values());
for (const g of groups) {
g.markers.sort(sortMarkers);
}
groups.sort((a, b) => a.ebookTitle.localeCompare(b.ebookTitle));
return groups;
}
+65
View File
@@ -0,0 +1,65 @@
import { ReactReaderStyle, type IReactReaderStyle } from "react-reader";
import type { ReadingSettings } from "../types/reader";
const THEME_ARROWS: Record<ReadingSettings["theme"], string> = {
sepia: "#b8a898",
dark: "#666666",
light: "#cccccc",
paper: "#c4b8a8",
};
const THEME_ARROW_HOVER: Record<ReadingSettings["theme"], string> = {
sepia: "#8a7a6a",
dark: "#aaaaaa",
light: "#888888",
paper: "#7a6a5a",
};
/** Build complete react-reader styles — must spread ReactReaderStyle; partial objects break layout. */
export function buildReaderStyles(settings: ReadingSettings): IReactReaderStyle {
return {
...ReactReaderStyle,
container: {
...ReactReaderStyle.container,
height: "100%",
width: "100%",
},
containerExpanded: ReactReaderStyle.containerExpanded,
readerArea: {
...ReactReaderStyle.readerArea,
backgroundColor: settings.background_color,
transition: undefined,
},
titleArea: {
...ReactReaderStyle.titleArea,
display: "none",
},
reader: {
...ReactReaderStyle.reader,
top: 0,
left: 0,
right: 0,
bottom: 0,
},
arrow: {
...ReactReaderStyle.arrow,
color: THEME_ARROWS[settings.theme],
fontSize: 48,
marginTop: -24,
},
arrowHover: {
...ReactReaderStyle.arrowHover,
color: THEME_ARROW_HOVER[settings.theme],
},
tocButton: {
...ReactReaderStyle.tocButton,
display: "none",
},
tocArea: ReactReaderStyle.tocArea,
tocAreaButton: ReactReaderStyle.tocAreaButton,
loadingView: {
...ReactReaderStyle.loadingView,
color: "#999",
},
};
}