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
This commit is contained in:
2026-04-03 16:53:41 -04:00
parent 74575d9a86
commit 5550b730ee
2 changed files with 663 additions and 31 deletions
+659 -29
View File
@@ -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<string, PanelState>;
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<void> {
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<void> {
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)
<!-- Dockable Panels Container -->
<div id="reader-panels" class="fixed inset-0 pointer-events-none z-30">
<!-- Left Sidebar (TOC, Settings) -->
<div id="left-sidebar" class="absolute left-0 top-0 bottom-0 pointer-events-auto flex flex-col">
<div id="toc-panel" class="panel-container pointer-events-auto" data-panel="toc">
@ReaderTOCPanel(metadata)
</div>
<div id="settings-panel" class="panel-container pointer-events-auto" data-panel="settings">
@ReaderSettingsPanel()
</div>
</div>
<!-- Right Sidebar (Navigator, Bookmarks) -->
<div id="right-sidebar" class="absolute right-0 top-0 bottom-0 pointer-events-auto flex flex-col">
<div id="navigator-panel" class="panel-container pointer-events-auto" data-panel="navigator">
@ReaderNavigatorPanel()
</div>
<div id="bookmarks-panel" class="panel-container pointer-events-auto" data-panel="bookmarks">
@ReaderBookmarksPanel(bookmarks)
</div>
</div>
</div>
<main id="reader-content" class="w-full h-full">
<!-- Content loaded by JavaScript based on media type -->
</main>
@ReaderSettingsPanel()
@ReaderTOCPanel(metadata)
@DictionaryPopup()
</body>
</html>
@@ -9992,10 +10555,20 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
}
templ ReaderSettingsPanel() {
<div id="settings-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Settings</h2>
<div
id="settings-panel"
class="dockable-panel panel-collapsed"
data-panel="settings"
data-side="left"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
<h3 class="panel-title font-semibold">⚙️ Settings</h3>
<div class="panel-controls flex items-center gap-2">
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
<button class="window-shade-toggle" data-action="window-shade">─</button>
</div>
</div>
<div class="panel-content p-4 overflow-y-auto">
<!-- Display settings -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Display</h3>
@@ -10068,28 +10641,85 @@ templ ReaderSettingsPanel() {
}
templ ReaderTOCPanel(metadata ReaderMetadata) {
<div id="toc-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Table of Contents</h2>
<div
id="toc-panel"
class="dockable-panel"
data-panel="toc"
data-side="left"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
<h3 class="panel-title font-semibold">📖 Table of Contents</h3>
<div class="panel-controls flex items-center gap-2">
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
<button class="window-shade-toggle" data-action="window-shade">─</button>
</div>
</div>
<div class="panel-content p-4 overflow-y-auto">
<nav id="toc-list" class="space-y-2">
<!-- TOC items populated by JavaScript -->
</nav>
</div>
</div>
}
<div id="toc-content">
if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
for _, chapter := range metadata.chapter_metadata.Chapters {
templ ReaderNavigatorPanel() {
<div
id="navigator-panel"
class="dockable-panel"
data-panel="navigator"
data-side="right"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
<h3 class="panel-title font-semibold">🗺️ Navigator</h3>
<div class="panel-controls flex items-center gap-2">
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
<button class="window-shade-toggle" data-action="window-shade">─</button>
</div>
</div>
<div class="panel-content p-2 overflow-hidden">
<div id="navigator-viewport" class="relative w-full h-full">
<!-- Full page preview with draggable viewport box -->
<!-- JavaScript renders current page as scaled thumbnail with draggable viewport -->
</div>
</div>
</div>
}
templ ReaderBookmarksPanel(bookmarks []Bookmark) {
<div
id="bookmarks-panel"
class="dockable-panel panel-collapsed"
data-panel="bookmarks"
data-side="right"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
<h3 class="panel-title font-semibold">🔖 Bookmarks</h3>
<div class="panel-controls flex items-center gap-2">
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
<button class="window-shade-toggle" data-action="window-shade">─</button>
</div>
</div>
<div class="panel-content p-4 overflow-y-auto">
if len(bookmarks) > 0 {
<div id="bookmarks-list" class="space-y-2">
for _, bookmark := range bookmarks {
<a
href="#"
data-chapter-id={ chapter.ID }
data-bookmark-id={ bookmark.ID }
class="block py-2 hover:bg-gray-700 rounded px-2"
>
{ chapter.Title }
<span class="font-medium">{ bookmark.Title }</span>
<span class="text-xs ml-2" style="color: var(--text-secondary)">
{ bookmark.Position }
</span>
</a>
}
} else {
<p class="text-sm">No chapters available</p>
}
</div>
<button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
Close
</div>
} else {
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
}
<button data-action="add-bookmark" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
+ Add Bookmark
</button>
</div>
</div>
+2
View File
@@ -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)