The kill-switch previously left comment UI/text visible in several places. Sweep the whole surface so a comments-off instance shows no trace: Frontend (main app): - VirtualFeed grid tile: gate the comment button/count on $commentsEnabled (was ungated — the only feed surface still showing it). - admin stats: hide the "Kommentare" count card. - UploadSheet "Uploads geschlossen" notice, host ban-modal description, and host/admin unban confirmations: drop the "…und kommentieren" wording. - export page: drop "Kommentaren" from the keepsake description. Keepsake export (had no concept of the flag): - export.rs: thread comments_enabled into the exported data (ViewerEvent), wired through spawn_export_jobs/recover_exports and their call sites (host.rs, main.rs). - export-viewer: gate comment counts (list + grid) and the lightbox comments section; older exports without the field default to enabled (?? true). Backend still 403s comment writes when disabled (unchanged) — this is the UI half so stale clients and archives match. Verified on the running stack (COMMENTS_ENABLED=false): /event reports comments_enabled=false, a regenerated keepsake embeds "comments_enabled": false with no comment UI, and the uploads-closed notice renders "…ansehen und liken." Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
823 lines
26 KiB
Svelte
823 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import type { ViewerData, ViewerPost } 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 ?? []);
|
|
|
|
// Mirror the live COMMENTS_ENABLED flag. Missing on older exports → treat as enabled.
|
|
let commentsEnabled = $derived(data?.event.comments_enabled ?? true);
|
|
|
|
let allTags = $derived.by(() => {
|
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state.
|
|
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 {
|
|
// The keepsake is opened by double-clicking index.html (file://), where a
|
|
// cross-origin `fetch()` is blocked. The export injects the data as a global
|
|
// so it's available without any network request; fall back to fetching
|
|
// data.json only when the viewer is actually served over http(s).
|
|
const injected = (globalThis as { __EXPORT_DATA__?: ViewerData }).__EXPORT_DATA__;
|
|
if (injected) {
|
|
data = injected;
|
|
} else {
|
|
const res = await fetch('./data.json');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
data = await res.json();
|
|
}
|
|
} catch {
|
|
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 (item.type + ':' + item.value)}
|
|
<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 (filter.type + ':' + filter.value)}
|
|
<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>
|
|
{#if commentsEnabled}
|
|
<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>
|
|
{/if}
|
|
</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>
|
|
{#if commentsEnabled}
|
|
<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>
|
|
{/if}
|
|
</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 -->
|
|
{#if commentsEnabled}
|
|
<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, i (i)}
|
|
<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>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|