more reader
Some checks failed
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-04-03 13:26:13 -04:00
parent aa812c6917
commit 0930054847
4 changed files with 372 additions and 189 deletions

View File

@@ -1,32 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-BQhAeK6-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CdRalUYN.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,4 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createActivity, getGetDocumentFileUrl, updateProgress } from '../generated/anthoLumeAPIV1';
import type { CreateActivityRequest } from '../generated/model/createActivityRequest';
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
@@ -10,6 +13,10 @@ interface UseEpubReaderOptions {
colorScheme: ReaderColorScheme;
fontFamily: ReaderFontFamily;
fontSize: number;
isPaginationDisabled: () => boolean;
onSwipeDown: () => void;
onSwipeUp: () => void;
onCenterTap: () => void;
}
interface UseEpubReaderResult {
@@ -37,9 +44,17 @@ export function useEpubReader({
colorScheme,
fontFamily,
fontSize,
isPaginationDisabled,
onSwipeDown,
onSwipeUp,
onCenterTap,
}: UseEpubReaderOptions): UseEpubReaderResult {
const [viewerNode, setViewerNode] = useState<HTMLDivElement | null>(null);
const readerRef = useRef<EBookReader | null>(null);
const isPaginationDisabledRef = useRef(isPaginationDisabled);
const onSwipeDownRef = useRef(onSwipeDown);
const onSwipeUpRef = useRef(onSwipeUp);
const onCenterTapRef = useRef(onCenterTap);
const [isReady, setIsReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -51,12 +66,23 @@ export function useEpubReader({
percentage: 0,
});
useEffect(() => {
isPaginationDisabledRef.current = isPaginationDisabled;
onSwipeDownRef.current = onSwipeDown;
onSwipeUpRef.current = onSwipeUp;
onCenterTapRef.current = onCenterTap;
}, [isPaginationDisabled, onCenterTap, onSwipeDown, onSwipeUp]);
useEffect(() => {
const container = viewerNode;
if (!container) {
return;
}
let isCancelled = false;
let objectUrl: string | null = null;
let reader: EBookReader | null = null;
setIsReady(false);
setIsLoading(true);
setError(null);
@@ -68,29 +94,92 @@ export function useEpubReader({
percentage: 0,
});
const reader = new EBookReader({
container,
documentId,
initialProgress,
deviceId,
deviceName,
colorScheme,
fontFamily,
fontSize,
onReady: () => setIsReady(true),
onLoading: loading => setIsLoading(loading),
onError: message => setError(message),
onStats: nextStats => setStats(nextStats),
onToc: nextToc => setToc(nextToc),
});
const saveProgress = async (payload: UpdateProgressRequest) => {
const response = await updateProgress(payload);
if (response.status >= 400) {
throw new Error(
'message' in response.data ? response.data.message : 'Unable to save reader progress'
);
}
};
readerRef.current = reader;
const saveActivity = async (payload: CreateActivityRequest) => {
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 initializeReader = async () => {
try {
const response = await fetch(getGetDocumentFileUrl(documentId));
const contentType = response.headers.get('content-type') || '';
if (!response.ok || contentType.includes('application/json')) {
let message = 'Unable to load document file';
try {
const errorData = (await response.json()) as { message?: string };
if (errorData.message) {
message = errorData.message;
}
} catch {
// ignore parse failure and use fallback message
}
throw new Error(message);
}
const blob = await response.blob();
if (isCancelled) {
return;
}
objectUrl = URL.createObjectURL(blob);
reader = new EBookReader({
container,
bookUrl: objectUrl,
documentId,
initialProgress,
deviceId,
deviceName,
colorScheme,
fontFamily,
fontSize,
onReady: () => setIsReady(true),
onLoading: loading => setIsLoading(loading),
onError: message => setError(message),
onStats: nextStats => setStats(nextStats),
onToc: nextToc => setToc(nextToc),
onSaveProgress: saveProgress,
onCreateActivity: saveActivity,
isPaginationDisabled: () => isPaginationDisabledRef.current(),
onSwipeDown: () => onSwipeDownRef.current(),
onSwipeUp: () => onSwipeUpRef.current(),
onCenterTap: () => onCenterTapRef.current(),
});
readerRef.current = reader;
} catch (err) {
if (isCancelled) {
return;
}
setError(err instanceof Error ? err.message : 'Unable to load document file');
setIsLoading(false);
}
};
void initializeReader();
return () => {
reader.destroy();
isCancelled = true;
reader?.destroy();
if (readerRef.current === reader) {
readerRef.current = null;
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [deviceId, deviceName, documentId, initialProgress, viewerNode]);

View File

@@ -1,5 +1,7 @@
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';
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
@@ -139,6 +141,7 @@ interface ReaderSettings {
interface EBookReaderOptions {
container: HTMLElement;
bookUrl: string;
documentId: string;
initialProgress?: string;
deviceId: string;
@@ -151,10 +154,17 @@ interface EBookReaderOptions {
onError: (_message: string) => void;
onStats: (_stats: ReaderStats) => void;
onToc: (_toc: ReaderTocItem[]) => void;
onSaveProgress: (_payload: UpdateProgressRequest) => Promise<void>;
onCreateActivity: (_payload: CreateActivityRequest) => Promise<void>;
isPaginationDisabled: () => boolean;
onSwipeDown: () => void;
onSwipeUp: () => void;
onCenterTap: () => void;
}
export class EBookReader {
private container: HTMLElement;
private bookUrl: string;
private documentId: string;
private deviceId: string;
private deviceName: string;
@@ -170,10 +180,18 @@ export class EBookReader {
private onError: (_message: string) => void;
private onStats: (_stats: ReaderStats) => void;
private onToc: (_toc: ReaderTocItem[]) => void;
private onSaveProgress: (_payload: UpdateProgressRequest) => Promise<void>;
private onCreateActivity: (_payload: CreateActivityRequest) => Promise<void>;
private isPaginationDisabled: () => boolean;
private onSwipeDown: () => void;
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;
this.bookUrl = options.bookUrl;
this.documentId = options.documentId;
this.deviceId = options.deviceId;
this.deviceName = options.deviceName;
@@ -182,6 +200,12 @@ export class EBookReader {
this.onError = options.onError;
this.onStats = options.onStats;
this.onToc = options.onToc;
this.onSaveProgress = options.onSaveProgress;
this.onCreateActivity = options.onCreateActivity;
this.isPaginationDisabled = options.isPaginationDisabled;
this.onSwipeDown = options.onSwipeDown;
this.onSwipeUp = options.onSwipeUp;
this.onCenterTap = options.onCenterTap;
this.bookState = {
pages: 0,
@@ -201,7 +225,7 @@ export class EBookReader {
};
this.onLoading(true);
this.book = ePub(`/api/v1/documents/${this.documentId}/file`, { openAs: 'epub' }) as EpubBook;
this.book = ePub(this.bookUrl, { openAs: 'epub' }) as EpubBook;
this.rendition = this.book.renderTo(this.container, {
manager: 'default',
flow: 'paginated',
@@ -216,15 +240,13 @@ export class EBookReader {
this.initViewerListeners();
this.initDocumentListeners();
this.book.ready
.then(this.setupReader.bind(this))
.catch(error => {
if (this.destroyed) {
return;
}
this.onError(error instanceof Error ? error.message : 'Unable to initialize reader');
this.onLoading(false);
});
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
if (this.destroyed) {
return;
}
this.onError(error instanceof Error ? error.message : 'Unable to initialize reader');
this.onLoading(false);
});
}
private loadSettings() {
@@ -246,9 +268,12 @@ export class EBookReader {
if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId);
}
this.wakeTimeoutId = setTimeout(() => {
void this.noSleep?.disable();
}, 1000 * 60 * 10);
this.wakeTimeoutId = setTimeout(
() => {
void this.noSleep?.disable();
},
1000 * 60 * 10
);
void this.noSleep.enable();
};
@@ -277,7 +302,9 @@ export class EBookReader {
this.rendition.getContents().forEach(content => {
const existing = content.document.getElementById('reader-fonts');
if (!existing) {
const nextLink = content.document.head.appendChild(content.document.createElement('link'));
const nextLink = content.document.head.appendChild(
content.document.createElement('link')
);
nextLink.id = 'reader-fonts';
nextLink.rel = 'stylesheet';
nextLink.href = FONT_FILE;
@@ -312,6 +339,41 @@ export class EBookReader {
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;
@@ -335,18 +397,65 @@ export class EBookReader {
const yCoord = event.clientY;
const xCoord = event.clientX - leftOffset;
if (yCoord < top || yCoord > bottom) {
return;
}
if (xCoord < left) {
if (yCoord < top) {
handleSwipeDown();
} else if (yCoord > bottom) {
handleSwipeUp();
} else if (!this.isPaginationDisabled() && xCoord < left) {
void prevPage();
} else if (xCoord > right) {
} 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);
@@ -411,11 +520,7 @@ export class EBookReader {
}, []);
}
setTheme(newTheme?: {
colorScheme?: ReaderColorScheme;
fontFamily?: string;
fontSize?: number;
}) {
setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
this.readerSettings.theme =
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
? this.readerSettings.theme
@@ -468,7 +573,9 @@ export class EBookReader {
});
});
const backgroundColor = getComputedStyle(this.bookState.progressElement.ownerDocument.body).backgroundColor;
const backgroundColor = getComputedStyle(
this.bookState.progressElement.ownerDocument.body
).backgroundColor;
Object.assign((this.bookState.progressElement as HTMLElement).style, {
background: backgroundColor,
@@ -478,7 +585,12 @@ export class EBookReader {
}
async nextPage() {
await this.createActivity();
try {
await this.createActivity();
} catch (error) {
this.onError(error instanceof Error ? error.message : 'Unable to save reader activity');
}
await this.rendition.next();
this.bookState.pageStart = Date.now();
const stats = await this.getBookStats();
@@ -541,30 +653,35 @@ export class EBookReader {
elapsedTime = (pageWords / WPM_MIN) * 60000;
}
if (!Number.isFinite(percentRead) || percentRead <= 0 || this.bookState.words <= 0) {
return;
}
const totalPages = Math.round(1 / percentRead);
if (totalPages === 0) {
if (!Number.isFinite(totalPages) || totalPages <= 0) {
return;
}
const currentPage = Math.round((currentWord * totalPages) / this.bookState.words);
if (!Number.isFinite(currentPage) || currentPage < 0) {
return;
}
await fetch('/api/v1/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: this.deviceId,
device_name: this.deviceName,
activity: [
{
document_id: this.documentId,
duration: Math.round(elapsedTime / 1000),
start_time: Math.round(pageStart / 1000),
page: currentPage,
pages: totalPages,
},
],
}),
});
const payload: CreateActivityRequest = {
device_id: this.deviceId,
device_name: this.deviceName,
activity: [
{
document_id: this.documentId,
duration: Math.round(elapsedTime / 1000),
start_time: Math.round(pageStart / 1000),
page: currentPage,
pages: totalPages,
},
],
};
await this.onCreateActivity(payload);
}
async createProgress() {
@@ -580,17 +697,19 @@ export class EBookReader {
: 0;
this.bookState.percentage = Math.round(percentage * 10000) / 100;
await fetch('/api/v1/progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document_id: this.documentId,
device_id: this.deviceId,
device_name: this.deviceName,
percentage,
progress: this.bookState.progress,
}),
});
const payload: UpdateProgressRequest = {
document_id: this.documentId,
device_id: this.deviceId,
device_name: this.deviceName,
percentage,
progress: this.bookState.progress,
};
try {
await this.onSaveProgress(payload);
} catch (error) {
this.onError(error instanceof Error ? error.message : 'Unable to save reader progress');
}
}
sectionProgress() {
@@ -626,7 +745,9 @@ export class EBookReader {
const currentLocation = await this.rendition.currentLocation();
const currentWord = await this.getBookWordPosition();
const currentTOC = this.book.navigation?.toc?.find(item => item.href === currentLocation.start.href);
const currentTOC = this.book.navigation?.toc?.find(
item => item.href === currentLocation.start.href
);
return {
sectionPage: currentProgress.sectionCurrentPage,
@@ -720,37 +841,36 @@ export class EBookReader {
const derivedSelectorElement = remainingXPath
.replace(/^\/html\/body/, 'body')
.split('/')
.reduce((element: ParentNode | null, item: string) => {
if (!element) {
return null;
}
.reduce(
(element: ParentNode | null, item: string) => {
if (!element) {
return null;
}
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
if (!indexMatch) {
return element.querySelector(item);
}
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);
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 docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
if (prefix === 'ns') {
return namespaceURI;
}
);
return null;
});
const xpathElement = docSearch.iterateNext();
const element = xpathElement || derivedSelectorElement;
@@ -771,7 +891,7 @@ export class EBookReader {
async getVisibleWordCount() {
const visibleText = await this.getVisibleText();
return visibleText.trim().split(/\s+/).filter(Boolean).length;
return visibleText.trim().split(/\s+/).length;
}
async getBookWordPosition() {
@@ -791,7 +911,7 @@ export class EBookReader {
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+/).filter(Boolean).length;
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);
@@ -853,8 +973,7 @@ export class EBookReader {
const newDoc = await item.load(this.book.load.bind(this.book));
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
.trim()
.split(/\s+/)
.filter(Boolean).length;
.split(/\s+/).length;
item.wordCount = spineWords;
return spineWords;
})
@@ -872,6 +991,9 @@ export class EBookReader {
if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId);
}
if (this.wheelTimeoutId) {
clearTimeout(this.wheelTimeoutId);
}
void this.noSleep?.disable();
this.rendition.destroy?.();
this.book.destroy?.();

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components/LoadingState';
@@ -22,7 +22,7 @@ const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab',
export default function ReaderPage() {
const { id } = useParams<{ id: string }>();
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
const [isBottomBarOpen, setIsBottomBarOpen] = useState(true);
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
@@ -41,6 +41,31 @@ export default function ReaderPage() {
const deviceId = defaultDeviceId;
const deviceName = defaultDeviceName;
const handleSwipeDown = useCallback(() => {
if (isBottomBarOpen) {
setIsBottomBarOpen(false);
return;
}
if (!isTopBarOpen) {
setIsTopBarOpen(true);
}
}, [isBottomBarOpen, isTopBarOpen]);
const handleSwipeUp = useCallback(() => {
if (isTopBarOpen) {
setIsTopBarOpen(false);
return;
}
if (!isBottomBarOpen) {
setIsBottomBarOpen(true);
}
}, [isBottomBarOpen, isTopBarOpen]);
const handleCenterTap = useCallback(() => {
setIsTopBarOpen(false);
setIsBottomBarOpen(false);
}, []);
const reader = useEpubReader({
documentId: id || '',
initialProgress: progress?.progress,
@@ -49,6 +74,10 @@ export default function ReaderPage() {
colorScheme,
fontFamily,
fontSize,
isPaginationDisabled: useCallback(() => isTopBarOpen || isBottomBarOpen, [isTopBarOpen, isBottomBarOpen]),
onSwipeDown: handleSwipeDown,
onSwipeUp: handleSwipeUp,
onCenterTap: handleCenterTap,
});
useEffect(() => {
@@ -61,6 +90,17 @@ export default function ReaderPage() {
reader.setTheme({ colorScheme, fontFamily, fontSize });
}, [colorScheme, fontFamily, fontSize, reader.setTheme]);
useEffect(() => {
if (isTopBarOpen || isBottomBarOpen) {
return;
}
const activeElement = window.document.activeElement;
if (activeElement instanceof HTMLElement) {
activeElement.blur();
}
}, [isBottomBarOpen, isTopBarOpen]);
if (isDocumentLoading || isProgressLoading) {
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
}
@@ -77,7 +117,7 @@ export default function ReaderPage() {
isTopBarOpen ? 'translate-y-0' : '-translate-y-full'
}`}
>
<div className="mx-auto flex max-h-[70vh] w-full max-w-6xl flex-col gap-4 overflow-auto p-4">
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4">
<Link to={`/documents/${document.id}`} className="block shrink-0">
@@ -113,7 +153,7 @@ export default function ReaderPage() {
</div>
</div>
<div className="grid gap-2 pb-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-2 sm:grid-cols-2 lg:grid-cols-3">
{reader.toc.map(item => (
<button
key={`${item.href}-${item.title}`}
@@ -131,23 +171,6 @@ export default function ReaderPage() {
</div>
</div>
<div className="absolute left-4 top-4 z-10 flex gap-2">
<button
type="button"
onClick={() => setIsTopBarOpen(open => !open)}
className="rounded bg-surface/90 px-3 py-2 text-sm font-medium text-content shadow backdrop-blur hover:bg-surface"
>
Contents
</button>
<button
type="button"
onClick={() => setIsBottomBarOpen(open => !open)}
className="rounded bg-surface/90 px-3 py-2 text-sm font-medium text-content shadow backdrop-blur hover:bg-surface"
>
Controls
</button>
</div>
<div className="absolute inset-0 pt-[env(safe-area-inset-top)]">
{reader.isLoading && (
<LoadingState
@@ -169,8 +192,8 @@ export default function ReaderPage() {
isBottomBarOpen ? 'translate-y-0' : 'translate-y-full'
}`}
>
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-content-muted">
<div className="mx-auto flex w-full max-w-screen-2xl flex-col gap-3 p-3">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-content-muted sm:text-sm">
<div>
<span className="text-content-subtle">Chapter:</span> {reader.stats.chapterName}
</div>
@@ -184,17 +207,17 @@ export default function ReaderPage() {
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-surface-strong">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full bg-tertiary-500 transition-all"
style={{ width: `${reader.stats.percentage}%` }}
/>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto]">
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">Theme</p>
<div className="flex flex-wrap gap-2">
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto] lg:items-start">
<div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Theme</p>
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
{colorSchemes.map(option => (
<button
key={option}
@@ -203,7 +226,7 @@ export default function ReaderPage() {
setColorSchemeState(option);
setReaderColorScheme(option);
}}
className={`rounded border px-3 py-2 text-sm capitalize ${
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'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
@@ -215,9 +238,9 @@ export default function ReaderPage() {
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">Font</p>
<div className="flex flex-wrap gap-2">
<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 => (
<button
key={option}
@@ -226,7 +249,7 @@ export default function ReaderPage() {
setFontFamilyState(option);
setReaderFontFamily(option);
}}
className={`rounded border px-3 py-2 text-sm ${
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
fontFamily === option
? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
@@ -239,10 +262,10 @@ export default function ReaderPage() {
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
Font Size
</p>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 lg:justify-end">
<button
type="button"
onClick={() => {
@@ -250,11 +273,11 @@ export default function ReaderPage() {
setFontSizeState(nextSize);
setReaderFontSize(nextSize);
}}
className="rounded border border-border px-3 py-2 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"
>
-
</button>
<div className="min-w-16 text-center text-sm text-content">
<div className="min-w-12 text-center text-xs text-content sm:text-sm">
{fontSize.toFixed(1)}x
</div>
<button
@@ -264,31 +287,12 @@ export default function ReaderPage() {
setFontSizeState(nextSize);
setReaderFontSize(nextSize);
}}
className="rounded border border-border px-3 py-2 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"
>
+
</button>
</div>
</div>
<div className="flex items-end gap-2">
<button
type="button"
onClick={() => void reader.prevPage()}
disabled={!reader.isReady}
className="rounded bg-secondary-700 px-4 py-2 text-sm font-medium text-white hover:bg-secondary-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<button
type="button"
onClick={() => void reader.nextPage()}
disabled={!reader.isReady}
className="rounded bg-secondary-700 px-4 py-2 text-sm font-medium text-white hover:bg-secondary-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
</div>