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/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
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