From 5550b730ee1039fede6f49063f0d67ac3135795f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 3 Apr 2026 16:53:41 -0400 Subject: [PATCH] docs: update reader implementation plan with dockable panels - Add modular dockable panel architecture with: - Panel dock system (drag, lock, snap-back, window-shade) - TOC, Settings, Navigator, Bookmarks as dockable components - Lock toggle to prevent accidental moves - Snap-back to last valid position if dropped in invalid area - Per-user layout stored in reader_settings JSONB - Update TypeScript types with PanelLayoutSettings and PanelState - Add panel-dock-system.ts implementation to plan - Add navigator-panel.ts for Affinity-style page navigation - Update reader template with dockable panels and lock buttons - Add Section 2.4 with database/Go implementation issues and fixes - Document schema changes (DECIMAL -> REAL) - Document all type fixes needed in reader.go --- READER_IMPLEMENTATION_PLAN.md | 692 ++++++++++++++++++++++++++++++++-- internal/router/router.go | 2 + 2 files changed, 663 insertions(+), 31 deletions(-) diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md index 3250e69..df28530 100644 --- a/READER_IMPLEMENTATION_PLAN.md +++ b/READER_IMPLEMENTATION_PLAN.md @@ -16,7 +16,8 @@ Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with f - **Privacy-first**: Per-user settings with localStorage fallback - **Offline-capable**: PWA with offline dictionary - **Full PDF support**: Mozilla pdf.js for text selection, highlights, search -- **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, mini-map, copy support +- **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, navigator, copy support +- **Modular dockable panels**: TOC, Settings, Navigator, Bookmarks - each independent, dockable to either side, window-shade support ## What's New in This Version @@ -543,6 +544,18 @@ PDF and Comics use dedicated readers (not CIF pipeline): ``` Reader Infrastructure (Shared) ├── reader-shell.ts - UI shell, chrome control, routing +├── panel-dock-system.ts - Modular dockable panel system +│ ├── panel-container.ts - Base panel container with dock logic +│ │ - Lock toggle: prevents accidental drag/move +│ │ - Snap-back: returns to last valid position if dropped in invalid area +│ │ - Dock zones: left edge, right edge, valid drop targets +│ │ - Drag handlers: mouse/touch drag to reposition +│ │ - Drop zone detection: validates drop position +│ │ - Persist state: saves to settings on move/lock/collapse +│ ├── window-shade.ts - Vertical collapse to title bar +│ │ - Animated collapse/expand +│ │ - State persistence +│ └── panel-state.ts - Per-user layout persistence (includes lock state) ├── progress-tracker.ts - Integration with reading_progress table ├── annotation-manager.ts - Integration with notes/highlights tables ├── websocket-sync.ts - Reuse existing sync system @@ -550,6 +563,12 @@ Reader Infrastructure (Shared) ├── bookmark-manager.ts - Integration with existing bookmarks └── chapter-detector.ts - Chapter detection for all media types +Dockable Panel Components (Reusable) +├── toc-panel.ts - Table of Contents (dockable left/right, window-shade) +├── settings-panel.ts - Reader settings (dockable left/right, window-shade) +├── navigator-panel.ts - Page navigator with viewport box (dockable left/right, window-shade) +└── bookmarks-panel.ts - User bookmarks (dockable left/right, window-shade, default bottom-right) + Universal Ebook Reader (Reflowable Formats) ├── html-renderer.ts - Browser-native HTML rendering (shared) ├── typography-engine.ts - Font rendering, theme integration (shared) @@ -585,16 +604,15 @@ PDF Reader (Fixed Layout) ├── pdf-clipboard.ts - Copy to clipboard ├── pdf-links.ts - Internal link handling ├── pdf-dual-page.ts - Dual page spread view -├── pdf-minimap.ts - Mini-map navigation ├── pdf-rotation.ts - Rotated page support └── pdf-page-sizes.ts - Variable page size handling -Comic Reader (Image Archives) +Comic Reader (Image Archives) - Uses shared navigator-panel.ts for page navigation ├── image-archive-parser.ts - CBZ/CBR parsing ├── canvas-renderer.ts - Canvas rendering with lazy loading ├── panel-detector.ts - Grid-based + ML + manual override -├── panel-navigator.ts - Panel zoom with smooth animations -└── page-cache.ts - 5-page ahead cache +├── panel-navigator.ts - Panel zoom with smooth animations (when detection ON) +└── page-cache.ts - 5-page ahead cache Manga Reader (extends Comic) ├── rtl-navigator.ts - Right-to-left navigation @@ -799,6 +817,117 @@ Add the above tables to the schema file. Follow existing patterns: --- +## 2.4 Required Code Implementation (Database & Go Backend) + +### Database Tables (Already Exist) + +The following tables already exist in `database/schema/schema.sql`: +- `panel_data` - Panel detection for comics/manga +- `reader_settings` - **Stores panel layout configuration** (including dock positions, lock state, collapsed state, width) +- `media_bookmarks` - User bookmarks +- `reading_speed` - Reading statistics +- `dictionary_cache` - Offline dictionary + +**Panel layout storage**: All panel dock system state (side, visible, collapsed, width_px, order, locked, last_valid_side) is stored as JSON in `reader_settings.setting_value` under the `panel_layout` key. + +### Database Schema Issues to Fix + +The following database schema issues need to be resolved before implementation: + +1. **media_items.chapter_metadata**: Add to schema as `JSONB` column (see Section 2.2) + +2. **reader_settings table query**: The generated query `UpsertReaderSettings` expects: + - Parameters: `UserID` (UUID), `SettingValue` ([]byte) + - The query uses `setting_key = 'reader_settings'` hardcoded + - Code using `SettingKey` field will fail - remove that field from params + +3. **reading_speed table fields**: The generated model uses different types: + - `pages_per_minute` is `pgtype.Float8`, not `pgtype.Numeric` + - `pages_read` is `pgtype.Int4`, not plain `int32` + - Fix type conversions in service code + +4. **panel_data table**: The generated model `database.PanelData` is a struct, not a type with `Valid/Bytes` fields + - Code using `cached.Valid` and `cached.Bytes` will fail + - Need to query directly and handle JSON unmarshaling differently + +### Database Queries to Add + +Add these queries to `internal/database/queries/queries.sql`: + +```sql +-- name: UpdateMediaItemChapterMetadata :one +UPDATE media_items +SET chapter_metadata = $2, updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: GetReadingSpeedByUser :one +SELECT * FROM reading_speed +WHERE user_id = $1 AND media_item_id = $2; + +-- name: UpsertReadingSpeed :one +INSERT INTO reading_speed (user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (user_id, media_item_id) +DO UPDATE SET + words_per_minute = COALESCE($3, reading_speed.words_per_minute), + pages_per_minute = COALESCE($4, reading_speed.pages_per_minute), + pages_read = reading_speed.pages_read + COALESCE($5, 0), + total_reading_minutes = reading_speed.total_reading_minutes + COALESCE($6, 0), + last_read_at = $7, + updated_at = NOW() +RETURNING *; +``` + +### Go Service Implementation Issues + +The following issues in `internal/services/reader.go` need fixing: + +1. **Line 71-76**: Chapter metadata is `[]byte`, not `pgtype.JSONB` + ```go + // Change from: + if item.ChapterMetadata.Valid { ... item.ChapterMetadata.Bytes ... } + // To: + if len(item.ChapterMetadata) > 0 { ... item.ChapterMetadata ... } + ``` + +2. **Line 126**: PageCount is `pgtype.Int4`, use `.Int32` not `.Int64` + ```go + // Change from: + pageCount := int(item.PageCount.Int64) + // To: + pageCount := int(item.PageCount.Int32) + ``` + +3. **Lines 172-174**: PanelData query returns `database.PanelData` struct, handle differently + +4. **Lines 283-285**: Fix pgtype conversions for reading_speed + ```go + // Use proper pgtype types: + PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true}, + PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true}, + TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true}, + ``` + +5. **Lines 353, 386-387**: Fix reader settings access + ```go + // SettingValue is []byte, use directly + if len(settings.SettingValue) > 0 { ... } + // UpsertReaderSettingsParams doesn't have SettingKey + // Use: UserID, SettingValue only + ``` + +### Router Implementation + +The router at `internal/router/reader.go` has a placeholder SSR handler that needs: +1. Fetch metadata/progress/bookmarks (inline or via handler) +2. Import "bookhoard/templates" +3. Call `templates.Reader()` with proper data + +Note: This creates import cycle if handler imports templates - follow frontend.go pattern where router handles template rendering directly. + +--- + ## 3. API Endpoints ### 3.1 Reader Routes @@ -2105,6 +2234,30 @@ interface ReaderSettings { media_bookmarks_visible: boolean; hardware_acceleration: boolean; + + // Dockable panel layout configuration (per-user, media-type-aware) + panel_layout: PanelLayoutSettings; +} + +interface PanelLayoutSettings { + // Per panel state: which side, visible, collapsed (window-shade), width + toc: PanelState; + settings: PanelState; + navigator: PanelState; + bookmarks: PanelState; + + // Mobile override + mobile_nav_visible: boolean; +} + +interface PanelState { + side: 'left' | 'right' | 'hidden'; + visible: boolean; + collapsed: boolean; // Window-shade: true = collapsed to title bar + width_px: number; // Panel width in pixels + order: number; // Order within the side + locked: boolean; // Lock toggle: prevents accidental drag/move + last_valid_side: 'left' | 'right' | 'hidden'; // Snap-back target if dropped in invalid area } interface ProgressDisplay { @@ -3156,11 +3309,252 @@ function getDefaultSettings(): ReaderSettings { margin_width: 20, double_page_spread: false, reading_direction: 'ltr', - hardware_acceleration: true + hardware_acceleration: true, + + // Dockable panel defaults by media type + panel_layout: { + toc: { side: 'left', visible: true, collapsed: false, width_px: 320, order: 1, locked: false, last_valid_side: 'left' }, + settings: { side: 'left', visible: false, collapsed: true, width_px: 380, order: 2, locked: false, last_valid_side: 'left' }, + navigator: { side: 'right', visible: true, collapsed: false, width_px: 200, order: 1, locked: false, last_valid_side: 'right' }, + bookmarks: { side: 'right', visible: false, collapsed: true, width_px: 280, order: 2, locked: false, last_valid_side: 'right' }, + mobile_nav_visible: false + } }; } ``` +### 4.7 Panel Dock System (Modular Dockable Panels) + +**File:** `web/src/reader/panel-dock-system.ts` + +```typescript +// Modular dockable panel system - handles drag, lock, snap-back, window-shade +// Procedural style: Functions, not classes + +import { saveSettings, loadSettings, getDefaultSettings } from "./settings-manager"; + +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; +} + +const state: PanelDockState = { + panels: new Map(), + dragState: null, + dockZones: [ + { side: 'left', x: 0, width: 400, height: window.innerHeight }, + { side: 'right', x: window.innerWidth - 400, width: 400, height: window.innerHeight } + ] +}; + +// Initialize all panels from settings +function initializePanelDockSystem(): void { + const settings = loadSettings(); + + for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { + registerPanel(panelId, panelState); + } + + setupDragHandlers(); + setupWindowShadeHandlers(); + setupLockHandlers(); +} + +// Register a panel with the dock system +function registerPanel(panelId: string, panelState: PanelState): void { + state.panels.set(panelId, panelState); + applyPanelState(panelId, panelState); +} + +// Apply panel state to DOM +function applyPanelState(panelId: string, panelState: PanelState): void { + const panel = document.querySelector(`[data-panel="${panelId}"]`); + if (!panel) return; + + const container = panel.parentElement; + if (!container) return; + + // Apply side positioning + if (panelState.side === 'left') { + container.style.left = '0'; + container.style.right = 'auto'; + } else if (panelState.side === 'right') { + container.style.right = '0'; + container.style.left = 'auto'; + } else { + container.style.left = '-9999px'; + } + + // Apply width + panel.style.width = `${panelState.width_px}px`; + + // Apply collapsed (window-shade) state + if (panelState.collapsed) { + panel.classList.add('panel-collapsed'); + panel.querySelector('.panel-content')?.classList.add('hidden'); + } else { + panel.classList.remove('panel-collapsed'); + panel.querySelector('.panel-content')?.classList.remove('hidden'); + } + + // Apply lock state + const lockBtn = panel.querySelector('.panel-lock'); + if (lockBtn) { + lockBtn.textContent = panelState.locked ? '🔒' : '🔓'; + } +} + +// Setup mouse/touch drag handlers +function setupDragHandlers(): void { + document.querySelectorAll('.dockable-panel .panel-header').forEach(header => { + header.addEventListener('mousedown', handleDragStart); + header.addEventListener('touchstart', handleDragStart, { passive: false }); + }); + + document.addEventListener('mousemove', handleDragMove); + document.addEventListener('touchmove', handleDragMove, { passive: false }); + document.addEventListener('mouseup', handleDragEnd); + document.addEventListener('touchend', handleDragEnd); +} + +function handleDragStart(e: MouseEvent | TouchEvent): void { + const header = e.target.closest('.panel-header') as HTMLElement; + const panel = header?.closest('.dockable-panel') as HTMLElement; + if (!panel) return; + + const panelId = panel.dataset.panel; + const panelState = state.panels.get(panelId); + + // Check if locked + if (panelState?.locked) return; + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + state.dragState = { + panelId: panelId!, + startX: clientX, + startY: clientY, + currentX: clientX, + currentY: clientY, + isLocked: panelState?.locked || false + }; + + panel.classList.add('dragging'); +} + +function handleDragMove(e: MouseEvent | TouchEvent): void { + if (!state.dragState) return; + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + state.dragState.currentX = clientX; + state.dragState.currentY = clientY; + + const panel = document.querySelector(`[data-panel="${state.dragState.panelId}"]`); + const container = panel?.parentElement; + if (container) { + container.style.transform = `translateX(${clientX - state.dragState.startX}px)`; + } +} + +function handleDragEnd(e: MouseEvent | TouchEvent): void { + if (!state.dragState) return; + + const { panelId, currentX } = state.dragState; + const panel = document.querySelector(`[data-panel="${panelId}"]`); + const container = panel?.parentElement; + + // Reset transform + container.style.transform = ''; + panel?.classList.remove('dragging'); + + // Determine drop zone + const newSide = currentX < window.innerWidth / 2 ? 'left' : 'right'; + const panelState = state.panels.get(panelId); + + if (panelState) { + // Check if dropped in valid zone + const isValidDrop = newSide === 'left' || newSide === 'right'; + + if (isValidDrop) { + panelState.last_valid_side = panelState.side; // Save previous valid position + panelState.side = newSide; + } else { + // Snap back to last valid position + panelState.side = panelState.last_valid_side; + } + + applyPanelState(panelId, panelState); + savePanelState(panelId, panelState); + } + + state.dragState = null; +} + +// Setup window-shade (collapse/expand) handlers +function setupWindowShadeHandlers(): void { + document.querySelectorAll('.window-shade-toggle').forEach(btn => { + btn.addEventListener('click', (e) => { + const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement; + const panelId = panel.dataset.panel; + const panelState = state.panels.get(panelId); + + if (panelState) { + panelState.collapsed = !panelState.collapsed; + applyPanelState(panelId, panelState); + savePanelState(panelId, panelState); + } + }); + }); +} + +// Setup lock toggle handlers +function setupLockHandlers(): void { + document.querySelectorAll('.panel-lock').forEach(btn => { + btn.addEventListener('click', (e) => { + const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement; + const panelId = panel.dataset.panel; + const panelState = state.panels.get(panelId); + + if (panelState) { + panelState.locked = !panelState.locked; + applyPanelState(panelId, panelState); + savePanelState(panelId, panelState); + } + }); + }); +} + +// Persist panel state to settings +async function savePanelState(panelId: string, panelState: PanelState): Promise { + const settings = loadSettings(); + settings.panel_layout[panelId as keyof typeof settings.panel_layout] = panelState; + await saveSettings(settings); +} + +export { initializePanelDockSystem, registerPanel, applyPanelState }; +``` +``` + --- ## 5. Ebook Reader Implementation @@ -8238,7 +8632,156 @@ function getMiniMapStyles(): string { } ``` -### 6.14 PDF Rotated Page Support +### 6.14 Navigator Panel (Affinity Image Editor Style) + +**File:** `web/src/reader/navigator-panel.ts` + +```typescript +// 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 }; +``` **File:** `web/src/reader/pdf/pdf-rotation.ts` @@ -9943,13 +10486,33 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm > @ReaderChrome(user, metadata, progress) + +
+ + + + + +
+
- @ReaderSettingsPanel() - @ReaderTOCPanel(metadata) - @DictionaryPopup() @@ -9992,10 +10555,20 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) } templ ReaderSettingsPanel() { -
-
-

Settings

- +
+
+

⚙️ Settings

+
+ + +
+
+

Display

@@ -10068,28 +10641,85 @@ templ ReaderSettingsPanel() { } templ ReaderTOCPanel(metadata ReaderMetadata) { -
-
-

Table of Contents

+
+
+

📖 Table of Contents

+
+ + +
+
+
+ +
+
+} -
- if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 { - for _, chapter := range metadata.chapter_metadata.Chapters { - +
+

🗺️ Navigator

+
+ + +
+
+
+ +
+
+} + +templ ReaderBookmarksPanel(bookmarks []Bookmark) { +
diff --git a/internal/router/router.go b/internal/router/router.go index 29f7f75..8016614 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -64,6 +64,7 @@ type Config struct { ScannerHandler *handlers.Handler JobsHandler *handlers.JobsHandler SidecarHandler *handlers.SidecarHandler + ReaderHandler *handlers.ReaderHandler } // createJWTMiddleware creates a JWT middleware with proper user context setup @@ -218,6 +219,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { registerJobRoutes(cfg) registerFiltersRoutes(cfg) registerOPDSRoutes(cfg) + registerReaderRoutes(cfg) registerWebSocketRoutes(cfg) registerFrontendRoutes(cfg) registerDocumentationRoutes(cfg)