From 258e2bd84d41e7db59437190e683c6edfa7bcd35 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 2 Apr 2026 20:56:21 +0200 Subject: [PATCH] feat: implement export engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/src/handlers/admin.rs | 77 ++++ backend/src/handlers/host.rs | 11 +- backend/src/main.rs | 4 +- backend/src/services/export.rs | 569 ++++++++++++++++++++++++ backend/src/services/mod.rs | 1 + frontend/package-lock.json | 7 + frontend/src/routes/export/+page.svelte | 195 ++++++++ 9 files changed, 864 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/export.rs create mode 100644 frontend/src/routes/export/+page.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ffb1622..a5ac022 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -907,6 +907,7 @@ dependencies = [ "sysinfo", "tokio", "tokio-stream", + "tokio-util", "tower", "tower-http", "tower_governor", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d864893..773d281 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -17,6 +17,7 @@ bcrypt = "0.15" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } tokio-stream = { version = "0.1", features = ["sync"] } +tokio-util = { version = "0.7", features = ["io", "compat"] } futures = "0.3" sha2 = "0.10" rand = "0.9" diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index e3162c6..5070559 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -167,6 +167,83 @@ pub async fn get_export_jobs( Ok(Json(jobs)) } +// ── Export download endpoints (authenticated guests) ───────────────────────── + +pub async fn download_zip( + State(state): State, + _auth: crate::auth::middleware::AuthUser, +) -> Result { + let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) + .await? + .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; + + if !event.export_zip_ready { + return Err(AppError::NotFound( + "Der ZIP-Export ist noch nicht verfügbar.".into(), + )); + } + + let path = state.config.media_path.join("exports").join("Gallery.zip"); + if !path.exists() { + return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); + } + + serve_file(path, "Gallery.zip", "application/zip").await +} + +pub async fn download_html( + State(state): State, + _auth: crate::auth::middleware::AuthUser, +) -> Result { + let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) + .await? + .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; + + if !event.export_html_ready { + return Err(AppError::NotFound( + "Der HTML-Export ist noch nicht verfügbar.".into(), + )); + } + + let path = state.config.media_path.join("exports").join("Memories.zip"); + if !path.exists() { + return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); + } + + serve_file(path, "Memories.zip", "application/zip").await +} + +async fn serve_file( + path: std::path::PathBuf, + filename: &str, + content_type: &str, +) -> Result { + use axum::body::Body; + use axum::http::{header, Response, StatusCode}; + use tokio_util::io::ReaderStream; + + let file = tokio::fs::File::open(&path) + .await + .map_err(|e| AppError::Internal(e.into()))?; + let metadata = file + .metadata() + .await + .map_err(|e| AppError::Internal(e.into()))?; + let stream = ReaderStream::new(file); + + let disposition = format!("attachment; filename=\"{filename}\""); + + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CONTENT_LENGTH, metadata.len()) + .body(Body::from_stream(stream)) + .map_err(|e| AppError::Internal(e.into()))?; + + Ok(response) +} + /// Also expose export status to all authenticated users (guests need it for the export page) pub async fn export_status( State(state): State, diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index e918053..34377b0 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -244,7 +244,7 @@ pub async fn release_gallery( .execute(&state.pool) .await?; - // Enqueue export jobs (processed by the export worker in a later step) + // Enqueue export jobs for export_type in ["zip", "html"] { sqlx::query( "INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type) @@ -256,5 +256,14 @@ pub async fn release_gallery( .await?; } + // Spawn export workers + crate::services::export::spawn_export_jobs( + event.id, + event.name, + state.pool.clone(), + state.config.media_path.clone(), + state.sse_tx.clone(), + ); + Ok(StatusCode::NO_CONTENT) } diff --git a/backend/src/main.rs b/backend/src/main.rs index ab95007..effb0b0 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -70,8 +70,10 @@ async fn main() -> Result<()> { .route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role)) .route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload)) .route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)) - // Export status (all authenticated users) + // Export (all authenticated users) .route("/api/v1/export/status", get(handlers::admin::export_status)) + .route("/api/v1/export/zip", get(handlers::admin::download_zip)) + .route("/api/v1/export/html", get(handlers::admin::download_html)) // Admin Dashboard .route("/api/v1/admin/stats", get(handlers::admin::get_stats)) .route( diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs new file mode 100644 index 0000000..2dd6e83 --- /dev/null +++ b/backend/src/services/export.rs @@ -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, + uploader_name: String, + like_count: i64, + created_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct ExportCommentRow { + upload_id: Uuid, + uploader_name: String, + body: String, + created_at: DateTime, +} + +// ── 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, + hashtags: Vec, +} + +// ── Entry point ────────────────────────────────────────────────────────────── + +pub fn spawn_export_jobs( + event_id: Uuid, + event_name: String, + pool: PgPool, + media_path: PathBuf, + sse_tx: broadcast::Sender, +) { + 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, +) -> 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, +) -> 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 = 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 = 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 = 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> { + 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> { + 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> { + 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, +) { + 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#" + + + + +{{ 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
+ + +"#; diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 376ef62..fff4d7b 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1 +1,2 @@ pub mod compression; +pub mod export; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aa7190e..e125a40 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1029,6 +1029,7 @@ "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1071,6 +1072,7 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -1444,6 +1446,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2381,6 +2384,7 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2516,6 +2520,7 @@ "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2616,6 +2621,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2637,6 +2643,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/src/routes/export/+page.svelte b/frontend/src/routes/export/+page.svelte new file mode 100644 index 0000000..d9fd7e8 --- /dev/null +++ b/frontend/src/routes/export/+page.svelte @@ -0,0 +1,195 @@ + + + +{#if showHtmlGuide} +
+
+

Hinweis zum HTML-Viewer

+
    +
  1. 1. ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).
  2. +
  3. 2. Memories.html im Browser öffnen.
  4. +
  5. 3. Kein Internet nötig — alles ist lokal gespeichert.
  6. +
+

+ Tipp: Am besten im WLAN herunterladen — die Datei kann mehrere GB groß sein. +

+
+ + +
+
+
+{/if} + +
+
+
+

Export

+ Zur Galerie +
+
+ +
+ {#if loading} +
Laden…
+ {:else if !status?.released} +
+ + + +

Export noch nicht verfügbar

+

Schau nach der Veranstaltung noch einmal vorbei.

+
+ {:else if status} +

Wähle dein bevorzugtes Format:

+ + +
+
+
+

ZIP-Archiv

+

Alle Original-Fotos und Videos in strukturierten Ordnern.

+

+ {statusText(status.zip)} +

+
+ +
+ {#if status.zip.status === 'running'} +
+
+
+
+
+ {/if} +
+ + +
+
+
+

HTML-Viewer

+

Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.

+

+ {statusText(status.html)} +

+
+ +
+ {#if status.html.status === 'running'} +
+
+
+
+
+ {/if} +
+ {/if} +
+