feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives

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>
This commit is contained in:
fabi
2026-06-30 19:16:48 +02:00
parent bbec815854
commit e79e020566
42 changed files with 1245 additions and 328 deletions

View File

@@ -13,6 +13,8 @@ export class ApiError extends Error {
}
}
const TIMEOUT_MS = 20_000;
async function request<T>(
method: string,
path: string,
@@ -27,23 +29,50 @@ async function request<T>(
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
// Abort hung requests so a dead connection surfaces as a friendly error
// instead of a spinner that never resolves.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new ApiError(0, 'timeout', 'Zeitüberschreitung bitte erneut versuchen.');
}
throw new ApiError(0, 'network', 'Netzwerkfehler bitte Verbindung prüfen.');
} finally {
clearTimeout(timer);
}
if (res.status === 204) {
return undefined as T;
}
const data = await res.json();
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
const raw = await res.text();
let data: { error?: string; message?: string } | unknown = null;
if (raw) {
try {
data = JSON.parse(raw);
} catch {
data = null;
}
}
if (!res.ok) {
if (res.status === 401) {
clearAuth();
}
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
const d = (data ?? {}) as { error?: string; message?: string };
throw new ApiError(res.status, d.error ?? 'unknown', d.message ?? `Serverfehler (${res.status}).`);
}
return data as T;