Files
EventSnap/backend/src/services/export.rs
MechaCat02 258e2bd84d feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.

Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
  async_zip (Stored compression), organised into Photos/ and Videos/
  with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
  inlined CSS + JS, relative media paths); packs it with README.txt and
  all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
  export_zip_ready / export_html_ready on the event, and broadcast SSE
  export-progress + export-available when both complete

Backend — new endpoints (AuthUser):
- GET /export/zip   → streams Gallery.zip if export_zip_ready
- GET /export/html  → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)

Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)

Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
  download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:56:21 +02:00

570 lines
22 KiB
Rust
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.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo};
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio_util::compat::TokioAsyncReadCompatExt;
use uuid::Uuid;
use crate::state::SseEvent;
// ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ExportUploadRow {
id: Uuid,
original_path: String,
mime_type: String,
caption: Option<String>,
uploader_name: String,
like_count: i64,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct ExportCommentRow {
upload_id: Uuid,
uploader_name: String,
body: String,
created_at: DateTime<Utc>,
}
// ── Template context structs ─────────────────────────────────────────────────
#[derive(Serialize)]
struct TmplComment {
uploader_name: String,
body: String,
created_at: String,
}
#[derive(Serialize)]
struct TmplUpload {
id: String,
path: String,
is_video: bool,
caption: String,
uploader_name: String,
like_count: i64,
created_at: String,
comments: Vec<TmplComment>,
hashtags: Vec<String>,
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let pool2 = pool.clone();
let media_path2 = media_path.clone();
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
}
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
}
// ── ZIP export ───────────────────────────────────────────────────────────────
async fn run_zip_export(
event_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
let out_path = exports_dir.join("Gallery.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = ((i + 1) as f32 / total * 100.0) as i16;
update_progress(pool, event_id, "zip", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'zip'::export_type",
)
.bind(event_id)
.bind("exports/Gallery.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "zip", "progress_pct": 100 }).to_string(),
});
tracing::info!("ZIP export complete for event {event_id}");
Ok(())
}
// ── HTML export ──────────────────────────────────────────────────────────────
async fn run_html_export(
event_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// Build template context
let mut tmpl_uploads: Vec<TmplUpload> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let ext = ext_from_path(&row.original_path);
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let upload_comments: Vec<TmplComment> = comments
.iter()
.filter(|c| c.upload_id == row.id)
.map(|c| TmplComment {
uploader_name: c.uploader_name.clone(),
body: c.body.clone(),
created_at: c.created_at.format("%d.%m.%Y %H:%M").to_string(),
})
.collect();
let tags: Vec<String> = hashtags_per_upload
.iter()
.filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone())
.collect();
tmpl_uploads.push(TmplUpload {
id: row.id.to_string(),
path: format!("{folder}/{filename}"),
is_video: row.mime_type.starts_with("video/"),
caption: row.caption.clone().unwrap_or_default(),
uploader_name: row.uploader_name.clone(),
like_count: row.like_count,
created_at: row.created_at.format("%d.%m.%Y %H:%M").to_string(),
comments: upload_comments,
hashtags: tags,
});
let pct = ((i + 1) as f32 / total * 50.0) as i16;
update_progress(pool, event_id, "html", pct.min(49)).await;
}
// Render HTML
let mut env = minijinja::Environment::new();
env.add_template("memories", MEMORIES_TEMPLATE)
.context("template compile error")?;
let tmpl = env.get_template("memories").unwrap();
let html = tmpl
.render(minijinja::context!(
event_name => event_name,
uploads => minijinja::Value::from_serialize(&tmpl_uploads),
generated_at => Utc::now().format("%d.%m.%Y").to_string(),
))
.context("template render error")?;
update_progress(pool, event_id, "html", 55).await;
let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
// Memories.html
{
let builder =
ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(html.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
update_progress(pool, event_id, "html", 60).await;
// README.txt
{
let builder =
ZipEntryBuilder::new("Memories/README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
// Media files
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let entry_name = format!("Memories/{folder}/{filename}");
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = 60 + ((i + 1) as f32 / total * 39.0) as i16;
update_progress(pool, event_id, "html", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type",
)
.bind(event_id)
.bind("exports/Memories.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
});
tracing::info!("HTML export complete for event {event_id}");
Ok(())
}
// ── DB helpers ───────────────────────────────────────────────────────────────
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
Ok(sqlx::query_as::<_, ExportUploadRow>(
"SELECT u.id, u.original_path, u.mime_type, u.caption,
usr.display_name AS uploader_name,
COUNT(DISTINCT l.user_id) AS like_count,
u.created_at
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.id
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_comments(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportCommentRow>> {
Ok(sqlx::query_as::<_, ExportCommentRow>(
"SELECT c.upload_id, usr.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" usr ON usr.id = c.user_id
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL AND u.deleted_at IS NULL
ORDER BY c.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, String)>> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT uh.upload_id, h.tag
FROM upload_hashtag uh
JOIN hashtag h ON h.id = uh.hashtag_id
JOIN upload u ON u.id = uh.upload_id
WHERE h.event_id = $1 AND u.deleted_at IS NULL",
)
.bind(event_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'failed', error_message = $3
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(msg)
.execute(pool)
.await;
}
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
let _ = sqlx::query(
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.execute(pool)
.await;
}
async fn maybe_broadcast_complete(
pool: &PgPool,
event_id: Uuid,
sse_tx: &broadcast::Sender<SseEvent>,
) {
let row: Option<(bool, bool)> = sqlx::query_as(
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
)
.bind(event_id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if let Some((zip_ready, html_ready)) = row {
if zip_ready && html_ready {
let _ = sse_tx.send(SseEvent {
event_type: "export-available".to_string(),
data: serde_json::json!({ "types": ["zip", "html"] }).to_string(),
});
}
}
}
fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin")
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect()
}
// ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "Willkommen in der Event-Galerie!\n\
\n\
So geht's:\n\
1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\
2. Öffne die Datei \"Memories.html\" in deinem Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.\n\
4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\
Viel Freude mit den Erinnerungen!\n";
const MEMORIES_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ event_name }} Erinnerungen</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:Georgia,serif;background:#faf7f2;color:#3d2b1f;min-height:100vh}
header{background:#fff8f0;border-bottom:1px solid #e8d9c8;padding:1.5rem 1rem;text-align:center}
header h1{font-size:1.75rem;font-weight:700;color:#5c3317;letter-spacing:.02em}
header p{font-size:.85rem;color:#9a7060;margin-top:.25rem}
.chips{display:flex;flex-wrap:wrap;gap:.5rem;padding:1rem;justify-content:center;border-bottom:1px solid #e8d9c8;background:#fff8f0}
.chip{cursor:pointer;padding:.3rem .8rem;border-radius:999px;border:1px solid #c8a98a;font-size:.8rem;color:#6b4c36;background:#fff;transition:all .15s}
.chip:hover,.chip.active{background:#c8a98a;color:#fff;border-color:#c8a98a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;padding:1.25rem;max-width:1100px;margin:0 auto}
.card{background:#fff;border-radius:.75rem;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;transition:transform .15s,box-shadow .15s}
.card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.12)}
.card.hidden{display:none}
.thumb-wrap{position:relative;width:100%;aspect-ratio:1;overflow:hidden;background:#e8d9c8}
.thumb{width:100%;height:100%;object-fit:cover;display:block}
.vid-icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;pointer-events:none}
.card-info{padding:.6rem .75rem}
.card-uploader{font-size:.75rem;color:#9a7060;margin-bottom:.2rem}
.card-caption{font-size:.85rem;color:#3d2b1f;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.card-meta{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.75rem;color:#b08060}
.lb{display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.88);overflow-y:auto}
.lb.open{display:flex;flex-direction:column}
.lb-close{position:fixed;top:.75rem;right:1rem;font-size:1.75rem;color:#fff;cursor:pointer;z-index:101;line-height:1}
.lb-close:hover{color:#e8c89a}
.lb-media{max-width:900px;width:100%;margin:3rem auto 0;padding:0 .5rem}
.lb-media img,.lb-media video{width:100%;border-radius:.5rem;max-height:70vh;object-fit:contain;background:#1a1a1a;display:block}
.lb-details{max-width:900px;width:100%;margin:1rem auto 2rem;padding:0 1rem}
.lb-caption{font-size:1rem;color:#fff;margin-bottom:.5rem}
.lb-meta{font-size:.8rem;color:#b08060;margin-bottom:.75rem}
.lb-tags{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.lb-tag{font-size:.75rem;color:#e8c89a;background:rgba(255,255,255,.1);padding:.2rem .6rem;border-radius:999px}
.lb-likes{font-size:.85rem;color:#d4a574;margin-bottom:.75rem}
.lb-comments h3{font-size:.9rem;color:#e8d9c8;margin-bottom:.5rem;font-weight:600}
.comment{border-top:1px solid rgba(255,255,255,.1);padding:.5rem 0}
.comment-name{font-size:.75rem;color:#b08060}
.comment-body{font-size:.85rem;color:#e8d9c8;margin-top:.15rem}
.empty{text-align:center;padding:3rem 1rem;color:#b08060;font-size:.95rem}
footer{text-align:center;padding:1.5rem;font-size:.75rem;color:#b08060;border-top:1px solid #e8d9c8;margin-top:2rem}
</style>
</head>
<body>
<header>
<h1>{{ event_name }}</h1>
<p>Erinnerungen · Erstellt am {{ generated_at }}</p>
</header>
{% set ns = namespace(all_tags=[]) %}
{% for u in uploads %}{% for t in u.hashtags %}{% if t not in ns.all_tags %}{% set ns.all_tags = ns.all_tags + [t] %}{% endif %}{% endfor %}{% endfor %}
{% if ns.all_tags %}
<div class="chips" id="chips">
<span class="chip active" data-tag="">Alle</span>
{% for tag in ns.all_tags %}<span class="chip" data-tag="{{ tag }}">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if uploads %}
<div class="grid" id="grid">
{% for u in uploads %}
<div class="card" data-tags="{{ u.hashtags | join(',') }}" onclick="openLb({{ loop.index0 }})">
<div class="thumb-wrap">
{% if u.is_video %}
<video class="thumb" src="{{ u.path }}" preload="none"></video>
<div class="vid-icon">▶</div>
{% else %}
<img class="thumb" src="{{ u.path }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="card-info">
<div class="card-uploader">{{ u.uploader_name }} · {{ u.created_at }}</div>
{% if u.caption %}<div class="card-caption">{{ u.caption }}</div>{% endif %}
<div class="card-meta">
<span>♡ {{ u.like_count }}</span>
{% if u.comments %}<span>💬 {{ u.comments | length }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">Noch keine Fotos vorhanden.</div>
{% endif %}
<div class="lb" id="lb">
<span class="lb-close" onclick="closeLb()">×</span>
<div class="lb-media" id="lb-media"></div>
<div class="lb-details" id="lb-details"></div>
</div>
<footer>{{ event_name }} · Offline-Galerie · EventSnap</footer>
<script>
const uploads = {{ uploads | tojson }};
let activeTag = '';
function filterCards(){document.querySelectorAll('#grid .card').forEach((card,i)=>{const tags=(card.dataset.tags||'').split(',').filter(Boolean);card.classList.toggle('hidden',activeTag!==''&&!tags.includes(activeTag));});}
document.querySelectorAll('#chips .chip').forEach(chip=>{chip.addEventListener('click',()=>{document.querySelectorAll('#chips .chip').forEach(c=>c.classList.remove('active'));chip.classList.add('active');activeTag=chip.dataset.tag;filterCards();});});
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function openLb(idx){
const u=uploads[idx];
const lb=document.getElementById('lb');
const media=document.getElementById('lb-media');
const details=document.getElementById('lb-details');
if(u.is_video){media.innerHTML=`<video src="${esc(u.path)}" controls autoplay playsinline></video>`;}
else{media.innerHTML=`<img src="${esc(u.path)}" alt="${esc(u.caption)}">`;}
const tags=u.hashtags.map(t=>`<span class="lb-tag">#${esc(t)}</span>`).join('');
const comments=u.comments.map(c=>`<div class="comment"><div class="comment-name">${esc(c.uploader_name)} · ${esc(c.created_at)}</div><div class="comment-body">${esc(c.body)}</div></div>`).join('');
details.innerHTML=(u.caption?`<div class="lb-caption">${esc(u.caption)}</div>`:'')+
`<div class="lb-meta">${esc(u.uploader_name)} · ${esc(u.created_at)}</div>`+
(tags?`<div class="lb-tags">${tags}</div>`:'')+
`<div class="lb-likes">♡ ${u.like_count} Likes</div>`+
(u.comments.length?`<div class="lb-comments"><h3>Kommentare (${u.comments.length})</h3>${comments}</div>`:'');
lb.classList.add('open');document.body.style.overflow='hidden';
}
function closeLb(){document.getElementById('lb').classList.remove('open');document.getElementById('lb-media').innerHTML='';document.body.style.overflow='';}
document.getElementById('lb').addEventListener('click',e=>{if(e.target===e.currentTarget)closeLb();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLb();});
</script>
</body>
</html>"#;