Files
bookhoard/templates/reader.templ
T
john-okeefe e500039d1b feat(reader): webtoon reading mode + brightness/contrast/night filters
Phase 4 of the reader redesign (foliate-js ea268df):

- Webtoon mode for comics: continuous vertical scroll of all pages
  (900px centered column on wide screens), lazy-loaded with a 150%
  IntersectionObserver margin, far pages unloaded to bound memory
  with stable aspect-ratio placeholders so the scrollbar never jumps.
  Chosen per book (Paged | Webtoon segmented control in Settings →
  Layout & Display; stored in localStorage per media item since a
  webtoon title and a paged manga volume want different flows).
  Toggling reloads the reader — the renderer is chosen at open time —
  and progress restores from the saved page. Relocate events flow
  through the same pipeline, so the slider, progress saving, back
  stack, tap zones, and edge zones all work unchanged. Zoom/fit/
  magnifier/spread controls hide in webtoon (natural-width scroll).
- Display filters for fixed-layout: brightness (30-130%) and
  contrast (70-130%) sliders with live preview, plus Night Mode
  (invert) — also a quick row in the ⋯ tools menu. One --fx-filter
  CSS var drives everything: ::part(filter) on foliate-view iframes
  (forwarded via the new exportparts attribute) and the webtoon
  page images alike. Persisted as fx_brightness/fx_contrast/fx_invert
  (types + defaults both sides); Restore Defaults resets them.
2026-08-18 08:25:00 -04:00

938 lines
42 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import (
"encoding/json"
"fmt"
)
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) 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
}
}
if len(bookmarks) > 0 {
items := make([]map[string]interface{}, 0, len(bookmarks))
for _, b := range bookmarks {
var page any
if b.PageNumber != nil {
page = *b.PageNumber
}
items = append(items, map[string]interface{}{
"id": b.ID,
"title": b.Title,
"positionLabel": b.Position,
"cfi": b.CfiPosition,
"page": page,
})
}
config["bookmarks"] = items
}
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"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body
x-data="readerShell"
x-init={ readerInitExpr(metadata, progress, bookmarks) }
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
>
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
drawers and scrim float above everything. -->
<div id="reader-viewport" class="fixed inset-0">
<foliate-view id="reader-view" exportparts="filter" class="block w-full h-full"></foliate-view>
<!-- Desktop edge page-turn zones: hover to reveal the arrow.
Hidden on touch (tap zones cover that) and while a
fixed-layout page is zoomed (edges belong to content then). -->
<div
class="reader-edge-zone left"
:class="edgeZonesActive ? '' : 'pointer-events-none'"
@click="goLeft()"
aria-hidden="true"
>
<svg class="reader-icon" width="28" height="28" aria-hidden="true">
<path d="M 18 6 L 10 14 L 18 22"></path>
</svg>
</div>
<div
class="reader-edge-zone right"
:class="edgeZonesActive ? '' : 'pointer-events-none'"
@click="goRight()"
aria-hidden="true"
>
<svg class="reader-icon" width="28" height="28" aria-hidden="true">
<path d="M 10 6 L 18 14 L 10 22"></path>
</svg>
</div>
</div>
@ReaderChrome(metadata, progress)
<!-- Drawer scrim -->
<div
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
x-transition.opacity.duration.200ms
@click="closeDrawers()"
class="drawer-scrim"
aria-hidden="true"
></div>
<!-- TOC drawer (left) -->
<div
id="toc-drawer"
x-show="tocOpen"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-150 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="reader-drawer left"
role="dialog"
aria-label="Table of contents"
>
@ReaderTOCDrawer()
</div>
<!-- Settings drawer (right) -->
<div
id="settings-drawer"
x-show="settingsOpen"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-150 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="reader-drawer right"
role="dialog"
aria-label="Reader settings"
>
@ReaderSettingsDrawer()
</div>
<!-- Selection popover (highlights) -->
<div
id="selection-popover"
x-show="selectionPopover.open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
:style="'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'"
class="reader-popover"
role="dialog"
aria-label="Highlight selection"
@pointerdown.stop
>
<div class="flex items-center gap-2">
<template x-for="c in highlightColors" :key="c">
<button
type="button"
class="color-dot"
:class="selectionPopover.color === c ? 'selected' : ''"
:style="'background-color:' + c"
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
:aria-label="'Highlight color ' + c"
@click="selectionPopover.mode === 'create' ? createHighlight(c) : (selectionPopover.color = c, saveHighlightChanges())"
></button>
</template>
<div class="w-px h-5 reader-sep"></div>
<button
type="button"
class="reader-popover-btn"
title="Note"
@click="selectionPopover.noteOpen = true"
>
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5"></path></svg>
</button>
<button
type="button"
class="reader-popover-btn"
title="Copy text"
@click="copySelectionText()"
>
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z"></path></svg>
</button>
<template x-if="selectionPopover.mode === 'edit'">
<button
type="button"
class="reader-popover-btn danger"
title="Delete highlight"
@click="deleteHighlightById(selectionPopover.id)"
>
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16"></path></svg>
</button>
</template>
</div>
<div x-show="selectionPopover.noteOpen" class="mt-2">
<textarea
x-model="selectionPopover.note"
rows="3"
class="reader-note-input"
placeholder="Note…"
></textarea>
<div class="flex gap-2 mt-1">
<button
type="button"
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
@click="selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()"
x-text="selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'"
>Save</button>
<button
type="button"
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
style="border-color: var(--border);"
@click="selectionPopover.noteOpen = false"
>Cancel</button>
</div>
</div>
</div>
<!-- Annotations drawer (right): highlights / notes / bookmarks -->
<div
id="annotations-drawer"
x-show="bookmarksOpen"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-150 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="reader-drawer right"
role="dialog"
aria-label="Annotations"
>
@ReaderAnnotationsDrawer()
</div>
<!-- Search drawer (right, reflowable only) -->
<div
id="search-drawer"
x-show="searchOpen"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-150 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="reader-drawer right"
role="dialog"
aria-label="Search"
>
@ReaderSearchDrawer()
</div>
<!-- Shortcuts & gestures help modal -->
<div
x-show="helpOpen"
x-transition.opacity.duration.200ms
class="reader-help-backdrop"
@click="helpOpen = false"
aria-hidden="true"
></div>
<div
x-show="helpOpen"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="reader-help"
role="dialog"
aria-label="Shortcuts and gestures"
>
<div class="reader-help-header">
<h3 class="font-semibold text-base">Shortcuts &amp; Gestures</h3>
<button @click="helpOpen = false" class="p-1.5 rounded-lg hover:bg-gray-700" title="Close (Esc)" aria-label="Close"></button>
</div>
<div class="reader-help-body">
<h4 class="reader-help-section">Navigation</h4>
<div class="help-row"><span>Previous / next page</span><span class="help-keys"><kbd class="kbd"></kbd><kbd class="kbd"></kbd> or <kbd class="kbd">h</kbd><kbd class="kbd">l</kbd></span></div>
<div class="help-row"><span>Return to previous location</span><span class="help-keys"><kbd class="kbd">Alt</kbd>+<kbd class="kbd"></kbd></span></div>
<div class="help-row"><span>Page edge zones <span class="help-note">(desktop)</span></span><span class="help-keys">click page edges</span></div>
<div class="help-row" x-show="!isFixedLayout || isPDF"><span>Search in book</span><span class="help-keys"><kbd class="kbd">/</kbd></span></div>
<div class="help-row"><span>Contents / thumbnails</span><span class="help-keys"><kbd class="kbd">t</kbd></span></div>
<div class="help-row"><span>Annotations drawer</span><span class="help-keys">📝 button</span></div>
<div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div>
<div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div>
<div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div>
<h4 class="reader-help-section" x-show="isFixedLayout">Zoom &amp; pan <span class="help-note">(comics, PDFs)</span></h4>
<div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd"></kbd><kbd class="kbd">0</kbd> or wheel</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys"> button</span></div>
<div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div>
<h4 class="reader-help-section">Highlights</h4>
<div class="help-row"><span>Create</span><span class="help-keys">select text pick a color</span></div>
<div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div>
<h4 class="reader-help-section">Touch</h4>
<div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div>
<div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Zoom / move</span><span class="help-keys">pinch · two-finger drag · double-tap</span></div>
</div>
</div>
</body>
</html>
}
templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'">
<!-- Top bar -->
<div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass">
<div class="flex items-center justify-between px-3 py-2 sm:px-4">
<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>
<div class="flex items-center gap-1">
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
<button x-show="!isFixedLayout || isPDF" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
<button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button>
</div>
</div>
</div>
<!-- Bottom bar -->
<div id="reader-bottombar" class="fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass">
<!-- Reflowable row -->
<div x-show="!isFixedLayout" class="flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1">
<div class="flex items-center gap-0.5 grow min-w-[190px]">
<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>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg>
</button>
<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>
<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>
</div>
<div class="flex items-center gap-1">
<div class="w-px h-6 reader-sep"></div>
<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>
<div class="w-px h-6 reader-sep"></div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div>
</div>
<!-- Fixed-layout row (comics, PDFs, manga) -->
<div x-show="isFixedLayout">
<!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. -->
<div class="hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1">
<div class="flex items-center gap-0.5">
<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>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg>
</button>
</div>
<input
id="progress-slider-fx"
type="range"
min="0"
max="1"
step="any"
@input="goToFraction($event.target.value)"
class="grow min-w-[3rem]"
/>
<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>
<div class="w-px h-6 mx-1 reader-sep"></div>
<!-- Fit mode + zoom cluster (not applicable in webtoon flow) -->
<div class="flex items-center gap-1" x-show="comicFlow !== 'webtoon'">
<select
@change="applyFitMode($event.target.value)"
class="reader-select text-xs px-1.5 py-1 rounded-lg shrink-0"
title="Fit mode"
aria-label="Fit mode"
>
<option value="fit-page">Fit Page</option>
<option value="fit-width">Fit Width</option>
</select>
<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>
<button @click="resetZoom()" class="text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]" title="Reset Zoom (0)" aria-label="Reset zoom">
<span x-text="zoomPercent + '%'">100%</span>
</button>
<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>
<!-- Recenter: reset pan, keep zoom -->
<button @click="recenterView()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Recenter page (reset pan, keep zoom)" aria-label="Recenter page">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M 12 2 V 5 M 12 19 V 22 M 2 12 H 5 M 19 12 H 22"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="magnifierEnabled ? 'bg-blue-600 hover:bg-blue-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>
</div>
<!-- Pointer mode (PDF only): Smart / Pan / Text -->
<div class="reader-seg shrink-0" x-show="isPDF" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
</div>
<!-- Double page spread (not applicable in webtoon flow) -->
<button x-show="comicFlow !== 'webtoon'" @click="toggleSpread()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="doublePageSpread ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Double Page Spread" aria-label="Toggle double page spread">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 3 5 h 6 v 14 h -6 z M 15 5 h 6 v 14 h -6 z"></path>
</svg>
</button>
<!-- Progress + TOC -->
<div class="flex items-center gap-1">
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span x-text="progressMain">
if progress.FormatGroup == "reflowable" {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
</div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div>
</div>
<!-- Compact row (phone-sized windows): single line + ⋯ overflow.
Standard mobile-reader pattern: paging, slider, progress stay;
power tools move into a labeled menu. -->
<div class="flex md:hidden items-center gap-1 px-1.5 py-1.5">
<button @click="goLeft()" class="p-1.5 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>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg>
</button>
<input
id="progress-slider-fx-compact"
type="range"
min="0"
max="1"
step="any"
@input="goToFraction($event.target.value)"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 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>
<div @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3rem] text-center cursor-pointer truncate shrink-0">
<span x-text="progressMain">0/0</span>
</div>
<button
data-tools-toggle
@click="toggleTools()"
class="p-1.5 rounded-lg hover:bg-gray-700"
:title="toolsOpen ? 'Close tools' : 'More tools'"
aria-label="More tools"
:aria-expanded="toolsOpen ? 'true' : 'false'"
>
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="5" cy="12" r="1.5" fill="currentColor"></circle>
<circle cx="12" cy="12" r="1.5" fill="currentColor"></circle>
<circle cx="19" cy="12" r="1.5" fill="currentColor"></circle>
</svg>
</button>
</div>
<!-- Tools overflow menu: labeled rows (mobile-reader pattern —
labels beat mystery icons). Anchored above the bottom bar. -->
<div
x-show="toolsOpen"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
class="reader-tools-popover"
role="menu"
aria-label="Reader tools"
@pointerdown.stop
>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Zoom</span>
<div class="flex items-center gap-1">
<button @click="zoomOut()" class="reader-popover-btn" title="Zoom out"></button>
<button @click="resetZoom()" class="text-xs px-1 min-w-[3rem] text-center" title="Reset zoom"><span x-text="zoomPercent + '%'">100%</span></button>
<button @click="zoomIn()" class="reader-popover-btn" title="Zoom in">+</button>
</div>
</div>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Fit</span>
<select
@change="applyFitMode($event.target.value)"
class="reader-select text-xs px-1.5 py-1 rounded-lg"
aria-label="Fit mode"
>
<option value="fit-page">Fit Page</option>
<option value="fit-width">Fit Width</option>
</select>
</div>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Page position</span>
<button @click="recenterView()" class="reader-popover-btn text-xs px-2" title="Reset pan, keep zoom">Recenter</button>
</div>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Magnifier</span>
<button @click="toggleMagnifier()" class="reader-popover-btn text-xs px-2" :class="magnifierEnabled ? 'text-blue-400' : ''" x-text="magnifierEnabled ? 'On' : 'Off'">Off</button>
</div>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Night mode</span>
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
</div>
<div class="reader-tools-row" x-show="isPDF">
<span>Pointer</span>
<div class="reader-seg" role="group" aria-label="Pointer mode">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')">Pan</button>
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')">Text</button>
</div>
</div>
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
<span>Double page</span>
<button @click="toggleSpread()" class="reader-popover-btn text-xs px-2" :class="doublePageSpread ? 'text-blue-400' : ''" x-text="doublePageSpread ? 'On' : 'Off'">Off</button>
</div>
<div class="reader-tools-row">
<span>Contents</span>
<button @click="toggleTOC()" class="reader-popover-btn text-xs px-2">Open</button>
</div>
</div>
</div>
</div>
</div>
}
templ drawerHeader(title string) {
<div class="reader-drawer-header">
<h3 class="font-semibold">{ title }</h3>
<button @click="closeDrawers()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Close (Esc)" aria-label="Close"></button>
</div>
}
templ ReaderTOCDrawer() {
@drawerHeader("Contents")
<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be
absent in manga-scan PDFs and CBZs. -->
<div class="reader-tabs" x-show="isFixedLayout">
<button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'">
Contents <span class="reader-tab-count" x-text="tocItems.length">0</span>
</button>
<button :class="tocTab === 'pages' ? 'active' : ''" @click="tocTab = 'pages'; initPageThumbs()">
Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span>
</button>
</div>
<div class="reader-drawer-body">
<nav id="toc-list" class="space-y-1" x-show="tocTab === 'contents' || !isFixedLayout">
<!-- Key must include the index: PDF outline entries can share the
same destination href, and Alpine's x-for renders nothing on
duplicate keys. -->
<template x-for="(item, idx) in tocItems" :key="item.href + '::' + idx">
<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>
<template x-if="tocItems.length === 0">
<p class="text-sm py-2" style="color: var(--text-secondary)">No table of contents</p>
</template>
</nav>
<div id="page-thumb-grid" class="reader-thumb-grid" x-show="isFixedLayout && tocTab === 'pages'">
<template x-for="p in pageThumbList" :key="p.index">
<button
class="reader-thumb"
:class="p.index === currentPageIndex ? 'active' : ''"
@click="goToPage(p.index)"
:title="'Page ' + (p.index + 1)"
>
<div class="reader-thumb-img" :data-page="p.index"></div>
<span class="reader-thumb-num" x-text="p.index + 1">1</span>
</button>
</template>
<template x-if="pageThumbList.length === 0">
<p class="text-sm py-2" style="color: var(--text-secondary)">No pages</p>
</template>
</div>
</div>
}
templ ReaderAnnotationsDrawer() {
@drawerHeader("Annotations")
<div class="reader-tabs">
<button :class="annotationsTab === 'highlights' ? 'active' : ''" @click="annotationsTab = 'highlights'">
Highlights <span class="reader-tab-count" x-text="highlightItems.length">0</span>
</button>
<button :class="annotationsTab === 'notes' ? 'active' : ''" @click="annotationsTab = 'notes'">
Notes <span class="reader-tab-count" x-text="noteItems.length">0</span>
</button>
<button :class="annotationsTab === 'bookmarks' ? 'active' : ''" @click="annotationsTab = 'bookmarks'">
Bookmarks <span class="reader-tab-count" x-text="bookmarkItems.length">0</span>
</button>
</div>
<div class="reader-drawer-body">
<!-- Highlights -->
<div x-show="annotationsTab === 'highlights'" class="space-y-2">
<template x-for="hl in highlightItems" :key="hl.id">
<div class="reader-hl-row group">
<a
href="#"
@click.prevent="goToHighlight(hl)"
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
>
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
<span class="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-text="'📝 ' + hl.note"></span>
</a>
<button
@click="deleteHighlightById(hl.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete highlight"
aria-label="Delete highlight"
>
</button>
</div>
</template>
<template x-if="highlightItems.length === 0">
<p class="text-sm" style="color: var(--text-secondary)">Select text in the book to highlight it</p>
</template>
</div>
<!-- Notes -->
<div x-show="annotationsTab === 'notes'">
<textarea
x-model="newNoteText"
rows="2"
class="reader-note-input"
placeholder="Add a note at the current position…"
></textarea>
<button @click="addNote(newNoteText); newNoteText = ''" class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
+ Add Note
</button>
<div class="space-y-2">
<template x-for="note in noteItems" :key="note.id">
<div class="reader-hl-row group">
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
<span class="block text-sm" x-text="note.content"></span>
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
</a>
<button
@click="deleteNoteById(note.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete note"
aria-label="Delete note"
>
</button>
</div>
</template>
<template x-if="noteItems.length === 0">
<p class="text-sm" style="color: var(--text-secondary)">No notes yet</p>
</template>
</div>
</div>
<!-- Bookmarks -->
<div x-show="annotationsTab === 'bookmarks'" class="space-y-2">
<template x-for="bookmark in bookmarkItems" :key="bookmark.id">
<div class="flex items-center gap-1 group">
<a
href="#"
@click.prevent="goToBookmark(bookmark)"
class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2"
>
<span class="font-medium block truncate" x-text="bookmark.title"></span>
<span class="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span>
</a>
<button
@click="deleteBookmark(bookmark.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete bookmark"
aria-label="Delete bookmark"
>
</button>
</div>
</template>
<template x-if="bookmarkItems.length === 0">
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
</template>
<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 ReaderSearchDrawer() {
@drawerHeader("Search")
<div class="reader-search-bar">
<input
type="search"
x-ref="searchInput"
x-model="searchQuery"
@keydown.enter.prevent="runSearch()"
placeholder="Search in book…"
class="reader-note-input grow"
aria-label="Search query"
/>
<button
x-show="searchQuery || searchGroups.length"
@click="searchQuery = ''; clearSearchResults()"
class="p-2 rounded-lg hover:bg-gray-700 shrink-0"
title="Clear results"
aria-label="Clear results"
>
</button>
</div>
<div class="reader-search-status" x-show="searchQuery || searching || searchGroups.length || searchError">
<span x-show="searching" x-text="'Searching… ' + Math.round(searchProgress * 100) + '%'">Searching…</span>
<span x-show="!searching && searchGroups.length" x-text="searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')">0 matches</span>
<span x-show="!searching && searchQuery && !searchGroups.length && !searchError">No matches</span>
<span x-show="searchError" x-text="searchError" style="color: #f87171;">Search failed</span>
</div>
<div class="reader-drawer-body">
<template x-for="(group, gi) in searchGroups" :key="gi">
<div class="mb-3">
<div class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)" x-text="group.label"></div>
<div class="space-y-1">
<template x-for="(item, si) in group.items" :key="gi + '-' + si">
<a
href="#"
@click.prevent="goToSearchResult(item)"
class="search-result"
>
<span x-text="item.pre"></span><mark x-text="item.match"></mark><span x-text="item.post"></span>
</a>
</template>
</div>
</div>
</template>
</div>
}
templ ReaderSettingsDrawer() {
@drawerHeader("Settings")
<div class="reader-drawer-body">
<!-- Behavior -->
<div class="mb-6">
<h3 class="font-semibold mb-2">Behavior</h3>
<label class="block mb-2">
Chrome
<select name="chrome_behavior" x-model="chromeBehavior" @change="applyChromeBehavior()" class="w-full mt-1 px-3 py-2 rounded border" style="border-color: var(--border); background-color: var(--bg-secondary);">
<option value="auto-hide">Auto Hide</option>
<option value="always-visible">Always Visible</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" style="border-color: var(--border); background-color: var(--bg-secondary);">
<option value="pages">Pages</option>
<option value="chapter">Chapter</option>
<option value="percentage">Percentage</option>
<option value="time-left">Time Left</option>
</select>
</label>
<label class="flex items-center mb-2">
<input type="checkbox" name="tap_zones_enabled" x-model="tapZonesEnabled" @change="applyTapZoneSettings()" class="mr-2"/>
Tap Zones <span class="text-xs ml-1" style="color: var(--text-secondary)">(touch)</span>
</label>
<label class="block mb-2" x-show="tapZonesEnabled">
Tap Zone Size
<input type="range" name="tap_zone_size" x-model.number="tapZoneSize" @change="applyTapZoneSettings()" min="10" max="50" class="w-full"/>
<span class="text-xs" x-text="tapZoneSize + '%'" style="color: var(--text-secondary)">30%</span>
</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>
<div class="grid grid-cols-6 gap-2">
<template x-for="sw in themeSwatches" :key="sw.value">
<button
type="button"
@click="readingTheme = sw.value; applyTheme()"
class="theme-swatch"
:class="readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''"
:style="'background-color:' + sw.bg + '; color:' + sw.fg"
:title="sw.label"
:aria-label="'Theme ' + sw.label"
:aria-pressed="readingTheme === sw.value"
>
<span class="text-xs font-semibold">Aa</span>
</button>
</template>
</div>
</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" style="border-color: var(--border); background-color: var(--bg-secondary);">
<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>
<!-- Layout & display (fixed-layout only) -->
<div class="mb-6" x-show="isFixedLayout">
<h3 class="font-semibold mb-2">Layout &amp; Display</h3>
<label class="block mb-3" x-show="isComic">
Reading Flow
<div class="reader-seg w-full mt-1" role="group" aria-label="Reading flow">
<button type="button" class="flex-1" :class="comicFlow !== 'webtoon' ? 'active' : ''" @click="setComicFlow('paged')" title="One page or spread at a time">Paged</button>
<button type="button" class="flex-1" :class="comicFlow === 'webtoon' ? 'active' : ''" @click="setComicFlow('webtoon')" title="Continuous vertical scroll (long-strip)">Webtoon</button>
</div>
</label>
<label class="block mb-2">
Brightness
<input type="range" name="fx_brightness" x-model.number="fxBrightness" @input="applyFxFilter()" @change="applyFxFilter(true)" min="0.3" max="1.3" step="0.05" class="w-full"/>
<span class="text-xs" x-text="Math.round(fxBrightness * 100) + '%'" style="color: var(--text-secondary)">100%</span>
</label>
<label class="block mb-2">
Contrast
<input type="range" name="fx_contrast" x-model.number="fxContrast" @input="applyFxFilter()" @change="applyFxFilter(true)" min="0.7" max="1.3" step="0.05" class="w-full"/>
<span class="text-xs" x-text="Math.round(fxContrast * 100) + '%'" style="color: var(--text-secondary)">100%</span>
</label>
<label class="flex items-center mb-2">
<input type="checkbox" name="fx_invert" x-model="fxInvert" @change="applyFxFilter(true)" class="mr-2"/>
Night Mode <span class="text-xs ml-1" style="color: var(--text-secondary)">(invert)</span>
</label>
<label class="flex items-center mb-2" x-show="comicFlow !== 'webtoon'">
<input type="checkbox" name="double_page_spread" x-model="doublePageSpread" @change="applyDoublePageSpread()" 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="closeDrawers()" class="flex-1 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Done
</button>
</div>
</div>
}