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 { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons'; import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings'; import { THEME_MODES } from '../utils/localSettings';
import HamburgerMenu from './HamburgerMenu'; import HamburgerMenu from './HamburgerMenu';
import { getPageTitle } from './navigation'; import { getPageTitle } from './navigation';
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -72,7 +70,7 @@ export default function Layout() {
Theme Theme
</p> </p>
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1"> <div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
{themeModes.map(mode => ( {THEME_MODES.map(mode => (
<button <button
key={mode} key={mode}
type="button" type="button"
+7 -7
View File
@@ -1,5 +1,5 @@
import type { ElementType } from 'react'; import type { ElementType } from 'react';
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons'; import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
export interface NavItem { export interface NavItem {
path: string; path: string;
@@ -10,16 +10,16 @@ export interface NavItem {
export const navItems: NavItem[] = [ export const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: HomeIcon }, { path: '/', label: 'Home', icon: HomeIcon },
{ path: '/documents', label: 'Documents', icon: DocumentsIcon }, { 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: '/activity', label: 'Activity', icon: ActivityIcon },
{ path: '/search', label: 'Search', icon: SearchIcon }, { path: '/search', label: 'Search', icon: SearchIcon },
]; ];
export const adminNavItems: NavItem[] = [ export const adminNavItems: { path: string; label: string }[] = [
{ path: '/admin', label: 'General', icon: SettingsIcon }, { path: '/admin', label: 'General' },
{ path: '/admin/import', label: 'Import', icon: SettingsIcon }, { path: '/admin/import', label: 'Import' },
{ path: '/admin/users', label: 'Users', icon: SettingsIcon }, { path: '/admin/users', label: 'Users' },
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon }, { path: '/admin/logs', label: 'Logs' },
]; ];
// Ordered most-specific-first so prefix matching resolves nested routes correctly. // 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 type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader'; import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings'; import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage, getResponseError } from '../utils/errors';
interface UseEpubReaderOptions { interface UseEpubReaderOptions {
documentId: string; documentId: string;
@@ -60,6 +62,7 @@ export function useEpubReader({
sectionTotalPages: 0, sectionTotalPages: 0,
percentage: 0, percentage: 0,
}); });
const { showError } = useToasts();
useEffect(() => { useEffect(() => {
isPaginationDisabledRef.current = isPaginationDisabled; isPaginationDisabledRef.current = isPaginationDisabled;
@@ -90,20 +93,28 @@ export function useEpubReader({
}); });
const saveProgress = async (payload: UpdateProgressRequest) => { 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); const response = await updateProgress(payload);
if (response.status >= 400) { const message = getResponseError(response);
throw new Error( if (message) {
'message' in response.data ? response.data.message : 'Unable to save reader progress' showError(`Failed to save progress: ${message}`);
); }
} catch (err) {
showError(`Failed to save progress: ${getErrorMessage(err)}`);
} }
}; };
const saveActivity = async (payload: CreateActivityRequest) => { const saveActivity = async (payload: CreateActivityRequest) => {
try {
const response = await createActivity(payload); const response = await createActivity(payload);
if (response.status >= 400) { const message = getResponseError(response);
throw new Error( if (message) {
'message' in response.data ? response.data.message : 'Unable to save reader activity' 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 { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors';
interface ApiResponse {
status: number;
data: unknown;
}
interface ToastMutationOptions { interface ToastMutationOptions {
success: string; success: string;
@@ -21,9 +16,10 @@ export function useMutationWithToast() {
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) { return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
return { return {
onSuccess: (response: ApiResponse) => { onSuccess: (response: ApiResponseLike) => {
if (response.status < 200 || response.status >= 300) { const message = getResponseError(response);
showError(`${error}: ${getErrorMessage(response.data)}`); if (message) {
showError(`${error}: ${message}`);
return; return;
} }
onSuccess?.(); onSuccess?.();
-66
View File
@@ -356,72 +356,6 @@ main {
display: none; 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 */ /* Skeleton Wave Animation */
@keyframes wave { @keyframes wave {
0% { 0% {
+47 -508
View File
@@ -2,125 +2,27 @@ import ePub from 'epubjs';
import NoSleep from 'nosleep.js'; import NoSleep from 'nosleep.js';
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest'; import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest'; 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 THEME_FILE = '/assets/reader/themes.css';
const FONT_FILE = '/assets/reader/fonts.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 { interface BookState {
pages: number; pages: number;
percentage: number; percentage: number;
@@ -174,6 +76,7 @@ export class EBookReader {
private rendition: EpubRendition; private rendition: EpubRendition;
private noSleep: NoSleep | null = null; private noSleep: NoSleep | null = null;
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null; private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
private gestureDispose: (() => void) | null = null;
private destroyed = false; private destroyed = false;
private onReady: () => void; private onReady: () => void;
private onLoading: (_loading: boolean) => void; private onLoading: (_loading: boolean) => void;
@@ -187,7 +90,6 @@ export class EBookReader {
private onSwipeUp: () => void; private onSwipeUp: () => void;
private onCenterTap: () => void; private onCenterTap: () => void;
private keyupHandler: ((event: KeyboardEvent) => void) | null = null; private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(options: EBookReaderOptions) { constructor(options: EBookReaderOptions) {
this.container = options.container; this.container = options.container;
@@ -217,7 +119,6 @@ export class EBookReader {
pageStart: Date.now(), pageStart: Date.now(),
}; };
this.loadSettings();
this.readerSettings.theme = { this.readerSettings.theme = {
colorScheme: options.colorScheme, colorScheme: options.colorScheme,
fontFamily: options.fontFamily, fontFamily: options.fontFamily,
@@ -237,7 +138,16 @@ export class EBookReader {
this.initCSP(); this.initCSP();
this.initWakeLock(); this.initWakeLock();
this.initThemes(); 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.initDocumentListeners();
this.book.ready.then(this.setupReader.bind(this)).catch(error => { 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() { private initWakeLock() {
this.noSleep = new NoSleep(); this.noSleep = new NoSleep();
document.addEventListener('wakelock', this.handleWakeLock); document.addEventListener('wakelock', this.handleWakeLock);
@@ -279,7 +183,7 @@ export class EBookReader {
}; };
private initThemes() { 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; let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
if (!themeLinkEl) { 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() { private initDocumentListeners() {
const nextPage = this.nextPage.bind(this); const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.bind(this); const prevPage = this.prevPage.bind(this);
@@ -469,9 +252,11 @@ export class EBookReader {
} }
if ((event.keyCode || event.which) === 84) { if ((event.keyCode || event.which) === 84) {
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan'; const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
const currentThemeIdx = THEMES.indexOf(currentTheme); const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
const colorScheme = 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) { if (colorScheme) {
this.setTheme({ colorScheme }); this.setTheme({ colorScheme });
} }
@@ -482,44 +267,20 @@ export class EBookReader {
} }
private async setupReader() { private async setupReader() {
this.bookState.words = await this.countWords(); this.bookState.words = await countWords(this.book);
const { cfi } = await this.getCFIFromXPath(this.bookState.progress); const { cfi } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
await this.setPosition(cfi); 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.bookState.progressElement = element ?? null;
this.highlightPositionMarker(); this.highlightPositionMarker();
const stats = await this.getBookStats(); const stats = await this.getBookStats();
this.onStats(stats); this.onStats(stats);
this.bookState.pageStart = Date.now(); this.bookState.pageStart = Date.now();
this.onToc(this.getParsedTOC()); this.onToc(getParsedTOC(this.book));
this.onLoading(false); this.onLoading(false);
this.onReady(); 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 }) { setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
this.readerSettings.theme = this.readerSettings.theme =
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
@@ -615,6 +376,8 @@ export class EBookReader {
return; 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); await this.rendition.display(cfi);
await this.rendition.display(cfi); await this.rendition.display(cfi);
@@ -627,10 +390,10 @@ export class EBookReader {
fontSize?: number; fontSize?: number;
}) { }) {
const currentProgress = this.bookState.progress; const currentProgress = this.bookState.progress;
const { cfi } = await this.getCFIFromXPath(currentProgress); const { cfi } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
this.setTheme(newTheme); this.setTheme(newTheme);
await this.setPosition(cfi); 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.bookState.progressElement = element ?? null;
this.highlightPositionMarker(); this.highlightPositionMarker();
} }
@@ -641,8 +404,8 @@ export class EBookReader {
const pageStart = this.bookState.pageStart; const pageStart = this.bookState.pageStart;
let elapsedTime = Date.now() - pageStart; let elapsedTime = Date.now() - pageStart;
const pageWords = await this.getVisibleWordCount(); const pageWords = await getVisibleWordCount(this.book, this.rendition);
const currentWord = await this.getBookWordPosition(); const currentWord = await getBookWordPosition(this.book, this.rendition);
const percentRead = pageWords / this.bookState.words; const percentRead = pageWords / this.bookState.words;
const pageWPM = pageWords / (elapsedTime / 60000); const pageWPM = pageWords / (elapsedTime / 60000);
@@ -686,8 +449,8 @@ export class EBookReader {
async createProgress() { async createProgress() {
const currentCFI = await this.rendition.currentLocation(); const currentCFI = await this.rendition.currentLocation();
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi); const { element, xpath } = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
const currentWord = await this.getBookWordPosition(); const currentWord = await getBookWordPosition(this.book, this.rendition);
this.bookState.progress = xpath ?? ''; this.bookState.progress = xpath ?? '';
this.bookState.progressElement = element ?? null; this.bookState.progressElement = element ?? null;
@@ -744,7 +507,7 @@ export class EBookReader {
} }
const currentLocation = await this.rendition.currentLocation(); 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( const currentTOC = this.book.navigation?.toc?.find(
item => item.href === currentLocation.start.href 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() { destroy() {
this.destroyed = true; this.destroyed = true;
if (this.keyupHandler) { if (this.keyupHandler) {
@@ -991,9 +532,7 @@ export class EBookReader {
if (this.wakeTimeoutId) { if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId); clearTimeout(this.wakeTimeoutId);
} }
if (this.wheelTimeoutId) { this.gestureDispose?.();
clearTimeout(this.wheelTimeoutId);
}
void this.noSleep?.disable(); void this.noSleep?.disable();
this.rendition.destroy?.(); this.rendition.destroy?.();
this.book.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 { LoadingState } from '../components';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import type { DirectoryItem } from '../generated/model'; import type { DirectoryItem } from '../generated/model';
import { getErrorMessage } from '../utils/errors';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons'; import { FolderOpenIcon } from '../icons';
import { useToasts } from '../components/ToastContext'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
export default function AdminImportPage() { export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>(''); const [currentPath, setCurrentPath] = useState<string>('');
const [selectedDirectory, setSelectedDirectory] = useState<string>(''); const [selectedDirectory, setSelectedDirectory] = useState<string>('');
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT'); const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
const { showInfo, showError } = useToasts();
const navigate = useNavigate(); const navigate = useNavigate();
const toastMutationOptions = useMutationWithToast();
const { data: directoryData, isLoading } = useGetImportDirectory( const { data: directoryData, isLoading } = useGetImportDirectory(
currentPath ? { directory: currentPath } : {} currentPath ? { directory: currentPath } : {}
@@ -48,15 +47,11 @@ export default function AdminImportPage() {
type: importType, type: importType,
}, },
}, },
{ toastMutationOptions({
onSuccess: _response => { success: 'Import completed successfully',
showInfo('Import completed successfully'); error: 'Import failed',
navigate('/admin/import-results'); onSuccess: () => navigate('/admin/import-results'),
}, })
onError: error => {
showError('Import failed: ' + getErrorMessage(error));
},
}
); );
}; };
+4 -3
View File
@@ -8,7 +8,7 @@ import {
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model'; import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters'; 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 { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
@@ -137,8 +137,9 @@ export default function DocumentPage() {
const save = async (data: EditDocumentBody): Promise<boolean> => { const save = async (data: EditDocumentBody): Promise<boolean> => {
try { try {
const response = await editMutation.mutateAsync({ id: document.id, data }); const response = await editMutation.mutateAsync({ id: document.id, data });
if (response.status !== 200) { const message = getResponseError(response);
showError('Failed to save: ' + getErrorMessage(response.data)); if (message) {
showError('Failed to save: ' + message);
return false; return false;
} }
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response); queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
+23 -42
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'; 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 { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document } from '../generated/model'; import type { Document } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
@@ -8,11 +8,7 @@ import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
getDocumentsViewMode,
setDocumentsViewMode,
type DocumentsViewMode,
} from '../utils/localSettings';
const DOCUMENTS_PAGE_SIZE = 9; const DOCUMENTS_PAGE_SIZE = 9;
@@ -22,25 +18,16 @@ interface DocumentItemProps {
} }
function DocumentItem({ doc, layout }: DocumentItemProps) { function DocumentItem({ doc, layout }: DocumentItemProps) {
const navigate = useNavigate();
const percentage = doc.percentage || 0; const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 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 = ( const icons = (
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted"> <div className="flex shrink-0 items-center justify-end gap-4 text-content-muted pointer-events-auto">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}> <Link to={`/activity?document=${doc.id}`}>
<ActivityIcon size={20} /> <ActivityIcon size={20} />
</Link> </Link>
{doc.filepath ? ( {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} /> <DownloadIcon size={20} />
</a> </a>
) : ( ) : (
@@ -58,22 +45,21 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
if (layout === 'grid') { if (layout === 'grid') {
return ( return (
<div className="relative w-full"> <div className="relative flex size-full gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted">
<div {/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
role="link" <Link
tabIndex={0} to={`/documents/${doc.id}`}
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden" aria-label={`Open ${doc.title || 'document'}`}
onClick={open} className="absolute inset-0"
onKeyDown={onKeyDown} />
> <div className="pointer-events-none my-auto h-48 min-w-fit">
<div className="relative my-auto h-48 min-w-fit">
<img <img
className="h-full rounded object-cover" className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`} src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title} alt={doc.title}
/> />
</div> </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 => ( {fields.map(f => (
<div key={f.label} className="inline-flex shrink-0 items-center"> <div key={f.label} className="inline-flex shrink-0 items-center">
<div> <div>
@@ -87,19 +73,18 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
{icons} {icons}
</div> </div>
</div> </div>
</div>
); );
} }
return ( return (
<div <div className="relative block rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
role="link" {/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
tabIndex={0} <Link
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden" to={`/documents/${doc.id}`}
onClick={open} aria-label={`Open ${doc.title || 'document'}`}
onKeyDown={onKeyDown} className="absolute inset-0"
> />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <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"> <div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
{fields.map(f => ( {fields.map(f => (
<div key={f.label}> <div key={f.label}>
@@ -119,15 +104,11 @@ export default function DocumentsPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = DOCUMENTS_PAGE_SIZE; const limit = DOCUMENTS_PAGE_SIZE;
const [uploadMode, setUploadMode] = useState(false); const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode); const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showWarning } = useToasts(); const { showWarning } = useToasts();
const toastMutationOptions = useMutationWithToast(); const toastMutationOptions = useMutationWithToast();
useEffect(() => {
setDocumentsViewMode(viewMode);
}, [viewMode]);
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [debouncedSearch]); }, [debouncedSearch]);
+16 -34
View File
@@ -4,28 +4,20 @@ import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components/LoadingState'; import { LoadingState } from '../components/LoadingState';
import { CloseIcon } from '../icons'; import { CloseIcon } from '../icons';
import { import {
getReaderColorScheme, READER_COLOR_SCHEMES,
READER_FONT_FAMILIES,
getReaderDevice, getReaderDevice,
getReaderFontFamily, useLocalSetting,
getReaderFontSize,
setReaderColorScheme,
setReaderFontFamily,
setReaderFontSize,
type ReaderColorScheme,
type ReaderFontFamily,
} from '../utils/localSettings'; } from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader'; 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() { export default function ReaderPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [isTopBarOpen, setIsTopBarOpen] = useState(false); const [isTopBarOpen, setIsTopBarOpen] = useState(false);
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false); const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme()); const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily()); const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize()); const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []); const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
@@ -219,14 +211,11 @@ export default function ReaderPage() {
Theme Theme
</p> </p>
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"> <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 <button
key={option} key={option}
type="button" type="button"
onClick={() => { onClick={() => setColorScheme(option)}
setColorSchemeState(option);
setReaderColorScheme(option);
}}
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${ className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
colorScheme === option colorScheme === option
? 'border-primary-500 bg-primary-500/10 text-content' ? 'border-primary-500 bg-primary-500/10 text-content'
@@ -242,14 +231,11 @@ export default function ReaderPage() {
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p> <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"> <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 <button
key={option} key={option}
type="button" type="button"
onClick={() => { onClick={() => setFontFamily(option)}
setFontFamilyState(option);
setReaderFontFamily(option);
}}
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${ className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
fontFamily === option fontFamily === option
? 'border-primary-500 bg-primary-500/10 text-content' ? '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"> <div className="flex items-center gap-1.5 lg:justify-end">
<button <button
type="button" type="button"
onClick={() => { onClick={() =>
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2))); setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))
setFontSizeState(nextSize); }
setReaderFontSize(nextSize);
}}
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content" 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> </div>
<button <button
type="button" type="button"
onClick={() => { onClick={() =>
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2))); setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))
setFontSizeState(nextSize); }
setReaderFontSize(nextSize);
}}
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content" 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 { import {
createContext, createContext,
useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
@@ -8,9 +7,8 @@ import {
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { import {
getThemeMode, useLocalSetting,
setThemeMode, readLocalSetting,
LOCAL_SETTINGS_KEY,
type ThemeMode, type ThemeMode,
} from '../utils/localSettings'; } from '../utils/localSettings';
@@ -49,23 +47,23 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
} }
export function initializeThemeMode(): ResolvedThemeMode { export function initializeThemeMode(): ResolvedThemeMode {
return applyThemeMode(getThemeMode()); return applyThemeMode(readLocalSetting('themeMode', 'system'));
} }
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode()); const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() => 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(() => { useEffect(() => {
setResolvedThemeMode(applyThemeMode(themeModeState)); setResolvedThemeMode(applyThemeMode(themeMode));
}, [themeModeState]); }, [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(() => { useEffect(() => {
if (typeof window === 'undefined' || themeModeState !== 'system') { if (typeof window === 'undefined' || themeMode !== 'system') {
return undefined; return undefined;
} }
@@ -78,39 +76,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return () => { return () => {
mediaQueryList.removeEventListener('change', handleSystemThemeChange); mediaQueryList.removeEventListener('change', handleSystemThemeChange);
}; };
}, [themeModeState]); }, [themeMode]);
// 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);
}, []);
const value = useMemo( const value = useMemo(
() => ({ () => ({
themeMode: themeModeState, themeMode,
resolvedThemeMode, resolvedThemeMode,
setThemeMode: updateThemeMode, setThemeMode,
}), }),
[resolvedThemeMode, themeModeState, updateThemeMode] [resolvedThemeMode, themeMode, setThemeMode]
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; 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; 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'; import { useCallback, useEffect, useState } from 'react';
export type DocumentsViewMode = 'grid' | 'list';
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black'; // Value arrays are the single source of truth; the union types derive from them so the
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato'; // 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'; export const LOCAL_SETTINGS_KEY = 'antholume:settings';
interface LocalSettings { interface LocalSettingsMap {
themeMode?: ThemeMode; themeMode: ThemeMode;
documentsViewMode?: DocumentsViewMode; documentsViewMode: DocumentsViewMode;
readerColorScheme?: ReaderColorScheme; readerColorScheme: ReaderColorScheme;
readerFontFamily?: ReaderFontFamily; readerFontFamily: ReaderFontFamily;
readerFontSize?: number; readerFontSize: number;
readerDeviceId?: string; readerDeviceId: string;
readerDeviceName?: string; readerDeviceName: string;
} }
type LocalSettingKey = keyof LocalSettingsMap;
function canUseLocalStorage(): boolean { function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
} }
function readLocalSettings(): LocalSettings { function readRawSettings(): Record<string, unknown> {
if (!canUseLocalStorage()) { if (!canUseLocalStorage()) {
return {}; return {};
} }
@@ -37,111 +50,103 @@ function readLocalSettings(): LocalSettings {
} }
} }
function writeLocalSettings(settings: LocalSettings): void { function writeRawSettings(settings: Record<string, unknown>): void {
if (!canUseLocalStorage()) { if (!canUseLocalStorage()) {
return; return;
} }
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings)); window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
} }
function updateLocalSettings(partialSettings: LocalSettings): void { function isValidValue(key: LocalSettingKey, value: unknown): boolean {
writeLocalSettings({ switch (key) {
...readLocalSettings(), case 'themeMode':
...partialSettings, return (THEME_MODES as readonly unknown[]).includes(value);
}); case 'documentsViewMode':
} return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
case 'readerColorScheme':
export function getThemeMode(): ThemeMode { return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
const settings = readLocalSettings(); case 'readerFontFamily':
return settings.themeMode === 'light' || settings.themeMode === 'dark' return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
? settings.themeMode case 'readerFontSize':
: 'system'; return typeof value === 'number' && value > 0;
} case 'readerDeviceId':
case 'readerDeviceName':
export function setThemeMode(themeMode: ThemeMode): void { return typeof value === 'string' && value.length > 0;
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;
default: default:
return 'tan'; return false;
} }
} }
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void { export function readLocalSetting<K extends LocalSettingKey>(
updateLocalSettings({ readerColorScheme }); key: K,
defaultValue: LocalSettingsMap[K]
): LocalSettingsMap[K] {
const value = readRawSettings()[key];
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
} }
export function getReaderFontFamily(): ReaderFontFamily { export function writeLocalSetting<K extends LocalSettingKey>(
const settings = readLocalSettings(); key: K,
switch (settings.readerFontFamily) { value: LocalSettingsMap[K]
case 'Serif': ): void {
case 'Open Sans': writeRawSettings({ ...readRawSettings(), [key]: value });
case 'Arbutus Slab': }
case 'Lato':
return settings.readerFontFamily; /**
default: * Stateful accessor for a single localStorage-backed setting. Persists on change and re-reads
return 'Serif'; * 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 { // Reader Device - First-run UUID registration is a read side-effect that doesn't fit a value hook.
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 });
}
export function getReaderDevice(): { id: string; name: string } { export function getReaderDevice(): { id: string; name: string } {
const settings = readLocalSettings(); const raw = readRawSettings();
const id = const id = isValidValue('readerDeviceId', raw.readerDeviceId)
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0 ? (raw.readerDeviceId as string)
? settings.readerDeviceId
: crypto.randomUUID(); : crypto.randomUUID();
const name = const name = isValidValue('readerDeviceName', raw.readerDeviceName)
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0 ? (raw.readerDeviceName as string)
? settings.readerDeviceName
: 'Web Reader'; : 'Web Reader';
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) { if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
updateLocalSettings({ writeLocalSetting('readerDeviceId', id);
readerDeviceId: id, writeLocalSetting('readerDeviceName', name);
readerDeviceName: name,
});
} }
return { id, name }; return { id, name };
} }
export function setReaderDevice(name: string, id?: string): void { export function setReaderDevice(name: string, id?: string): void {
updateLocalSettings({ writeLocalSetting('readerDeviceName', name);
readerDeviceId: id ?? crypto.randomUUID(), writeLocalSetting('readerDeviceId', id ?? crypto.randomUUID());
readerDeviceName: name,
});
} }