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>
This commit is contained in:
569
backend/src/services/export.rs
Normal file
569
backend/src/services/export.rs
Normal file
@@ -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>"#;
|
||||
@@ -1 +1,2 @@
|
||||
pub mod compression;
|
||||
pub mod export;
|
||||
|
||||
Reference in New Issue
Block a user