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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ backend/target/
|
||||
frontend/node_modules/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
frontend/export-viewer/node_modules/
|
||||
frontend/export-viewer/.svelte-kit/
|
||||
|
||||
# Media uploads (mounted volume in production)
|
||||
media/
|
||||
|
||||
2153
frontend/export-viewer/package-lock.json
generated
Normal file
2153
frontend/export-viewer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/export-viewer/package.json
Normal file
22
frontend/export-viewer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/export-viewer/src/app.css
Normal file
1
frontend/export-viewer/src/app.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
11
frontend/export-viewer/src/app.html
Normal file
11
frontend/export-viewer/src/app.html
Normal 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>
|
||||
28
frontend/export-viewer/src/lib/types.ts
Normal file
28
frontend/export-viewer/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
6
frontend/export-viewer/src/routes/+layout.svelte
Normal file
6
frontend/export-viewer/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
2
frontend/export-viewer/src/routes/+layout.ts
Normal file
2
frontend/export-viewer/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
613
frontend/export-viewer/src/routes/+page.svelte
Normal file
613
frontend/export-viewer/src/routes/+page.svelte
Normal 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} · Offline-Galerie · 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}
|
||||
21
frontend/export-viewer/svelte.config.js
Normal file
21
frontend/export-viewer/svelte.config.js
Normal 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;
|
||||
15
frontend/export-viewer/tsconfig.json
Normal file
15
frontend/export-viewer/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
frontend/export-viewer/vite.config.ts
Normal file
7
frontend/export-viewer/vite.config.ts
Normal 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()]
|
||||
});
|
||||
Reference in New Issue
Block a user