The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
12 KiB
Svelte
309 lines
12 KiB
Svelte
<script lang="ts">
|
|
// DOM-windowing for the feed. Only the cards/rows inside (and a small overscan
|
|
// around) the viewport are kept in the DOM — at ~1000 uploads this is the
|
|
// difference between ~1000 heavy cards (each with its own image, HeartBurst,
|
|
// long-press + double-tap listeners) and ~10-15.
|
|
//
|
|
// We use TanStack's *window* virtualizer (not an inner scroll container) on
|
|
// purpose: the feed scrolls the document, and the page's sticky header,
|
|
// pull-to-refresh, infinite-scroll sentinel and bottom nav all rely on that.
|
|
// The window virtualizer measures against `window` scroll, so every one of
|
|
// those keeps working untouched.
|
|
//
|
|
// Two layouts share one mechanism:
|
|
// list — one full-width FeedListCard per row, heights *measured* (captions
|
|
// make them variable). Keyed by upload id + `anchorTo:'start'` so an
|
|
// SSE prepend (new upload) doesn't yank a scrolled-down reader.
|
|
// grid — three square tiles per row, uniform height; still measured to shrug
|
|
// off sub-pixel drift over hundreds of rows.
|
|
import { createWindowVirtualizer } from '@tanstack/svelte-virtual';
|
|
import { untrack } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
import type { FeedUpload } from '$lib/types';
|
|
import { dataMode } from '$lib/data-mode-store';
|
|
import { longpress } from '$lib/actions/longpress';
|
|
import FeedListCard from './FeedListCard.svelte';
|
|
|
|
interface Props {
|
|
uploads: FeedUpload[];
|
|
mode: 'list' | 'grid';
|
|
myUserId: string | null;
|
|
onlike: (id: string) => void;
|
|
oncomment: (id: string) => void;
|
|
onselect: (upload: FeedUpload) => void;
|
|
oncontextmenu?: (upload: FeedUpload) => void;
|
|
}
|
|
|
|
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
|
|
|
const COLS = 3;
|
|
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
|
const LIST_ESTIMATE = 700; // px — first-paint guess; real heights replace it on measure.
|
|
|
|
let listEl = $state<HTMLDivElement>();
|
|
let containerWidth = $state(0);
|
|
|
|
// Distance from the top of the document to the list container, i.e. the height
|
|
// of everything above it (sticky header + chips/search). Items' `start` values
|
|
// are document-absolute (they include this margin), so we feed it back as
|
|
// `scrollMargin` and subtract it again when positioning. Read live from layout
|
|
// (getBoundingClientRect + scrollY is scroll-independent) so it self-corrects
|
|
// when the header grows — e.g. grid filter chips appear.
|
|
let scrollMargin = $state(0);
|
|
|
|
const rowCount = $derived(mode === 'grid' ? Math.ceil(uploads.length / COLS) : uploads.length);
|
|
const colWidth = $derived(
|
|
containerWidth > 0 ? (containerWidth - (COLS - 1) * GRID_GAP) / COLS : 120
|
|
);
|
|
|
|
function isVideo(mime: string): boolean {
|
|
return mime.startsWith('video/');
|
|
}
|
|
|
|
// Grid tiles always use the small thumbnail — full media is one tap away in the
|
|
// lightbox where the data-mode picker decides for real.
|
|
function tileUrl(upload: FeedUpload): string {
|
|
if (upload.thumbnail_url) return upload.thumbnail_url;
|
|
if (upload.preview_url) return upload.preview_url;
|
|
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
|
}
|
|
|
|
// STABLE option callbacks — created once, never swapped. They read the live
|
|
// reactive values (`colWidth`, `uploads`, `mode`) at *call* time, so they stay
|
|
// current without needing a new function reference. This matters: `getItemKey`
|
|
// is a dependency of virtual-core's measurements memo, so handing it a fresh
|
|
// closure on every render would force an O(n) recompute. List keys by upload id
|
|
// (so an SSE prepend keeps measured heights attached to the right card); grid
|
|
// keys by row index (uniform rows, nothing to preserve).
|
|
const estimateSize = (_i: number) => (mode === 'grid' ? colWidth : LIST_ESTIMATE);
|
|
const getItemKey = (i: number) => (mode === 'list' ? (uploads[i]?.id ?? i) : i);
|
|
|
|
// `mode` is fixed for the lifetime of an instance (list and grid are rendered as
|
|
// separate <VirtualFeed> elements in the parent's {#if} branches, so toggling
|
|
// remounts rather than mutating this prop). Snapshot it without a reactive read
|
|
// to set the layout-constant options once.
|
|
const isGrid = untrack(() => mode === 'grid');
|
|
|
|
// Created with static placeholder count/margin; `applyOptions` pushes those.
|
|
const virtualizer = createWindowVirtualizer<HTMLDivElement>({
|
|
count: 0,
|
|
estimateSize,
|
|
getItemKey,
|
|
overscan: isGrid ? 4 : 3,
|
|
gap: isGrid ? GRID_GAP : 0,
|
|
anchorTo: 'start',
|
|
scrollMargin: 0
|
|
});
|
|
|
|
// Only `count` (load-more / prepend / delete) and `scrollMargin` (header height
|
|
// shifts) actually need to be pushed into the virtualizer. Like/comment SSE
|
|
// patches reassign `uploads` without changing its length — those must NOT
|
|
// trigger a setOptions (and its getBoundingClientRect reflow + store churn); the
|
|
// affected card re-renders through normal reactivity instead. We read the raw
|
|
// instance via `get()` rather than `$virtualizer` so writing never re-triggers
|
|
// this effect (that would loop).
|
|
let appliedCount = -1;
|
|
let appliedMargin = Number.NaN;
|
|
let appliedWidth = Number.NaN;
|
|
|
|
function applyOptions() {
|
|
const count = rowCount;
|
|
const width = containerWidth;
|
|
const margin = listEl ? listEl.getBoundingClientRect().top + window.scrollY : 0;
|
|
scrollMargin = margin; // drives the template transforms
|
|
// A width change (rotation, or the first 0→real clientWidth landing) changes
|
|
// every card's / tile-row's real height, so the heights cached from the old
|
|
// width are stale even when count and margin are unchanged. Invalidate the
|
|
// measurement cache so getTotalSize + positions recompute (rendered rows then
|
|
// re-measure immediately via their ResizeObserver) instead of trusting them.
|
|
const widthChanged = width > 0 && Math.abs(width - appliedWidth) > 0.5;
|
|
if (count === appliedCount && Math.abs(margin - appliedMargin) < 0.5 && !widthChanged) return;
|
|
appliedCount = count;
|
|
appliedMargin = margin;
|
|
appliedWidth = width;
|
|
get(virtualizer).setOptions({ count, scrollMargin: margin });
|
|
if (widthChanged) get(virtualizer).measure();
|
|
}
|
|
|
|
// Re-apply when the row count or container width changes (load-more, prepend,
|
|
// delete, filter, rotate). `containerWidth`/`rowCount` are the only tracked
|
|
// reads, so a length-stable like/comment patch doesn't re-run this.
|
|
$effect(() => {
|
|
void rowCount;
|
|
void containerWidth;
|
|
applyOptions();
|
|
});
|
|
|
|
// Header offset can also shift on resize without a count change (orientation,
|
|
// on-screen keyboard, font scaling).
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
const onResize = () => applyOptions();
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
});
|
|
|
|
// Hand each rendered row to the virtualizer's ResizeObserver (it reads the row's
|
|
// `data-index` and caches the real height by item key). On `destroy` we call
|
|
// `measureElement(null)`, which sweeps now-disconnected nodes out of the
|
|
// internal cache + ResizeObserver — without it, every card that scrolls out of
|
|
// the window stays observed and retained, defeating the point of virtualizing.
|
|
function measure(node: HTMLDivElement) {
|
|
get(virtualizer).measureElement(node);
|
|
return {
|
|
update() {
|
|
get(virtualizer).measureElement(node);
|
|
},
|
|
destroy() {
|
|
get(virtualizer).measureElement(null);
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<div bind:this={listEl} bind:clientWidth={containerWidth} class="w-full">
|
|
{#if browser}
|
|
<div style="position: relative; width: 100%; height: {$virtualizer.getTotalSize()}px;">
|
|
{#each $virtualizer.getVirtualItems() as item (item.key)}
|
|
{#if mode === 'list'}
|
|
{@const upload = uploads[item.index]}
|
|
<div
|
|
data-index={item.index}
|
|
use:measure
|
|
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
|
|
scrollMargin}px);"
|
|
>
|
|
{#if upload}
|
|
<FeedListCard
|
|
{upload}
|
|
isOwn={upload.user_id === myUserId}
|
|
{onlike}
|
|
{oncomment}
|
|
{onselect}
|
|
{oncontextmenu}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div
|
|
data-index={item.index}
|
|
use:measure
|
|
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
|
|
scrollMargin}px);"
|
|
>
|
|
<div class="grid grid-cols-3 gap-0.5">
|
|
{#each uploads.slice(item.index * COLS, item.index * COLS + COLS) as upload (upload.id)}
|
|
<!-- Tile — mirrors the markup the old FeedGrid used. -->
|
|
<div
|
|
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
|
|
use:longpress={{ duration: 500 }}
|
|
onlongpress={() => oncontextmenu?.(upload)}
|
|
>
|
|
<button
|
|
onclick={() => onselect(upload)}
|
|
class="block h-full w-full"
|
|
aria-label="Upload anzeigen"
|
|
>
|
|
{#if isVideo(upload.mime_type)}
|
|
<div class="flex h-full items-center justify-center bg-gray-800">
|
|
{#if tileUrl(upload)}
|
|
<img
|
|
src={tileUrl(upload)}
|
|
alt=""
|
|
class="h-full w-full object-cover"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
{/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 tileUrl(upload)}
|
|
<img
|
|
src={tileUrl(upload)}
|
|
alt=""
|
|
class="h-full w-full object-cover"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
{: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>
|
|
|
|
<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">{upload.uploader_name}</p>
|
|
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
|
|
<button
|
|
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onlike(upload.id);
|
|
}}
|
|
aria-pressed={upload.liked_by_me}
|
|
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
|
>
|
|
<svg
|
|
class="h-4 w-4 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}"
|
|
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>
|
|
{upload.like_count}
|
|
</button>
|
|
<button
|
|
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
oncomment(upload.id);
|
|
}}
|
|
aria-label="Kommentare anzeigen"
|
|
>
|
|
<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="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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|