Recortes de paneles desde el lector, reescalados aparte, con galería y acceso directo a la página
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+236
-2
@@ -35,6 +35,8 @@ const ICONS = {
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
|
||||
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
|
||||
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
|
||||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
||||
}
|
||||
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
@@ -138,6 +140,7 @@ async function router() {
|
||||
if (parts[0] === 'history') return await viewHistory()
|
||||
if (parts[0] === 'downloads') return await viewDownloads()
|
||||
if (parts[0] === 'stats') return await viewStats()
|
||||
if (parts[0] === 'clips') return await viewClips(params)
|
||||
if (parts[0] === 'settings') return viewSettings()
|
||||
return await viewHome(params)
|
||||
} catch (e) {
|
||||
@@ -302,7 +305,7 @@ async function viewManga(id, langOverride) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const dlSet = new Set(downloaded)
|
||||
const newSet = new Set(newKeys.map(String))
|
||||
let isFollowing = following
|
||||
@@ -336,7 +339,7 @@ async function viewManga(id, langOverride) {
|
||||
<button class="btn" id="follow"></button>
|
||||
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
|
||||
</div>
|
||||
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}</div>
|
||||
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
|
||||
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
||||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
|
||||
</div>
|
||||
@@ -736,6 +739,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||||
<button class="btn small" id="modequick"></button>
|
||||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
@@ -1047,7 +1051,118 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
|
||||
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
|
||||
|
||||
// --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD)
|
||||
// y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas del documento para
|
||||
// poder desplazarse (rueda) en vertical mientras se recorta.
|
||||
let crop = null
|
||||
function endCrop() {
|
||||
if (!crop) return
|
||||
crop.ov.remove()
|
||||
window.removeEventListener('scroll', crop.draw)
|
||||
crop = null
|
||||
$('#clipbtn')?.classList.remove('on')
|
||||
}
|
||||
function startCrop() {
|
||||
if (crop) return endCrop()
|
||||
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
|
||||
reader.classList.remove('hide-ui')
|
||||
$('.rpanel', reader).hidden = true
|
||||
const ov = document.createElement('div')
|
||||
ov.className = 'cropov'
|
||||
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
|
||||
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
|
||||
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
|
||||
<div class="crop-rect" hidden></div>`
|
||||
reader.append(ov)
|
||||
$('#clipbtn').classList.add('on')
|
||||
const rectEl = $('.crop-rect', ov)
|
||||
crop = { ov, r: null, start: null }
|
||||
crop.draw = () => {
|
||||
const r = crop?.r
|
||||
if (!r) return
|
||||
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
|
||||
rectEl.hidden = false
|
||||
}
|
||||
window.addEventListener('scroll', crop.draw, { passive: true })
|
||||
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
|
||||
ov.addEventListener('pointerdown', e => {
|
||||
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0)) return
|
||||
ov.setPointerCapture(e.pointerId)
|
||||
crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY }
|
||||
crop.r = null
|
||||
rectEl.hidden = true
|
||||
$('[data-a=save]', ov).disabled = true
|
||||
})
|
||||
ov.addEventListener('pointermove', e => {
|
||||
if (!crop.start) return
|
||||
const x = e.clientX + scrollX, y = e.clientY + scrollY
|
||||
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
|
||||
crop.draw()
|
||||
})
|
||||
const up = () => {
|
||||
if (!crop?.start) return
|
||||
crop.start = null
|
||||
const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12
|
||||
if (!ok) { crop.r = null; rectEl.hidden = true }
|
||||
$('[data-a=save]', ov).disabled = !ok
|
||||
}
|
||||
ov.addEventListener('pointerup', up)
|
||||
ov.addEventListener('pointercancel', up)
|
||||
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
|
||||
$('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button')))
|
||||
}
|
||||
// La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen.
|
||||
function cropTarget(r) {
|
||||
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
|
||||
let best = null
|
||||
for (const img of box.querySelectorAll('img')) {
|
||||
if (!img.complete || !img.naturalWidth) continue
|
||||
const b = img.getBoundingClientRect()
|
||||
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
|
||||
if (w > 0 && h > 0 && (!best || w * h > best.area)) best = { img, b, area: w * h }
|
||||
}
|
||||
if (!best) return null
|
||||
const { img, b } = best
|
||||
const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top
|
||||
const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top
|
||||
return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height }
|
||||
}
|
||||
async function saveCrop(btn) {
|
||||
const t = crop?.r && cropTarget(crop.r)
|
||||
if (!t) return toast('Marca una zona sobre la página')
|
||||
btn.disabled = true
|
||||
try {
|
||||
const u = new URL(t.img.currentSrc || t.img.src, location.href)
|
||||
u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo
|
||||
const src = await new Promise((resolve, reject) => {
|
||||
const im = new Image()
|
||||
im.onload = () => resolve(im)
|
||||
im.onerror = () => reject(new Error('No se pudo cargar la página'))
|
||||
im.src = u.href
|
||||
})
|
||||
const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight)
|
||||
const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight))
|
||||
if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña')
|
||||
const k = Math.min(1, 4000 / Math.max(sw, sh))
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = Math.round(sw * k); cv.height = Math.round(sh * k)
|
||||
cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height)
|
||||
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
|
||||
const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
|
||||
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||||
endCrop()
|
||||
toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD')
|
||||
} catch (err) {
|
||||
toast(err.message)
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
$('#clipbtn').addEventListener('click', startCrop)
|
||||
|
||||
const onKey = e => {
|
||||
if (crop) { if (e.key === 'Escape') endCrop(); return }
|
||||
if (!isPaged() || e.target.matches('input,select')) return
|
||||
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
|
||||
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
|
||||
@@ -1080,6 +1195,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
|
||||
render()
|
||||
cleanup = () => {
|
||||
endCrop()
|
||||
if (saveTimer) saveNow()
|
||||
observer?.disconnect()
|
||||
clearTimeout(uiTimer)
|
||||
@@ -1091,6 +1207,124 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Recortes ----------
|
||||
const clipStatus = c => c.status === 'done' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
|
||||
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
|
||||
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
|
||||
async function viewClips(params) {
|
||||
const mangaId = params.get('manga')
|
||||
const here = () => location.hash.split('?')[0] === '#/clips'
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`)
|
||||
if (!here()) return
|
||||
const total = data.mangas.reduce((a, m) => a + m.n, 0)
|
||||
app.innerHTML = `<h1>${icon('scissors')} Recortes</h1>
|
||||
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
|
||||
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
|
||||
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
|
||||
<div id="cgrid" class="clips"></div>
|
||||
<div id="lb" class="lightbox" hidden></div>`
|
||||
app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' }))
|
||||
const grid = $('#cgrid'), lb = $('#lb')
|
||||
let open = -1, showHd = true
|
||||
|
||||
function renderGrid() {
|
||||
grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
|
||||
<figure class="clip" data-i="${i}">
|
||||
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
|
||||
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
|
||||
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
|
||||
</figure>`).join('')
|
||||
: `<p class="muted">Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.</p>`
|
||||
grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i))))
|
||||
}
|
||||
function openClip(i) {
|
||||
open = i
|
||||
const c = data.clips[i]
|
||||
if (!c) return closeClip()
|
||||
const src = c.hd && showHd ? c.hd : c.img
|
||||
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
|
||||
lb.innerHTML = `
|
||||
<div class="lb-img"><img src="${src}" alt=""></div>
|
||||
<div class="lb-side">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<span class="muted">${i + 1} / ${data.clips.length}</span>
|
||||
<span class="row" style="gap:6px">
|
||||
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
|
||||
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
|
||||
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
|
||||
</span>
|
||||
</div>
|
||||
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
|
||||
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
|
||||
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
|
||||
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
|
||||
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
|
||||
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
|
||||
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
|
||||
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
|
||||
<div class="row" style="gap:6px;flex-wrap:wrap">
|
||||
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
|
||||
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
|
||||
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
|
||||
</div>
|
||||
</div>`
|
||||
lb.hidden = false
|
||||
document.body.classList.add('noscroll')
|
||||
lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1))
|
||||
lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1))
|
||||
lb.querySelector('[data-a=close]').addEventListener('click', closeClip)
|
||||
$('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) })
|
||||
$('#note', lb).addEventListener('change', async e => {
|
||||
try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) }
|
||||
})
|
||||
lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => {
|
||||
try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) }
|
||||
})
|
||||
lb.querySelector('[data-a=del]').addEventListener('click', async () => {
|
||||
if (!confirm('¿Eliminar este recorte?')) return
|
||||
try {
|
||||
await api(`/api/clips/${c.id}`, { method: 'DELETE' })
|
||||
data.clips.splice(open, 1)
|
||||
renderGrid()
|
||||
data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip()
|
||||
} catch (err) { toast(err.message) }
|
||||
})
|
||||
}
|
||||
function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') }
|
||||
lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() })
|
||||
const onKey = e => {
|
||||
if (open < 0 || e.target.matches('input')) return
|
||||
if (e.key === 'Escape') closeClip()
|
||||
else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1)
|
||||
else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
|
||||
// Mientras haya recortes en cola, refrescar su estado.
|
||||
let timer = null
|
||||
function poll() {
|
||||
clearTimeout(timer)
|
||||
if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return
|
||||
timer = setTimeout(async () => {
|
||||
if (!here()) return
|
||||
const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null)
|
||||
if (!fresh || !here()) return
|
||||
const key = d => d.clips.map(c => `${c.id}:${c.status}`).join()
|
||||
if (key(fresh) !== key(data)) {
|
||||
const openId = data.clips[open]?.id
|
||||
data = fresh
|
||||
renderGrid()
|
||||
if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() }
|
||||
}
|
||||
poll()
|
||||
}, 3000)
|
||||
}
|
||||
renderGrid()
|
||||
poll()
|
||||
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
|
||||
}
|
||||
|
||||
// ---------- Historial ----------
|
||||
async function viewHistory() {
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> Manga Reader</a>
|
||||
<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="#/history" data-icon="history">Historial</a>
|
||||
<a href="#/downloads" data-icon="download">Descargas</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>
|
||||
|
||||
@@ -271,3 +271,35 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m
|
||||
#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; }
|
||||
@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; 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; }
|
||||
.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)); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user