Files
bookhoard/templates/reader.templ
T
john-okeefe e66308c323 feat(reader): wire up progress mode switching with four display modes
Connect the existing progress_mode setting dropdown to the reader's
progress display. Four modes are now functional:
- pages: overall percent + page/location number (default, existing)
- chapter: chapter title + page X / Y within current section
- percentage: overall percent only
- time-left: percent + estimated time remaining via reading speed API
The progress display in the bottom bar is now clickable to cycle through
modes with immediate visual feedback. The settings dropdown is bound
with x-model for persistence. Reading speed is fetched once on init
from the backend reading-speed API for time-left estimates.
2026-04-25 13:40:18 -04:00

405 lines
16 KiB
Templ

package templates
import (
"encoding/json"
"fmt"
)
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
config := map[string]interface{}{
"mediaItemId": metadata.MediaItemID,
"fileUrl": metadata.FileURL,
"formatGroup": metadata.FormatGroup,
"readingDirection": metadata.ReadingDirection,
"mangaType": metadata.MangaType,
}
if progress.Percentage > 0 {
config["savedPercentage"] = progress.Percentage
}
if progress.EpubCfi != "" {
config["savedCfi"] = progress.EpubCfi
}
jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
}
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"/>
<script src="/static/htmx.min.js"></script>
<script type="module" src="/static/reader.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body
x-data="readerShell"
x-init={ readerInitExpr(metadata, progress) }
class="theme-tokyo-night h-screen overflow-hidden"
>
@ReaderChrome(user, metadata, progress)
<!-- Dockable Panels Container -->
<div id="reader-panels" class="fixed inset-0 pointer-events-none z-30">
<!-- Left Sidebar (TOC) -->
<div id="left-sidebar" class="absolute left-0 top-0 bottom-0 pointer-events-auto flex flex-col">
<div id="toc-panel" x-show="tocOpen" x-transition class="panel-container pointer-events-auto" data-panel="toc">
@ReaderTOCPanel(metadata)
</div>
</div>
<!-- Right Sidebar (Settings, Navigator, Bookmarks) -->
<div id="right-sidebar" class="absolute right-0 top-0 bottom-0 pointer-events-auto flex flex-col">
<div id="settings-panel" x-show="settingsOpen" x-transition class="panel-container pointer-events-auto" data-panel="settings">
@ReaderSettingsPanel()
</div>
<div id="navigator-panel" x-show="navigatorOpen" x-transition class="panel-container pointer-events-auto" data-panel="navigator">
@ReaderNavigatorPanel()
</div>
<div id="bookmarks-panel" x-show="bookmarksOpen" x-transition class="panel-container pointer-events-auto" data-panel="bookmarks">
@ReaderBookmarksPanel(bookmarks)
</div>
</div>
</div>
<div id="reader-viewport" class="absolute inset-x-0 top-[52px] bottom-[52px]">
<foliate-view id="reader-view" class="block w-full h-full"></foliate-view>
</div>
@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/" + metadata.MediaItemID } class="text-lg hover:underline">
Back
</a>
<h1 class="text-lg font-semibold">{ metadata.Title }</h1>
<button
@click="toggleSettings()"
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="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" @click="cycleProgressMode()" title="Click to change progress mode" class="text-sm min-w-[4rem] text-center cursor-pointer">
if progress.FormatGroup == "reflowable" {
if metadata.EstimatedPages > 0 {
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
} else {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
}
} else {
{ 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 @click="toggleTOC()" class="p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
<button @click="addBookmark()" class="p-2 rounded-lg hover:bg-gray-700" title="Bookmark">🏷️</button>
<button @click="toggleBookmarks()" class="p-2 rounded-lg hover:bg-gray-700" title="Notes">📝</button>
</div>
</div>
</div>
</div>
}
templ ReaderSettingsPanel() {
<div
class="dockable-panel"
data-panel="settings"
data-side="left"
x-ref="settingsPanel"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" @click="toggleWindowShade($refs.settingsPanel)">
<h3 class="panel-title font-semibold">⚙️ Settings</h3>
<div class="panel-controls flex items-center gap-2">
<button class="window-shade-toggle"></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" x-model="progressMode" @change="applyProgressMode()" 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>
<!-- Reading Theme (reflowable only) -->
<div class="mb-6" x-show="!isFixedLayout">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Reading Theme</h3>
<button
@click="toggleReadingMode()"
class="relative w-14 h-7 rounded-full transition-colors duration-200 flex items-center px-1"
:class="readingMode === 'dark' ? 'bg-blue-500' : 'bg-gray-400'"
type="button"
:aria-label="readingMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<span
class="absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 flex items-center justify-center text-xs"
:class="readingMode === 'dark' ? 'translate-x-7' : 'translate-x-0'"
x-text="readingMode === 'dark' ? '🌙' : '☀️'"
>☀️</span>
</button>
</div>
<label class="block mb-2">
<select name="reading_theme" x-model="readingTheme" @change="applyTheme()" 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>
</label>
</div>
<!-- Typography (reflowable only) -->
<div class="mb-6" x-show="!isFixedLayout">
<h3 class="font-semibold mb-2">Typography</h3>
<label class="block mb-2">
Reading Font
<select name="reading_font" x-model="readingFont" @change="applyFont()" class="w-full mt-1 px-3 py-2 rounded border">
<option value="literata">Literata (Default)</option>
<option value="crimson">Crimson Text</option>
<option value="source-serif">Source Serif 4</option>
<option value="eb-garamond">EB Garamond</option>
<option value="libertinus">Libertinus Serif</option>
<option value="noto-serif">Noto Serif</option>
<option value="charis-sil">Charis SIL</option>
<option value="ibm-plex">IBM Plex Serif</option>
</select>
</label>
<label class="block mb-2">
Font Size
<input type="range" name="font_size" x-model.number="fontSize" @change="applyFont()" min="10" max="40" class="w-full"/>
<span class="text-xs" x-text="fontSize + 'px'" style="color: var(--text-secondary)">18px</span>
</label>
<label class="block mb-2">
Line Height
<input type="range" name="line_height" x-model.number="lineHeight" @change="applyFont()" min="0.8" max="3.0" step="0.1" class="w-full"/>
<span class="text-xs" x-text="lineHeight" style="color: var(--text-secondary)">1.6</span>
</label>
</div>
<!-- Navigation -->
<div class="mb-6" x-show="isFixedLayout">
<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
</label>
</div>
<div class="flex gap-2 mt-6">
<button @click="restoreDefaults()" class="flex-1 py-2 border rounded hover:opacity-80" style="border-color: var(--border);">
Restore Defaults
</button>
<button @click="toggleSettings()" class="flex-1 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Done
</button>
</div>
</div>
</div>
}
templ ReaderTOCPanel(_ ReaderMetadata) {
<div
class="dockable-panel"
data-panel="toc"
data-side="left"
x-ref="tocPanel"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" @click="toggleWindowShade($refs.tocPanel)">
<h3 class="panel-title font-semibold">📖 Table of Contents</h3>
<div class="panel-controls flex items-center gap-2">
<button class="window-shade-toggle"></button>
</div>
</div>
<div class="panel-content p-4 overflow-y-auto">
<nav id="toc-list" class="space-y-1">
<template x-for="item in tocItems" :key="item.href">
<a
href="#"
@click.prevent="goToTOCItem(item)"
class="block py-1 px-2 rounded hover:bg-gray-700 text-sm"
:style="'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'"
x-text="item.label"
></a>
</template>
</nav>
</div>
</div>
}
templ ReaderNavigatorPanel() {
<div
class="dockable-panel"
data-panel="navigator"
data-side="right"
x-ref="navigatorPanel"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" @click="toggleWindowShade($refs.navigatorPanel)">
<h3 class="panel-title font-semibold">🗺️ Navigator</h3>
<div class="panel-controls flex items-center gap-2">
<button class="window-shade-toggle"></button>
</div>
</div>
<div class="panel-content p-2 overflow-hidden">
<div id="navigator-viewport" class="relative w-full h-full"></div>
</div>
</div>
}
templ ReaderBookmarksPanel(bookmarks []Bookmark) {
<div
class="dockable-panel"
data-panel="bookmarks"
data-side="right"
x-ref="bookmarksPanel"
>
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" @click="toggleWindowShade($refs.bookmarksPanel)">
<h3 class="panel-title font-semibold">🔖 Bookmarks</h3>
<div class="panel-controls flex items-center gap-2">
<button class="window-shade-toggle"></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="#"
@click.prevent="goToBookmarkTarget($el.dataset.cfi)"
data-cfi={ bookmark.CfiPosition }
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 @click="addBookmark()" 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>
}