From bb586984d1393b2dafcdedff781672e596d1e537 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 4 Apr 2026 14:20:48 -0400 Subject: [PATCH] feat: Add missing feature files for build Create panel-dock-system.ts and navigator-panel.ts in features/ directory with proper init() implementations. Copy offline-manager and reading-speed-tracker to features/ as well. --- web/src/reader/features/navigator-panel.ts | 133 ++++++++++++ web/src/reader/features/offline-manager.ts | 49 +++++ web/src/reader/features/panel-dock-system.ts | 190 ++++++++++++++++++ .../reader/features/reading-speed-tracker.ts | 125 ++++++++++++ 4 files changed, 497 insertions(+) create mode 100644 web/src/reader/features/navigator-panel.ts create mode 100644 web/src/reader/features/offline-manager.ts create mode 100644 web/src/reader/features/panel-dock-system.ts create mode 100644 web/src/reader/features/reading-speed-tracker.ts diff --git a/web/src/reader/features/navigator-panel.ts b/web/src/reader/features/navigator-panel.ts new file mode 100644 index 0000000..cdac449 --- /dev/null +++ b/web/src/reader/features/navigator-panel.ts @@ -0,0 +1,133 @@ +// Navigator panel - shows full page with draggable viewport box +// Affinity/Photoshop-style mini-map for page navigation +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: NavigatorState | null = null; + + context.events.on("navigator:initialize", (detail: { containerSelector: string; totalPages: number }) => { + state = initializeNavigator(detail.containerSelector); + state.totalPages = detail.totalPages; + }); + + context.events.on("navigator:update", (detail: { currentPage: number; contentImage?: HTMLImageElement }) => { + if (state) { + state.currentPage = detail.currentPage; + if (detail.contentImage) { + state.contentImage = detail.contentImage; + updateNavigatorViewport(state); + } + } + }); + + context.events.on("navigator:pan", (detail: { x: number; y: number }) => { + if (state) { + handleNavigatorPan(state, detail.x, detail.y); + } + }); +} + +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; +} + +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(); + + const relX = (e.clientX - imgRect.left) / imgRect.width; + const relY = (e.clientY - imgRect.top) / imgRect.height; + + const mainViewer = document.getElementById("reader-content"); + if (mainViewer) { + mainViewer.dispatchEvent( + new CustomEvent("navigator-pan", { + detail: { x: relX, y: relY }, + }), + ); + } + }); + + document.addEventListener("mouseup", () => { + if (state.isDragging) { + state.isDragging = false; + state.viewport.style.cursor = "move"; + } + }); +} + +function updateNavigatorViewport(state: NavigatorState): void { + if (!state.contentImage) return; + + const containerRect = state.container.getBoundingClientRect(); + const imgRect = state.contentImage.getBoundingClientRect(); + + state.scale = containerRect.width / imgRect.width; + + state.viewport.style.width = `${containerRect.width}px`; + state.viewport.style.height = `${containerRect.height * state.scale}px`; + + state.viewport.style.left = "0"; + state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`; +} + +function handleNavigatorPan(state: NavigatorState, x: number, y: number): void { + if (!state.contentImage) return; + + const imgRect = state.contentImage.getBoundingClientRect(); + + const viewportX = x * state.container.offsetWidth; + const viewportY = y * state.container.offsetHeight; + + state.viewport.style.left = `${viewportX}px`; + state.viewport.style.top = `${viewportY}px`; +} \ No newline at end of file diff --git a/web/src/reader/features/offline-manager.ts b/web/src/reader/features/offline-manager.ts new file mode 100644 index 0000000..0aab3f1 --- /dev/null +++ b/web/src/reader/features/offline-manager.ts @@ -0,0 +1,49 @@ +// Offline manager for PWA functionality +// Feature Registration Pattern implementation + +import type { ReaderContext } from "./core/reader-context"; + +export function init(context: ReaderContext): void { + registerServiceWorker(); + + window.addEventListener("online", () => { + const isOnline = checkOnlineStatus(); + if (isOnline) { + context.events.emit("offline:online", {}); + syncPendingChanges(context); + } + }); + + window.addEventListener("offline", () => { + context.events.emit("offline:offline", {}); + }); + + context.events.on("offline:check", () => { + const isOnline = checkOnlineStatus(); + context.events.emit("offline:status", { isOnline }); + }); +} + +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; +} + +function syncPendingChanges(context: ReaderContext): void { + context.events.emit("offline:sync", {}); +} \ No newline at end of file diff --git a/web/src/reader/features/panel-dock-system.ts b/web/src/reader/features/panel-dock-system.ts new file mode 100644 index 0000000..5578fa4 --- /dev/null +++ b/web/src/reader/features/panel-dock-system.ts @@ -0,0 +1,190 @@ +// Modular dockable panel system - handles drag, lock, snap-back, window-shade +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + const dockZones: DockZone[] = [ + { side: "left", x: 0, width: 400, height: window.innerHeight }, + { + side: "right", + x: window.innerWidth - 400, + width: 400, + height: window.innerHeight, + }, + ]; + + const state: PanelDockState = { + panels: new Map(), + dragState: null, + dockZones, + }; + + context.events.on("panels:initialize", async () => { + const settings = await loadSettings(); + for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { + state.panels.set(panelId, panelState as PanelState); + createPanel(context, panelId, panelState as PanelState); + } + setupGlobalDragHandlers(context, state); + }); + + context.events.on("panel:toggle", (detail: { panelId: string }) => { + const panelState = state.panels.get(detail.panelId); + if (panelState) { + panelState.visible = !panelState.visible; + updatePanelVisibility(context, detail.panelId, panelState.visible); + } + }); + + context.events.on("panel:collapse", (detail: { panelId: string }) => { + const panelState = state.panels.get(detail.panelId); + if (panelState) { + panelState.collapsed = !panelState.collapsed; + updatePanelCollapsed(context, detail.panelId, panelState.collapsed); + } + }); + + context.events.on("panel:move", (detail: { panelId: string; side: "left" | "right" }) => { + movePanelToSide(context, state, detail.panelId, detail.side); + }); +} + +interface PanelDockState { + panels: Map; + dragState: DragState | null; + dockZones: DockZone[]; +} + +interface DragState { + panelId: string; + startX: number; + startY: number; + currentX: number; + currentY: number; + isLocked: boolean; +} + +interface DockZone { + side: "left" | "right"; + x: number; + width: number; + height: number; +} + +interface PanelState { + side: "left" | "right"; + visible: boolean; + collapsed: boolean; + width_px: number; + order: number; + locked: boolean; + last_valid_side: "left" | "right"; +} + +async function loadSettings(): Promise { + const response = await fetch("/readers/settings"); + if (!response.ok) return {}; + return await response.json(); +} + +function createPanel(context: ReaderContext, panelId: string, panelState: PanelState): void { + const panel = document.createElement("div"); + panel.id = `panel-${panelId}`; + panel.className = `reader-panel panel-${panelState.side}`; + panel.dataset.panelId = panelId; + panel.style.width = `${panelState.width_px}px`; + + if (!panelState.visible) { + panel.classList.add("panel-hidden"); + } + + if (panelState.collapsed) { + panel.classList.add("panel-collapsed"); + } + + context.elements.readerContent.appendChild(panel); +} + +function updatePanelVisibility(context: ReaderContext, panelId: string, visible: boolean): void { + const panel = document.getElementById(`panel-${panelId}`); + if (panel) { + panel.classList.toggle("panel-hidden", !visible); + } +} + +function updatePanelCollapsed(context: ReaderContext, panelId: string, collapsed: boolean): void { + const panel = document.getElementById(`panel-${panelId}`); + if (panel) { + panel.classList.toggle("panel-collapsed", collapsed); + } +} + +function setupGlobalDragHandlers(context: ReaderContext, state: PanelDockState): void { + document.addEventListener("mousedown", (e) => { + const panelHeader = (e.target as HTMLElement).closest(".panel-header"); + if (panelHeader) { + const panelId = (panelHeader as HTMLElement).dataset.panelId; + if (panelId) { + const panelState = state.panels.get(panelId); + if (panelState && !panelState.locked) { + state.dragState = { + panelId, + startX: e.clientX, + startY: e.clientY, + currentX: e.clientX, + currentY: e.clientY, + isLocked: false, + }; + } + } + } + }); + + document.addEventListener("mousemove", (e) => { + if (!state.dragState) return; + state.dragState.currentX = e.clientX; + state.dragState.currentY = e.clientY; + }); + + document.addEventListener("mouseup", () => { + if (state.dragState) { + const nearestZone = findNearestDockZone(state.dragState.currentX, state.dockZones); + if (nearestZone) { + movePanelToSide(context, state, state.dragState.panelId, nearestZone.side); + } + state.dragState = null; + } + }); +} + +function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null { + let nearest: DockZone | null = null; + let minDistance = Infinity; + + for (const zone of zones) { + const distance = Math.abs(x - (zone.x + zone.width / 2)); + if (distance < minDistance) { + minDistance = distance; + nearest = zone; + } + } + + return nearest; +} + +function movePanelToSide(context: ReaderContext, state: PanelDockState, panelId: string, side: "left" | "right"): void { + const panelState = state.panels.get(panelId); + if (!panelState) return; + + panelState.side = side; + panelState.last_valid_side = side; + + const panel = document.getElementById(`panel-${panelId}`); + if (panel) { + panel.classList.remove("panel-left", "panel-right"); + panel.classList.add(`panel-${side}`); + } + + context.events.emit("panel:moved", { panelId, side }); +} \ No newline at end of file diff --git a/web/src/reader/features/reading-speed-tracker.ts b/web/src/reader/features/reading-speed-tracker.ts new file mode 100644 index 0000000..b663402 --- /dev/null +++ b/web/src/reader/features/reading-speed-tracker.ts @@ -0,0 +1,125 @@ +// Track reading speed and update database +// Feature Registration Pattern implementation + +import type { ReaderContext } from "./core/reader-context"; + +export function init(context: ReaderContext): void { + let state: ReadingSpeedTrackerState | null = null; + + context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { + state = createReadingSpeedTracker(detail.mediaItemId); + }); + + context.events.on("reading-session:start", () => { + if (state) { + startReadingSession(state); + } + }); + + context.events.on("page-changed", () => { + if (state) { + recordPageTurn(state); + } + }); + + context.events.on("words-read", (detail: { wordCount: number }) => { + if (state) { + recordWordsRead(state, detail.wordCount); + } + }); + + context.events.on("reading-session:end", async () => { + if (state) { + await syncReadingSpeed(state); + } + }); + + context.events.on("reader:unload", async () => { + if (state) { + await syncReadingSpeed(state); + state = null; + } + }); +} + +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 { + state.startTime = Date.now(); + state.pagesRead = 0; + state.wordsRead = 0; + return state; +} + +function recordPageTurn( + state: ReadingSpeedTrackerState, +): ReadingSpeedTrackerState { + if (!state.startTime) return state; + + state.pagesRead += 1; + const now = Date.now(); + + if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) { + syncReadingSpeed(state); + state.lastSync = now; + } + + return state; +} + +function recordWordsRead( + state: ReadingSpeedTrackerState, + wordCount: number, +): ReadingSpeedTrackerState { + state.wordsRead += wordCount; + return state; +} + +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; + + try { + const token = localStorage.getItem("token"); + await fetch(`/readers/${state.mediaItemId}/reading-speed`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + pages_per_minute: pagesPerMinute, + words_per_minute: wordsPerMinute, + pages_read: state.pagesRead, + total_reading_minutes: minutesElapsed, + }), + }); + } catch (error) { + console.error("Failed to sync reading speed:", error); + } +} \ No newline at end of file