From ffc926bf4dc60bd217dfd2e4b6af1ef5bd2b8ea5 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 6 Apr 2026 21:26:03 +0200 Subject: [PATCH] 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 --- backend/Cargo.lock | 37 +-- backend/Cargo.toml | 2 +- backend/Dockerfile | 1 + backend/src/services/export.rs | 450 ++++++++++++++++++--------------- 4 files changed, 271 insertions(+), 219 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a5ac022..6ce95e0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -896,8 +896,8 @@ dependencies = [ "dotenvy", "futures", "image", + "include_dir", "jsonwebtoken", - "minijinja", "oxipng", "rand 0.9.2", "serde", @@ -1577,6 +1577,25 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "indexmap" version = "2.13.0" @@ -1832,12 +1851,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memo-map" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" - [[package]] name = "mime" version = "0.3.17" @@ -1854,16 +1867,6 @@ dependencies = [ "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]] name = "miniz_oxide" version = "0.8.9" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d909aed..6704236 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -29,7 +29,7 @@ sysinfo = "0.32" image = "0.25" oxipng = "9" async_zip = { version = "0.0.17", features = ["tokio", "deflate"] } -minijinja = { version = "2", features = ["json"] } +include_dir = "0.7" [profile.release] opt-level = 3 diff --git a/backend/Dockerfile b/backend/Dockerfile index 0da036e..39e4159 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,6 +11,7 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \ rm -rf src COPY src ./src +COPY static ./static RUN touch src/main.rs && cargo build --release # --- Runtime stage --- diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index 2dd6e83..0665f16 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -5,6 +5,7 @@ use async_zip::tokio::write::ZipFileWriter; use async_zip::{Compression, ZipEntryBuilder}; use chrono::{DateTime, Utc}; use futures::io::{copy as fcopy, AllowStdIo}; +use include_dir::{include_dir, Dir}; use serde::Serialize; use sqlx::PgPool; use tokio::sync::broadcast; @@ -13,6 +14,10 @@ use uuid::Uuid; 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 ──────────────────────────────────────────────────────────── #[derive(sqlx::FromRow)] @@ -34,26 +39,45 @@ struct ExportCommentRow { created_at: DateTime, } -// ── Template context structs ───────────────────────────────────────────────── +// ── Viewer JSON structs (serialised to data.json) ─────────────────────────── #[derive(Serialize)] -struct TmplComment { - uploader_name: String, - body: String, - created_at: String, +struct ViewerData { + event: ViewerEvent, + posts: Vec, } #[derive(Serialize)] -struct TmplUpload { +struct ViewerEvent { + name: String, + exported_at: String, +} + +#[derive(Serialize)] +struct ViewerPost { id: String, - path: String, - is_video: bool, + uploader: String, caption: String, - uploader_name: String, - like_count: i64, - created_at: String, - comments: Vec, - hashtags: Vec, + tags: Vec, + timestamp: String, + likes: i64, + comments: Vec, + 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 ────────────────────────────────────────────────────────────── @@ -162,7 +186,7 @@ async fn run_zip_export( Ok(()) } -// ── HTML export ────────────────────────────────────────────────────────────── +// ── HTML viewer export ────────────────────────────────────────────────────── async fn run_html_export( event_id: Uuid, @@ -173,70 +197,180 @@ async fn run_html_export( ) -> Result<()> { mark_running(pool, event_id, "html").await; + // 1. Query data 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; + update_progress(pool, event_id, "html", 5).await; + let exports_dir = media_path.join("exports"); tokio::fs::create_dir_all(&exports_dir).await?; - // Build template context - let mut tmpl_uploads: Vec = 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); + // 2. Create temp directory for media processing + let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}")); + let media_tmp = tmp_dir.join("media"); + tokio::fs::create_dir_all(&media_tmp).await?; - let upload_comments: Vec = comments + // 3. Process media and build post data + let mut viewer_posts: Vec = 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 = 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(), + .map(|c| ViewerComment { + author: c.uploader_name.clone(), + text: c.body.clone(), + timestamp: c.created_at.to_rfc3339(), }) .collect(); + // Build tags for this upload let tags: Vec = 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/"), + viewer_posts.push(ViewerPost { + id: id_str, + uploader: row.uploader_name.clone(), 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, + tags, + timestamp: row.created_at.to_rfc3339(), + likes: row.like_count, + comments: post_comments, + 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; - update_progress(pool, event_id, "html", pct.min(49)).await; + let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16; + update_progress(pool, event_id, "html", pct.min(69)).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")?; + // 4. Build data.json + let viewer_data = ViewerData { + event: ViewerEvent { + name: event_name.to_string(), + exported_at: Utc::now().to_rfc3339(), + }, + posts: viewer_posts, + }; + let data_json = + serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?; - 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 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 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 = - ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate); + let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate); 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?; entry.close().await?; } - update_progress(pool, event_id, "html", 60).await; - - // README.txt + // Write README.txt { - let builder = - ZipEntryBuilder::new("Memories/README.txt".into(), Compression::Deflate); + let builder = ZipEntryBuilder::new("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; + update_progress(pool, event_id, "html", 78).await; + + // Write media files from temp directory + let mut media_entries = tokio::fs::read_dir(&media_tmp).await?; + 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 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 file_total = file_count.max(1) as f32; + + while let Some(dir_entry) = media_entries.next_entry().await? { + let filename = dir_entry.file_name(); + let entry_name = format!("media/{}", filename.to_string_lossy()); 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 mut zip_entry = zip.write_entry_stream(builder).await?; + let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat(); + fcopy(&mut f, &mut zip_entry).await?; + zip_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; + files_written += 1; + 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?; } + // 6. Finalise tokio::fs::rename(&tmp_path, &out_path).await?; + // Clean up temp directory + let _ = tokio::fs::remove_dir_all(&tmp_dir).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", @@ -313,7 +460,7 @@ async fn run_html_export( 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(()) } @@ -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, +) -> 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 { path.rsplit('.').next().unwrap_or("bin") } @@ -433,137 +600,18 @@ fn sanitize_name(name: &str) -> String { // ── Static content ─────────────────────────────────────────────────────────── -const README_TEXT: &str = "Willkommen in der Event-Galerie!\n\ +const README_TEXT: &str = "EventSnap Offline-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\ +2. Öffne \"index.html\" im 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\ + Du kannst zwischen Listen- und Rasteransicht wechseln,\n\ + nach Hashtags filtern und nach Nutzern suchen.\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#" - - - - -{{ event_name }} – Erinnerungen - - - -
-

{{ event_name }}

-

Erinnerungen · Erstellt am {{ generated_at }}

-
-{% 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 %} -
- Alle - {% for tag in ns.all_tags %}#{{ tag }}{% endfor %} -
-{% endif %} -{% if uploads %} -
- {% for u in uploads %} -
-
- {% if u.is_video %} - -
- {% else %} - - {% endif %} -
-
-
{{ u.uploader_name }} · {{ u.created_at }}
- {% if u.caption %}
{{ u.caption }}
{% endif %} -
- ♡ {{ u.like_count }} - {% if u.comments %}💬 {{ u.comments | length }}{% endif %} -
-
-
- {% endfor %} -
-{% else %} -
Noch keine Fotos vorhanden.
-{% endif %} -
- × -
-
-
-
{{ event_name }} · Offline-Galerie · EventSnap
- - -"#;