Archived
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user