diff --git a/web/src/reader/gestures.ts b/web/src/reader/gestures.ts
new file mode 100644
index 0000000..16abd0a
--- /dev/null
+++ b/web/src/reader/gestures.ts
@@ -0,0 +1,115 @@
+// Touch gesture controls for mobile/tablet
+// Procedural implementation (no OOP)
+
+interface GestureHandlers {
+ onSwipeLeft: () => void;
+ onSwipeRight: () => void;
+ onSwipeUp: () => void;
+ onSwipeDown: () => void;
+ onPinch: (scale: number) => void;
+ onTap: () => void;
+ onDoubleTap: () => void;
+}
+
+interface GestureState {
+ touchStartX: number;
+ touchStartY: number;
+ touchStartTime: number;
+ lastTapTime: number;
+ initialPinchDistance: number;
+ scale: number;
+}
+
+function setupGestureControls(
+ container: HTMLElement,
+ handlers: GestureHandlers,
+): void {
+ let state: GestureState = {
+ touchStartX: 0,
+ touchStartY: 0,
+ touchStartTime: 0,
+ lastTapTime: 0,
+ initialPinchDistance: 0,
+ scale: 1,
+ };
+
+ container.addEventListener(
+ "touchstart",
+ (e) => {
+ if (e.touches.length === 1) {
+ state.touchStartX = e.touches[0].clientX;
+ state.touchStartY = e.touches[0].clientY;
+ state.touchStartTime = Date.now();
+ } else if (e.touches.length === 2) {
+ state.initialPinchDistance = getPinchDistance(e.touches);
+ }
+ },
+ { passive: true },
+ );
+
+ container.addEventListener(
+ "touchend",
+ (e) => {
+ const deltaX = e.changedTouches[0].clientX - state.touchStartX;
+ const deltaY = e.changedTouches[0].clientY - state.touchStartY;
+ const deltaTime = Date.now() - state.touchStartTime;
+
+ if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
+ const now = Date.now();
+ if (now - state.lastTapTime < 300) {
+ handlers.onDoubleTap();
+ state.lastTapTime = 0;
+ } else {
+ state.lastTapTime = now;
+ setTimeout(() => {
+ if (state.lastTapTime !== 0) {
+ handlers.onTap();
+ }
+ }, 300);
+ }
+ return;
+ }
+
+ const minSwipeDistance = 50;
+ const maxSwipeTime = 500;
+
+ if (deltaTime > maxSwipeTime) return;
+
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
+ if (deltaX > minSwipeDistance) {
+ handlers.onSwipeRight();
+ } else if (deltaX < -minSwipeDistance) {
+ handlers.onSwipeLeft();
+ }
+ } else {
+ if (deltaY > minSwipeDistance) {
+ handlers.onSwipeDown();
+ } else if (deltaY < -minSwipeDistance) {
+ handlers.onSwipeUp();
+ }
+ }
+ },
+ { passive: true },
+ );
+
+ container.addEventListener(
+ "touchmove",
+ (e) => {
+ if (e.touches.length === 2) {
+ const currentDistance = getPinchDistance(e.touches);
+ if (state.initialPinchDistance > 0) {
+ const scale = currentDistance / state.initialPinchDistance;
+ state.scale = scale;
+ handlers.onPinch(scale);
+ }
+ }
+ },
+ { passive: true },
+ );
+}
+
+function getPinchDistance(touches: TouchList): number {
+ const dx = touches[0].clientX - touches[1].clientX;
+ const dy = touches[0].clientY - touches[1].clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+}
diff --git a/web/src/reader/keyboard-shortcuts.ts b/web/src/reader/keyboard-shortcuts.ts
new file mode 100644
index 0000000..7f87e6f
--- /dev/null
+++ b/web/src/reader/keyboard-shortcuts.ts
@@ -0,0 +1,146 @@
+// Extended keyboard shortcuts for all readers
+// Procedural implementation (no OOP)
+
+interface KeyboardShortcutHandler {
+ onNextPage: () => void;
+ onPreviousPage: () => void;
+ onNextChapter: () => void;
+ onPreviousChapter: () => void;
+ onGoToPage: (page: number) => void;
+ onToggleBookmark: () => void;
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onToggleFullscreen: () => void;
+ onClose: () => void;
+}
+
+function setupKeyboardShortcuts(
+ container: HTMLElement,
+ handlers: KeyboardShortcutHandler,
+ maxPage: number,
+): void {
+ container.addEventListener("keydown", (e) => {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+
+ switch (e.key) {
+ case "ArrowRight":
+ case "PageDown":
+ case "l":
+ e.preventDefault();
+ handlers.onNextPage();
+ break;
+
+ case "ArrowLeft":
+ case "PageUp":
+ case "h":
+ e.preventDefault();
+ handlers.onPreviousPage();
+ break;
+
+ case "ArrowUp":
+ case "k":
+ e.preventDefault();
+ handlers.onPreviousPage();
+ break;
+
+ case "ArrowDown":
+ case "j":
+ e.preventDefault();
+ handlers.onNextPage();
+ break;
+
+ case " ":
+ e.preventDefault();
+ handlers.onNextPage();
+ break;
+
+ case "Home":
+ e.preventDefault();
+ handlers.onGoToPage(1);
+ break;
+
+ case "End":
+ e.preventDefault();
+ handlers.onGoToPage(maxPage);
+ break;
+
+ case "b":
+ if (!e.ctrlKey && !e.metaKey) {
+ e.preventDefault();
+ handlers.onToggleBookmark();
+ }
+ break;
+
+ case "+":
+ case "=":
+ e.preventDefault();
+ handlers.onZoomIn();
+ break;
+
+ case "-":
+ case "_":
+ e.preventDefault();
+ handlers.onZoomOut();
+ break;
+
+ case "0":
+ e.preventDefault();
+ handlers.onZoomIn();
+ handlers.onZoomIn();
+ handlers.onZoomIn();
+ break;
+
+ case "f":
+ if (!e.ctrlKey && !e.metaKey) {
+ e.preventDefault();
+ handlers.onToggleFullscreen();
+ }
+ break;
+
+ case "Escape":
+ e.preventDefault();
+ handlers.onClose();
+ break;
+
+ default:
+ if (e.key >= "1" && e.key <= "9") {
+ const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
+ e.preventDefault();
+ handlers.onGoToPage(targetPage);
+ }
+ }
+ });
+}
+
+function showShortcutHelp(): void {
+ const help = document.createElement("div");
+ help.className =
+ "keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50";
+ help.innerHTML = `
+
+
Keyboard Shortcuts
+
+
→ / Space Next page
+
← Previous page
+
Home First page
+
End Last page
+
+ / - Zoom
+
B Toggle bookmark
+
F Fullscreen
+
1-9 Jump to %
+
+
+
+ `;
+ document.body.appendChild(help);
+ help.addEventListener("click", (e) => {
+ if (e.target === help) help.remove();
+ });
+}
diff --git a/web/src/reader/navigator-panel.ts b/web/src/reader/navigator-panel.ts
new file mode 100644
index 0000000..40179d8
--- /dev/null
+++ b/web/src/reader/navigator-panel.ts
@@ -0,0 +1,152 @@
+// Navigator panel - shows full page with draggable viewport box
+// Affinity/Photoshop-style mini-map for page navigation
+// Procedural implementation (no OOP)
+
+import { loadSettings } from "./settings-manager";
+
+interface NavigatorState {
+ panelId: string;
+ container: HTMLElement;
+ viewport: HTMLElement;
+ currentPage: number;
+ totalPages: number;
+ scale: number;
+ contentImage: HTMLImageElement | null;
+ isDragging: boolean;
+}
+
+function initializeNavigator(containerSelector: string): NavigatorState {
+ const container = document.querySelector(containerSelector) as HTMLElement;
+ if (!container) throw new Error("Navigator container not found");
+
+ const viewport = document.createElement("div");
+ viewport.className = "navigator-viewport-box";
+ viewport.style.cssText = `
+ position: absolute;
+ border: 2px solid var(--accent-color, #3b82f6);
+ background: rgba(59, 130, 246, 0.1);
+ cursor: move;
+ z-index: 10;
+ `;
+
+ container.appendChild(viewport);
+
+ const state: NavigatorState = {
+ panelId: "navigator",
+ container,
+ viewport,
+ currentPage: 1,
+ totalPages: 1,
+ scale: 0.1,
+ contentImage: null,
+ isDragging: false,
+ };
+
+ setupNavigatorDragHandler(state);
+ return state;
+}
+
+// Setup draggable viewport box within navigator
+function setupNavigatorDragHandler(state: NavigatorState): void {
+ state.viewport.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ state.isDragging = true;
+ state.viewport.style.cursor = "grabbing";
+ });
+
+ document.addEventListener("mousemove", (e) => {
+ if (!state.isDragging || !state.contentImage) return;
+
+ const containerRect = state.container.getBoundingClientRect();
+ const imgRect = state.contentImage.getBoundingClientRect();
+
+ // Calculate position relative to scaled image
+ const relX = (e.clientX - imgRect.left) / imgRect.width;
+ const relY = (e.clientY - imgRect.top) / imgRect.height;
+
+ // Update main viewer's position (call external handler)
+ const mainViewer = document.getElementById("reader-content");
+ if (mainViewer) {
+ mainViewer.dataset.panX = relX.toString();
+ mainViewer.dataset.panY = relY.toString();
+ // Dispatch event for main viewer to handle
+ mainViewer.dispatchEvent(
+ new CustomEvent("navigator-pan", {
+ detail: { x: relX, y: relY },
+ }),
+ );
+ }
+ });
+
+ document.addEventListener("mouseup", () => {
+ state.isDragging = false;
+ state.viewport.style.cursor = "move";
+ });
+}
+
+// Update navigator with current page image
+async function updateNavigatorContent(
+ state: NavigatorState,
+ pageNumber: number,
+): Promise {
+ state.currentPage = pageNumber;
+
+ // Get current page image (from PDF viewer, comic reader, or manga reader)
+ const contentArea = document.getElementById("reader-content");
+ const img = contentArea?.querySelector("img, canvas") as
+ | HTMLImageElement
+ | HTMLCanvasElement
+ | null;
+
+ if (!img) return;
+
+ // Create thumbnail version for navigator
+ const thumb = document.createElement("img");
+ thumb.src = img.src || (img as HTMLCanvasElement).toDataURL();
+ thumb.style.cssText = `
+ width: 100%;
+ height: auto;
+ display: block;
+ pointer-events: none;
+ `;
+
+ // Clear and populate container
+ state.container.innerHTML = "";
+ state.container.appendChild(thumb);
+ state.contentImage = thumb;
+
+ // Recreate viewport box
+ const viewport = document.createElement("div");
+ viewport.className = "navigator-viewport-box";
+ viewport.style.cssText = `
+ position: absolute;
+ border: 2px solid var(--accent-color, #3b82f6);
+ background: rgba(59, 130, 246, 0.1);
+ cursor: move;
+ z-index: 10;
+ width: ${100 / state.scale}%;
+ height: ${100 / state.scale}%;
+ `;
+ state.container.appendChild(viewport);
+ state.viewport = viewport;
+
+ // Re-attach drag handler
+ setupNavigatorDragHandler(state);
+
+ // Calculate viewport size relative to container
+ const containerRect = state.container.getBoundingClientRect();
+ const viewportWidth = (containerRect.width / img.width) * 100;
+ const viewportHeight = (containerRect.height / img.height) * 100;
+
+ viewport.style.width = `${viewportWidth}%`;
+ viewport.style.height = `${viewportHeight}%`;
+}
+
+// Handle window resize
+function handleNavigatorResize(state: NavigatorState): void {
+ if (state.contentImage) {
+ updateNavigatorContent(state, state.currentPage);
+ }
+}
+
+export { initializeNavigator, updateNavigatorContent, handleNavigatorResize };
diff --git a/web/src/reader/offline-manager.ts b/web/src/reader/offline-manager.ts
new file mode 100644
index 0000000..8b62b5d
--- /dev/null
+++ b/web/src/reader/offline-manager.ts
@@ -0,0 +1,32 @@
+// Offline manager for PWA functionality
+
+export function registerServiceWorker(): void {
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker
+ .register("/static/service-worker.js")
+ .then((registration) => {
+ console.log("Service worker registered:", registration);
+ })
+ .catch((error) => {
+ console.error("Service worker registration failed:", error);
+ });
+ }
+}
+
+export function checkOnlineStatus(): boolean {
+ if (typeof navigator !== "undefined" && navigator.onLine) {
+ return true;
+ }
+ return false;
+}
+
+// Listen for online/offline events
+window.addEventListener("online", () => {
+ showToast("Back online", "success");
+ // Sync any pending changes
+ syncPendingChanges();
+});
+
+window.addEventListener("offline", () => {
+ showToast("You are offline. Some features may be limited.", "warning");
+});
diff --git a/web/src/reader/reading-speed-tracker.ts b/web/src/reader/reading-speed-tracker.ts
new file mode 100644
index 0000000..602d245
--- /dev/null
+++ b/web/src/reader/reading-speed-tracker.ts
@@ -0,0 +1,78 @@
+// Track reading speed and update database
+
+// Reading speed tracker
+// Procedural implementation (no OOP)
+
+interface ReadingSpeedTrackerState {
+ startTime: number | null;
+ pagesRead: number;
+ wordsRead: number;
+ lastSync: number;
+ mediaItemId: string;
+}
+
+function createReadingSpeedTracker(
+ mediaItemId: string,
+): ReadingSpeedTrackerState {
+ return {
+ startTime: null,
+ pagesRead: 0,
+ wordsRead: 0,
+ lastSync: Date.now(),
+ mediaItemId,
+ };
+}
+
+function startReadingSession(
+ state: ReadingSpeedTrackerState,
+): ReadingSpeedTrackerState {
+ return {
+ ...state,
+ startTime: Date.now(),
+ pagesRead: 0,
+ wordsRead: 0,
+ };
+}
+
+function recordPageTurn(
+ state: ReadingSpeedTrackerState,
+): ReadingSpeedTrackerState {
+ if (!state.startTime) return state;
+
+ const newPagesRead = state.pagesRead + 1;
+ const now = Date.now();
+
+ if (newPagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
+ syncReadingSpeed({ ...state, pagesRead: newPagesRead });
+ return { ...state, pagesRead: newPagesRead, lastSync: now };
+ }
+
+ return { ...state, pagesRead: newPagesRead };
+}
+
+function recordWordsRead(
+ state: ReadingSpeedTrackerState,
+ wordCount: number,
+): ReadingSpeedTrackerState {
+ return {
+ ...state,
+ wordsRead: state.wordsRead + wordCount,
+ };
+}
+
+async function syncReadingSpeed(
+ state: ReadingSpeedTrackerState,
+): Promise {
+ if (!state.startTime) return;
+
+ const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
+ const pagesPerMinute = state.pagesRead / minutesElapsed;
+ const wordsPerMinute = state.wordsRead / minutesElapsed;
+
+ await apiPut(`/readers/${state.mediaItemId}/reading-speed`, {
+ pages_per_minute: pagesPerMinute,
+ words_per_minute: wordsPerMinute,
+ pages_read: state.pagesRead,
+ total_reading_minutes: minutesElapsed,
+ });
+}
diff --git a/web/static/manifest.json b/web/static/manifest.json
new file mode 100644
index 0000000..82e73ca
--- /dev/null
+++ b/web/static/manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "Bookhoard Reader",
+ "short_name": "Reader",
+ "description": "Offline-capable ebook and comic reader",
+ "start_url": "/dashboard",
+ "display": "fullscreen",
+ "background_color": "#1a1b26",
+ "theme_color": "#1a1b26",
+ "icons": [
+ {
+ "src": "/static/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/static/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "offline_enabled": true
+}