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 +}