cleanup 3
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 17:29:54 -04:00
parent 6231befaa8
commit 232a821dc0
16 changed files with 794 additions and 847 deletions
+2 -4
View File
@@ -3,12 +3,10 @@ import { Link, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
import { THEME_MODES } from '../utils/localSettings';
import HamburgerMenu from './HamburgerMenu';
import { getPageTitle } from './navigation';
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
export default function Layout() {
const location = useLocation();
const { user, logout } = useAuth();
@@ -72,7 +70,7 @@ export default function Layout() {
Theme
</p>
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
{themeModes.map(mode => (
{THEME_MODES.map(mode => (
<button
key={mode}
type="button"
+7 -7
View File
@@ -1,5 +1,5 @@
import type { ElementType } from 'react';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
export interface NavItem {
path: string;
@@ -10,16 +10,16 @@ export interface NavItem {
export const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
{ path: '/progress', label: 'Progress', icon: ActivityIcon },
{ path: '/progress', label: 'Progress', icon: ClockIcon },
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
{ path: '/search', label: 'Search', icon: SearchIcon },
];
export const adminNavItems: NavItem[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
export const adminNavItems: { path: string; label: string }[] = [
{ path: '/admin', label: 'General' },
{ path: '/admin/import', label: 'Import' },
{ path: '/admin/users', label: 'Users' },
{ path: '/admin/logs', label: 'Logs' },
];
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
+19 -8
View File
@@ -4,6 +4,8 @@ import type { CreateActivityRequest } from '../generated/model/createActivityReq
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage, getResponseError } from '../utils/errors';
interface UseEpubReaderOptions {
documentId: string;
@@ -60,6 +62,7 @@ export function useEpubReader({
sectionTotalPages: 0,
percentage: 0,
});
const { showError } = useToasts();
useEffect(() => {
isPaginationDisabledRef.current = isPaginationDisabled;
@@ -90,20 +93,28 @@ export function useEpubReader({
});
const saveProgress = async (payload: UpdateProgressRequest) => {
// Swallow Save Failures - Transient progress-save errors must not take down the reader
// (they previously routed to onError, which hides the whole book behind an error overlay).
try {
const response = await updateProgress(payload);
if (response.status >= 400) {
throw new Error(
'message' in response.data ? response.data.message : 'Unable to save reader progress'
);
const message = getResponseError(response);
if (message) {
showError(`Failed to save progress: ${message}`);
}
} catch (err) {
showError(`Failed to save progress: ${getErrorMessage(err)}`);
}
};
const saveActivity = async (payload: CreateActivityRequest) => {
try {
const response = await createActivity(payload);
if (response.status >= 400) {
throw new Error(
'message' in response.data ? response.data.message : 'Unable to save reader activity'
);
const message = getResponseError(response);
if (message) {
showError(`Failed to save activity: ${message}`);
}
} catch (err) {
showError(`Failed to save activity: ${getErrorMessage(err)}`);
}
};
+5 -9
View File
@@ -1,10 +1,5 @@
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
interface ApiResponse {
status: number;
data: unknown;
}
import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors';
interface ToastMutationOptions {
success: string;
@@ -21,9 +16,10 @@ export function useMutationWithToast() {
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
return {
onSuccess: (response: ApiResponse) => {
if (response.status < 200 || response.status >= 300) {
showError(`${error}: ${getErrorMessage(response.data)}`);
onSuccess: (response: ApiResponseLike) => {
const message = getResponseError(response);
if (message) {
showError(`${error}: ${message}`);
return;
}
onSuccess?.();
-66
View File
@@ -356,72 +356,6 @@ main {
display: none;
}
/* Button visibility toggle */
.css-button:checked + div {
visibility: visible;
opacity: 1;
}
.css-button + div {
visibility: hidden;
opacity: 0;
}
/* Mobile Navigation */
#mobile-nav-button span {
transform-origin: 5px 0;
transition:
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
opacity 0.55s ease;
}
#mobile-nav-button span:first-child {
transform-origin: 0 0;
}
#mobile-nav-button span:nth-last-child(2) {
transform-origin: 0 100%;
}
#mobile-nav-button:checked ~ span {
opacity: 1;
transform: rotate(45deg) translate(2px, -2px);
}
#mobile-nav-button:checked ~ span:nth-last-child(3) {
opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2);
}
#mobile-nav-button:checked ~ span:nth-last-child(2) {
transform: rotate(-45deg) translate(0, 6px);
}
#mobile-nav-button:checked ~ #menu {
transform: translate(0, 0) !important;
}
@media (min-width: 1024px) {
#mobile-nav-button ~ #menu {
transform: none;
}
}
#menu {
top: 0;
padding-top: env(safe-area-inset-top);
transform-origin: 0 0;
transform: translate(-100%, 0);
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
}
@media (orientation: landscape) {
#menu {
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
}
}
/* Skeleton Wave Animation */
@keyframes wave {
0% {
+47 -508
View File
@@ -2,125 +2,27 @@ import ePub from 'epubjs';
import NoSleep from 'nosleep.js';
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest';
import type { ReaderColorScheme, ReaderFontFamily } from '../../utils/localSettings';
import {
READER_COLOR_SCHEMES,
type ReaderColorScheme,
type ReaderFontFamily,
} from '../../utils/localSettings';
import type { EpubBook, EpubRendition, ReaderStats, ReaderTocItem } from './types';
import {
countWords,
getBookWordPosition,
getCFIFromXPath,
getParsedTOC,
getVisibleWordCount,
getXPathFromCFI,
} from './epubUtils';
import { registerRenditionGestures } from './gestures';
export type { ReaderStats, ReaderTocItem } from './types';
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
const THEME_FILE = '/assets/reader/themes.css';
const FONT_FILE = '/assets/reader/fonts.css';
interface TocNode {
href: string;
label?: string;
subitems?: TocNode[];
}
interface EpubContents {
document: Document;
sectionIndex?: number;
range: (cfi: string) => Range;
}
interface EpubVisibleSection {
index: number;
layout: { width: number; divisor: number };
width: () => number;
expand: () => void;
}
interface EpubLocation {
start: {
cfi: string;
href?: string;
};
end: {
cfi: string;
};
}
interface EpubNavigation {
toc?: TocNode[];
}
interface EpubSpineItem {
cfiBase: string;
index: number;
document: Document;
load: (_loader: unknown) => Promise<Document>;
cfiFromElement: (element: Element) => string;
wordCount?: number;
}
interface EpubBook {
ready: Promise<void>;
navigation?: EpubNavigation;
loaded: { navigation: Promise<EpubNavigation> };
spine: {
spineItems: EpubSpineItem[];
get: (index: number) => EpubSpineItem;
hooks: {
content: { register: (_callback: (output: Document) => void) => void };
};
};
load: (...args: unknown[]) => unknown;
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
getRange: (cfiRange: string) => Promise<Range>;
destroy?: () => void;
}
interface EpubRendition {
next: () => Promise<void>;
prev: () => Promise<void>;
display: (target?: string) => Promise<void>;
currentLocation: () => Promise<EpubLocation>;
getContents: () => EpubContents[];
themes: {
default: (styles: Record<string, unknown>) => void;
register: (name: string, styles: Record<string, unknown> | string) => void;
select: (name: string) => void;
};
hooks: {
content: { register: (_callback: () => void) => void };
render: { register: (_callback: (contents: EpubContents) => void) => void };
};
manager?: {
visible?: () => EpubVisibleSection[];
};
views: () => { container: { scrollLeft: number } };
destroy?: () => void;
}
interface ParsedCfiPath {
steps: unknown[];
terminal: unknown;
}
interface ParsedCfi {
base: unknown;
path: ParsedCfiPath;
}
interface EpubCfiHelper {
parse: (_value: string) => ParsedCfi;
equalStep: (_a: unknown, _b: unknown) => boolean;
segmentString: (_value: unknown) => string;
}
interface EpubWithCfiConstructor {
CFI: new () => EpubCfiHelper;
}
export interface ReaderStats {
chapterName: string;
sectionPage: number;
sectionTotalPages: number;
percentage: number;
}
export interface ReaderTocItem {
title: string;
href: string;
}
interface BookState {
pages: number;
percentage: number;
@@ -174,6 +76,7 @@ export class EBookReader {
private rendition: EpubRendition;
private noSleep: NoSleep | null = null;
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
private gestureDispose: (() => void) | null = null;
private destroyed = false;
private onReady: () => void;
private onLoading: (_loading: boolean) => void;
@@ -187,7 +90,6 @@ export class EBookReader {
private onSwipeUp: () => void;
private onCenterTap: () => void;
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(options: EBookReaderOptions) {
this.container = options.container;
@@ -217,7 +119,6 @@ export class EBookReader {
pageStart: Date.now(),
};
this.loadSettings();
this.readerSettings.theme = {
colorScheme: options.colorScheme,
fontFamily: options.fontFamily,
@@ -237,7 +138,16 @@ export class EBookReader {
this.initCSP();
this.initWakeLock();
this.initThemes();
this.initViewerListeners();
this.gestureDispose = registerRenditionGestures(this.rendition, {
isPaginationDisabled: () => this.isPaginationDisabled(),
nextPage: () => this.nextPage(),
prevPage: () => this.prevPage(),
onSwipeDown: () => this.onSwipeDown(),
onSwipeUp: () => this.onSwipeUp(),
onCenterTap: () => this.onCenterTap(),
});
this.initDocumentListeners();
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
@@ -249,12 +159,6 @@ export class EBookReader {
});
}
private loadSettings() {
this.readerSettings = {
theme: this.readerSettings.theme ?? {},
};
}
private initWakeLock() {
this.noSleep = new NoSleep();
document.addEventListener('wakelock', this.handleWakeLock);
@@ -279,7 +183,7 @@ export class EBookReader {
};
private initThemes() {
THEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
READER_COLOR_SCHEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
if (!themeLinkEl) {
@@ -335,127 +239,6 @@ export class EBookReader {
});
}
private initViewerListeners() {
const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.bind(this);
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
const handleSwipeDown = () => {
this.resetWheelCooldown();
this.onSwipeDown();
};
const handleSwipeUp = () => {
this.resetWheelCooldown();
this.onSwipeUp();
};
const handleGesture = () => {
const drasticity = 50;
if (touchEndY - drasticity > touchStartY) {
return handleSwipeDown();
}
if (touchEndY + drasticity < touchStartY) {
return handleSwipeUp();
}
if (!this.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
void nextPage();
}
if (!this.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
void prevPage();
}
};
this.rendition.hooks.render.register((contents: EpubContents) => {
const renderDoc = contents.document;
const wakeLockListener = () => {
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
};
renderDoc.addEventListener('click', wakeLockListener);
renderDoc.addEventListener('gesturechange', wakeLockListener);
renderDoc.addEventListener('touchstart', wakeLockListener);
renderDoc.addEventListener('click', (event: MouseEvent) => {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const barPixels = windowHeight * 0.2;
const pagePixels = windowWidth * 0.2;
const top = barPixels;
const bottom = window.innerHeight - top;
const left = pagePixels;
const right = windowWidth - left;
const leftOffset = this.rendition.views().container.scrollLeft;
const yCoord = event.clientY;
const xCoord = event.clientX - leftOffset;
if (yCoord < top) {
handleSwipeDown();
} else if (yCoord > bottom) {
handleSwipeUp();
} else if (!this.isPaginationDisabled() && xCoord < left) {
void prevPage();
} else if (!this.isPaginationDisabled() && xCoord > right) {
void nextPage();
} else {
this.onCenterTap();
}
});
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
if (this.wheelTimeoutId) {
return;
}
if (event.deltaY > 25) {
handleSwipeUp();
return;
}
if (event.deltaY < -25) {
handleSwipeDown();
}
});
renderDoc.addEventListener(
'touchstart',
(event: TouchEvent) => {
touchStartX = event.changedTouches[0]?.screenX ?? 0;
touchStartY = event.changedTouches[0]?.screenY ?? 0;
},
false
);
renderDoc.addEventListener(
'touchend',
(event: TouchEvent) => {
touchEndX = event.changedTouches[0]?.screenX ?? 0;
touchEndY = event.changedTouches[0]?.screenY ?? 0;
handleGesture();
},
false
);
});
}
private resetWheelCooldown() {
if (this.wheelTimeoutId) {
clearTimeout(this.wheelTimeoutId);
this.wheelTimeoutId = null;
}
this.wheelTimeoutId = setTimeout(() => {
this.wheelTimeoutId = null;
}, 400);
}
private initDocumentListeners() {
const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.bind(this);
@@ -469,9 +252,11 @@ export class EBookReader {
}
if ((event.keyCode || event.which) === 84) {
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
const currentThemeIdx = THEMES.indexOf(currentTheme);
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
const colorScheme =
THEMES.length === currentThemeIdx + 1 ? THEMES[0] : THEMES[currentThemeIdx + 1];
READER_COLOR_SCHEMES.length === currentThemeIdx + 1
? READER_COLOR_SCHEMES[0]
: READER_COLOR_SCHEMES[currentThemeIdx + 1];
if (colorScheme) {
this.setTheme({ colorScheme });
}
@@ -482,44 +267,20 @@ export class EBookReader {
}
private async setupReader() {
this.bookState.words = await this.countWords();
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
this.bookState.words = await countWords(this.book);
const { cfi } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
await this.setPosition(cfi);
const { element } = await this.getCFIFromXPath(this.bookState.progress);
const { element } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
this.bookState.progressElement = element ?? null;
this.highlightPositionMarker();
const stats = await this.getBookStats();
this.onStats(stats);
this.bookState.pageStart = Date.now();
this.onToc(this.getParsedTOC());
this.onToc(getParsedTOC(this.book));
this.onLoading(false);
this.onReady();
}
private getParsedTOC(): ReaderTocItem[] {
if (!this.book.navigation?.toc) {
return [];
}
return this.book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
const sectionTitle = item.label?.trim() ?? '';
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
if (!item.subitems || item.subitems.length === 0) {
return agg;
}
const allSubSections = item.subitems.map(subitem => {
let itemTitle = subitem.label?.trim() ?? 'Untitled';
if (sectionTitle !== '') {
itemTitle = `${sectionTitle} - ${itemTitle}`;
}
return { title: itemTitle, href: subitem.href };
});
agg.push(...allSubSections);
return agg;
}, []);
}
setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
this.readerSettings.theme =
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
@@ -615,6 +376,8 @@ export class EBookReader {
return;
}
// Triple Display - epubjs occasionally renders at the wrong position on a single display()
// when restoring a CFI; calling it three times is a known workaround to land on the exact page.
await this.rendition.display(cfi);
await this.rendition.display(cfi);
await this.rendition.display(cfi);
@@ -627,10 +390,10 @@ export class EBookReader {
fontSize?: number;
}) {
const currentProgress = this.bookState.progress;
const { cfi } = await this.getCFIFromXPath(currentProgress);
const { cfi } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
this.setTheme(newTheme);
await this.setPosition(cfi);
const { element } = await this.getCFIFromXPath(currentProgress);
const { element } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
this.bookState.progressElement = element ?? null;
this.highlightPositionMarker();
}
@@ -641,8 +404,8 @@ export class EBookReader {
const pageStart = this.bookState.pageStart;
let elapsedTime = Date.now() - pageStart;
const pageWords = await this.getVisibleWordCount();
const currentWord = await this.getBookWordPosition();
const pageWords = await getVisibleWordCount(this.book, this.rendition);
const currentWord = await getBookWordPosition(this.book, this.rendition);
const percentRead = pageWords / this.bookState.words;
const pageWPM = pageWords / (elapsedTime / 60000);
@@ -686,8 +449,8 @@ export class EBookReader {
async createProgress() {
const currentCFI = await this.rendition.currentLocation();
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
const currentWord = await this.getBookWordPosition();
const { element, xpath } = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
const currentWord = await getBookWordPosition(this.book, this.rendition);
this.bookState.progress = xpath ?? '';
this.bookState.progressElement = element ?? null;
@@ -744,7 +507,7 @@ export class EBookReader {
}
const currentLocation = await this.rendition.currentLocation();
const currentWord = await this.getBookWordPosition();
const currentWord = await getBookWordPosition(this.book, this.rendition);
const currentTOC = this.book.navigation?.toc?.find(
item => item.href === currentLocation.start.href
);
@@ -760,228 +523,6 @@ export class EBookReader {
};
}
async getXPathFromCFI(cfi: string) {
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
if (!cfiBaseMatch?.[1]) {
return {} as { xpath?: string; element?: Element | null };
}
const startCFI = cfiBaseMatch[1];
const docFragmentIndex =
(this.book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
if (docFragmentIndex <= 0) {
return {} as { xpath?: string; element?: Element | null };
}
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
const contents = this.rendition.getContents()[0];
const currentNodeStart = contents?.range(cfi).startContainer;
if (!currentNodeStart) {
return {} as { xpath?: string; element?: Element | null };
}
let currentNode: Node | null = currentNodeStart;
const element =
currentNode.nodeType === Node.ELEMENT_NODE
? (currentNode as Element)
: currentNode.parentElement;
let allPos = '';
while (currentNode && currentNode.nodeName !== 'BODY') {
let parentElement: Element | null = currentNode.parentElement;
if (!parentElement) {
break;
}
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
currentNode = parentElement;
continue;
}
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
parentElement = parentElement.parentElement;
}
const currentElement = currentNode as Element;
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
currentNode = parentElement;
allPos = `/${nodePos}${allPos}`;
}
return { xpath: `${basePos}${allPos}`, element };
}
async getCFIFromXPath(xpath?: string) {
if (!xpath) {
return {} as { cfi?: string; element?: Element | null };
}
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch?.[1]) {
return {} as { cfi?: string; element?: Element | null };
}
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
const sectionItem = this.book.spine.get(spinePosition);
await sectionItem.load(this.book.load.bind(this.book));
const renderedContent = this.rendition
.getContents()
.find(item => item.sectionIndex == spinePosition);
const docItem = renderedContent?.document || sectionItem.document;
const namespaceURI = docItem.documentElement.namespaceURI;
let remainingXPath = xpath
.replace(fragMatch[0], '/html')
.replace(/\.(\d+)$/, '')
.replace(/\/text\(\)(\[\d+\])?$/, '');
const derivedSelectorElement = remainingXPath
.replace(/^\/html\/body/, 'body')
.split('/')
.reduce(
(element: ParentNode | null, item: string) => {
if (!element) {
return null;
}
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
if (!indexMatch) {
return element.querySelector(item);
}
const [, tag, rawIndex] = indexMatch;
if (!tag || !rawIndex) {
return null;
}
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
},
docItem as ParentNode | null
);
if (namespaceURI) {
remainingXPath = remainingXPath.split('/').join('/ns:');
}
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
if (prefix === 'ns') {
return namespaceURI;
}
return null;
});
const xpathElement = docSearch.iterateNext();
const element = xpathElement || derivedSelectorElement;
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
if (!isElementNode) {
return {} as { cfi?: string; element?: Element | null };
}
const resolvedElement = element as Element;
let cfi = sectionItem.cfiFromElement(resolvedElement);
if (cfi.endsWith('!/)')) {
cfi = `${cfi.slice(0, -1)}0)`;
}
return { cfi, element: resolvedElement };
}
async getVisibleWordCount() {
const visibleText = await this.getVisibleText();
return visibleText.trim().split(/\s+/).length;
}
async getBookWordPosition() {
const contents = this.rendition.getContents()[0];
if (!contents) {
return 0;
}
const spineItem = this.book.spine.get(contents.sectionIndex ?? 0);
const firstElement = spineItem.document.body.children[0];
if (!firstElement) {
return 0;
}
const firstCFI = spineItem.cfiFromElement(firstElement);
const currentLocation = await this.rendition.currentLocation();
const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
const textRange = await this.book.getRange(cfiRange);
const chapterText = textRange.toString();
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
const preChapterWordPosition = this.book.spine.spineItems
.slice(0, contents.sectionIndex ?? 0)
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
return chapterWordPosition + preChapterWordPosition;
}
async getVisibleText() {
this.rendition.manager?.visible?.()?.forEach(item => item.expand());
const currentLocation = await this.rendition.currentLocation();
const cfiRange = this.getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
const textRange = await this.book.getRange(cfiRange);
return textRange.toString();
}
getCFIRange(a: string, b: string) {
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
const start = CFI.parse(a);
const end = CFI.parse(b);
const cfi: {
range: boolean;
base: unknown;
path: ParsedCfiPath;
start: ParsedCfiPath;
end: ParsedCfiPath;
} = {
range: true,
base: start.base,
path: { steps: [], terminal: null },
start: start.path,
end: end.path,
};
const len = cfi.start.steps.length;
for (let i = 0; i < len; i += 1) {
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
if (i === len - 1) {
if (cfi.start.terminal === cfi.end.terminal) {
cfi.path.steps.push(cfi.start.steps[i]);
cfi.range = false;
}
} else {
cfi.path.steps.push(cfi.start.steps[i]);
}
} else {
break;
}
}
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
}
async countWords() {
const spineWC = await Promise.all(
this.book.spine.spineItems.map(async item => {
const newDoc = await item.load(this.book.load.bind(this.book));
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
.trim()
.split(/\s+/).length;
item.wordCount = spineWords;
return spineWords;
})
);
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
}
destroy() {
this.destroyed = true;
if (this.keyupHandler) {
@@ -991,9 +532,7 @@ export class EBookReader {
if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId);
}
if (this.wheelTimeoutId) {
clearTimeout(this.wheelTimeoutId);
}
this.gestureDispose?.();
void this.noSleep?.disable();
this.rendition.destroy?.();
this.book.destroy?.();
+258
View File
@@ -0,0 +1,258 @@
import ePub from 'epubjs';
import type {
EpubBook,
EpubRendition,
EpubWithCfiConstructor,
ParsedCfiPath,
ReaderTocItem,
} from './types';
export function getParsedTOC(book: EpubBook): ReaderTocItem[] {
if (!book.navigation?.toc) {
return [];
}
return book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
const sectionTitle = item.label?.trim() ?? '';
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
if (!item.subitems || item.subitems.length === 0) {
return agg;
}
const allSubSections = item.subitems.map(subitem => {
let itemTitle = subitem.label?.trim() ?? 'Untitled';
if (sectionTitle !== '') {
itemTitle = `${sectionTitle} - ${itemTitle}`;
}
return { title: itemTitle, href: subitem.href };
});
agg.push(...allSubSections);
return agg;
}, []);
}
export async function countWords(book: EpubBook) {
const spineWC = await Promise.all(
book.spine.spineItems.map(async item => {
const newDoc = await item.load(book.load.bind(book));
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
.trim()
.split(/\s+/).length;
item.wordCount = spineWords;
return spineWords;
})
);
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
}
export function getCFIRange(a: string, b: string) {
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
const start = CFI.parse(a);
const end = CFI.parse(b);
const cfi: {
range: boolean;
base: unknown;
path: ParsedCfiPath;
start: ParsedCfiPath;
end: ParsedCfiPath;
} = {
range: true,
base: start.base,
path: { steps: [], terminal: null },
start: start.path,
end: end.path,
};
const len = cfi.start.steps.length;
for (let i = 0; i < len; i += 1) {
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
if (i === len - 1) {
if (cfi.start.terminal === cfi.end.terminal) {
cfi.path.steps.push(cfi.start.steps[i]);
cfi.range = false;
}
} else {
cfi.path.steps.push(cfi.start.steps[i]);
}
} else {
break;
}
}
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
}
export async function getVisibleText(book: EpubBook, rendition: EpubRendition) {
rendition.manager?.visible?.()?.forEach(item => item.expand());
const currentLocation = await rendition.currentLocation();
const cfiRange = getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
const textRange = await book.getRange(cfiRange);
return textRange.toString();
}
export async function getVisibleWordCount(book: EpubBook, rendition: EpubRendition) {
const visibleText = await getVisibleText(book, rendition);
return visibleText.trim().split(/\s+/).length;
}
export async function getBookWordPosition(book: EpubBook, rendition: EpubRendition) {
const contents = rendition.getContents()[0];
if (!contents) {
return 0;
}
const spineItem = book.spine.get(contents.sectionIndex ?? 0);
const firstElement = spineItem.document.body.children[0];
if (!firstElement) {
return 0;
}
const firstCFI = spineItem.cfiFromElement(firstElement);
const currentLocation = await rendition.currentLocation();
const cfiRange = getCFIRange(firstCFI, currentLocation.start.cfi);
const textRange = await book.getRange(cfiRange);
const chapterText = textRange.toString();
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
const preChapterWordPosition = book.spine.spineItems
.slice(0, contents.sectionIndex ?? 0)
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
return chapterWordPosition + preChapterWordPosition;
}
export async function getXPathFromCFI(book: EpubBook, rendition: EpubRendition, cfi: string) {
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
if (!cfiBaseMatch?.[1]) {
return {} as { xpath?: string; element?: Element | null };
}
const startCFI = cfiBaseMatch[1];
const docFragmentIndex =
(book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
if (docFragmentIndex <= 0) {
return {} as { xpath?: string; element?: Element | null };
}
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
const contents = rendition.getContents()[0];
const currentNodeStart = contents?.range(cfi).startContainer;
if (!currentNodeStart) {
return {} as { xpath?: string; element?: Element | null };
}
let currentNode: Node | null = currentNodeStart;
const element =
currentNode.nodeType === Node.ELEMENT_NODE
? (currentNode as Element)
: currentNode.parentElement;
let allPos = '';
while (currentNode && currentNode.nodeName !== 'BODY') {
let parentElement: Element | null = currentNode.parentElement;
if (!parentElement) {
break;
}
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
currentNode = parentElement;
continue;
}
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
parentElement = parentElement.parentElement;
}
const currentElement = currentNode as Element;
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
currentNode = parentElement;
allPos = `/${nodePos}${allPos}`;
}
return { xpath: `${basePos}${allPos}`, element };
}
export async function getCFIFromXPath(
book: EpubBook,
rendition: EpubRendition,
xpath?: string
) {
if (!xpath) {
return {} as { cfi?: string; element?: Element | null };
}
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch?.[1]) {
return {} as { cfi?: string; element?: Element | null };
}
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
const sectionItem = book.spine.get(spinePosition);
await sectionItem.load(book.load.bind(book));
const renderedContent = rendition
.getContents()
.find(item => item.sectionIndex == spinePosition);
const docItem = renderedContent?.document || sectionItem.document;
const namespaceURI = docItem.documentElement.namespaceURI;
let remainingXPath = xpath
.replace(fragMatch[0], '/html')
.replace(/\.(\d+)$/, '')
.replace(/\/text\(\)(\[\d+\])?$/, '');
const derivedSelectorElement = remainingXPath
.replace(/^\/html\/body/, 'body')
.split('/')
.reduce(
(element: ParentNode | null, item: string) => {
if (!element) {
return null;
}
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
if (!indexMatch) {
return element.querySelector(item);
}
const [, tag, rawIndex] = indexMatch;
if (!tag || !rawIndex) {
return null;
}
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
},
docItem as ParentNode | null
);
if (namespaceURI) {
remainingXPath = remainingXPath.split('/').join('/ns:');
}
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
if (prefix === 'ns') {
return namespaceURI;
}
return null;
});
const xpathElement = docSearch.iterateNext();
const element = xpathElement || derivedSelectorElement;
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
if (!isElementNode) {
return {} as { cfi?: string; element?: Element | null };
}
const resolvedElement = element as Element;
let cfi = sectionItem.cfiFromElement(resolvedElement);
if (cfi.endsWith('!/)')) {
cfi = `${cfi.slice(0, -1)}0)`;
}
return { cfi, element: resolvedElement };
}
+144
View File
@@ -0,0 +1,144 @@
import type { EpubContents, EpubRendition } from './types';
export interface GestureHandlers {
isPaginationDisabled: () => boolean;
nextPage: () => Promise<void>;
prevPage: () => Promise<void>;
onSwipeDown: () => void;
onSwipeUp: () => void;
onCenterTap: () => void;
}
const WHEEL_COOLDOWN_MS = 400;
const SWIPE_THRESHOLD = 25;
/**
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose
* that clears the pending wheel-cooldown timeout (called from EBookReader.destroy).
*/
export function registerRenditionGestures(
rendition: EpubRendition,
handlers: GestureHandlers
): () => void {
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
const resetWheelCooldown = () => {
if (wheelTimeoutId) {
clearTimeout(wheelTimeoutId);
}
wheelTimeoutId = setTimeout(() => {
wheelTimeoutId = null;
}, WHEEL_COOLDOWN_MS);
};
const handleSwipeDown = () => {
resetWheelCooldown();
handlers.onSwipeDown();
};
const handleSwipeUp = () => {
resetWheelCooldown();
handlers.onSwipeUp();
};
const handleGesture = () => {
const drasticity = 50;
if (touchEndY - drasticity > touchStartY) {
return handleSwipeDown();
}
if (touchEndY + drasticity < touchStartY) {
return handleSwipeUp();
}
if (!handlers.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
void handlers.nextPage();
}
if (!handlers.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
void handlers.prevPage();
}
};
rendition.hooks.render.register((contents: EpubContents) => {
const renderDoc = contents.document;
const wakeLockListener = () => {
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
};
renderDoc.addEventListener('click', wakeLockListener);
renderDoc.addEventListener('gesturechange', wakeLockListener);
renderDoc.addEventListener('touchstart', wakeLockListener);
renderDoc.addEventListener('click', (event: MouseEvent) => {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const barPixels = windowHeight * 0.2;
const pagePixels = windowWidth * 0.2;
const top = barPixels;
const bottom = window.innerHeight - top;
const left = pagePixels;
const right = windowWidth - left;
const leftOffset = rendition.views().container.scrollLeft;
const yCoord = event.clientY;
const xCoord = event.clientX - leftOffset;
if (yCoord < top) {
handleSwipeDown();
} else if (yCoord > bottom) {
handleSwipeUp();
} else if (!handlers.isPaginationDisabled() && xCoord < left) {
void handlers.prevPage();
} else if (!handlers.isPaginationDisabled() && xCoord > right) {
void handlers.nextPage();
} else {
handlers.onCenterTap();
}
});
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
if (wheelTimeoutId) {
return;
}
if (event.deltaY > SWIPE_THRESHOLD) {
handleSwipeUp();
return;
}
if (event.deltaY < -SWIPE_THRESHOLD) {
handleSwipeDown();
}
});
renderDoc.addEventListener(
'touchstart',
(event: TouchEvent) => {
touchStartX = event.changedTouches[0]?.screenX ?? 0;
touchStartY = event.changedTouches[0]?.screenY ?? 0;
},
false
);
renderDoc.addEventListener(
'touchend',
(event: TouchEvent) => {
touchEndX = event.changedTouches[0]?.screenX ?? 0;
touchEndY = event.changedTouches[0]?.screenY ?? 0;
handleGesture();
},
false
);
});
return () => {
if (wheelTimeoutId) {
clearTimeout(wheelTimeoutId);
wheelTimeoutId = null;
}
};
}
+112
View File
@@ -0,0 +1,112 @@
export interface TocNode {
href: string;
label?: string;
subitems?: TocNode[];
}
export interface EpubContents {
document: Document;
sectionIndex?: number;
range: (cfi: string) => Range;
}
export interface EpubVisibleSection {
index: number;
layout: { width: number; divisor: number };
width: () => number;
expand: () => void;
}
export interface EpubLocation {
start: {
cfi: string;
href?: string;
};
end: {
cfi: string;
};
}
export interface EpubNavigation {
toc?: TocNode[];
}
export interface EpubSpineItem {
cfiBase: string;
index: number;
document: Document;
load: (_loader: unknown) => Promise<Document>;
cfiFromElement: (element: Element) => string;
wordCount?: number;
}
export interface EpubBook {
ready: Promise<void>;
navigation?: EpubNavigation;
loaded: { navigation: Promise<EpubNavigation> };
spine: {
spineItems: EpubSpineItem[];
get: (index: number) => EpubSpineItem;
hooks: {
content: { register: (_callback: (output: Document) => void) => void };
};
};
load: (...args: unknown[]) => unknown;
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
getRange: (cfiRange: string) => Promise<Range>;
destroy?: () => void;
}
export interface EpubRendition {
next: () => Promise<void>;
prev: () => Promise<void>;
display: (target?: string) => Promise<void>;
currentLocation: () => Promise<EpubLocation>;
getContents: () => EpubContents[];
themes: {
default: (styles: Record<string, unknown>) => void;
register: (name: string, styles: Record<string, unknown> | string) => void;
select: (name: string) => void;
};
hooks: {
content: { register: (_callback: () => void) => void };
render: { register: (_callback: (contents: EpubContents) => void) => void };
};
manager?: {
visible?: () => EpubVisibleSection[];
};
views: () => { container: { scrollLeft: number } };
destroy?: () => void;
}
export interface ParsedCfiPath {
steps: unknown[];
terminal: unknown;
}
export interface ParsedCfi {
base: unknown;
path: ParsedCfiPath;
}
export interface EpubCfiHelper {
parse: (_value: string) => ParsedCfi;
equalStep: (_a: unknown, _b: unknown) => boolean;
segmentString: (_value: unknown) => string;
}
export interface EpubWithCfiConstructor {
CFI: new () => EpubCfiHelper;
}
export interface ReaderStats {
chapterName: string;
sectionPage: number;
sectionTotalPages: number;
percentage: number;
}
export interface ReaderTocItem {
title: string;
href: string;
}
+7 -12
View File
@@ -3,17 +3,16 @@ import { useNavigate } from 'react-router-dom';
import { LoadingState } from '../components';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import type { DirectoryItem } from '../generated/model';
import { getErrorMessage } from '../utils/errors';
import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons';
import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>('');
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
const { showInfo, showError } = useToasts();
const navigate = useNavigate();
const toastMutationOptions = useMutationWithToast();
const { data: directoryData, isLoading } = useGetImportDirectory(
currentPath ? { directory: currentPath } : {}
@@ -48,15 +47,11 @@ export default function AdminImportPage() {
type: importType,
},
},
{
onSuccess: _response => {
showInfo('Import completed successfully');
navigate('/admin/import-results');
},
onError: error => {
showError('Import failed: ' + getErrorMessage(error));
},
}
toastMutationOptions({
success: 'Import completed successfully',
error: 'Import failed',
onSuccess: () => navigate('/admin/import-results'),
})
);
};
+4 -3
View File
@@ -8,7 +8,7 @@ import {
} from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters';
import { getErrorMessage } from '../utils/errors';
import { getErrorMessage, getResponseError } from '../utils/errors';
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
import { useToasts } from '../components/ToastContext';
@@ -137,8 +137,9 @@ export default function DocumentPage() {
const save = async (data: EditDocumentBody): Promise<boolean> => {
try {
const response = await editMutation.mutateAsync({ id: document.id, data });
if (response.status !== 200) {
showError('Failed to save: ' + getErrorMessage(response.data));
const message = getResponseError(response);
if (message) {
showError('Failed to save: ' + message);
return false;
}
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
+23 -42
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
@@ -8,11 +8,7 @@ import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters';
import { useDebouncedState } from '../hooks/useDebouncedState';
import {
getDocumentsViewMode,
setDocumentsViewMode,
type DocumentsViewMode,
} from '../utils/localSettings';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
const DOCUMENTS_PAGE_SIZE = 9;
@@ -22,25 +18,16 @@ interface DocumentItemProps {
}
function DocumentItem({ doc, layout }: DocumentItemProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0;
const open = () => navigate(`/documents/${doc.id}`);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
};
const icons = (
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted pointer-events-auto">
<Link to={`/activity?document=${doc.id}`}>
<ActivityIcon size={20} />
</Link>
{doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
<a href={`/api/v1/documents/${doc.id}/file`}>
<DownloadIcon size={20} />
</a>
) : (
@@ -58,22 +45,21 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
if (layout === 'grid') {
return (
<div className="relative w-full">
<div
role="link"
tabIndex={0}
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
onClick={open}
onKeyDown={onKeyDown}
>
<div className="relative my-auto h-48 min-w-fit">
<div className="relative flex size-full gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted">
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
<Link
to={`/documents/${doc.id}`}
aria-label={`Open ${doc.title || 'document'}`}
className="absolute inset-0"
/>
<div className="pointer-events-none my-auto h-48 min-w-fit">
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title}
/>
</div>
<div className="flex w-full flex-col justify-around text-sm text-content">
<div className="pointer-events-none flex w-full flex-col justify-around text-sm text-content">
{fields.map(f => (
<div key={f.label} className="inline-flex shrink-0 items-center">
<div>
@@ -87,19 +73,18 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
{icons}
</div>
</div>
</div>
);
}
return (
<div
role="link"
tabIndex={0}
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
onClick={open}
onKeyDown={onKeyDown}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative block rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
<Link
to={`/documents/${doc.id}`}
aria-label={`Open ${doc.title || 'document'}`}
className="absolute inset-0"
/>
<div className="pointer-events-none flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
{fields.map(f => (
<div key={f.label}>
@@ -119,15 +104,11 @@ export default function DocumentsPage() {
const [page, setPage] = useState(1);
const limit = DOCUMENTS_PAGE_SIZE;
const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
const fileInputRef = useRef<HTMLInputElement>(null);
const { showWarning } = useToasts();
const toastMutationOptions = useMutationWithToast();
useEffect(() => {
setDocumentsViewMode(viewMode);
}, [viewMode]);
useEffect(() => {
setPage(1);
}, [debouncedSearch]);
+16 -34
View File
@@ -4,28 +4,20 @@ import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components/LoadingState';
import { CloseIcon } from '../icons';
import {
getReaderColorScheme,
READER_COLOR_SCHEMES,
READER_FONT_FAMILIES,
getReaderDevice,
getReaderFontFamily,
getReaderFontSize,
setReaderColorScheme,
setReaderFontFamily,
setReaderFontSize,
type ReaderColorScheme,
type ReaderFontFamily,
useLocalSetting,
} from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader';
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
export default function ReaderPage() {
const { id } = useParams<{ id: string }>();
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
@@ -219,14 +211,11 @@ export default function ReaderPage() {
Theme
</p>
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
{colorSchemes.map(option => (
{READER_COLOR_SCHEMES.map(option => (
<button
key={option}
type="button"
onClick={() => {
setColorSchemeState(option);
setReaderColorScheme(option);
}}
onClick={() => setColorScheme(option)}
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
colorScheme === option
? 'border-primary-500 bg-primary-500/10 text-content'
@@ -242,14 +231,11 @@ export default function ReaderPage() {
<div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
{fontFamilies.map(option => (
{READER_FONT_FAMILIES.map(option => (
<button
key={option}
type="button"
onClick={() => {
setFontFamilyState(option);
setReaderFontFamily(option);
}}
onClick={() => setFontFamily(option)}
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
fontFamily === option
? 'border-primary-500 bg-primary-500/10 text-content'
@@ -269,11 +255,9 @@ export default function ReaderPage() {
<div className="flex items-center gap-1.5 lg:justify-end">
<button
type="button"
onClick={() => {
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
setFontSizeState(nextSize);
setReaderFontSize(nextSize);
}}
onClick={() =>
setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))
}
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
>
-
@@ -283,11 +267,9 @@ export default function ReaderPage() {
</div>
<button
type="button"
onClick={() => {
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
setFontSizeState(nextSize);
setReaderFontSize(nextSize);
}}
onClick={() =>
setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))
}
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
>
+
+14 -40
View File
@@ -1,6 +1,5 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
@@ -8,9 +7,8 @@ import {
type ReactNode,
} from 'react';
import {
getThemeMode,
setThemeMode,
LOCAL_SETTINGS_KEY,
useLocalSetting,
readLocalSetting,
type ThemeMode,
} from '../utils/localSettings';
@@ -49,23 +47,23 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
}
export function initializeThemeMode(): ResolvedThemeMode {
return applyThemeMode(getThemeMode());
return applyThemeMode(readLocalSetting('themeMode', 'system'));
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
resolveThemeMode(getThemeMode())
resolveThemeMode(themeMode)
);
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just sets `themeModeState`.
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just calls `setThemeMode`.
useEffect(() => {
setResolvedThemeMode(applyThemeMode(themeModeState));
}, [themeModeState]);
setResolvedThemeMode(applyThemeMode(themeMode));
}, [themeMode]);
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeMode` is unchanged.
useEffect(() => {
if (typeof window === 'undefined' || themeModeState !== 'system') {
if (typeof window === 'undefined' || themeMode !== 'system') {
return undefined;
}
@@ -78,39 +76,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return () => {
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
};
}, [themeModeState]);
// Cross-Tab Sync - Another tab changed the persisted theme; adopt its mode and let the mode effect apply it.
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleStorage = (event: StorageEvent) => {
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
return;
}
setThemeModeState(getThemeMode());
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
};
}, []);
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
setThemeMode(nextThemeMode);
setThemeModeState(nextThemeMode);
}, []);
}, [themeMode]);
const value = useMemo(
() => ({
themeMode: themeModeState,
themeMode,
resolvedThemeMode,
setThemeMode: updateThemeMode,
setThemeMode,
}),
[resolvedThemeMode, themeModeState, updateThemeMode]
[resolvedThemeMode, themeMode, setThemeMode]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
+17
View File
@@ -12,3 +12,20 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
return fallback;
}
export interface ApiResponseLike {
status: number;
data: unknown;
}
/**
* Non-2xx Check - The generated client resolves non-2xx instead of throwing; this returns the
* extracted error message for failure responses, or null for success (2xx) so callers can branch.
*/
export function getResponseError(response: ApiResponseLike): string | null {
if (response.status >= 200 && response.status < 300) {
return null;
}
return getErrorMessage(response.data, 'Request failed');
}
+100 -95
View File
@@ -1,25 +1,38 @@
export type ThemeMode = 'light' | 'dark' | 'system';
export type DocumentsViewMode = 'grid' | 'list';
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
import { useCallback, useEffect, useState } from 'react';
// Value arrays are the single source of truth; the union types derive from them so the
// runtime iteration lists (reader theme picker, nav, etc.) and the schema can't drift.
export const THEME_MODES = ['light', 'dark', 'system'] as const;
export type ThemeMode = (typeof THEME_MODES)[number];
export const DOCUMENTS_VIEW_MODES = ['grid', 'list'] as const;
export type DocumentsViewMode = (typeof DOCUMENTS_VIEW_MODES)[number];
export const READER_COLOR_SCHEMES = ['light', 'tan', 'blue', 'gray', 'black'] as const;
export type ReaderColorScheme = (typeof READER_COLOR_SCHEMES)[number];
export const READER_FONT_FAMILIES = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'] as const;
export type ReaderFontFamily = (typeof READER_FONT_FAMILIES)[number];
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
interface LocalSettings {
themeMode?: ThemeMode;
documentsViewMode?: DocumentsViewMode;
readerColorScheme?: ReaderColorScheme;
readerFontFamily?: ReaderFontFamily;
readerFontSize?: number;
readerDeviceId?: string;
readerDeviceName?: string;
interface LocalSettingsMap {
themeMode: ThemeMode;
documentsViewMode: DocumentsViewMode;
readerColorScheme: ReaderColorScheme;
readerFontFamily: ReaderFontFamily;
readerFontSize: number;
readerDeviceId: string;
readerDeviceName: string;
}
type LocalSettingKey = keyof LocalSettingsMap;
function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function readLocalSettings(): LocalSettings {
function readRawSettings(): Record<string, unknown> {
if (!canUseLocalStorage()) {
return {};
}
@@ -37,111 +50,103 @@ function readLocalSettings(): LocalSettings {
}
}
function writeLocalSettings(settings: LocalSettings): void {
function writeRawSettings(settings: Record<string, unknown>): void {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
}
function updateLocalSettings(partialSettings: LocalSettings): void {
writeLocalSettings({
...readLocalSettings(),
...partialSettings,
});
}
export function getThemeMode(): ThemeMode {
const settings = readLocalSettings();
return settings.themeMode === 'light' || settings.themeMode === 'dark'
? settings.themeMode
: 'system';
}
export function setThemeMode(themeMode: ThemeMode): void {
updateLocalSettings({ themeMode });
}
export function getDocumentsViewMode(): DocumentsViewMode {
const settings = readLocalSettings();
return settings.documentsViewMode === 'list' ? 'list' : 'grid';
}
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
updateLocalSettings({ documentsViewMode });
}
export function getReaderColorScheme(): ReaderColorScheme {
const settings = readLocalSettings();
switch (settings.readerColorScheme) {
case 'light':
case 'tan':
case 'blue':
case 'gray':
case 'black':
return settings.readerColorScheme;
function isValidValue(key: LocalSettingKey, value: unknown): boolean {
switch (key) {
case 'themeMode':
return (THEME_MODES as readonly unknown[]).includes(value);
case 'documentsViewMode':
return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
case 'readerColorScheme':
return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
case 'readerFontFamily':
return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
case 'readerFontSize':
return typeof value === 'number' && value > 0;
case 'readerDeviceId':
case 'readerDeviceName':
return typeof value === 'string' && value.length > 0;
default:
return 'tan';
return false;
}
}
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
updateLocalSettings({ readerColorScheme });
export function readLocalSetting<K extends LocalSettingKey>(
key: K,
defaultValue: LocalSettingsMap[K]
): LocalSettingsMap[K] {
const value = readRawSettings()[key];
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
}
export function getReaderFontFamily(): ReaderFontFamily {
const settings = readLocalSettings();
switch (settings.readerFontFamily) {
case 'Serif':
case 'Open Sans':
case 'Arbutus Slab':
case 'Lato':
return settings.readerFontFamily;
default:
return 'Serif';
export function writeLocalSetting<K extends LocalSettingKey>(
key: K,
value: LocalSettingsMap[K]
): void {
writeRawSettings({ ...readRawSettings(), [key]: value });
}
/**
* Stateful accessor for a single localStorage-backed setting. Persists on change and re-reads
* on cross-tab `storage` events. Validation rejects stale/invalid stored values in favor of
* `defaultValue`, so callers never need their own get/set/validate pair.
*/
export function useLocalSetting<K extends LocalSettingKey>(
key: K,
defaultValue: LocalSettingsMap[K]
) {
const [value, setValue] = useState<LocalSettingsMap[K]>(() => readLocalSetting(key, defaultValue));
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
return;
}
setValue(readLocalSetting(key, defaultValue));
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
};
}, [key, defaultValue]);
const setValuePersisted = useCallback(
(next: LocalSettingsMap[K]) => {
writeLocalSetting(key, next);
setValue(next);
},
[key]
);
return [value, setValuePersisted] as const;
}
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
updateLocalSettings({ readerFontFamily });
}
export function getReaderFontSize(): number {
const settings = readLocalSettings();
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
? settings.readerFontSize
: 1;
}
export function setReaderFontSize(readerFontSize: number): void {
updateLocalSettings({ readerFontSize });
}
// Reader Device - First-run UUID registration is a read side-effect that doesn't fit a value hook.
export function getReaderDevice(): { id: string; name: string } {
const settings = readLocalSettings();
const id =
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
? settings.readerDeviceId
const raw = readRawSettings();
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
? (raw.readerDeviceId as string)
: crypto.randomUUID();
const name =
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
? settings.readerDeviceName
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
? (raw.readerDeviceName as string)
: 'Web Reader';
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
updateLocalSettings({
readerDeviceId: id,
readerDeviceName: name,
});
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
writeLocalSetting('readerDeviceId', id);
writeLocalSetting('readerDeviceName', name);
}
return { id, name };
}
export function setReaderDevice(name: string, id?: string): void {
updateLocalSettings({
readerDeviceId: id ?? crypto.randomUUID(),
readerDeviceName: name,
});
writeLocalSetting('readerDeviceName', name);
writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID());
}