Files
nixos-configs/hosts/manatee/modules/komga/komga-reader.html
2026-02-17 20:25:11 +00:00

1554 lines
48 KiB
HTML
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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Komga Reader</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #0a0a0c;
--bg-surface: #131318;
--bg-card: #1a1a22;
--bg-card-hover: #22222e;
--bg-input: #1e1e28;
--border: #2a2a38;
--border-focus: #5a5aff;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
--text-muted: #555568;
--accent: #6c6cff;
--accent-hover: #8080ff;
--accent-dim: rgba(108, 108, 255, 0.15);
--danger: #ff4466;
--green: #44cc88;
--radius: 8px;
--radius-lg: 12px;
--transition: 150ms ease;
}
html, body {
touch-action: pan-x pan-y;
height: 100%;
width: 100%;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg-deep);
color: var(--text-primary);
overflow: hidden;
height: 100%;
width: 100%;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ── Login Screen ── */
#login-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: var(--bg-deep);
}
.login-box {
width: 380px;
padding: 40px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.login-box h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.login-box .subtitle {
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 28px;
}
.field { margin-bottom: 16px; }
.field label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field input {
width: 100%;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
transition: border-color var(--transition);
}
.field input:focus { border-color: var(--border-focus); }
.field input::placeholder { color: var(--text-muted); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: var(--radius);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
}
.btn-primary {
width: 100%;
background: var(--accent);
color: #fff;
margin-top: 8px;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.login-error {
color: var(--danger);
font-size: 13px;
margin-top: 12px;
display: none;
}
/* ── App Layout ── */
#app {
display: none;
height: 100%;
width: 100%;
}
/* ── Sidebar ── */
#sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
width: 320px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 100;
transition: transform 300ms ease;
}
#sidebar.hidden { transform: translateX(-100%); }
.sidebar-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.sidebar-header h2 {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.2px;
}
.sidebar-header .server-info {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.breadcrumb {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.breadcrumb-item {
font-size: 12px;
color: var(--accent);
cursor: pointer;
transition: color var(--transition);
}
.breadcrumb-item:hover { color: var(--accent-hover); }
.breadcrumb-item.current {
color: var(--text-primary);
cursor: default;
font-weight: 600;
}
.breadcrumb-sep {
color: var(--text-muted);
font-size: 11px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
padding: 12px 12px 6px;
}
.section-divider {
height: 1px;
background: var(--border);
margin: 8px 12px;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: background var(--transition);
border: 1px solid transparent;
}
.list-item:hover { background: var(--bg-card-hover); }
.list-item.active {
background: var(--accent-dim);
border-color: var(--accent);
}
.list-item-thumb {
width: 48px;
height: 64px;
border-radius: 4px;
object-fit: cover;
background: var(--bg-card);
flex-shrink: 0;
}
.list-item-info {
flex: 1;
min-width: 0;
}
.list-item-title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-item-meta {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.list-item-icon {
width: 36px;
height: 36px;
border-radius: var(--radius);
background: var(--bg-card);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
}
.progress-bar {
height: 3px;
background: var(--border);
border-radius: 2px;
margin-top: 5px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--green);
border-radius: 2px;
}
.sidebar-footer {
padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.sidebar-footer-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.ui-scale-controls {
display: flex;
align-items: center;
gap: 6px;
}
.ui-scale-btn {
width: 28px; height: 28px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
}
.ui-scale-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.ui-scale-value {
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
color: var(--text-secondary);
min-width: 36px;
text-align: center;
user-select: none;
}
/* ── Reader Area ── */
#reader-area {
position: fixed;
left: 320px; top: 0; right: 0; bottom: 0;
background: var(--bg-deep);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: left 300ms ease;
}
#reader-area.full { left: 0; }
.reader-placeholder {
text-align: center;
color: var(--text-muted);
}
.reader-placeholder .icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.reader-placeholder p { font-size: 14px; }
#image-container {
position: absolute;
inset: 0;
display: none;
overflow: hidden;
cursor: grab;
touch-action: none;
}
#image-container.grabbing { cursor: grabbing; }
#comic-image, #comic-canvas {
position: absolute;
transform-origin: 0 0;
max-width: none;
max-height: none;
user-select: none;
-webkit-user-drag: none;
}
/* ── Reader Controls ── */
#reader-controls {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: none;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: rgba(19, 19, 24, 0.92);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 40px;
z-index: 200;
}
.ctrl-btn {
width: 36px; height: 36px;
border: none; border-radius: 50%;
background: transparent;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
font-family: inherit;
}
.ctrl-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.ctrl-divider {
width: 1px; height: 20px;
background: var(--border);
margin: 0 4px;
}
.page-indicator, .zoom-indicator {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
user-select: none;
text-align: center;
}
.page-indicator {
font-size: 12px;
color: var(--text-secondary);
padding: 0 10px;
min-width: 60px;
}
.zoom-indicator {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
padding: 0 6px;
min-width: 48px;
}
.fit-dropdown { position: relative; }
.fit-menu {
position: absolute;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
display: none;
min-width: 140px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.fit-menu.open { display: block; }
.fit-option {
display: block;
width: 100%;
padding: 8px 12px;
border: none; border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: all var(--transition);
}
.fit-option:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.fit-option.active {
color: var(--accent);
background: var(--accent-dim);
}
.loading-overlay {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(10, 10, 12, 0.7);
z-index: 50;
}
.spinner {
width: 32px; height: 32px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.help-hint {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: var(--text-muted);
background: rgba(19, 19, 24, 0.8);
padding: 6px 14px;
border-radius: 20px;
z-index: 150;
display: none;
white-space: nowrap;
pointer-events: none;
}
kbd {
display: inline-block;
padding: 1px 5px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 3px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
/* ── Shortcuts modal ── */
#shortcuts-modal {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px 24px;
z-index: 250;
display: none;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
min-width: 340px;
}
#shortcuts-modal.open { display: block; }
.shortcuts-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.shortcuts-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
align-items: center;
}
.shortcut-keys {
text-align: right;
white-space: nowrap;
}
.shortcut-desc {
font-size: 12px;
color: var(--text-secondary);
}
.shortcut-section {
grid-column: 1 / -1;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-top: 8px;
padding-bottom: 2px;
}
.shortcut-section:first-child { margin-top: 0; }
/* ── Fullscreen mode ── */
body.is-fullscreen #sidebar {
display: none !important;
}
body.is-fullscreen #reader-area {
left: 0 !important;
}
body.is-fullscreen #reader-controls,
body.is-fullscreen #shortcuts-modal {
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
body.is-fullscreen.show-controls #reader-controls,
body.is-fullscreen.show-controls #shortcuts-modal {
opacity: 1;
pointer-events: auto;
}
body.is-fullscreen .help-hint {
display: none !important;
}
</style>
</head>
<body>
<!-- Login Screen -->
<div id="login-screen">
<div class="login-box">
<h1>Komga Reader</h1>
<p class="subtitle">Connect to your Komga server</p>
<div class="field">
<label>Server URL</label>
<input type="url" id="server-url" placeholder="http://localhost:25600" autocomplete="url">
</div>
<div class="field">
<label>Username</label>
<input type="text" id="username" placeholder="user@email.com" autocomplete="username">
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password">
</div>
<button class="btn btn-primary" id="login-btn">Connect</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- Main App -->
<div id="app">
<div id="sidebar">
<div class="sidebar-header">
<div>
<h2>Komga Reader</h2>
<div class="server-info" id="server-display"></div>
</div>
</div>
<div class="breadcrumb" id="breadcrumb"></div>
<div class="sidebar-content" id="sidebar-list"></div>
<div class="sidebar-footer">
<span class="sidebar-footer-label">UI Scale</span>
<div class="ui-scale-controls">
<button class="ui-scale-btn" id="ui-scale-down" title="Decrease UI size"></button>
<span class="ui-scale-value" id="ui-scale-value">100%</span>
<button class="ui-scale-btn" id="ui-scale-up" title="Increase UI size">+</button>
</div>
</div>
</div>
<div id="reader-area">
<div class="reader-placeholder" id="reader-placeholder">
<div class="icon">📖</div>
<p>Select a book from the sidebar to start reading</p>
</div>
<div id="image-container">
<img id="comic-image" alt="Comic page">
<canvas id="comic-canvas" style="display:none;"></canvas>
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
</div>
</div>
</div>
<div id="shortcuts-modal">
<div class="shortcuts-title">Keyboard Shortcuts</div>
<div class="shortcuts-grid">
<div class="shortcut-section">Navigation</div>
<div class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd></kbd> <kbd></kbd></div>
<div class="shortcut-desc">Previous / next page</div>
<div class="shortcut-keys"><kbd></kbd> <kbd></kbd> <kbd></kbd> <kbd></kbd></div>
<div class="shortcut-desc">Pan</div>
<div class="shortcut-section">Zoom</div>
<div class="shortcut-keys">scroll</div>
<div class="shortcut-desc">Zoom at cursor</div>
<div class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd></kbd> <kbd></kbd></div>
<div class="shortcut-desc">Zoom in / out</div>
<div class="shortcut-keys"><kbd>+</kbd> <kbd>-</kbd></div>
<div class="shortcut-desc">Zoom in / out</div>
<div class="shortcut-keys"><kbd>0</kbd></div>
<div class="shortcut-desc">Reset zoom to fit</div>
<div class="shortcut-keys">double-click</div>
<div class="shortcut-desc">Reset zoom to fit</div>
<div class="shortcut-section">View</div>
<div class="shortcut-keys"><kbd>C</kbd></div>
<div class="shortcut-desc">Center page horizontally</div>
<div class="shortcut-keys"><kbd>D</kbd></div>
<div class="shortcut-desc">Toggle single / double page</div>
<div class="shortcut-keys"><kbd>F</kbd></div>
<div class="shortcut-desc">Toggle fullscreen</div>
<div class="shortcut-keys"><kbd>Tab</kbd></div>
<div class="shortcut-desc">Toggle sidebar</div>
<div class="shortcut-keys"><kbd>?</kbd></div>
<div class="shortcut-desc">Toggle this help</div>
</div>
</div>
<div id="reader-controls">
<button class="ctrl-btn" id="btn-prev" title="Previous page (Ctrl+←)"></button>
<span class="page-indicator" id="page-indicator">-</span>
<button class="ctrl-btn" id="btn-next" title="Next page (Ctrl+→)"></button>
<div class="ctrl-divider"></div>
<button class="ctrl-btn" id="btn-zoom-out" title="Zoom out (-)"></button>
<span class="zoom-indicator" id="zoom-indicator">100%</span>
<button class="ctrl-btn" id="btn-zoom-in" title="Zoom in (+)">+</button>
<button class="ctrl-btn" id="btn-zoom-reset" title="Reset zoom (0)"></button>
<div class="ctrl-divider"></div>
<div class="fit-dropdown">
<button class="ctrl-btn" id="btn-fit" title="Fit mode"></button>
<div class="fit-menu" id="fit-menu">
<button class="fit-option active" data-fit="width">Fit width</button>
<button class="fit-option" data-fit="height">Fit height</button>
<button class="fit-option" data-fit="both">Fit page</button>
<button class="fit-option" data-fit="original">Original size</button>
</div>
</div>
<div class="ctrl-divider"></div>
<button class="ctrl-btn" id="btn-page-mode" title="Toggle single/double page (D)"></button>
<div class="ctrl-divider"></div>
<button class="ctrl-btn" id="btn-fullscreen" title="Fullscreen (F)"></button>
<button class="ctrl-btn" id="btn-toggle-sidebar" title="Toggle sidebar (Tab)"></button>
<button class="ctrl-btn" id="btn-shortcuts" title="Keyboard shortcuts (?)">?</button>
</div>
<div class="help-hint" id="help-hint">
<kbd></kbd> <kbd></kbd> <kbd></kbd> <kbd></kbd> pan &nbsp;·&nbsp; <kbd>Ctrl</kbd>+<kbd></kbd> <kbd></kbd> prev/next page &nbsp;·&nbsp; scroll to zoom &nbsp;·&nbsp; drag to pan
</div>
</div>
<script>
(function() {
'use strict';
// ══════════════════════════════════════════════════════════════
// BROWSER ZOOM PREVENTION
//
// Firefox handles trackpad pinch at the compositor level (APZ)
// BEFORE JavaScript can preventDefault. We use two strategies:
//
// 1. Intercept ctrl+wheel at capture phase (works in Chrome,
// partially in Firefox for mouse wheel)
// 2. Monitor visualViewport.scale and counteract any browser
// zoom by inverse-scaling the body (works in all browsers)
// ══════════════════════════════════════════════════════════════
// Strategy 1: capture phase interception
window.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.metaKey) e.preventDefault();
}, { passive: false, capture: true });
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '0'].includes(e.key)) e.preventDefault();
}, { passive: false, capture: true });
document.addEventListener('gesturestart', (e) => e.preventDefault(), { passive: false });
document.addEventListener('gesturechange', (e) => e.preventDefault(), { passive: false });
// Strategy 2: counteract via visualViewport
if (window.visualViewport) {
const vv = window.visualViewport;
function counteractZoom() {
// Only counteract browser-initiated zoom (pinch), not our intentional UI zoom
const browserScale = vv.scale;
if (Math.abs(browserScale - 1) > 0.01) {
const inv = 1 / browserScale;
document.body.style.transform = `scale(${inv})`;
document.body.style.transformOrigin = `${vv.offsetLeft}px ${vv.offsetTop}px`;
document.body.style.width = (browserScale * 100) + '%';
document.body.style.height = (browserScale * 100) + '%';
} else {
document.body.style.transform = '';
document.body.style.transformOrigin = '';
document.body.style.width = '';
document.body.style.height = '';
}
}
vv.addEventListener('resize', counteractZoom);
vv.addEventListener('scroll', counteractZoom);
}
// ══════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════
let serverUrl = '';
let authHeader = '';
let currentView = 'libraries';
let currentLibrary = null;
let currentSeries = null;
let currentBook = null;
let currentPage = 0;
let totalPages = 0;
let scale = 1, panX = 0, panY = 0;
let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
let fitMode = 'width';
let naturalW = 0, naturalH = 0;
let pageMode = 'single'; // 'single' or 'double'
const el = id => document.getElementById(id);
const $loginScreen = el('login-screen');
const $app = el('app');
const $serverUrl = el('server-url');
const $username = el('username');
const $password = el('password');
const $loginBtn = el('login-btn');
const $loginError = el('login-error');
const $serverDisplay = el('server-display');
const $breadcrumb = el('breadcrumb');
const $sidebarList = el('sidebar-list');
const $sidebar = el('sidebar');
const $readerArea = el('reader-area');
const $readerPlaceholder = el('reader-placeholder');
const $imageContainer = el('image-container');
const $comicImage = el('comic-image');
const $readerControls = el('reader-controls');
const $pageIndicator = el('page-indicator');
const $zoomIndicator = el('zoom-indicator');
const $loadingOverlay = el('loading-overlay');
const $helpHint = el('help-hint');
const $fitMenu = el('fit-menu');
// ══════════════════════════════════════════════
// API
// ══════════════════════════════════════════════
async function api(path) {
const resp = await fetch(serverUrl.replace(/\/$/, '') + path, {
headers: { 'Authorization': authHeader }
});
if (!resp.ok) throw new Error(`API ${resp.status}`);
return resp.json();
}
function thumbnailUrl(type, id) {
return serverUrl.replace(/\/$/, '') + `/api/v1/${type}/${id}/thumbnail`;
}
function authImg(img, url) {
img.onerror = () => {
fetch(url, { headers: { 'Authorization': authHeader } })
.then(r => r.blob())
.then(blob => img.src = URL.createObjectURL(blob))
.catch(() => {});
};
img.src = url;
}
// ══════════════════════════════════════════════
// LOGIN
// ══════════════════════════════════════════════
$loginBtn.addEventListener('click', doLogin);
document.querySelectorAll('#login-screen input').forEach(i =>
i.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); })
);
const saved = localStorage.getItem('komga-reader-conn');
if (saved) {
try {
const s = JSON.parse(saved);
$serverUrl.value = s.url || '';
$username.value = s.user || '';
if (s.url && s.auth) {
serverUrl = s.url;
authHeader = s.auth;
api('/api/v1/libraries').then(() => showApp()).catch(() => {
// Saved session expired, show login
authHeader = '';
});
}
} catch(e) {}
}
async function doLogin() {
const url = $serverUrl.value.trim();
const user = $username.value.trim();
const pass = $password.value;
if (!url || !user) return;
$loginBtn.disabled = true;
$loginBtn.textContent = 'Connecting…';
$loginError.style.display = 'none';
serverUrl = url;
authHeader = 'Basic ' + btoa(user + ':' + pass);
try {
await api('/api/v1/libraries');
localStorage.setItem('komga-reader-conn', JSON.stringify({ url, user, auth: authHeader }));
showApp();
} catch (e) {
$loginError.textContent = 'Connection failed. Check URL and credentials.';
$loginError.style.display = 'block';
} finally {
$loginBtn.disabled = false;
$loginBtn.textContent = 'Connect';
}
}
function showApp() {
$loginScreen.style.display = 'none';
$app.style.display = 'block';
try { $serverDisplay.textContent = new URL(serverUrl).host; } catch(e) { $serverDisplay.textContent = serverUrl; }
loadLibraries();
}
// ══════════════════════════════════════════════
// NAVIGATION
// ══════════════════════════════════════════════
async function loadLibraries() {
currentView = 'libraries';
currentLibrary = null;
currentSeries = null;
updateBreadcrumb();
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">Loading…</div>';
try {
const [libs, keepReading] = await Promise.all([
api('/api/v1/libraries'),
api('/api/v1/books?read_status=IN_PROGRESS&sort=readProgress.readDate,desc&size=20')
.catch(() => ({ content: [] }))
]);
$sidebarList.innerHTML = '';
// ── Continue Reading section ──
const inProgress = keepReading.content || keepReading;
if (inProgress.length > 0) {
appendLabel('Continue Reading');
inProgress.forEach(book => {
const progress = book.readProgress;
const pagesCount = book.media?.pagesCount || 0;
const pct = pagesCount > 0 ? Math.round((progress.page / pagesCount) * 100) : 0;
const seriesTitle = book.seriesTitle || '';
$sidebarList.appendChild(makeListItem({
id: book.id,
title: book.metadata?.title || book.name,
meta: (seriesTitle ? seriesTitle + ' · ' : '') + `p.${progress.page}/${pagesCount} · ${pct}%`,
thumbUrl: thumbnailUrl('books', book.id),
onClick: () => openBook(book),
progress: pct,
}));
});
const divider = document.createElement('div');
divider.className = 'section-divider';
$sidebarList.appendChild(divider);
}
// ── Libraries section ──
appendLabel('Libraries');
libs.forEach(lib => {
$sidebarList.appendChild(makeListItem({
id: lib.id,
title: lib.name,
meta: lib.root,
icon: '📚',
onClick: () => loadSeries(lib),
}));
});
} catch(e) {
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--danger);font-size:13px;">Failed to load</div>';
}
}
async function loadSeries(library) {
currentView = 'series';
currentLibrary = library;
currentSeries = null;
updateBreadcrumb();
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">Loading…</div>';
try {
const data = await api(`/api/v1/series?library_id=${library.id}&size=500&sort=metadata.titleSort,asc`);
const list = data.content || data;
$sidebarList.innerHTML = '';
list.forEach(s => {
$sidebarList.appendChild(makeListItem({
id: s.id,
title: s.metadata?.title || s.name,
meta: `${s.booksCount || '?'} book${s.booksCount !== 1 ? 's' : ''}`,
thumbUrl: thumbnailUrl('series', s.id),
onClick: () => loadBooks(s),
}));
});
if (!list.length) $sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">No series</div>';
} catch(e) {
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--danger);font-size:13px;">Failed to load</div>';
}
}
async function loadBooks(series) {
currentView = 'books';
currentSeries = series;
updateBreadcrumb();
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">Loading…</div>';
try {
const data = await api(`/api/v1/series/${series.id}/books?sort=metadata.numberSort,asc&size=500`);
const list = data.content || data;
$sidebarList.innerHTML = '';
list.forEach(b => {
const pagesCount = b.media?.pagesCount || 0;
const progress = b.readProgress;
let meta = `${pagesCount} pages`;
let pct = null;
if (progress && !progress.completed && progress.page > 0) {
pct = pagesCount > 0 ? Math.round((progress.page / pagesCount) * 100) : 0;
meta += ` · ${pct}%`;
} else if (progress && progress.completed) {
meta += ' · done';
}
$sidebarList.appendChild(makeListItem({
id: b.id,
title: b.metadata?.title || b.name,
meta,
thumbUrl: thumbnailUrl('books', b.id),
onClick: () => openBook(b),
progress: pct,
}));
});
if (!list.length) $sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">No books</div>';
} catch(e) {
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--danger);font-size:13px;">Failed to load</div>';
}
}
function appendLabel(text) {
const l = document.createElement('div');
l.className = 'section-label';
l.textContent = text;
$sidebarList.appendChild(l);
}
function makeListItem(item) {
const div = document.createElement('div');
div.className = 'list-item';
if (currentBook && item.id === currentBook.id) div.classList.add('active');
if (item.thumbUrl) {
const img = document.createElement('img');
img.className = 'list-item-thumb';
img.loading = 'lazy';
authImg(img, item.thumbUrl);
div.appendChild(img);
} else if (item.icon) {
const icon = document.createElement('div');
icon.className = 'list-item-icon';
icon.textContent = item.icon;
div.appendChild(icon);
}
const info = document.createElement('div');
info.className = 'list-item-info';
const title = document.createElement('div');
title.className = 'list-item-title';
title.textContent = item.title;
info.appendChild(title);
const meta = document.createElement('div');
meta.className = 'list-item-meta';
meta.textContent = item.meta;
info.appendChild(meta);
if (item.progress != null && item.progress > 0 && item.progress < 100) {
const bar = document.createElement('div');
bar.className = 'progress-bar';
const fill = document.createElement('div');
fill.className = 'progress-bar-fill';
fill.style.width = item.progress + '%';
bar.appendChild(fill);
info.appendChild(bar);
}
div.appendChild(info);
div.addEventListener('click', () => item.onClick());
return div;
}
function updateBreadcrumb() {
const parts = [{ label: 'Libraries', onClick: loadLibraries, current: currentView === 'libraries' }];
if (currentLibrary) parts.push({ label: currentLibrary.name, onClick: () => loadSeries(currentLibrary), current: currentView === 'series' });
if (currentSeries) parts.push({ label: currentSeries.metadata?.title || currentSeries.name, onClick: () => loadBooks(currentSeries), current: currentView === 'books' });
$breadcrumb.innerHTML = '';
parts.forEach((p, i) => {
if (i > 0) { const sep = document.createElement('span'); sep.className = 'breadcrumb-sep'; sep.textContent = ''; $breadcrumb.appendChild(sep); }
const s = document.createElement('span');
s.className = 'breadcrumb-item' + (p.current ? ' current' : '');
s.textContent = p.label;
if (!p.current) s.addEventListener('click', p.onClick);
$breadcrumb.appendChild(s);
});
}
// ══════════════════════════════════════════════
// BOOK READER
// ══════════════════════════════════════════════
async function openBook(book) {
currentBook = book;
currentPage = 1;
document.querySelectorAll('.list-item').forEach(e => e.classList.remove('active'));
try {
const pages = await api(`/api/v1/books/${book.id}/pages`);
totalPages = pages.length;
try {
const bd = await api(`/api/v1/books/${book.id}`);
if (bd.readProgress && !bd.readProgress.completed) currentPage = bd.readProgress.page || 1;
} catch(e) {}
$readerPlaceholder.style.display = 'none';
$imageContainer.style.display = 'block';
$readerControls.style.display = 'flex';
$helpHint.style.display = 'block';
setTimeout(() => { $helpHint.style.display = 'none'; }, 4000);
loadPage(currentPage);
} catch(e) {
console.error('Failed to open book', e);
}
}
function fetchPageBlob(pageNum) {
const url = serverUrl.replace(/\/$/, '') + `/api/v1/books/${currentBook.id}/pages/${pageNum}`;
return fetch(url, { headers: { 'Authorization': authHeader } }).then(r => r.blob());
}
function blobToImage(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => resolve({ img, url });
img.onerror = reject;
img.src = url;
});
}
const $comicCanvas = el('comic-canvas');
let activeElement = $comicImage; // track which element is visible
function showElement(elem) {
$comicImage.style.display = elem === $comicImage ? '' : 'none';
$comicCanvas.style.display = elem === $comicCanvas ? '' : 'none';
activeElement = elem;
}
function loadPage(pageNum) {
if (pageNum < 1 || pageNum > totalPages) return;
currentPage = pageNum;
$loadingOverlay.style.display = 'flex';
if (pageMode === 'double') {
const leftPage = currentPage;
const rightPage = currentPage + 1 <= totalPages ? currentPage + 1 : null;
$pageIndicator.textContent = rightPage
? `${leftPage}-${rightPage} / ${totalPages}`
: `${leftPage} / ${totalPages}`;
const fetches = [fetchPageBlob(leftPage)];
if (rightPage) fetches.push(fetchPageBlob(rightPage));
Promise.all(fetches)
.then(blobs => Promise.all(blobs.map(blobToImage)))
.then(pages => {
const left = pages[0];
const right = pages[1];
const lw = left.img.naturalWidth, lh = left.img.naturalHeight;
const rw = right ? right.img.naturalWidth : 0;
const rh = right ? right.img.naturalHeight : 0;
const gap = 0;
const canvasW = lw + (right ? gap + rw : 0);
const canvasH = Math.max(lh, rh || 0);
$comicCanvas.width = canvasW;
$comicCanvas.height = canvasH;
const ctx = $comicCanvas.getContext('2d');
ctx.fillStyle = '#0a0a0c';
ctx.fillRect(0, 0, canvasW, canvasH);
ctx.drawImage(left.img, 0, (canvasH - lh) / 2);
if (right) ctx.drawImage(right.img, lw + gap, (canvasH - rh) / 2);
URL.revokeObjectURL(left.url);
if (right) URL.revokeObjectURL(right.url);
const prevW = naturalW;
naturalW = canvasW;
naturalH = canvasH;
showElement($comicCanvas);
if (!prevW) applyFitMode(); else centerAtCurrentScale();
$loadingOverlay.style.display = 'none';
updateReadProgress(rightPage || leftPage);
})
.catch(() => { $loadingOverlay.style.display = 'none'; });
} else {
$pageIndicator.textContent = `${currentPage} / ${totalPages}`;
fetchPageBlob(currentPage)
.then(blobToImage)
.then(({ img, url: objUrl }) => {
if ($comicImage.src.startsWith('blob:')) URL.revokeObjectURL($comicImage.src);
$comicImage.src = objUrl;
const prevW = naturalW;
naturalW = img.naturalWidth;
naturalH = img.naturalHeight;
showElement($comicImage);
if (!prevW) applyFitMode(); else centerAtCurrentScale();
$loadingOverlay.style.display = 'none';
updateReadProgress(currentPage);
})
.catch(() => { $loadingOverlay.style.display = 'none'; });
}
}
function pageStep() {
return pageMode === 'double' ? 2 : 1;
}
function updateReadProgress(page) {
fetch(serverUrl.replace(/\/$/, '') + `/api/v1/books/${currentBook.id}/read-progress`, {
method: 'PATCH',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ page, completed: page >= totalPages })
}).catch(() => {});
}
// ══════════════════════════════════════════════
// ZOOM & PAN
// ══════════════════════════════════════════════
function applyFitMode() {
const cw = $imageContainer.clientWidth, ch = $imageContainer.clientHeight;
if (!naturalW || !naturalH) return;
switch (fitMode) {
case 'width': scale = cw / naturalW; break;
case 'height': scale = ch / naturalH; break;
case 'both': scale = Math.min(cw / naturalW, ch / naturalH); break;
case 'original': scale = 1; break;
default: scale = cw / naturalW;
}
panX = Math.max(0, (cw - naturalW * scale) / 2);
panY = Math.max(0, (ch - naturalH * scale) / 2);
updateTransform();
}
function centerAtCurrentScale() {
const cw = $imageContainer.clientWidth, ch = $imageContainer.clientHeight;
if (!naturalW || !naturalH) return;
const scaledW = naturalW * scale;
const scaledH = naturalH * scale;
panX = (cw - scaledW) / 2;
panY = Math.max(0, (ch - scaledH) / 2);
updateTransform();
}
function updateTransform() {
const t = `translate(${panX}px, ${panY}px) scale(${scale})`;
activeElement.style.transform = t;
$zoomIndicator.textContent = Math.round(scale * 100) + '%';
}
function zoomAt(cx, cy, delta) {
const oldScale = scale;
scale = Math.max(0.1, Math.min(10, scale * (delta > 0 ? 0.92 : 1.08)));
const imgX = (cx - panX) / oldScale;
const imgY = (cy - panY) / oldScale;
panX = cx - imgX * scale;
panY = cy - imgY * scale;
updateTransform();
}
function zoomStep(dir) {
zoomAt($imageContainer.clientWidth / 2, $imageContainer.clientHeight / 2, dir);
}
// ── ALL wheel on reader = zoom (no ctrl needed) ──
$imageContainer.addEventListener('wheel', (e) => {
e.preventDefault();
e.stopPropagation();
const rect = $imageContainer.getBoundingClientRect();
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
zoomAt(e.clientX - rect.left, e.clientY - rect.top, delta);
}, { passive: false });
// ── Touch pinch ──
let lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
$imageContainer.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const t = Array.from(e.touches);
lastTouchDist = getTouchDist(t);
const m = getTouchMid(t);
lastTouchMidX = m.x; lastTouchMidY = m.y;
}
}, { passive: false });
$imageContainer.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const t = Array.from(e.touches);
const d = getTouchDist(t), m = getTouchMid(t);
const rect = $imageContainer.getBoundingClientRect();
const cx = m.x - rect.left, cy = m.y - rect.top;
const old = scale;
scale = Math.max(0.1, Math.min(10, scale * (d / lastTouchDist)));
const ix = (cx - panX) / old, iy = (cy - panY) / old;
panX = cx - ix * scale + (m.x - lastTouchMidX);
panY = cy - iy * scale + (m.y - lastTouchMidY);
updateTransform();
lastTouchDist = d; lastTouchMidX = m.x; lastTouchMidY = m.y;
}
}, { passive: false });
function getTouchDist(t) { const dx = t[0].clientX-t[1].clientX, dy = t[0].clientY-t[1].clientY; return Math.sqrt(dx*dx+dy*dy); }
function getTouchMid(t) { return { x: (t[0].clientX+t[1].clientX)/2, y: (t[0].clientY+t[1].clientY)/2 }; }
// ── Drag to pan ──
$imageContainer.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
dragStartX = e.clientX; dragStartY = e.clientY;
dragStartPanX = panX; dragStartPanY = panY;
$imageContainer.classList.add('grabbing');
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
panX = dragStartPanX + (e.clientX - dragStartX);
panY = dragStartPanY + (e.clientY - dragStartY);
updateTransform();
});
window.addEventListener('mouseup', () => {
isDragging = false;
$imageContainer.classList.remove('grabbing');
});
$imageContainer.addEventListener('dblclick', (e) => { e.preventDefault(); applyFitMode(); });
// ══════════════════════════════════════════════
// CONTROLS
// ══════════════════════════════════════════════
el('btn-prev').addEventListener('click', () => loadPage(currentPage - pageStep()));
el('btn-next').addEventListener('click', () => loadPage(currentPage + pageStep()));
el('btn-zoom-in').addEventListener('click', () => zoomStep(-1));
el('btn-zoom-out').addEventListener('click', () => zoomStep(1));
el('btn-zoom-reset').addEventListener('click', () => applyFitMode());
el('btn-fit').addEventListener('click', (e) => { e.stopPropagation(); $fitMenu.classList.toggle('open'); });
document.querySelectorAll('.fit-option').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
fitMode = btn.dataset.fit;
document.querySelectorAll('.fit-option').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
$fitMenu.classList.remove('open');
applyFitMode();
});
});
document.addEventListener('click', () => {
$fitMenu.classList.remove('open');
el('shortcuts-modal').classList.remove('open');
});
function toggleSidebar() {
$sidebar.classList.toggle('hidden');
$readerArea.classList.toggle('full');
setTimeout(() => { if (naturalW) applyFitMode(); }, 310);
}
el('btn-toggle-sidebar').addEventListener('click', toggleSidebar);
el('btn-shortcuts').addEventListener('click', (e) => {
e.stopPropagation();
el('shortcuts-modal').classList.toggle('open');
});
// ══════════════════════════════════════════════
// FULLSCREEN
// ══════════════════════════════════════════════
let fsControlsTimeout = null;
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
document.body.classList.add('is-fullscreen');
showFsControls();
} else {
document.body.classList.remove('is-fullscreen', 'show-controls');
clearTimeout(fsControlsTimeout);
// Restore sidebar state
if (!$sidebar.classList.contains('hidden')) {
$readerArea.classList.remove('full');
}
setTimeout(() => { if (naturalW) applyFitMode(); }, 100);
}
setTimeout(() => { if (naturalW) applyFitMode(); }, 100);
});
function showFsControls() {
document.body.classList.add('show-controls');
clearTimeout(fsControlsTimeout);
fsControlsTimeout = setTimeout(() => {
document.body.classList.remove('show-controls');
}, 2500);
}
document.addEventListener('mousemove', () => {
if (document.fullscreenElement) showFsControls();
});
el('btn-fullscreen').addEventListener('click', toggleFullscreen);
function togglePageMode() {
pageMode = pageMode === 'single' ? 'double' : 'single';
const btn = el('btn-page-mode');
btn.textContent = pageMode === 'double' ? '⊟' : '⊡';
btn.title = pageMode === 'double' ? 'Double page (D)' : 'Single page (D)';
if (currentBook) {
naturalW = 0; // force fit mode recalc
loadPage(currentPage);
}
}
el('btn-page-mode').addEventListener('click', togglePageMode);
// ── Keyboard ──
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.ctrlKey || e.metaKey) {
if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomStep(-1); }
else if (e.key === '-') { e.preventDefault(); zoomStep(1); }
else if (e.key === '0') { e.preventDefault(); applyFitMode(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); loadPage(currentPage - pageStep()); }
else if (e.key === 'ArrowRight') { e.preventDefault(); loadPage(currentPage + pageStep()); }
else if (e.key === 'ArrowUp') { e.preventDefault(); zoomStep(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); zoomStep(1); }
return;
}
switch (e.key) {
case 'ArrowLeft': e.preventDefault(); panX += 80; updateTransform(); break;
case 'ArrowRight': e.preventDefault(); panX -= 80; updateTransform(); break;
case 'ArrowUp': e.preventDefault(); panY += 80; updateTransform(); break;
case 'ArrowDown': e.preventDefault(); panY -= 80; updateTransform(); break;
case '+': case '=': e.preventDefault(); zoomStep(-1); break;
case '-': case '_': e.preventDefault(); zoomStep(1); break;
case '0': e.preventDefault(); applyFitMode(); break;
case 'c': case 'C':
e.preventDefault();
panX = ($imageContainer.clientWidth - naturalW * scale) / 2;
updateTransform();
break;
case 'Escape':
el('shortcuts-modal').classList.remove('open');
break;
case 'Tab': e.preventDefault(); toggleSidebar(); break;
case 'd': case 'D':
e.preventDefault();
togglePageMode();
break;
case '?':
e.preventDefault();
el('shortcuts-modal').classList.toggle('open');
break;
case 'f': case 'F':
e.preventDefault();
toggleFullscreen();
break;
}
});
// ══════════════════════════════════════════════
// UI SCALE
// ══════════════════════════════════════════════
let uiScale = parseFloat(localStorage.getItem('komga-ui-scale') || '1');
applyUiScale();
function applyUiScale() {
document.documentElement.style.zoom = uiScale;
el('ui-scale-value').textContent = Math.round(uiScale * 100) + '%';
localStorage.setItem('komga-ui-scale', uiScale);
}
el('ui-scale-up').addEventListener('click', (e) => {
e.stopPropagation();
uiScale = Math.min(2, uiScale + 0.05);
applyUiScale();
});
el('ui-scale-down').addEventListener('click', (e) => {
e.stopPropagation();
uiScale = Math.max(0.5, uiScale - 0.05);
applyUiScale();
});
window.addEventListener('resize', () => { if (naturalW) applyFitMode(); });
})();
</script>
</body>
</html>