The reader's back button was hardcoded to the book detail page
(/media/{id}), so even when you launched the reader straight from the
dashboard the back button ignored that and sent you to the detail view.
Mirror the existing book-detail.ts referrer pattern: capture
document.referrer into sessionStorage on load (excluding other reader
pages and the reader's own URL), then override the back link's click to
navigate there. Falls back to the link's original href (/media/{id}) when
no valid referrer exists (direct URL access).
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 id="reader-back" 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>
|
|
}
|