Manga Reader: lector web autoalojado con modo libro 3D y estante

This commit is contained in:
Nino
2026-11-06 09:42:34 -06:00
commit e40127265f
45 changed files with 12822 additions and 0 deletions
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// Detector de rostros de anime, en un Worker para no trabar el estante.
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
const integral = (gray, w, h) => {
const I = new Int32Array((w + 1) * (h + 1))
for (let y = 0; y < h; y++) {
let row = 0
for (let x = 0; x < w; x++) {
row += gray[y * w + x]
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
}
}
return I
}
function equalize(gray) {
const hist = new Int32Array(256), n = gray.length
for (let i = 0; i < n; i++) hist[gray[i]]++
let i = 0
while (i < 255 && !hist[i]) i++
const out = new Uint8Array(n), first = hist[i]
if (first === n) return out.fill(i)
const scale = 255 / (n - first), lut = new Uint8Array(256)
let sum = 0
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
return out
}
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
for (let y = 0; y < H; y++) {
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
for (let x = 0; x < W; x++) {
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
}
}
return out
}
function scan(cascade, gray, W, H, step, emit) {
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
for (let wy = 0; wy + ch <= H; wy += step) {
for (let wx = 0; wx + cw <= W; wx += step) {
let pass = true
for (let s = 0; s < stages.length && pass; s++) {
const [limit, weak] = stages[s]
let sum = 0
for (let q = 0; q < weak.length; q++) {
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
const c = p10 - p9 - p6 + p5
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
}
if (sum < limit) pass = false
}
if (pass) emit(wx, wy)
}
}
}
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
function group(rects, minNeighbors, eps = 0.2) {
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
const by = new Map()
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
// Quita los que quedan dentro de otro mayor con más apoyos.
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
}
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
const raw = new Uint8Array(w * h)
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
const gray = equalize(raw), rects = []
for (let f = 1; ; f *= scale) {
const size = Math.round(cascade.w * f)
const W = Math.round(w / f), H = Math.round(h / f)
if (W < cascade.w || H < cascade.h) break
if (size < minSize) continue
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
}
return group(rects, minNeighbors)
}
let cascade = null
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
try {
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
} catch (e) { self.postMessage({ id, faces: null }) }
}
+4585
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+1066
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#bf78fa"/><path d="M14 16h16v32H14zM34 16h16v32H34z" fill="#14121a"/><path d="M18 22h8M18 28h8M18 34h6M38 22h8M38 28h8M38 34h6" stroke="#bf78fa" stroke-width="2.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 298 B

+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="sw-version" content="">
<meta name="b3d-version" content="">
<meta name="shelf-version" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a">
<title>Manga Reader</title>
<link rel="icon" href="/icon.svg">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mangas">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header id="topbar">
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
<nav>
<a href="#/" data-icon="home">Inicio</a>
<a href="#/library" data-icon="library">Librería</a>
<a href="#/shelf" data-icon="shelf">Estante</a>
<a href="#/queues" data-icon="queue">Colas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a>
<a href="#/settings" data-icon="settings">Ajustes</a>
</nav>
</header>
<main id="app"></main>
<div id="toast" hidden></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Manga Reader",
"short_name": "Mangas",
"description": "Lector de manga autoalojado",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#14121a",
"theme_color": "#14121a",
"lang": "es",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
File diff suppressed because it is too large Load Diff
+513
View File
@@ -0,0 +1,513 @@
[hidden] { display: none !important; }
:root {
--bg: #14121a;
--surface: #1e1b26;
--surface-2: #2a2635;
--text: #ece8f4;
--muted: #9a93ab;
--accent: #bf78fa;
--accent-ink: #14121a;
--danger: #ff6b81;
--radius: 10px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; color: inherit; }
img { display: block; }
#topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 16px; background: rgba(20, 18, 26, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--surface-2); }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
#topbar nav { display: flex; gap: 4px; }
#topbar nav a { padding: 6px 10px; border-radius: 8px; color: var(--muted); }
#topbar nav a.active, #topbar nav a:hover { color: var(--text); background: var(--surface-2); }
body.reading #topbar { display: none; }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
body.reading main { max-width: none; padding: 0; }
h1 { font-size: 1.6rem; margin: 0 0 8px; }
h2 { font-size: 1.15rem; margin: 24px 0 12px; }
.muted { color: var(--muted); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--surface-2);
border-radius: var(--radius); background: var(--surface); cursor: pointer; }
.btn:hover { background: var(--surface-2); }
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.small { padding: 5px 10px; font-size: .88rem; }
.btn.danger { color: var(--danger); }
input[type=text], input[type=password], input[type=search], select {
padding: 9px 12px; border-radius: var(--radius); border: 1px solid var(--surface-2); background: var(--surface); outline: none; }
input:focus, select:focus { border-color: var(--accent); }
.auth { max-width: 360px; margin: 10vh auto; background: var(--surface); padding: 24px; border-radius: 14px; }
.auth form { display: grid; gap: 12px; }
.error { color: var(--danger); min-height: 1.2em; margin: 0; }
.search { display: flex; gap: 8px; margin-bottom: 8px; }
.search input { flex: 1; min-width: 0; }
.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; }
.tabs button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--surface-2); background: transparent; cursor: pointer; color: var(--muted); }
.tabs button.on { background: var(--surface-2); color: var(--text); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }
.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); }
.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); }
/* Margen (no padding) para que el recorte de 2 líneas no deje asomar la 3.ª */
.card .t { padding: 0; margin: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; }
.bar { height: 4px; background: var(--surface-2); }
.bar > i { display: block; height: 100%; background: var(--accent); }
.continue { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 320px); gap: 12px; overflow-x: auto; padding-bottom: 6px; }
.cont { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius); background: var(--surface); align-items: center; }
.cont img { width: 56px; height: 84px; object-fit: cover; border-radius: 6px; background: var(--surface-2); flex: none; }
.cont .info { min-width: 0; flex: 1; }
.cont .info b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cont .bar { margin-top: 6px; border-radius: 2px; }
.detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
.detail .cover { width: 100%; border-radius: var(--radius); aspect-ratio: 2/3; object-fit: cover; background: var(--surface-2); }
.desc { white-space: pre-line; color: #cfc8dc; max-height: 12em; overflow: auto; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.tag { font-size: .78rem; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); }
.chapters { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.chapters a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); align-items: center; }
.chapters a:hover { background: var(--surface-2); }
.chapters a.read { opacity: .5; }
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
.chapters .voltoggle > span { display: grid; gap: 2px; }
.chapters .voltoggle .meta { text-align: left; }
.chapters .volhead > .btn { flex: none; }
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
.history { display: grid; gap: 10px; }
.history .cont { justify-content: space-between; }
.settings { max-width: 480px; display: grid; gap: 16px; }
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
.tableprev[hidden] { display: none; }
.loading { padding: 40px; text-align: center; color: var(--muted); }
/* ---- Lector ---- */
.reader { min-height: 100vh; background: #000; position: relative; }
/* Filtro de luz azul: tono ámbar multiplicado sobre las páginas (bajo las barras del lector). */
.reader.bluelight::after { content: ''; position: fixed; inset: 0; z-index: 25; pointer-events: none;
background: #ff9a3c; mix-blend-mode: multiply; opacity: .42; }
.rbar { position: fixed; left: 0; right: 0; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: rgba(10, 9, 14, .9); backdrop-filter: blur(6px); transition: transform .2s; flex-wrap: wrap; }
.rbar.top { top: 0; padding-top: max(8px, env(safe-area-inset-top)); }
.rbar.bottom { bottom: 0; justify-content: center; padding-bottom: max(8px, env(safe-area-inset-bottom)); }
.rbar.bottom.rtl, .rbar.bottom.rtl a.btn { flex-direction: row-reverse; }
/* Móvil: arriba la página y la barra de progreso; abajo cada botón en su extremo. */
@media (max-width: 640px) {
.rbar.bottom { justify-content: space-between; }
.rbar.bottom #pnum { order: -2; }
.rbar.bottom input[type=range] { order: -1; flex: 1 1 calc(100% - 80px); max-width: none; }
.rbar.bottom .chnext { margin-left: auto; }
.rbar.bottom.rtl .chnext { margin-left: 0; margin-right: auto; }
}
.rbar.bottom.rtl a.btn .ic, .paged[data-dir="rtl"] .chapter-end a.primary .ic { transform: scaleX(-1); }
.reader.hide-ui .rbar.top { transform: translateY(-110%); }
.reader.hide-ui .rbar.bottom { transform: translateY(110%); }
.rbar .ttl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .9rem; }
.rbar select { padding: 5px 8px; }
.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); }
#pages img { filter: var(--page-filter, none); transition: filter .2s, opacity .25s; }
/* Página que aún no ha llegado: indicador de carga y fundido al aparecer */
#pages .stage img.pending { opacity: 0; }
.paged.waiting::after, .paged .stage.loading::after { content: ''; position: absolute; left: 50%; top: 50%; width: 34px; height: 34px;
margin: -17px 0 0 -17px; border-radius: 50%; border: 3px solid rgba(255,255,255,.18); border-top-color: var(--accent);
animation: pgspin .8s linear infinite; z-index: 6; pointer-events: none; }
.paged .stage.loading { min-height: 100%; }
@keyframes pgspin { to { transform: rotate(360deg); } }
.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; }
.paged img { max-width: 100%; max-height: 100%; object-fit: contain; }
.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; }
.paged.fit-width img { max-height: none; width: 100%; max-width: 900px; }
.vertical { display: flex; flex-direction: column; align-items: center; }
.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; }
.vertical img[data-loaded] { min-height: 0; }
/* Vertical para mangas: cada página entera en pantalla, pegadas */
.vertical.fit-height img { width: auto; max-width: 100%; max-height: 100vh; max-height: 100dvh; }
.vertical.fit-height img:not([data-loaded]) { width: min(100%, 640px); }
.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; }
#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2);
padding: 10px 16px; border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.4); }
@media (max-width: 640px) {
.detail { grid-template-columns: 1fr; }
.detail .cover { max-width: 180px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; }
#topbar .brand span { display: none; }
#topbar nav a { padding: 6px 8px; }
}
/* Categorías y descargas */
.cats h3 { font-size: .95rem; color: var(--muted); margin: 18px 0 8px; }
a.tag { cursor: pointer; }
a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
.tag.big { font-size: .88rem; padding: 5px 12px; }
.dlbox { display: grid; gap: 6px; margin-top: 10px; max-width: 520px; }
.dlbar { height: 8px; border-radius: 4px; overflow: hidden; }
.dlbar > i { transition: width .6s ease; }
.history .cont a.info { display: block; min-width: 0; flex: 1; }
/* Iconos */
.ic { width: 1.15em; height: 1.15em; flex: none; vertical-align: -0.2em; }
#topbar nav a { display: inline-flex; align-items: center; gap: 6px; }
.tabs button { display: inline-flex; align-items: center; gap: 6px; }
.chapters .meta .ic { width: 1em; height: 1em; }
@media (max-width: 560px) {
#topbar nav a span { display: none; } /* en móvil solo iconos en el menú */
#topbar nav a { padding: 8px 10px; }
#topbar nav a .ic { width: 1.35em; height: 1.35em; }
.search .btn .lbl { display: none; }
}
/* Lector: escenario de páginas, doble página y zoom */
.paged { overflow: hidden; touch-action: none; }
.paged.fit-width:not(.zoomed) { overflow: visible; touch-action: pan-y; }
.paged .stage { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; transform-origin: 0 0; will-change: transform; }
.paged.fit-width .stage { height: auto; align-items: flex-start; }
.paged[data-dir="rtl"] .stage { flex-direction: row-reverse; }
.paged .stage.two img { max-width: 50%; }
.paged.fit-width .stage.two img { width: 50%; max-width: 700px; }
.paged img { -webkit-user-drag: none; user-select: none; }
.paged.zoomed { cursor: grab; }
/* Insignias y botón Seguir */
.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; }
.chapters .meta .queued { opacity: .55; }
.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); }
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
.btn.on { border-color: var(--accent); color: var(--accent); }
h2 .ic { color: var(--accent); }
/* Estadísticas */
:root { --viz-1: #5b3f8c; --viz-2: #7d4fc0; --viz-3: #a067e3; --viz-4: #c993ff; --viz-5: #ecd6ff; }
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; }
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; }
.tile .tv { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
.tile .tl { color: var(--muted); font-size: .88rem; }
.tile .ts { color: var(--muted); font-size: .78rem; margin-top: 4px; }
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
.panel { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; min-width: 0; }
.panel h3 { margin: 0 0 10px; font-size: .95rem; }
.chart { width: 100%; height: auto; display: block; }
.chart .grid { stroke: var(--surface-2); stroke-width: 1; }
.chart .axis { fill: var(--muted); font-size: 11px; }
.chart .mark { fill: var(--accent); }
.chart .hit { fill: transparent; }
.chart .col:hover .hit { fill: rgba(255, 255, 255, .04); }
.heat-wrap { overflow-x: auto; }
.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-auto-columns: 14px; gap: 3px; }
.cell { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: var(--surface-2); }
.cell.empty { background: transparent; }
.cell.l1 { background: var(--viz-1); } .cell.l2 { background: var(--viz-2); } .cell.l3 { background: var(--viz-3); }
.cell.l4 { background: var(--viz-4); } .cell.l5 { background: var(--viz-5); }
.heat-legend { display: flex; align-items: center; gap: 3px; font-size: .8rem; margin-top: 8px; }
.hbars { display: grid; gap: 8px; }
.hbar { display: grid; grid-template-columns: minmax(90px, 140px) 1fr 3em; gap: 8px; align-items: center; font-size: .88rem; }
.hbar .hl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar .ht { height: 10px; background: var(--surface-2); border-radius: 0 4px 4px 0; }
.hbar .ht i { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
.hbar .hv { text-align: right; color: var(--muted); }
.toplist { display: grid; gap: 8px; }
.toplist .cont { padding: 6px 8px; background: var(--bg); }
.toplist .cont img { width: 36px; height: 54px; }
.toplist .rank { width: 1.4em; text-align: center; color: var(--muted); font-weight: 700; }
details.panel summary { cursor: pointer; color: var(--muted); }
table.data { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: .88rem; }
table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--surface-2); }
table.data td:not(:first-child), table.data th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
#tip { position: fixed; z-index: 60; pointer-events: none; background: #000; color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: .82rem; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 280px; }
.rpanel { position: fixed; top: 52px; right: 8px; left: auto; z-index: 31; display: grid; gap: 8px; padding: 12px; width: min(320px, calc(100vw - 16px));
background: rgba(10, 9, 14, .96); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,.5); }
.rpanel select, .rpanel label { width: 100%; }
.rpanel p { margin: 0; font-size: .78rem; }
.reader.hide-ui .rpanel { display: none; }
.chart .axis { font-size: 12px; }
/* Ficha: acciones, menú ⋯ y panel de biblioteca local */
.detail-body { min-width: 0; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.btn.on { border-color: var(--accent); color: var(--accent); }
.facts { display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: .85rem; margin-top: 10px; }
.menu { position: relative; }
.menu > summary { list-style: none; padding-left: 12px; padding-right: 12px; }
.menu > summary::-webkit-details-marker { display: none; }
.menu[open] > summary { background: var(--surface-2); }
.menu-list { position: absolute; left: 0; top: calc(100% + 6px); z-index: 15; min-width: 230px; display: grid; padding: 6px;
background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 10px 28px rgba(0,0,0,.45); }
.menu-list a, .menu-list button { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: 8px;
background: none; text-align: left; cursor: pointer; white-space: nowrap; }
.menu-list a:hover, .menu-list button:hover { background: var(--surface-2); }
.menu-list .danger { color: var(--danger); }
.libpanel { margin-top: 20px; max-width: 640px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; }
.psec, .psec-grid { display: grid; gap: 10px; }
.psec { padding: 14px 16px; }
.psec + .psec { border-top: 1px solid var(--surface-2); }
.psec-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; }
.psec-title { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.psec-title .ic { color: var(--accent); }
.psec-status { font-size: .85rem; display: inline-flex; align-items: center; gap: 4px; }
.psec-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.psec-actions .push { margin-left: auto; }
.note { font-size: .83rem; margin: 0; }
.psec .dlbar { height: 6px; }
.check { display: flex; align-items: center; gap: 8px; font-size: .85rem; color: var(--muted); cursor: pointer; }
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; flex: none; }
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
.hdform { display: grid; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--surface-2); }
.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; justify-self: start; border-radius: 10px; background: var(--surface); border: 1px solid var(--surface-2); }
.seg button { border: 0; background: none; padding: 5px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-size: .88rem; }
.seg button.on { background: var(--surface-2); color: var(--text); }
.range label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; }
.range select { padding: 6px 10px; }
.sum { font-size: .9rem; font-weight: 600; }
.list-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 28px 0 12px; }
.list-head h2 { margin: 0; }
.list-head select { padding: 5px 10px; font-size: .88rem; }
.chapters label.pick { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); cursor: pointer; user-select: none; }
.chapters label.pick:hover { background: var(--surface-2); }
.chapters label.pick .t { flex: 1; min-width: 0; }
.chapters label.pick.off { opacity: .4; cursor: default; }
.chapters label.pick:has(input:checked) { outline: 1px solid var(--accent); }
#selbar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 25; display: flex; align-items: center; gap: 10px;
max-width: calc(100% - 32px); padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--surface-2); border: 1px solid #3a3548;
box-shadow: 0 10px 28px rgba(0,0,0,.5); font-size: .9rem; }
#selbar span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapters.picking { padding-bottom: 72px; }
@media (max-width: 640px) {
.actions .btn.primary { flex: 1 1 100%; }
.libpanel { max-width: none; }
.psec-actions .push { margin-left: 0; }
.seg { display: grid; grid-template-columns: repeat(3, 1fr); justify-self: stretch; }
#selbar { left: 12px; right: 12px; transform: none; max-width: none; }
#selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; }
}
/* Recortes: herramienta del lector */
.cropov { position: fixed; inset: 0; z-index: 40; cursor: crosshair; touch-action: none; background: rgba(0, 0, 0, .25); user-select: none; }
.crop-bar { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1;
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: rgba(20, 18, 26, .94);
box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); }
.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; }
.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; }
.crop-h { position: absolute; width: 44px; height: 44px; pointer-events: auto; touch-action: none; }
.crop-h::after { content: ''; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px; box-sizing: border-box; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.6); }
.crop-h[data-h="nw"] { left: -23px; top: -23px; cursor: nwse-resize; }
.crop-h[data-h="ne"] { right: -23px; top: -23px; cursor: nesw-resize; }
.crop-h[data-h="sw"] { left: -23px; bottom: -23px; cursor: nesw-resize; }
.crop-h[data-h="se"] { right: -23px; bottom: -23px; cursor: nwse-resize; }
.crop-bar em { font-style: normal; }
@media (max-width: 560px) { .crop-bar span { display: none; } }
/* Recortes: galería */
.clips { columns: 220px; column-gap: 12px; }
.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; }
.clip img { display: block; width: 100%; height: auto; max-height: 520px; object-fit: cover; object-position: top; background: var(--surface-2); }
.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; }
.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; }
.clip figcaption span { color: var(--muted); }
.clip figcaption i { color: var(--text); opacity: .85; }
.clip:hover { outline: 1px solid var(--accent); }
@media (max-width: 560px) { .clips { columns: 2; column-gap: 8px; } .clip { margin-bottom: 8px; } }
.lightbox { position: fixed; inset: 0; z-index: 45; background: rgba(8, 7, 12, .94); display: grid; grid-template-columns: 1fr 320px; }
.lightbox[hidden] { display: none; }
.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; }
.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
/* Recorte largo de lectura vertical: a su ancho y con desplazamiento; entero en pantalla sería una tira ilegible. */
.lb-img.long { align-items: flex-start; }
.lb-img.long img { max-height: none; height: auto; flex: none; }
.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
.lb-side h2 a { color: var(--text); }
body.noscroll { overflow: hidden; }
@media (max-width: 760px) {
.lightbox { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.lb-img { padding: 10px; }
.lb-side { max-height: 48vh; padding: 12px 14px max(12px, env(safe-area-inset-bottom)); }
}
/* Historial: tarjetas verticales */
.hcard .bar { margin: 0; height: 5px; }
.hcard .t { margin-bottom: 4px; }
.hcard .sub { margin-top: 0; }
.hcard .corner { position: absolute; top: 8px; left: 8px; }
.hdel { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; border: 0;
background: rgba(20, 18, 26, .75); color: var(--muted); cursor: pointer; opacity: 0; transition: opacity .15s; }
.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; }
.hdel:hover { color: var(--danger); }
@media (hover: none) { .hdel { opacity: 1; } }
/* Colas: tarjetas verticales como en Historial */
.qsec { margin-bottom: 26px; }
.qsec h2 { display: flex; align-items: center; gap: 8px; }
.qcard { display: flex; flex-direction: column; }
.qcard > a { color: inherit; }
.qcard .sub { display: grid; gap: 1px; padding-bottom: 8px; }
.qcard .sub .error { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.qact { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; margin-top: auto; }
.qact .btn { flex: 1; padding: 6px 8px; font-size: .8rem; }
.qpos { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(20, 18, 26, .85); color: var(--text); font-size: .8rem; font-weight: 700; }
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
.small { font-size: .82rem; }
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
/* Librería */
.libtabs button .muted { font-size: .78rem; }
.lhead { gap: 8px; flex-wrap: wrap; margin: -4px 0 12px; }
.lhead .small { flex: 1; min-width: 200px; }
.lcheck { padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.lcheck:hover { background: var(--surface-2); }
.chapters .meta .dev .ic { color: var(--accent); }
/* Recomendaciones: fila desplazable */
.rhead { gap: 10px; flex-wrap: wrap; align-items: baseline; }
.rhead h2 { margin-bottom: 4px; }
.rhead > span.small { flex: 1; min-width: 180px; }
.rhead .small a { color: var(--accent); }
.recrow { display: grid; grid-auto-flow: column; grid-auto-columns: 140px; gap: 12px; overflow-x: auto; padding-bottom: 8px; scroll-snap-type: x mandatory; }
.recrow .card { scroll-snap-align: start; }
@media (max-width: 640px) { .recrow { grid-auto-columns: 115px; gap: 10px; } }
.banlist { display: grid; gap: 6px; margin-top: 8px; } .banlist .row { gap: 8px; flex-wrap: wrap; }
/* Aviso: el servidor no tiene internet */
#netbanner { position: sticky; top: 53px; z-index: 19; display: flex; align-items: center; gap: 10px; padding: 8px 16px;
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
#netbanner svg { flex: none; width: 18px; height: 18px; }
body.reading #netbanner { display: none; }
/* Animación al pasar página (modos por página) */
.paged { position: relative; perspective: 2200px; }
.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; }
.paged.fit-width .stage.leaving { bottom: auto; }
.paged .curlgl { position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; filter: var(--page-filter, none); }
.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
.paged .curl .sf { position: absolute; top: 0; bottom: 0; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
.paged .curl .back { transform: rotateY(180deg); }
.paged .curl .back.paper { background: #e9e5dc; }
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
/* Hendidura del lomo: la página se funde con el negro de detrás. Los puntos coinciden con gutter() en app.js. */
.paged img[data-gutter="l"], .paged img[data-gutter="r"] {
--gutter-stops: rgba(0,0,0,.4) 0, rgba(0,0,0,.6) 1.2%, rgba(0,0,0,.8) 3%, rgba(0,0,0,.93) 6%, #000 10%;
--gutter-mask: linear-gradient(var(--gutter-side), var(--gutter-stops));
-webkit-mask-image: var(--gutter-mask);
mask-image: var(--gutter-mask);
}
.paged img[data-gutter="l"] { --gutter-side: to right; }
.paged img[data-gutter="r"] { --gutter-side: to left; }
.paged .panmask { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: #000; }
.paged .leafkeepback { position: absolute; z-index: 2; pointer-events: none; background: #000; }
.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
/* Info de la obra */
.chapters .meta .cdate { white-space: nowrap; }
.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
.banner[hidden] { display: none; }
.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
.detail-body .info-block h2:first-child { margin-top: 18px; }
.detail-body .info-block + .tags { margin-top: 14px; }
.drawer .kv { margin: 0; }
.info-block .desc.long { max-height: none; }
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
.info-block > .note { margin-top: 16px; }
.kv:empty, #mscores:empty { display: none; }
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
.list-head { scroll-margin-top: 64px; }
/* Menú lateral de la ficha (botón ⋯) */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
.drawer.open { transform: none; visibility: visible; }
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
.drawer-opts { display: grid; gap: 8px; }
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
.drawer-back[hidden] { display: none; }
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
@keyframes libpulse { 50% { opacity: .25; } }
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
.score b { font-size: 1.25rem; }
.score span { font-size: .75rem; color: var(--muted); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 14px 0; font-size: .92rem; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
.tag.hot { color: var(--accent); }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; }
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
#shelf3d canvas { display: block; width: 100%; height: 100%; }
.shelftools { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); display: flex; gap: 8px; }
.shelfnote:empty { display: none; } /* vacía no se ve; [hidden] dice que ya llegaron los tomos */
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
.libhead { justify-content: space-between; align-items: center; }
#b3d { position: fixed; inset: 0; z-index: 0; }
#b3d[hidden] { display: none; }
#b3d canvas { display: block; width: 100%; height: 100%; }
.reader.b3d { background: #4a2e19; }
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
.reader.b3d > #pages img { visibility: hidden; }
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
.reader.b3d > #pages > * { pointer-events: none; }
/* El final del capítulo, sobre la contraportada y con sus colores. */
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
/* Para recortar se ve la página plana. */
.reader.b3d.b3dcrop { background: #000; }
.reader.b3d.b3dcrop > #b3d { display: none; }
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
/* Vistazo del estante: la nota del tomo sacado, como un pósit pegado junto a él (a la derecha en apaisado, abajo en vertical).
Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */
.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg;
position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np);
box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); }
.shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); }
.shelfpeek .ptitle { margin: 0 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; }
.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; }
.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; }
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; }
.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); }
.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; }
@media (orientation: portrait) {
.shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); }
.shelfpeek.on { transform: rotate(var(--nt)); }
}
body.handing .reader .loading { visibility: hidden; }
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
.handoff.out { opacity: 0; }
@media (max-width: 420px) { #topbar nav a { padding: 8px 7px; } }
+80
View File
@@ -0,0 +1,80 @@
// Service worker: la app abre sin conexión (shell en caché) y los capítulos guardados "en este
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
async function installShell() {
const c = await caches.open(SHELL)
const html = await fetch('/', { cache: 'reload' })
const text = await html.clone().text()
const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1])
const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))]
for (const f of files) await c.delete(f, { ignoreSearch: true })
await c.addAll(files.map(u => new Request(u, { cache: 'reload' })))
await c.put('/', html)
}
self.addEventListener('install', e => {
e.waitUntil(installShell().then(() => self.skipWaiting()))
})
self.addEventListener('activate', e => {
e.waitUntil(caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
.then(() => self.clients.claim()))
})
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. Si hay copia guardada
// y el servidor no contesta en NET_WAIT ms (red que no llega, servidor caído o colgado), se usa la copia
// sin esperar a que el navegador dé la conexión por perdida; la petición sigue y actualiza la caché.
const NET_WAIT = 3000
const DOWN_FOR = 20e3
let downUntil = 0
// La app distingue así una copia guardada de una respuesta real del servidor.
async function fromCache(res) {
const headers = new Headers(res.headers)
headers.set('x-from-cache', '1')
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
}
// single: guardar solo la última versión (app.js?v=… sustituye a la anterior).
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) {
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
const net = fetch(req).then(async res => {
if (store && res.ok) {
const c = await caches.open(cacheName)
if (single) await c.delete(req, { ignoreSearch: true })
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
}
return res
})
net.then(() => { downUntil = 0 }, () => {})
const hit = await cached()
if (!hit) return net // sin copia: solo queda esperar a la red
// Si hace poco que el servidor no contestaba, la copia directamente (la red sigue intentándolo).
if (Date.now() < downUntil) return fromCache(hit)
let timer
const timeout = new Promise(r => { timer = setTimeout(() => r(null), NET_WAIT) })
const res = await Promise.race([net.catch(() => null), timeout])
clearTimeout(timer)
if (!res) downUntil = Date.now() + DOWN_FOR
return res || fromCache(hit)
}
self.addEventListener('fetch', e => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== location.origin) return
const p = url.pathname
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true }))
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true }))
// Imágenes: si están guardadas, sin red (más rápido y sin gastar datos).
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) {
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
}
})