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>
This commit is contained in:
MechaCat02
2026-04-06 21:01:01 +02:00
parent 4a5506f32d
commit 4f966533fe
12 changed files with 2881 additions and 0 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/

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()]
});