feat: replace HTML export with SvelteKit viewer

Replace the minijinja-based HTML export with a full SvelteKit static
viewer app. The new export produces a ZIP with:
- Pre-built viewer assets (index.html + JS/CSS bundle)
- data.json with all posts, comments, tags, and like counts
- Processed media: 400px thumbnails for grid, full images (2000px
  cap if >5MB), video thumbnails via ffmpeg

Remove minijinja dependency, add include_dir to embed viewer assets
at compile time. Update Dockerfile to copy static/ for builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-04-06 21:26:03 +02:00
parent 2fd66a800a
commit ffc926bf4d
4 changed files with 271 additions and 219 deletions

37
backend/Cargo.lock generated
View File

@@ -896,8 +896,8 @@ dependencies = [
"dotenvy", "dotenvy",
"futures", "futures",
"image", "image",
"include_dir",
"jsonwebtoken", "jsonwebtoken",
"minijinja",
"oxipng", "oxipng",
"rand 0.9.2", "rand 0.9.2",
"serde", "serde",
@@ -1577,6 +1577,25 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -1832,12 +1851,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1854,16 +1867,6 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minijinja"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
dependencies = [
"memo-map",
"serde",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"

View File

@@ -29,7 +29,7 @@ sysinfo = "0.32"
image = "0.25" image = "0.25"
oxipng = "9" oxipng = "9"
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] } async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
minijinja = { version = "2", features = ["json"] } include_dir = "0.7"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

View File

@@ -11,6 +11,7 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
rm -rf src rm -rf src
COPY src ./src COPY src ./src
COPY static ./static
RUN touch src/main.rs && cargo build --release RUN touch src/main.rs && cargo build --release
# --- Runtime stage --- # --- Runtime stage ---

View File

@@ -5,6 +5,7 @@ use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo}; use futures::io::{copy as fcopy, AllowStdIo};
use include_dir::{include_dir, Dir};
use serde::Serialize; use serde::Serialize;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -13,6 +14,10 @@ use uuid::Uuid;
use crate::state::SseEvent; use crate::state::SseEvent;
// ── Embedded viewer assets (pre-built SvelteKit static output) ──────────────
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
// ── DB query rows ──────────────────────────────────────────────────────────── // ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -34,26 +39,45 @@ struct ExportCommentRow {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
// ── Template context structs ───────────────────────────────────────────────── // ── Viewer JSON structs (serialised to data.json) ───────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct TmplComment { struct ViewerData {
uploader_name: String, event: ViewerEvent,
body: String, posts: Vec<ViewerPost>,
created_at: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct TmplUpload { struct ViewerEvent {
name: String,
exported_at: String,
}
#[derive(Serialize)]
struct ViewerPost {
id: String, id: String,
path: String, uploader: String,
is_video: bool,
caption: String, caption: String,
uploader_name: String, tags: Vec<String>,
like_count: i64, timestamp: String,
created_at: String, likes: i64,
comments: Vec<TmplComment>, comments: Vec<ViewerComment>,
hashtags: Vec<String>, media: ViewerMedia,
}
#[derive(Serialize)]
struct ViewerComment {
author: String,
text: String,
timestamp: String,
}
#[derive(Serialize)]
struct ViewerMedia {
#[serde(rename = "type")]
media_type: String,
thumb: String,
full: String,
} }
// ── Entry point ────────────────────────────────────────────────────────────── // ── Entry point ──────────────────────────────────────────────────────────────
@@ -162,7 +186,7 @@ async fn run_zip_export(
Ok(()) Ok(())
} }
// ── HTML export ────────────────────────────────────────────────────────────── // ── HTML viewer export ──────────────────────────────────────────────────────
async fn run_html_export( async fn run_html_export(
event_id: Uuid, event_id: Uuid,
@@ -173,70 +197,180 @@ async fn run_html_export(
) -> Result<()> { ) -> Result<()> {
mark_running(pool, event_id, "html").await; mark_running(pool, event_id, "html").await;
// 1. Query data
let uploads = query_uploads(pool, event_id).await?; let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?; let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?; let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32; let total = uploads.len().max(1) as f32;
update_progress(pool, event_id, "html", 5).await;
let exports_dir = media_path.join("exports"); let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?; tokio::fs::create_dir_all(&exports_dir).await?;
// Build template context // 2. Create temp directory for media processing
let mut tmpl_uploads: Vec<TmplUpload> = Vec::new(); let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
for (i, row) in uploads.iter().enumerate() { let media_tmp = tmp_dir.join("media");
let ext = ext_from_path(&row.original_path); tokio::fs::create_dir_all(&media_tmp).await?;
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 // 3. Process media and build post data
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let is_video = row.mime_type.starts_with("video/");
let id_str = row.id.to_string();
// Generate thumbnail and full variant
let (thumb_name, full_name) = if is_video {
let thumb = format!("{id_str}_thumb.jpg");
let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}");
// Video thumbnail via ffmpeg
let thumb_path = media_tmp.join(&thumb);
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
.args([
"-i",
src.to_str().unwrap_or_default(),
"-vframes",
"1",
"-ss",
"00:00:01",
"-vf",
"scale=400:-1",
"-y",
thumb_path.to_str().unwrap_or_default(),
])
.output()
.await;
match ffmpeg_result {
Ok(output) if output.status.success() => {}
_ => {
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
// Create empty thumb entry — viewer handles missing thumbs gracefully
}
}
// Copy video as-is
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
(thumb, full)
} else {
let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path);
let full = format!("{id_str}_full.{ext}");
// Image thumbnail: resize to 400px wide
let src_clone = src.clone();
let thumb_path = media_tmp.join(&thumb);
let thumb_path_clone = thumb_path.clone();
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
.context("failed to save thumbnail")?;
Ok(())
})
.await?;
if let Err(e) = thumb_result {
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
}
// Full variant: compress if >5MB, otherwise copy original
let src_meta = tokio::fs::metadata(&src).await?;
let full_path = media_tmp.join(&full);
if src_meta.len() > 5_000_000 {
// Resize to max 2000px
let src_clone = src.clone();
let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img =
image::open(&src_clone).context("failed to open image for compression")?;
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
.context("failed to save compressed full image")?;
Ok(())
})
.await?;
if let Err(e) = compress_result {
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
tokio::fs::copy(&src, &full_path).await?;
}
} else {
tokio::fs::copy(&src, &full_path).await?;
}
(thumb, full)
};
// Build comments for this upload
let post_comments: Vec<ViewerComment> = comments
.iter() .iter()
.filter(|c| c.upload_id == row.id) .filter(|c| c.upload_id == row.id)
.map(|c| TmplComment { .map(|c| ViewerComment {
uploader_name: c.uploader_name.clone(), author: c.uploader_name.clone(),
body: c.body.clone(), text: c.body.clone(),
created_at: c.created_at.format("%d.%m.%Y %H:%M").to_string(), timestamp: c.created_at.to_rfc3339(),
}) })
.collect(); .collect();
// Build tags for this upload
let tags: Vec<String> = hashtags_per_upload let tags: Vec<String> = hashtags_per_upload
.iter() .iter()
.filter(|(uid, _)| *uid == row.id) .filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone()) .map(|(_, tag)| tag.clone())
.collect(); .collect();
tmpl_uploads.push(TmplUpload { viewer_posts.push(ViewerPost {
id: row.id.to_string(), id: id_str,
path: format!("{folder}/{filename}"), uploader: row.uploader_name.clone(),
is_video: row.mime_type.starts_with("video/"),
caption: row.caption.clone().unwrap_or_default(), caption: row.caption.clone().unwrap_or_default(),
uploader_name: row.uploader_name.clone(), tags,
like_count: row.like_count, timestamp: row.created_at.to_rfc3339(),
created_at: row.created_at.format("%d.%m.%Y %H:%M").to_string(), likes: row.like_count,
comments: upload_comments, comments: post_comments,
hashtags: tags, media: ViewerMedia {
media_type: if is_video {
"video".to_string()
} else {
"image".to_string()
},
thumb: format!("media/{thumb_name}"),
full: format!("media/{full_name}"),
},
}); });
let pct = ((i + 1) as f32 / total * 50.0) as i16; let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
update_progress(pool, event_id, "html", pct.min(49)).await; update_progress(pool, event_id, "html", pct.min(69)).await;
} }
// Render HTML // 4. Build data.json
let mut env = minijinja::Environment::new(); let viewer_data = ViewerData {
env.add_template("memories", MEMORIES_TEMPLATE) event: ViewerEvent {
.context("template compile error")?; name: event_name.to_string(),
let tmpl = env.get_template("memories").unwrap(); exported_at: Utc::now().to_rfc3339(),
let html = tmpl },
.render(minijinja::context!( posts: viewer_posts,
event_name => event_name, };
uploads => minijinja::Value::from_serialize(&tmpl_uploads), let data_json =
generated_at => Utc::now().format("%d.%m.%Y").to_string(), serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
))
.context("template render error")?;
update_progress(pool, event_id, "html", 55).await; update_progress(pool, event_id, "html", 72).await;
// 5. Create ZIP
let tmp_path = exports_dir.join("Memories.zip.tmp"); let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip"); let out_path = exports_dir.join("Memories.zip");
@@ -244,56 +378,69 @@ async fn run_html_export(
let file = tokio::fs::File::create(&tmp_path).await?; let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file); let mut zip = ZipFileWriter::with_tokio(file);
// Memories.html // Write embedded viewer assets (index.html, _app/*, etc.)
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
update_progress(pool, event_id, "html", 75).await;
// Write data.json
{ {
let builder = let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?; let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(html.as_bytes())); let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
fcopy(&mut cursor, &mut entry).await?; fcopy(&mut cursor, &mut entry).await?;
entry.close().await?; entry.close().await?;
} }
update_progress(pool, event_id, "html", 60).await; // Write README.txt
// README.txt
{ {
let builder = let builder = ZipEntryBuilder::new("README.txt".into(), Compression::Deflate);
ZipEntryBuilder::new("Memories/README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?; let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes())); let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?; fcopy(&mut cursor, &mut entry).await?;
entry.close().await?; entry.close().await?;
} }
// Media files update_progress(pool, event_id, "html", 78).await;
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path); // Write media files from temp directory
if !src.exists() { let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
continue; let mut file_count = 0u32;
let mut files_written = 0u32;
// Count files first
{
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
while counter.next_entry().await?.is_some() {
file_count += 1;
} }
let ext = ext_from_path(&row.original_path); }
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string(); let file_total = file_count.max(1) as f32;
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" }; while let Some(dir_entry) = media_entries.next_entry().await? {
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id); let filename = dir_entry.file_name();
let entry_name = format!("Memories/{folder}/{filename}"); let entry_name = format!("media/{}", filename.to_string_lossy());
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored); let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?; let mut zip_entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat(); let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
fcopy(&mut f, &mut entry).await?; fcopy(&mut f, &mut zip_entry).await?;
entry.close().await?; zip_entry.close().await?;
let pct = 60 + ((i + 1) as f32 / total * 39.0) as i16; files_written += 1;
update_progress(pool, event_id, "html", pct.min(99)).await; let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
update_progress(pool, event_id, "html", pct.min(98)).await;
} }
zip.close().await?; zip.close().await?;
} }
// 6. Finalise
tokio::fs::rename(&tmp_path, &out_path).await?; tokio::fs::rename(&tmp_path, &out_path).await?;
// Clean up temp directory
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
sqlx::query( sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW() "UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type", WHERE event_id = $1 AND type = 'html'::export_type",
@@ -313,7 +460,7 @@ async fn run_html_export(
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(), data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
}); });
tracing::info!("HTML export complete for event {event_id}"); tracing::info!("HTML viewer export complete for event {event_id}");
Ok(()) Ok(())
} }
@@ -421,6 +568,26 @@ async fn maybe_broadcast_complete(
} }
} }
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
async fn write_dir_to_zip(
dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>,
) -> Result<()> {
for file in dir.files() {
let path = file.path().to_string_lossy().to_string();
let contents = file.contents();
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
for sub_dir in dir.dirs() {
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
}
Ok(())
}
fn ext_from_path(path: &str) -> &str { fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin") path.rsplit('.').next().unwrap_or("bin")
} }
@@ -433,137 +600,18 @@ fn sanitize_name(name: &str) -> String {
// ── Static content ─────────────────────────────────────────────────────────── // ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "Willkommen in der Event-Galerie!\n\ const README_TEXT: &str = "EventSnap Offline-Galerie\n\
\n\ \n\
So geht's:\n\ So geht's:\n\
1. Entpacke diese ZIP-Datei\n\ 1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\ (Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\ Handy: Dateimanager-App verwenden).\n\
2. Öffne die Datei \"Memories.html\" in deinem Browser\n\ 2. Öffne \"index.html\" im Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\ (z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\ 3. Stöbere durch alle Fotos und Videos.\n\
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.\n\ Du kannst zwischen Listen- und Rasteransicht wechseln,\n\
nach Hashtags filtern und nach Nutzern suchen.\n\
4. Eine Internetverbindung ist nicht nötig.\n\ 4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\ Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\ \n\
Viel Freude mit den Erinnerungen!\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>"#;