Frontend - Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and bottom nav working. List = dynamic measured heights keyed by upload id + anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured square rows. Drops the content-visibility band-aid and the old FeedGrid. measureElement(null) on row unmount prevents ResizeObserver retention; stable option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches. - Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest + maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers. - Feed reactivity: like/comment SSE patch the single card in place; upload-processed debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock. - CLS: list cards reserve the skeleton aspect box. - Destructive actions (promote/demote/unban, gallery release) routed through ConfirmSheet (host + admin). - Export OOM: streamed download via single-use ticket instead of res.blob(). - Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y. - Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry, ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons. Backend - social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients patch one card in place instead of refetching page 1. - admin.rs / main.rs: supporting changes for the above. Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed scroll-feel validation still owed (documented in FOLLOWUPS.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
7.2 KiB
Svelte
203 lines
7.2 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import type { FeedUpload } from '$lib/types';
|
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
|
import { longpress } from '$lib/actions/longpress';
|
|
import { doubletap } from '$lib/actions/doubletap';
|
|
import { avatarPalette, initials } from '$lib/avatar';
|
|
import { vibrate } from '$lib/haptics';
|
|
import { now } from '$lib/now';
|
|
import HeartBurst from './HeartBurst.svelte';
|
|
|
|
interface Props {
|
|
upload: FeedUpload;
|
|
isOwn?: boolean;
|
|
onlike: (id: string) => void;
|
|
oncomment: (id: string) => void;
|
|
onselect: (upload: FeedUpload) => void;
|
|
oncontextmenu?: (upload: FeedUpload) => void;
|
|
}
|
|
|
|
let {
|
|
upload,
|
|
isOwn = false,
|
|
onlike,
|
|
oncomment,
|
|
onselect,
|
|
oncontextmenu
|
|
}: Props = $props();
|
|
|
|
function isVideo(mime: string): boolean {
|
|
return mime.startsWith('video/');
|
|
}
|
|
|
|
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
|
|
|
function relativeTime(iso: string, nowMs: number): string {
|
|
const diff = nowMs - new Date(iso).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return 'gerade eben';
|
|
if (mins < 60) return `vor ${mins} Min.`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `vor ${hrs} Std.`;
|
|
const days = Math.floor(hrs / 24);
|
|
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
|
}
|
|
|
|
// Re-derives off the shared 60s clock so "vor 5 Min." actually advances.
|
|
const relTime = $derived(relativeTime(upload.created_at, $now));
|
|
|
|
function openContext() {
|
|
oncontextmenu?.(upload);
|
|
}
|
|
|
|
// Inline heart-burst on double-tap (consistent with the lightbox).
|
|
let heartBurst = $state(false);
|
|
let burstTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function handleDoubleTap() {
|
|
heartBurst = true;
|
|
vibrate(10);
|
|
onlike(upload.id);
|
|
if (burstTimer) clearTimeout(burstTimer);
|
|
burstTimer = setTimeout(() => (heartBurst = false), 700);
|
|
}
|
|
|
|
// Clear the burst timer on unmount (a feed-delta SSE event can remove this
|
|
// card mid-animation) so it can't fire against a stale component.
|
|
onDestroy(() => {
|
|
if (burstTimer) clearTimeout(burstTimer);
|
|
});
|
|
</script>
|
|
|
|
<!-- Off-screen cards are removed from the DOM entirely by the parent VirtualFeed
|
|
window-virtualizer, so this card no longer needs content-visibility — and must
|
|
not use it, since the virtualizer measures each card's real rendered height. -->
|
|
<article
|
|
class="bg-white dark:bg-gray-900"
|
|
use:longpress={{ duration: 500 }}
|
|
onlongpress={openContext}
|
|
>
|
|
<!-- Uploader row -->
|
|
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<div
|
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
|
|
{avatarPalette(upload.uploader_name)}"
|
|
>
|
|
{initials(upload.uploader_name)}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
|
|
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if oncontextmenu}
|
|
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
|
|
<button
|
|
type="button"
|
|
onclick={(e) => { e.stopPropagation(); openContext(); }}
|
|
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active:bg-gray-800 dark:hover:text-gray-200"
|
|
aria-label="Mehr Aktionen"
|
|
>
|
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Media -->
|
|
<button
|
|
class="relative block w-full"
|
|
use:doubletap
|
|
onsingletap={() => onselect(upload)}
|
|
ondoubletap={handleDoubleTap}
|
|
onclick={(e) => { if (e.detail === 0) onselect(upload); }}
|
|
aria-label="Bild vergrößern"
|
|
>
|
|
<HeartBurst active={heartBurst} />
|
|
{#if isVideo(upload.mime_type)}
|
|
<div class="relative aspect-video w-full bg-gray-900">
|
|
{#if upload.thumbnail_url || upload.preview_url}
|
|
<img
|
|
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
|
|
alt=""
|
|
class="h-full w-full object-cover opacity-80"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
{/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 mediaSrc}
|
|
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
|
|
to height 0 and reflow as images stream in. The uncropped original is one
|
|
tap away in the lightbox. -->
|
|
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
|
|
<img
|
|
src={mediaSrc}
|
|
alt=""
|
|
class="h-full w-full object-cover"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
|
|
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" 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>
|
|
|
|
<!-- Actions row -->
|
|
<div class="flex items-center gap-4 px-4 py-2">
|
|
<button
|
|
onclick={() => { vibrate(10); onlike(upload.id); }}
|
|
aria-pressed={upload.liked_by_me}
|
|
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
|
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
|
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
|
|
>
|
|
<svg
|
|
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
|
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>
|
|
{upload.like_count}
|
|
</button>
|
|
<button
|
|
onclick={() => oncomment(upload.id)}
|
|
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
|
>
|
|
<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>
|
|
{upload.comment_count}
|
|
</button>
|
|
{#if isOwn}
|
|
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Caption -->
|
|
{#if upload.caption}
|
|
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere] dark:text-gray-200">
|
|
{upload.caption}
|
|
</p>
|
|
{/if}
|
|
|
|
<div class="border-b border-gray-100 dark:border-gray-800"></div>
|
|
</article>
|