@@ -0,0 +1,569 @@
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
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>"# ;