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:
37
backend/Cargo.lock
generated
37
backend/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
// ── 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<ViewerPost>,
|
||||
}
|
||||
|
||||
#[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<TmplComment>,
|
||||
hashtags: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
timestamp: String,
|
||||
likes: i64,
|
||||
comments: Vec<ViewerComment>,
|
||||
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<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);
|
||||
// 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<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()
|
||||
.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<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/"),
|
||||
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<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 {
|
||||
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#"<!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>"#;
|
||||
|
||||
Reference in New Issue
Block a user