Files
bookhoard/templates/reader.templ
T
john-okeefe afeb3f5b45 refactor(reader): rewrite reader module for foliate-js pan/zoom integration
Major rewrite of the web reader to properly interface with
@bookhoard/foliate-js, replacing the abandoned panel-detection
architecture with direct pan and zoom support built into the
foliate-js FixedLayout renderer.

Template (reader.templ):
- Fix critical bug: x-init config was using literal strings
  '{ readerData.X }' inside a quoted attribute, which templ
  treated as raw text and never interpolated. Values were never
  actually passed to JavaScript. Now uses fmt.Sprintf() with
  templ's expression attribute syntax ={ }.
- Pass fileUrl from server so foliate-js can open books directly.
- Redesign bottom bar with foliate-js parity: left/right navigation
  buttons, progress slider with tick marks, and zoom controls
  (zoom out, percentage display, zoom in, magnifier, pan/select
  mode toggle for PDFs).
- Remove panel editor button and enablePanelDetection config.
- Add SVG icon styles for consistent reader controls.

Go types (templates/types.go):
- Expand ReaderMetadata with FormatGroup, MangaType,
  ReadingDirection, FileURL, and LibraryID fields needed by
  the reader frontend.

Router (internal/router/reader.go):
- Populate new ReaderMetadata fields from database values.
- Construct FileURL from library ID and file path for the
  /uploads/library-{id}/* file serving route.

Reader JS (reader.ts):
- Full rewrite modeled on foliate-js Reader class, adapted for
  Alpine.js. Opens books via view.open(fileUrl), accesses
  view.renderer for zoom/pan/navigation, and wires up keyboard
  shortcuts (+/-/0 for zoom, arrows for nav, Escape for magnifier).
- Uses view.isFixedLayout instead of importing FixedLayout class,
  avoiding a TypeScript module resolution issue with the Vite alias.

Settings manager (settings-manager.ts):
- Remove dependency on deleted ReaderContext event bus.
- Export loadSettings/saveSettings/syncSettings directly as
  standalone async functions.

Cleanup:
- Delete reader-context.ts and reader-events.ts (over-engineered
  event system replaced by direct function calls).
- Remove panel_zoom_enabled from ReaderSettings type.
2026-04-19 14:27:56 -04:00

368 lines
14 KiB
Templ

package templates
import "fmt"
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ metadata.Title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<link href="/static/reader-fonts.css" rel="stylesheet"/>
<link href="/static/foliate-themes.css" rel="stylesheet"/>
<script src="/static/htmx.min.js"></script>
<script type="module" src="/static/reader.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<style>
.reader-icon {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
#progress-slider {
flex-grow: 1;
}
</style>
</head>
<body
x-data="readerShell"
x-init={ fmt.Sprintf(`initReader({mediaItemId:'%s',fileUrl:'%s',formatGroup:'%s',readingDirection:'%s',mangaType:'%s'})`, metadata.MediaItemID, metadata.FileURL, metadata.FormatGroup, metadata.ReadingDirection, metadata.MangaType) }
class="theme-tokyo-night"
>
@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>
<foliate-view id="reader-view"></foliate-view>
@DictionaryPopup()
</body>
</html>
}
templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
<div id="reader-chrome" class="transition-opacity duration-300">
<!-- Top bar -->
<div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<a href="/media-items/{ metadata.MediaItemID }" class="text-lg hover:underline">
Back
</a>
<h1 class="text-lg font-semibold">{ metadata.Title }</h1>
<button
data-action="open-settings"
class="p-2 rounded-lg hover:bg-gray-700"
title="Settings"
>
⚙️
</button>
</div>
</div>
<!-- Bottom bar -->
<div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center px-2 py-2 gap-1">
<!-- Left navigation -->
<button @click="goLeft()" class="p-2 rounded-lg hover:bg-gray-700" title="Go Left" aria-label="Go left">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"></path>
</svg>
</button>
<!-- Progress slider -->
<input
id="progress-slider"
type="range"
min="0"
max="1"
step="any"
list="tick-marks"
@input="goToFraction($event.target.value)"
class="flex-grow"
/>
<datalist id="tick-marks"></datalist>
<!-- Right navigation -->
<button @click="goRight()" class="p-2 rounded-lg hover:bg-gray-700" title="Go Right" aria-label="Go right">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 9 6 L 15 12 L 9 18"></path>
</svg>
</button>
<!-- Separator -->
<div class="w-px h-6 bg-gray-600 mx-1"></div>
<!-- Zoom controls (fixed-layout only) -->
<template x-if="isFixedLayout">
<div class="flex items-center gap-1">
<!-- Zoom out -->
<button @click="zoomOut()" class="p-2 rounded-lg hover:bg-gray-700" title="Zoom Out" aria-label="Zoom out">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Zoom percentage -->
<button @click="resetZoom()" class="text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]" title="Reset Zoom" aria-label="Reset zoom">
<span x-text="zoomPercent + '%'">100%</span>
</button>
<!-- Zoom in -->
<button @click="zoomIn()" class="p-2 rounded-lg hover:bg-gray-700" title="Zoom In" aria-label="Zoom in">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 10 5 L 10 15 M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-2 rounded-lg hover:bg-gray-700" title="Magnifier" aria-label="Toggle magnifier">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="9" cy="9" r="5"></circle>
<path d="M 13 13 L 18 18"></path>
</svg>
</button>
<!-- Pan/Select mode (PDF only) -->
<button x-show="isPDF" @click="toggleInteractionMode()" class="p-2 rounded-lg hover:bg-gray-700" title="Pan/Select Mode" aria-label="Toggle pan/select mode">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3"></path>
</svg>
</button>
</div>
</template>
<!-- Separator -->
<div class="w-px h-6 bg-gray-600 mx-1"></div>
<!-- Progress display -->
<div id="progress-display" x-text="progressText" data-progress-mode="pages" class="text-sm min-w-[4rem] text-center">
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
</div>
<!-- Separator -->
<div class="w-px h-6 bg-gray-600 mx-1"></div>
<!-- Action buttons -->
<div class="flex items-center gap-1">
<button data-action="toggle-toc" class="p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
<button data-action="add-bookmark" class="p-2 rounded-lg hover:bg-gray-700" title="Bookmark">🏷️</button>
<button data-action="add-note" class="p-2 rounded-lg hover:bg-gray-700" title="Note">📝</button>
</div>
</div>
</div>
</div>
}
templ ReaderSettingsPanel() {
<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>
<label class="block mb-2">
Chrome Behavior
<select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
<option value="auto-hide">Auto Hide</option>
<option value="always-visible">Always Visible</option>
<option value="hide-on-scroll">Hide on Scroll</option>
</select>
</label>
<label class="block mb-2">
Progress Mode
<select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
<option value="pages">Pages</option>
<option value="chapter">Chapter</option>
<option value="percentage">Percentage</option>
<option value="time-left">Time Left</option>
</select>
</label>
</div>
<!-- Typography (ebooks only) -->
<div class="mb-6" data-visible-for="ebook">
<h3 class="font-semibold mb-2">Reading Theme</h3>
<label class="block mb-2">
<select name="reading_theme" class="w-full mt-1 px-3 py-2 rounded border">
<optgroup label="📖 Classic Reading">
<option value="light">Light</option>
<option value="paper">Paper</option>
<option value="sepia">Sepia</option>
<option value="parchment">Parchment</option>
<option value="warm">Warm</option>
<option value="candlelight">Candlelight</option>
</optgroup>
<optgroup label="🌤️ Sky & Atmosphere">
<option value="azure">Azure</option>
<option value="sky">Sky</option>
<option value="arctic">Arctic</option>
<option value="frost">Frost</option>
</optgroup>
<optgroup label="🌅 Sunset & Warmth">
<option value="dusk">Dusk</option>
<option value="sunset">Sunset</option>
<option value="twilight">Twilight</option>
</optgroup>
<optgroup label="🌲 Nature & Earth">
<option value="forest">Forest</option>
<option value="moss">Moss</option>
<option value="slate">Slate</option>
</optgroup>
<optgroup label="⚡ High Performance">
<option value="oled">OLED</option>
<option value="solarized">Solarized</option>
</optgroup>
</select>
<p class="text-xs mt-1" style="color: var(--text-secondary)">18 themes organized by category</p>
</label>
</div>
<div class="mb-6" data-visible-for="ebook">
<h3 class="font-semibold mb-2">Typography</h3>
<label class="block mb-2">
Reading Font
<select name="reading_font" class="w-full mt-1 px-3 py-2 rounded border">
<option value="literata">Literata (Default - Designed for ebooks)</option>
<option value="crimson">Crimson Text (Screen-optimized)</option>
<option value="source-serif">Source Serif 4 (Adobe quality)</option>
<option value="eb-garamond">EB Garamond (Classic)</option>
<option value="libertinus">Libertinus Serif (Technical)</option>
<option value="noto-serif">Noto Serif (All languages)</option>
<option value="charis-sil">Charis SIL (Multilingual)</option>
<option value="ibm-plex">IBM Plex Serif (Modern)</option>
</select>
<p class="text-xs ml-2" style="color: var(--text-secondary)">8 libre fonts bundled with Bookhoard</p>
</label>
<label class="block mb-2">
Font Size
<input type="range" name="font_size" min="12" max="24" value="16" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">12-24px</span>
</label>
<label class="block mb-2">
Line Height
<input type="range" name="line_height" min="1.0" max="2.5" step="0.1" value="1.6" class="w-full"/>
<span class="text-xs ml-2" style="color: var(--text-secondary)">1.0-2.5</span>
</label>
</div>
<!-- Navigation -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Navigation</h3>
<label class="flex items-center mb-2">
<input type="checkbox" name="double_page_spread" class="mr-2"/>
Double Page Spread (Comics/Manga)
</label>
</div>
<button data-action="close-settings" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Done
</button>
</div>
</div>
}
templ ReaderTOCPanel(metadata ReaderMetadata) {
<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>
}
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-bookmark-id={ bookmark.ID }
class="block py-2 hover:bg-gray-700 rounded px-2"
>
<span class="font-medium">{ bookmark.Title }</span>
<span class="text-xs ml-2" style="color: var(--text-secondary)">
{ bookmark.Position }
</span>
</a>
}
</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>
}
templ DictionaryPopup() {
<div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
}