Release / build-and-push (push) Successful in 2m39s
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.
Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.
Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.
Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
415 lines
16 KiB
Templ
415 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 / 100
|
|
}
|
|
if progress.EpubCfi != "" {
|
|
config["savedCfi"] = progress.EpubCfi
|
|
}
|
|
// Fixed-layout & comic formats: the page index is the canonical, exact
|
|
// locator (pages are fixed images). Pass it so the reader restores by page.
|
|
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
|
|
config["savedPage"] = progress.CurrentPage
|
|
if progress.TotalPages > 0 {
|
|
config["savedTotalPages"] = progress.TotalPages
|
|
}
|
|
}
|
|
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, viewport-fit=cover"/>
|
|
<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" style="top: 56px; bottom: 56px;">
|
|
<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 id="reader-topbar" class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40 pt-[env(safe-area-inset-top)]" style="background-color: var(--bg-primary);">
|
|
<div class="flex items-center justify-between px-3 py-2 sm:px-4 sm:py-3">
|
|
<a href={ "/media/" + metadata.MediaItemID } class="text-base sm:text-lg hover:underline">
|
|
← Back
|
|
</a>
|
|
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
|
|
<button
|
|
@click="toggleSettings()"
|
|
class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700"
|
|
title="Settings"
|
|
>
|
|
⚙️
|
|
</button>
|
|
</div>
|
|
</div> <!-- Bottom bar -->
|
|
<div id="reader-bottombar" class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40 pb-[env(safe-area-inset-bottom)]" style="background-color: var(--bg-primary);">
|
|
<div class="flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1">
|
|
<!-- Left navigation -->
|
|
<button @click="goLeft()" class="p-1.5 sm: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-1.5 sm: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-1.5 sm: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-1.5 sm: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-1.5 sm: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-1.5 sm: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" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
|
|
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">
|
|
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) }
|
|
}
|
|
</span>
|
|
</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-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
|
|
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark">🏷️</button>
|
|
<button @click="toggleBookmarks()" class="p-1.5 sm: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>
|
|
}
|