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:
@@ -6,6 +6,7 @@
|
||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||
import { toastError } from '$lib/toast-store';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
|
||||
interface JobStatus {
|
||||
status: 'locked' | 'pending' | 'running' | 'done' | 'failed';
|
||||
@@ -75,35 +76,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(endpoint: string, filename: string) {
|
||||
// Stream the (potentially multi-GB) archive straight to disk via a top-level
|
||||
// download navigation instead of fetch()+blob() — buffering the whole ZIP in
|
||||
// memory crashes mobile Safari/Chrome. The navigation can't send the Bearer
|
||||
// header, so we first exchange the token for a single-use ticket and pass it
|
||||
// as a query param; the server responds with Content-Disposition: attachment,
|
||||
// so the browser downloads it and we stay on the page.
|
||||
async function downloadFile(endpoint: string) {
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(endpoint, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (!res.ok) {
|
||||
toastError(new Error(`Download fehlgeschlagen (${res.status}).`));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const { ticket } = await api.post<{ ticket: string }>('/export/ticket');
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.href = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadZip() {
|
||||
downloadFile('/api/v1/export/zip', 'Gallery.zip');
|
||||
downloadFile('/api/v1/export/zip');
|
||||
}
|
||||
|
||||
function downloadHtml() {
|
||||
if (localStorage.getItem(HTML_GUIDE_KEY)) {
|
||||
downloadFile('/api/v1/export/html', 'Memories.zip');
|
||||
downloadFile('/api/v1/export/html');
|
||||
} else {
|
||||
showHtmlGuide = true;
|
||||
}
|
||||
@@ -112,7 +111,7 @@
|
||||
function confirmHtmlDownload() {
|
||||
localStorage.setItem(HTML_GUIDE_KEY, '1');
|
||||
showHtmlGuide = false;
|
||||
downloadFile('/api/v1/export/html', 'Memories.zip');
|
||||
downloadFile('/api/v1/export/html');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -154,19 +153,13 @@
|
||||
{/if}
|
||||
|
||||
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="mx-auto flex max-w-lg items-center gap-2 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto('/feed')}
|
||||
data-testid="export-back"
|
||||
class="-ml-2 flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 active:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 dark:active:bg-gray-700"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<IconButton label="Zurück" onclick={() => goto('/feed')} data-testid="export-back" class="-ml-2">
|
||||
<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>
|
||||
</IconButton>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Export</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user