4 Commits

Author SHA1 Message Date
MechaCat02
1685bf105c fix: update export page guide text for new viewer
Change "Memories.html" to "index.html" in the HTML export
download guide modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:26:06 +02:00
MechaCat02
ffc926bf4d 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>
2026-04-06 21:26:03 +02:00
MechaCat02
2fd66a800a chore: build and commit export-viewer static output
Pre-built SvelteKit static output for embedding into HTML export ZIPs.
When viewer source changes, rebuild with `npm run build` in
frontend/export-viewer/ and re-commit this directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:01:08 +02:00
MechaCat02
4f966533fe feat: add export-viewer SvelteKit static app
Standalone SvelteKit project at frontend/export-viewer/ using
adapter-static. Replicates the live feed experience as a read-only
offline gallery: list/grid views, search with autocomplete, hashtag
filtering, lightbox with swipe navigation and comments.

Built output goes to backend/static/export-viewer/ for embedding
into the HTML export ZIP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:01:01 +02:00
33 changed files with 3208 additions and 220 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ backend/target/
frontend/node_modules/ frontend/node_modules/
frontend/.svelte-kit/ frontend/.svelte-kit/
frontend/build/ frontend/build/
frontend/export-viewer/node_modules/
frontend/export-viewer/.svelte-kit/
# Media uploads (mounted volume in production) # Media uploads (mounted volume in production)
media/ media/

37
backend/Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo}; use futures::io::{copy as fcopy, AllowStdIo};
use include_dir::{include_dir, Dir};
use serde::Serialize; use serde::Serialize;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -13,6 +14,10 @@ use uuid::Uuid;
use crate::state::SseEvent; use crate::state::SseEvent;
// ── Embedded viewer assets (pre-built SvelteKit static output) ──────────────
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
// ── DB query rows ──────────────────────────────────────────────────────────── // ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -34,26 +39,45 @@ struct ExportCommentRow {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
// ── Template context structs ───────────────────────────────────────────────── // ── Viewer JSON structs (serialised to data.json) ───────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct TmplComment { struct ViewerData {
uploader_name: String, event: ViewerEvent,
body: String, posts: Vec<ViewerPost>,
created_at: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct TmplUpload { struct ViewerEvent {
name: String,
exported_at: String,
}
#[derive(Serialize)]
struct ViewerPost {
id: String, id: String,
path: String, uploader: String,
is_video: bool,
caption: String, caption: String,
uploader_name: String, tags: Vec<String>,
like_count: i64, timestamp: String,
created_at: String, likes: i64,
comments: Vec<TmplComment>, comments: Vec<ViewerComment>,
hashtags: Vec<String>, media: ViewerMedia,
}
#[derive(Serialize)]
struct ViewerComment {
author: String,
text: String,
timestamp: String,
}
#[derive(Serialize)]
struct ViewerMedia {
#[serde(rename = "type")]
media_type: String,
thumb: String,
full: String,
} }
// ── Entry point ────────────────────────────────────────────────────────────── // ── Entry point ──────────────────────────────────────────────────────────────
@@ -162,7 +186,7 @@ async fn run_zip_export(
Ok(()) Ok(())
} }
// ── HTML export ────────────────────────────────────────────────────────────── // ── HTML viewer export ──────────────────────────────────────────────────────
async fn run_html_export( async fn run_html_export(
event_id: Uuid, event_id: Uuid,
@@ -173,70 +197,180 @@ async fn run_html_export(
) -> Result<()> { ) -> Result<()> {
mark_running(pool, event_id, "html").await; mark_running(pool, event_id, "html").await;
// 1. Query data
let uploads = query_uploads(pool, event_id).await?; let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?; let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?; let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32; let total = uploads.len().max(1) as f32;
update_progress(pool, event_id, "html", 5).await;
let exports_dir = media_path.join("exports"); let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?; tokio::fs::create_dir_all(&exports_dir).await?;
// Build template context // 2. Create temp directory for media processing
let mut tmpl_uploads: Vec<TmplUpload> = Vec::new(); let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
for (i, row) in uploads.iter().enumerate() { let media_tmp = tmp_dir.join("media");
let ext = ext_from_path(&row.original_path); tokio::fs::create_dir_all(&media_tmp).await?;
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let upload_comments: Vec<TmplComment> = comments // 3. Process media and build post data
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let is_video = row.mime_type.starts_with("video/");
let id_str = row.id.to_string();
// Generate thumbnail and full variant
let (thumb_name, full_name) = if is_video {
let thumb = format!("{id_str}_thumb.jpg");
let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}");
// Video thumbnail via ffmpeg
let thumb_path = media_tmp.join(&thumb);
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
.args([
"-i",
src.to_str().unwrap_or_default(),
"-vframes",
"1",
"-ss",
"00:00:01",
"-vf",
"scale=400:-1",
"-y",
thumb_path.to_str().unwrap_or_default(),
])
.output()
.await;
match ffmpeg_result {
Ok(output) if output.status.success() => {}
_ => {
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
// Create empty thumb entry — viewer handles missing thumbs gracefully
}
}
// Copy video as-is
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
(thumb, full)
} else {
let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path);
let full = format!("{id_str}_full.{ext}");
// Image thumbnail: resize to 400px wide
let src_clone = src.clone();
let thumb_path = media_tmp.join(&thumb);
let thumb_path_clone = thumb_path.clone();
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
.context("failed to save thumbnail")?;
Ok(())
})
.await?;
if let Err(e) = thumb_result {
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
}
// Full variant: compress if >5MB, otherwise copy original
let src_meta = tokio::fs::metadata(&src).await?;
let full_path = media_tmp.join(&full);
if src_meta.len() > 5_000_000 {
// Resize to max 2000px
let src_clone = src.clone();
let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img =
image::open(&src_clone).context("failed to open image for compression")?;
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
.context("failed to save compressed full image")?;
Ok(())
})
.await?;
if let Err(e) = compress_result {
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
tokio::fs::copy(&src, &full_path).await?;
}
} else {
tokio::fs::copy(&src, &full_path).await?;
}
(thumb, full)
};
// Build comments for this upload
let post_comments: Vec<ViewerComment> = comments
.iter() .iter()
.filter(|c| c.upload_id == row.id) .filter(|c| c.upload_id == row.id)
.map(|c| TmplComment { .map(|c| ViewerComment {
uploader_name: c.uploader_name.clone(), author: c.uploader_name.clone(),
body: c.body.clone(), text: c.body.clone(),
created_at: c.created_at.format("%d.%m.%Y %H:%M").to_string(), timestamp: c.created_at.to_rfc3339(),
}) })
.collect(); .collect();
// Build tags for this upload
let tags: Vec<String> = hashtags_per_upload let tags: Vec<String> = hashtags_per_upload
.iter() .iter()
.filter(|(uid, _)| *uid == row.id) .filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone()) .map(|(_, tag)| tag.clone())
.collect(); .collect();
tmpl_uploads.push(TmplUpload { viewer_posts.push(ViewerPost {
id: row.id.to_string(), id: id_str,
path: format!("{folder}/{filename}"), uploader: row.uploader_name.clone(),
is_video: row.mime_type.starts_with("video/"),
caption: row.caption.clone().unwrap_or_default(), caption: row.caption.clone().unwrap_or_default(),
uploader_name: row.uploader_name.clone(), tags,
like_count: row.like_count, timestamp: row.created_at.to_rfc3339(),
created_at: row.created_at.format("%d.%m.%Y %H:%M").to_string(), likes: row.like_count,
comments: upload_comments, comments: post_comments,
hashtags: tags, media: ViewerMedia {
media_type: if is_video {
"video".to_string()
} else {
"image".to_string()
},
thumb: format!("media/{thumb_name}"),
full: format!("media/{full_name}"),
},
}); });
let pct = ((i + 1) as f32 / total * 50.0) as i16; let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
update_progress(pool, event_id, "html", pct.min(49)).await; update_progress(pool, event_id, "html", pct.min(69)).await;
} }
// Render HTML // 4. Build data.json
let mut env = minijinja::Environment::new(); let viewer_data = ViewerData {
env.add_template("memories", MEMORIES_TEMPLATE) event: ViewerEvent {
.context("template compile error")?; name: event_name.to_string(),
let tmpl = env.get_template("memories").unwrap(); exported_at: Utc::now().to_rfc3339(),
let html = tmpl },
.render(minijinja::context!( posts: viewer_posts,
event_name => event_name, };
uploads => minijinja::Value::from_serialize(&tmpl_uploads), let data_json =
generated_at => Utc::now().format("%d.%m.%Y").to_string(), serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
))
.context("template render error")?;
update_progress(pool, event_id, "html", 55).await; update_progress(pool, event_id, "html", 72).await;
// 5. Create ZIP
let tmp_path = exports_dir.join("Memories.zip.tmp"); let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip"); let out_path = exports_dir.join("Memories.zip");
@@ -244,56 +378,69 @@ async fn run_html_export(
let file = tokio::fs::File::create(&tmp_path).await?; let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file); let mut zip = ZipFileWriter::with_tokio(file);
// Memories.html // Write embedded viewer assets (index.html, _app/*, etc.)
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
update_progress(pool, event_id, "html", 75).await;
// Write data.json
{ {
let builder = let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?; let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(html.as_bytes())); let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
fcopy(&mut cursor, &mut entry).await?; fcopy(&mut cursor, &mut entry).await?;
entry.close().await?; entry.close().await?;
} }
update_progress(pool, event_id, "html", 60).await; // Write README.txt
// README.txt
{ {
let builder = let builder = ZipEntryBuilder::new("README.txt".into(), Compression::Deflate);
ZipEntryBuilder::new("Memories/README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?; let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes())); let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?; fcopy(&mut cursor, &mut entry).await?;
entry.close().await?; entry.close().await?;
} }
// Media files update_progress(pool, event_id, "html", 78).await;
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path); // Write media files from temp directory
if !src.exists() { let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
continue; let mut file_count = 0u32;
let mut files_written = 0u32;
// Count files first
{
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
while counter.next_entry().await?.is_some() {
file_count += 1;
} }
let ext = ext_from_path(&row.original_path); }
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string(); let file_total = file_count.max(1) as f32;
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" }; while let Some(dir_entry) = media_entries.next_entry().await? {
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id); let filename = dir_entry.file_name();
let entry_name = format!("Memories/{folder}/{filename}"); let entry_name = format!("media/{}", filename.to_string_lossy());
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored); let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?; let mut zip_entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat(); let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
fcopy(&mut f, &mut entry).await?; fcopy(&mut f, &mut zip_entry).await?;
entry.close().await?; zip_entry.close().await?;
let pct = 60 + ((i + 1) as f32 / total * 39.0) as i16; files_written += 1;
update_progress(pool, event_id, "html", pct.min(99)).await; let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
update_progress(pool, event_id, "html", pct.min(98)).await;
} }
zip.close().await?; zip.close().await?;
} }
// 6. Finalise
tokio::fs::rename(&tmp_path, &out_path).await?; tokio::fs::rename(&tmp_path, &out_path).await?;
// Clean up temp directory
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
sqlx::query( sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW() "UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type", WHERE event_id = $1 AND type = 'html'::export_type",
@@ -313,7 +460,7 @@ async fn run_html_export(
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(), data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
}); });
tracing::info!("HTML export complete for event {event_id}"); tracing::info!("HTML viewer export complete for event {event_id}");
Ok(()) Ok(())
} }
@@ -421,6 +568,26 @@ async fn maybe_broadcast_complete(
} }
} }
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
async fn write_dir_to_zip(
dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>,
) -> Result<()> {
for file in dir.files() {
let path = file.path().to_string_lossy().to_string();
let contents = file.contents();
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
for sub_dir in dir.dirs() {
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
}
Ok(())
}
fn ext_from_path(path: &str) -> &str { fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin") path.rsplit('.').next().unwrap_or("bin")
} }
@@ -433,137 +600,18 @@ fn sanitize_name(name: &str) -> String {
// ── Static content ─────────────────────────────────────────────────────────── // ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "Willkommen in der Event-Galerie!\n\ const README_TEXT: &str = "EventSnap Offline-Galerie\n\
\n\ \n\
So geht's:\n\ So geht's:\n\
1. Entpacke diese ZIP-Datei\n\ 1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\ (Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\ Handy: Dateimanager-App verwenden).\n\
2. Öffne die Datei \"Memories.html\" in deinem Browser\n\ 2. Öffne \"index.html\" im Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\ (z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\ 3. Stöbere durch alle Fotos und Videos.\n\
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.\n\ Du kannst zwischen Listen- und Rasteransicht wechseln,\n\
nach Hashtags filtern und nach Nutzern suchen.\n\
4. Eine Internetverbindung ist nicht nötig.\n\ 4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\ Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\ \n\
Viel Freude mit den Erinnerungen!\n"; Viel Freude mit den Erinnerungen!\n";
const MEMORIES_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ event_name }} Erinnerungen</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:Georgia,serif;background:#faf7f2;color:#3d2b1f;min-height:100vh}
header{background:#fff8f0;border-bottom:1px solid #e8d9c8;padding:1.5rem 1rem;text-align:center}
header h1{font-size:1.75rem;font-weight:700;color:#5c3317;letter-spacing:.02em}
header p{font-size:.85rem;color:#9a7060;margin-top:.25rem}
.chips{display:flex;flex-wrap:wrap;gap:.5rem;padding:1rem;justify-content:center;border-bottom:1px solid #e8d9c8;background:#fff8f0}
.chip{cursor:pointer;padding:.3rem .8rem;border-radius:999px;border:1px solid #c8a98a;font-size:.8rem;color:#6b4c36;background:#fff;transition:all .15s}
.chip:hover,.chip.active{background:#c8a98a;color:#fff;border-color:#c8a98a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;padding:1.25rem;max-width:1100px;margin:0 auto}
.card{background:#fff;border-radius:.75rem;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;transition:transform .15s,box-shadow .15s}
.card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.12)}
.card.hidden{display:none}
.thumb-wrap{position:relative;width:100%;aspect-ratio:1;overflow:hidden;background:#e8d9c8}
.thumb{width:100%;height:100%;object-fit:cover;display:block}
.vid-icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;pointer-events:none}
.card-info{padding:.6rem .75rem}
.card-uploader{font-size:.75rem;color:#9a7060;margin-bottom:.2rem}
.card-caption{font-size:.85rem;color:#3d2b1f;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.card-meta{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.75rem;color:#b08060}
.lb{display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.88);overflow-y:auto}
.lb.open{display:flex;flex-direction:column}
.lb-close{position:fixed;top:.75rem;right:1rem;font-size:1.75rem;color:#fff;cursor:pointer;z-index:101;line-height:1}
.lb-close:hover{color:#e8c89a}
.lb-media{max-width:900px;width:100%;margin:3rem auto 0;padding:0 .5rem}
.lb-media img,.lb-media video{width:100%;border-radius:.5rem;max-height:70vh;object-fit:contain;background:#1a1a1a;display:block}
.lb-details{max-width:900px;width:100%;margin:1rem auto 2rem;padding:0 1rem}
.lb-caption{font-size:1rem;color:#fff;margin-bottom:.5rem}
.lb-meta{font-size:.8rem;color:#b08060;margin-bottom:.75rem}
.lb-tags{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.lb-tag{font-size:.75rem;color:#e8c89a;background:rgba(255,255,255,.1);padding:.2rem .6rem;border-radius:999px}
.lb-likes{font-size:.85rem;color:#d4a574;margin-bottom:.75rem}
.lb-comments h3{font-size:.9rem;color:#e8d9c8;margin-bottom:.5rem;font-weight:600}
.comment{border-top:1px solid rgba(255,255,255,.1);padding:.5rem 0}
.comment-name{font-size:.75rem;color:#b08060}
.comment-body{font-size:.85rem;color:#e8d9c8;margin-top:.15rem}
.empty{text-align:center;padding:3rem 1rem;color:#b08060;font-size:.95rem}
footer{text-align:center;padding:1.5rem;font-size:.75rem;color:#b08060;border-top:1px solid #e8d9c8;margin-top:2rem}
</style>
</head>
<body>
<header>
<h1>{{ event_name }}</h1>
<p>Erinnerungen · Erstellt am {{ generated_at }}</p>
</header>
{% set ns = namespace(all_tags=[]) %}
{% for u in uploads %}{% for t in u.hashtags %}{% if t not in ns.all_tags %}{% set ns.all_tags = ns.all_tags + [t] %}{% endif %}{% endfor %}{% endfor %}
{% if ns.all_tags %}
<div class="chips" id="chips">
<span class="chip active" data-tag="">Alle</span>
{% for tag in ns.all_tags %}<span class="chip" data-tag="{{ tag }}">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if uploads %}
<div class="grid" id="grid">
{% for u in uploads %}
<div class="card" data-tags="{{ u.hashtags | join(',') }}" onclick="openLb({{ loop.index0 }})">
<div class="thumb-wrap">
{% if u.is_video %}
<video class="thumb" src="{{ u.path }}" preload="none"></video>
<div class="vid-icon">▶</div>
{% else %}
<img class="thumb" src="{{ u.path }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="card-info">
<div class="card-uploader">{{ u.uploader_name }} · {{ u.created_at }}</div>
{% if u.caption %}<div class="card-caption">{{ u.caption }}</div>{% endif %}
<div class="card-meta">
<span>♡ {{ u.like_count }}</span>
{% if u.comments %}<span>💬 {{ u.comments | length }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">Noch keine Fotos vorhanden.</div>
{% endif %}
<div class="lb" id="lb">
<span class="lb-close" onclick="closeLb()">×</span>
<div class="lb-media" id="lb-media"></div>
<div class="lb-details" id="lb-details"></div>
</div>
<footer>{{ event_name }} · Offline-Galerie · EventSnap</footer>
<script>
const uploads = {{ uploads | tojson }};
let activeTag = '';
function filterCards(){document.querySelectorAll('#grid .card').forEach((card,i)=>{const tags=(card.dataset.tags||'').split(',').filter(Boolean);card.classList.toggle('hidden',activeTag!==''&&!tags.includes(activeTag));});}
document.querySelectorAll('#chips .chip').forEach(chip=>{chip.addEventListener('click',()=>{document.querySelectorAll('#chips .chip').forEach(c=>c.classList.remove('active'));chip.classList.add('active');activeTag=chip.dataset.tag;filterCards();});});
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function openLb(idx){
const u=uploads[idx];
const lb=document.getElementById('lb');
const media=document.getElementById('lb-media');
const details=document.getElementById('lb-details');
if(u.is_video){media.innerHTML=`<video src="${esc(u.path)}" controls autoplay playsinline></video>`;}
else{media.innerHTML=`<img src="${esc(u.path)}" alt="${esc(u.caption)}">`;}
const tags=u.hashtags.map(t=>`<span class="lb-tag">#${esc(t)}</span>`).join('');
const comments=u.comments.map(c=>`<div class="comment"><div class="comment-name">${esc(c.uploader_name)} · ${esc(c.created_at)}</div><div class="comment-body">${esc(c.body)}</div></div>`).join('');
details.innerHTML=(u.caption?`<div class="lb-caption">${esc(u.caption)}</div>`:'')+
`<div class="lb-meta">${esc(u.uploader_name)} · ${esc(u.created_at)}</div>`+
(tags?`<div class="lb-tags">${tags}</div>`:'')+
`<div class="lb-likes">♡ ${u.like_count} Likes</div>`+
(u.comments.length?`<div class="lb-comments"><h3>Kommentare (${u.comments.length})</h3>${comments}</div>`:'');
lb.classList.add('open');document.body.style.overflow='hidden';
}
function closeLb(){document.getElementById('lb').classList.remove('open');document.getElementById('lb-media').innerHTML='';document.body.style.overflow='';}
document.getElementById('lb').addEventListener('click',e=>{if(e.target===e.currentTarget)closeLb();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLb();});
</script>
</body>
</html>"#;

View File

@@ -0,0 +1 @@
export const env={}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};

View File

@@ -0,0 +1 @@
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/Dy1jDy4J.js";export{o as load_css,r as start};

View File

@@ -0,0 +1 @@
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};

View File

@@ -0,0 +1 @@
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/Dy1jDy4J.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":"1775501323159"}

View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/_app/immutable/entry/start.ctwmcI8C.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dy1jDy4J.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CcONa1Mr.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BJ__EZ0W.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.jfkZT8Zg.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bb9JxzU7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/RsTAN2PN.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bxn6SmGf.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BRDva_z9.js" rel="modulepreload">
</head>
<body>
<div style="display: contents">
<script>
{
__sveltekit_ftrcoq = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.ctwmcI8C.js"),
import("/_app/immutable/entry/app.jfkZT8Zg.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

2153
frontend/export-viewer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "export-viewer",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"svelte": "^5.54.0",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
export interface ViewerData {
event: {
name: string;
exported_at: string;
};
posts: ViewerPost[];
}
export interface ViewerPost {
id: string;
uploader: string;
caption: string;
tags: string[];
timestamp: string;
likes: number;
comments: ViewerComment[];
media: {
type: 'image' | 'video';
thumb: string;
full: string;
};
}
export interface ViewerComment {
author: string;
text: string;
timestamp: string;
}

View File

@@ -0,0 +1,6 @@
<script>
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

View File

@@ -0,0 +1,613 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ViewerData, ViewerPost, ViewerComment } from '$lib/types';
let data = $state<ViewerData | null>(null);
let loading = $state(true);
let error = $state('');
// View mode
let viewMode = $state<'list' | 'grid'>('list');
// Grid search / filter state
let searchQuery = $state('');
let showAutocomplete = $state(false);
interface Filter { type: 'tag' | 'user'; value: string }
let activeFilters = $state<Filter[]>([]);
// Lightbox state
let selectedPost = $state<ViewerPost | null>(null);
let lightboxIndex = $state(0);
let touchStartX = 0;
// List view hashtag filter
let selectedHashtag = $state<string | null>(null);
// ── Derived data ────────────────────────────────────────────────────────────
let posts = $derived(data?.posts ?? []);
let allTags = $derived.by(() => {
const freq = new Map<string, number>();
for (const p of posts) {
for (const t of p.tags) {
const lower = t.toLowerCase();
freq.set(lower, (freq.get(lower) ?? 0) + 1);
}
}
return [...freq.entries()].sort((a, b) => b[1] - a[1]);
});
let allUploaders = $derived([...new Set(posts.map((p) => p.uploader))].sort());
let suggestions = $derived.by((): Filter[] => {
const q = searchQuery.trim();
if (!q) {
if (!showAutocomplete) return [];
return [
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
...allTags.slice(0, 3).map(([t]) => ({ type: 'tag' as const, value: t })),
];
}
if (q.startsWith('#')) {
const prefix = q.slice(1).toLowerCase();
return allTags
.filter(([t]) => t.startsWith(prefix))
.slice(0, 8)
.map(([t]) => ({ type: 'tag' as const, value: t }));
}
const lower = q.toLowerCase();
return [
...allUploaders
.filter((u) => u.toLowerCase().includes(lower))
.slice(0, 4)
.map((u) => ({ type: 'user' as const, value: u })),
...allTags
.filter(([t]) => t.includes(lower))
.slice(0, 4)
.map(([t]) => ({ type: 'tag' as const, value: t })),
];
});
// List view: filter by selected hashtag
let listPosts = $derived.by(() => {
if (!selectedHashtag) return posts;
return posts.filter((p) => p.tags.some((t) => t.toLowerCase() === selectedHashtag));
});
// Grid view: filter by active filters (OR within type, AND across types)
let filteredPosts = $derived.by(() => {
if (activeFilters.length === 0) return posts;
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
const users = activeFilters.filter((f) => f.type === 'user').map((f) => f.value);
return posts.filter((p) => {
const postTags = p.tags.map((t) => t.toLowerCase());
const passTag = !tags.length || tags.some((t) => postTags.includes(t));
const passUser = !users.length || users.includes(p.uploader);
return passTag && passUser;
});
});
let displayPosts = $derived(viewMode === 'list' ? listPosts : filteredPosts);
// ── Data loading ────────────────────────────────────────────────────────────
onMount(async () => {
try {
const res = await fetch('./data.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
} catch (e) {
error = 'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
} finally {
loading = false;
}
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function formatShortDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
});
}
function initial(name: string): string {
return name[0]?.toUpperCase() ?? '?';
}
const COLORS = [
'bg-blue-100 text-blue-700',
'bg-purple-100 text-purple-700',
'bg-green-100 text-green-700',
'bg-amber-100 text-amber-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
];
function avatarColor(name: string): string {
let hash = 0;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
return COLORS[hash % COLORS.length];
}
// ── View switching ──────────────────────────────────────────────────────────
function switchView(mode: 'list' | 'grid') {
viewMode = mode;
if (mode === 'list') {
searchQuery = '';
showAutocomplete = false;
}
}
// ── Search / filter ─────────────────────────────────────────────────────────
function selectSuggestion(item: Filter) {
if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
activeFilters = [...activeFilters, item];
}
searchQuery = '';
showAutocomplete = false;
}
function removeFilter(item: Filter) {
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
}
function clearFilters() {
activeFilters = [];
searchQuery = '';
}
function selectHashtag(tag: string | null) {
selectedHashtag = tag;
}
// ── Lightbox ────────────────────────────────────────────────────────────────
function openLightbox(post: ViewerPost) {
const idx = displayPosts.indexOf(post);
lightboxIndex = idx >= 0 ? idx : 0;
selectedPost = post;
}
function closeLightbox() {
selectedPost = null;
}
function navigateLightbox(delta: number) {
const len = displayPosts.length;
if (len === 0) return;
lightboxIndex = (lightboxIndex + delta + len) % len;
selectedPost = displayPosts[lightboxIndex];
}
function handleKeydown(e: KeyboardEvent) {
if (!selectedPost) return;
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') navigateLightbox(-1);
else if (e.key === 'ArrowRight') navigateLightbox(1);
}
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
}
function handleTouchEnd(e: TouchEvent) {
const diff = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(diff) > 50) {
navigateLightbox(diff > 0 ? -1 : 1);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="inline-block h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
</div>
{:else if error}
<div class="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center">
<p class="text-sm text-red-700">{error}</p>
</div>
</div>
{:else if data}
<div class="min-h-screen bg-gray-50 pb-8">
<!-- Sticky header -->
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur">
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
<h1 class="text-lg font-bold text-gray-900">{data.event.name}</h1>
<!-- List / Grid toggle -->
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
<button
onclick={() => switchView('list')}
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
aria-label="Listenansicht"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<button
onclick={() => switchView('grid')}
class="rounded-md p-1.5 transition-colors {viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
aria-label="Rasteransicht"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
</button>
</div>
</div>
<!-- List view: hashtag chips -->
{#if viewMode === 'list' && allTags.length > 0}
<div class="mx-auto max-w-2xl px-4 pb-2">
<div class="flex gap-2 overflow-x-auto pb-2">
<button
onclick={() => selectHashtag(null)}
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selectedHashtag === null
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
>
Alle
</button>
{#each allTags as [tag, count] (tag)}
<button
onclick={() => selectHashtag(tag)}
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selectedHashtag === tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
>
#{tag}
<span class="ml-1 text-xs opacity-70">{count}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Grid view: search bar + autocomplete -->
{#if viewMode === 'grid'}
<div class="mx-auto max-w-2xl px-4 pb-3">
<div class="relative">
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200">
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
type="search"
placeholder="Nutzer oder #Tag suchen..."
bind:value={searchQuery}
onfocus={() => (showAutocomplete = true)}
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
/>
{#if searchQuery}
<button
onclick={() => { searchQuery = ''; }}
class="shrink-0 text-gray-400 hover:text-gray-600"
aria-label="Suche loschen"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Autocomplete dropdown -->
{#if showAutocomplete && suggestions.length > 0}
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
{#each suggestions as item}
<button
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
onmousedown={() => selectSuggestion(item)}
>
{#if item.type === 'user'}
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
<span class="font-medium text-gray-900">{item.value}</span>
{:else}
<span class="font-medium text-blue-500">#</span>
<span class="font-medium text-gray-900">{item.value}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Active filter chips -->
{#if activeFilters.length > 0}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{#each activeFilters as filter}
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{filter.type === 'tag' ? '#' : ''}{filter.value}
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900" aria-label="Filter entfernen">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
{/each}
{#if activeFilters.length >= 2}
<button onclick={clearFilters} class="text-xs text-gray-400 hover:text-gray-600">
Alle loschen
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Content -->
{#if displayPosts.length === 0}
<div class="py-20 text-center">
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
{#if activeFilters.length > 0}
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline">Filter zurucksetzen</button>
{/if}
</div>
{:else if viewMode === 'list'}
<!-- List view: chronological full-width cards -->
<div class="mx-auto max-w-2xl">
{#each displayPosts as post (post.id)}
<article class="bg-white">
<!-- Uploader row -->
<div class="flex items-center gap-3 px-4 py-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
{avatarColor(post.uploader)}"
>
{initial(post.uploader)}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900">{post.uploader}</p>
<p class="text-xs text-gray-400">{formatDate(post.timestamp)}</p>
</div>
</div>
<!-- Media -->
<button
class="block w-full"
onclick={() => openLightbox(post)}
aria-label="Bild vergrößern"
>
{#if post.media.type === 'video'}
<div class="relative aspect-video w-full bg-gray-900">
{#if post.media.thumb}
<img src={post.media.thumb} alt="" class="h-full w-full object-cover opacity-80" />
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</div>
</div>
{:else if post.media.full}
<img
src={post.media.full}
alt=""
class="w-full object-cover"
style="max-height: 80svh"
loading="lazy"
/>
{:else}
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
<svg class="h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
</button>
<!-- Stats row (read-only) -->
<div class="flex items-center gap-4 px-4 py-2">
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{post.likes}
</span>
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{post.comments.length}
</span>
</div>
<!-- Caption -->
{#if post.caption}
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere]">
{post.caption}
</p>
{/if}
<div class="border-b border-gray-100"></div>
</article>
{/each}
</div>
{:else}
<!-- Grid view: 3-col -->
<div class="mx-auto max-w-2xl">
<div class="grid grid-cols-3 gap-0.5">
{#each displayPosts as post (post.id)}
<div class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100">
<button
onclick={() => openLightbox(post)}
class="block h-full w-full"
aria-label="Upload anzeigen"
>
{#if post.media.type === 'video'}
<div class="flex h-full items-center justify-center bg-gray-800">
{#if post.media.thumb}
<img src={post.media.thumb} alt="" class="h-full w-full object-cover" />
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{:else if post.media.thumb}
<img src={post.media.thumb} alt="" class="h-full w-full object-cover" loading="lazy" />
{:else}
<div class="flex h-full items-center justify-center text-gray-400">
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
</button>
<!-- Overlay with name and stats -->
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p class="truncate text-xs font-medium text-white">{post.uploader}</p>
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
<span class="flex items-center gap-0.5">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{post.likes}
</span>
<span class="flex items-center gap-0.5">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{post.comments.length}
</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
<footer class="mt-8 border-t border-gray-200 py-6 text-center text-xs text-gray-400">
<p>{data.event.name} &middot; Offline-Galerie &middot; EventSnap</p>
<p class="mt-1">Exportiert am {formatDate(data.event.exported_at)}</p>
</footer>
</div>
<!-- Lightbox -->
{#if selectedPost}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
role="dialog"
tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) closeLightbox(); }}
ontouchstart={handleTouchStart}
ontouchend={handleTouchEnd}
>
<div class="flex max-h-[95vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white sm:m-4">
<!-- Media -->
<div class="relative bg-black">
<button onclick={closeLightbox} class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Nav arrows -->
{#if displayPosts.length > 1}
<button
onclick={() => navigateLightbox(-1)}
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
aria-label="Vorheriges"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
<button
onclick={() => navigateLightbox(1)}
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
aria-label="Nachstes"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
{/if}
{#if selectedPost.media.type === 'video'}
<video
src={selectedPost.media.full}
controls
class="max-h-[50vh] w-full object-contain"
poster={selectedPost.media.thumb || undefined}
></video>
{:else}
<img
src={selectedPost.media.full}
alt=""
class="max-h-[50vh] w-full object-contain"
/>
{/if}
</div>
<!-- Info + Comments -->
<div class="flex flex-1 flex-col overflow-hidden">
<div class="border-b border-gray-100 p-3">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-gray-900">{selectedPost.uploader}</span>
<span class="ml-2 text-xs text-gray-400">{formatShortDate(selectedPost.timestamp)}</span>
</div>
<span class="flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-sm text-gray-600">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{selectedPost.likes}
</span>
</div>
{#if selectedPost.caption}
<p class="mt-1 text-sm text-gray-700 [overflow-wrap:anywhere]">{selectedPost.caption}</p>
{/if}
</div>
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-3">
{#if selectedPost.comments.length === 0}
<p class="text-center text-sm text-gray-400">Keine Kommentare.</p>
{:else}
<div class="space-y-3">
{#each selectedPost.comments as comment}
<div>
<span class="text-sm font-medium text-gray-900">{comment.author}</span>
<span class="ml-1 text-sm text-gray-700">{comment.text}</span>
<div class="mt-0.5 text-xs text-gray-400">{formatShortDate(comment.timestamp)}</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
runes: true
},
kit: {
adapter: adapter({
pages: '../../backend/static/export-viewer',
assets: '../../backend/static/export-viewer',
fallback: 'index.html',
strict: false
}),
paths: {
relative: true
}
}
};
export default config;

View File

@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View File

@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});

View File

@@ -113,7 +113,7 @@
<h2 class="mb-3 text-lg font-bold text-gray-900">Hinweis zum HTML-Viewer</h2> <h2 class="mb-3 text-lg font-bold text-gray-900">Hinweis zum HTML-Viewer</h2>
<ol class="mb-4 space-y-2 text-sm text-gray-700"> <ol class="mb-4 space-y-2 text-sm text-gray-700">
<li class="flex gap-2"><span class="font-bold text-blue-600">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li> <li class="flex gap-2"><span class="font-bold text-blue-600">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
<li class="flex gap-2"><span class="font-bold text-blue-600">2.</span> <strong>Memories.html</strong> im Browser öffnen.</li> <li class="flex gap-2"><span class="font-bold text-blue-600">2.</span> <strong>index.html</strong> im Browser öffnen.</li>
<li class="flex gap-2"><span class="font-bold text-blue-600">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li> <li class="flex gap-2"><span class="font-bold text-blue-600">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li>
</ol> </ol>
<p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700"> <p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">