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:
+661
-31
@@ -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
|
- **Privacy-first**: Per-user settings with localStorage fallback
|
||||||
- **Offline-capable**: PWA with offline dictionary
|
- **Offline-capable**: PWA with offline dictionary
|
||||||
- **Full PDF support**: Mozilla pdf.js for text selection, highlights, search
|
- **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
|
## What's New in This Version
|
||||||
|
|
||||||
@@ -543,6 +544,18 @@ PDF and Comics use dedicated readers (not CIF pipeline):
|
|||||||
```
|
```
|
||||||
Reader Infrastructure (Shared)
|
Reader Infrastructure (Shared)
|
||||||
├── reader-shell.ts - UI shell, chrome control, routing
|
├── 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
|
├── progress-tracker.ts - Integration with reading_progress table
|
||||||
├── annotation-manager.ts - Integration with notes/highlights tables
|
├── annotation-manager.ts - Integration with notes/highlights tables
|
||||||
├── websocket-sync.ts - Reuse existing sync system
|
├── websocket-sync.ts - Reuse existing sync system
|
||||||
@@ -550,6 +563,12 @@ Reader Infrastructure (Shared)
|
|||||||
├── bookmark-manager.ts - Integration with existing bookmarks
|
├── bookmark-manager.ts - Integration with existing bookmarks
|
||||||
└── chapter-detector.ts - Chapter detection for all media types
|
└── 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)
|
Universal Ebook Reader (Reflowable Formats)
|
||||||
├── html-renderer.ts - Browser-native HTML rendering (shared)
|
├── html-renderer.ts - Browser-native HTML rendering (shared)
|
||||||
├── typography-engine.ts - Font rendering, theme integration (shared)
|
├── typography-engine.ts - Font rendering, theme integration (shared)
|
||||||
@@ -585,16 +604,15 @@ PDF Reader (Fixed Layout)
|
|||||||
├── pdf-clipboard.ts - Copy to clipboard
|
├── pdf-clipboard.ts - Copy to clipboard
|
||||||
├── pdf-links.ts - Internal link handling
|
├── pdf-links.ts - Internal link handling
|
||||||
├── pdf-dual-page.ts - Dual page spread view
|
├── pdf-dual-page.ts - Dual page spread view
|
||||||
├── pdf-minimap.ts - Mini-map navigation
|
|
||||||
├── pdf-rotation.ts - Rotated page support
|
├── pdf-rotation.ts - Rotated page support
|
||||||
└── pdf-page-sizes.ts - Variable page size handling
|
└── 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
|
├── image-archive-parser.ts - CBZ/CBR parsing
|
||||||
├── canvas-renderer.ts - Canvas rendering with lazy loading
|
├── canvas-renderer.ts - Canvas rendering with lazy loading
|
||||||
├── panel-detector.ts - Grid-based + ML + manual override
|
├── panel-detector.ts - Grid-based + ML + manual override
|
||||||
├── panel-navigator.ts - Panel zoom with smooth animations
|
├── panel-navigator.ts - Panel zoom with smooth animations (when detection ON)
|
||||||
└── page-cache.ts - 5-page ahead cache
|
└── page-cache.ts - 5-page ahead cache
|
||||||
|
|
||||||
Manga Reader (extends Comic)
|
Manga Reader (extends Comic)
|
||||||
├── rtl-navigator.ts - Right-to-left navigation
|
├── 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. API Endpoints
|
||||||
|
|
||||||
### 3.1 Reader Routes
|
### 3.1 Reader Routes
|
||||||
@@ -2105,6 +2234,30 @@ interface ReaderSettings {
|
|||||||
media_bookmarks_visible: boolean;
|
media_bookmarks_visible: boolean;
|
||||||
|
|
||||||
hardware_acceleration: 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 {
|
interface ProgressDisplay {
|
||||||
@@ -3156,11 +3309,252 @@ function getDefaultSettings(): ReaderSettings {
|
|||||||
margin_width: 20,
|
margin_width: 20,
|
||||||
double_page_spread: false,
|
double_page_spread: false,
|
||||||
reading_direction: 'ltr',
|
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
|
## 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`
|
**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)
|
@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">
|
<main id="reader-content" class="w-full h-full">
|
||||||
<!-- Content loaded by JavaScript based on media type -->
|
<!-- Content loaded by JavaScript based on media type -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ReaderSettingsPanel()
|
|
||||||
@ReaderTOCPanel(metadata)
|
|
||||||
|
|
||||||
@DictionaryPopup()
|
@DictionaryPopup()
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -9992,10 +10555,20 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ ReaderSettingsPanel() {
|
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
|
||||||
<div class="p-4">
|
id="settings-panel"
|
||||||
<h2 class="text-xl font-bold mb-4">Settings</h2>
|
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 -->
|
<!-- Display settings -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="font-semibold mb-2">Display</h3>
|
<h3 class="font-semibold mb-2">Display</h3>
|
||||||
@@ -10068,28 +10641,85 @@ templ ReaderSettingsPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ ReaderTOCPanel(metadata ReaderMetadata) {
|
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
|
||||||
<div class="p-4">
|
id="toc-panel"
|
||||||
<h2 class="text-xl font-bold mb-4">Table of Contents</h2>
|
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">
|
templ ReaderNavigatorPanel() {
|
||||||
if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
|
<div
|
||||||
for _, chapter := range metadata.chapter_metadata.Chapters {
|
id="navigator-panel"
|
||||||
<a
|
class="dockable-panel"
|
||||||
href="#"
|
data-panel="navigator"
|
||||||
data-chapter-id={ chapter.ID }
|
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-bookmark-id={ bookmark.ID }
|
||||||
class="block py-2 hover:bg-gray-700 rounded px-2"
|
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>
|
</a>
|
||||||
}
|
}
|
||||||
} else {
|
</div>
|
||||||
<p class="text-sm">No chapters available</p>
|
} else {
|
||||||
}
|
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
|
||||||
</div>
|
}
|
||||||
|
<button data-action="add-bookmark" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
<button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
|
+ Add Bookmark
|
||||||
Close
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type Config struct {
|
|||||||
ScannerHandler *handlers.Handler
|
ScannerHandler *handlers.Handler
|
||||||
JobsHandler *handlers.JobsHandler
|
JobsHandler *handlers.JobsHandler
|
||||||
SidecarHandler *handlers.SidecarHandler
|
SidecarHandler *handlers.SidecarHandler
|
||||||
|
ReaderHandler *handlers.ReaderHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||||
@@ -218,6 +219,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
registerJobRoutes(cfg)
|
registerJobRoutes(cfg)
|
||||||
registerFiltersRoutes(cfg)
|
registerFiltersRoutes(cfg)
|
||||||
registerOPDSRoutes(cfg)
|
registerOPDSRoutes(cfg)
|
||||||
|
registerReaderRoutes(cfg)
|
||||||
registerWebSocketRoutes(cfg)
|
registerWebSocketRoutes(cfg)
|
||||||
registerFrontendRoutes(cfg)
|
registerFrontendRoutes(cfg)
|
||||||
registerDocumentationRoutes(cfg)
|
registerDocumentationRoutes(cfg)
|
||||||
|
|||||||
Reference in New Issue
Block a user