chore(frontend): add ESLint + Prettier; fix real findings; format the tree
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>
This commit is contained in:
@@ -56,7 +56,10 @@
|
||||
let searchQuery = $state('');
|
||||
let showAutocomplete = $state(false);
|
||||
|
||||
interface Filter { type: 'tag' | 'user'; value: string }
|
||||
interface Filter {
|
||||
type: 'tag' | 'user';
|
||||
value: string;
|
||||
}
|
||||
let activeFilters = $state<Filter[]>([]);
|
||||
|
||||
let unsubscribers: (() => void)[] = [];
|
||||
@@ -82,7 +85,9 @@
|
||||
label: 'Löschen',
|
||||
icon: '🗑',
|
||||
tone: 'danger',
|
||||
onClick: () => { pendingDeleteId = target.id; }
|
||||
onClick: () => {
|
||||
pendingDeleteId = target.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
@@ -108,6 +113,7 @@
|
||||
|
||||
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
|
||||
let allTags = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state, so no reactivity is involved.
|
||||
const freq = new Map<string, number>();
|
||||
for (const u of uploads) {
|
||||
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
|
||||
@@ -156,7 +162,7 @@
|
||||
if (!showAutocomplete) return [];
|
||||
return [
|
||||
...frozenUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
||||
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })),
|
||||
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t }))
|
||||
];
|
||||
}
|
||||
if (q.startsWith('#')) {
|
||||
@@ -175,7 +181,7 @@
|
||||
...frozenTags
|
||||
.filter((t) => t.includes(lower))
|
||||
.slice(0, 4)
|
||||
.map((t) => ({ type: 'tag' as const, value: t })),
|
||||
.map((t) => ({ type: 'tag' as const, value: t }))
|
||||
];
|
||||
});
|
||||
|
||||
@@ -230,7 +236,9 @@
|
||||
return;
|
||||
}
|
||||
uploads = [upload, ...uploads];
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// A processed upload gains preview/thumbnail URLs. Coalesce bursts (bulk
|
||||
// uploads fire one per file) into a single in-place merge so the feed
|
||||
@@ -241,7 +249,9 @@
|
||||
const payload = JSON.parse(data) as { upload_id: string };
|
||||
uploads = uploads.filter((u) => u.id !== payload.upload_id);
|
||||
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// A background transcode failed: the backend already cleaned up (refunded
|
||||
// quota, removed the row) and an upload-deleted evicts the card. Only the
|
||||
@@ -255,7 +265,9 @@
|
||||
toast('Ein Upload konnte nicht verarbeitet werden.', 'error');
|
||||
void refreshQuota();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// A banned user's uploads were hidden — drop all their cards live.
|
||||
onSseEvent('user-hidden', (data) => {
|
||||
@@ -263,7 +275,9 @@
|
||||
const { user_id } = JSON.parse(data) as { user_id: string };
|
||||
uploads = uploads.filter((u) => u.user_id !== user_id);
|
||||
if (selectedUpload && selectedUpload.user_id === user_id) selectedUpload = null;
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// Patch the single affected card in place from the SSE payload instead of
|
||||
// refetching page 1 — a busy event fires these constantly and a full reload
|
||||
@@ -308,7 +322,9 @@
|
||||
// disconnected or lagged (a `resync`). Debounced page-1 refresh merges
|
||||
// those fresh counts in place without disturbing scroll.
|
||||
scheduleInPlaceRefresh();
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -345,7 +361,9 @@
|
||||
if (selectedUpload?.id === payload.upload_id) {
|
||||
selectedUpload = { ...selectedUpload, [field]: value };
|
||||
}
|
||||
} catch { /* ignore malformed payloads */ }
|
||||
} catch {
|
||||
/* ignore malformed payloads */
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced page-1 fetch that *merges* (updates existing cards in place, prepends
|
||||
@@ -361,6 +379,7 @@
|
||||
|
||||
async function refreshFeedInPlace() {
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||||
const params = new URLSearchParams();
|
||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||
params.set('limit', '20');
|
||||
@@ -381,6 +400,7 @@
|
||||
// only in the pill's own onclick, or a pull-to-refresh leaves it stranded.
|
||||
if (refresh) feedStale = false;
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||||
const params = new URLSearchParams();
|
||||
if (!refresh && nextCursor) params.set('cursor', nextCursor);
|
||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||
@@ -400,6 +420,7 @@
|
||||
if (!nextCursor || loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||||
const params = new URLSearchParams();
|
||||
params.set('cursor', nextCursor);
|
||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||
@@ -447,7 +468,9 @@
|
||||
// local state. On a second device (same recovered user), the `like-update`
|
||||
// broadcast only carries `like_count` — so a blind invert would drift
|
||||
// `liked_by_me` until refresh. The response gives both, exactly.
|
||||
const res = await api.post<{ liked: boolean; like_count: number | null }>(`/upload/${id}/like`);
|
||||
const res = await api.post<{ liked: boolean; like_count: number | null }>(
|
||||
`/upload/${id}/like`
|
||||
);
|
||||
vibrate(10);
|
||||
// like_count is null when the server's count query hiccuped — keep the current
|
||||
// count in that case rather than adopting a wrong number.
|
||||
@@ -460,7 +483,7 @@
|
||||
selectedUpload = {
|
||||
...selectedUpload,
|
||||
liked_by_me: res.liked,
|
||||
like_count: res.like_count ?? selectedUpload.like_count,
|
||||
like_count: res.like_count ?? selectedUpload.like_count
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -510,14 +533,18 @@
|
||||
<!-- Live pull-progress indicator: grows during the drag, rotates past threshold,
|
||||
swaps to a spinner once the network refresh kicks off. -->
|
||||
{#if refreshing || pullProgress > 0}
|
||||
<div class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center">
|
||||
<div
|
||||
class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center"
|
||||
>
|
||||
<div
|
||||
class="rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-blue-600 shadow transition-opacity dark:bg-gray-900/90 dark:text-blue-300"
|
||||
style="opacity: {refreshing ? 1 : Math.min(1, pullProgress)}"
|
||||
>
|
||||
{#if refreshing}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600 dark:border-blue-700 dark:border-t-blue-300"></span>
|
||||
<span
|
||||
class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600 dark:border-blue-700 dark:border-t-blue-300"
|
||||
></span>
|
||||
Aktualisiere…
|
||||
</span>
|
||||
{:else}
|
||||
@@ -530,25 +557,36 @@
|
||||
stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if feedStale}
|
||||
<div class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center">
|
||||
<div
|
||||
class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto rounded-full bg-blue-600 px-4 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-blue-700"
|
||||
onclick={() => { feedStale = false; void loadFeed(true); }}
|
||||
onclick={() => {
|
||||
feedStale = false;
|
||||
void loadFeed(true);
|
||||
}}
|
||||
>
|
||||
Neue Beiträge – tippen zum Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
|
||||
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
|
||||
<div
|
||||
class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900"
|
||||
>
|
||||
<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 dark:text-gray-100">Galerie</h1>
|
||||
|
||||
@@ -560,9 +598,23 @@
|
||||
aria-label="Diashow starten"
|
||||
title="Diashow"
|
||||
>
|
||||
<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 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9.75 14.5 12 10 14.25v-4.5Z" />
|
||||
<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 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10 9.75 14.5 12 10 14.25v-4.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -570,22 +622,46 @@
|
||||
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
|
||||
<button
|
||||
onclick={() => switchView('list')}
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'list'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
aria-label="Listenansicht"
|
||||
>
|
||||
<!-- bars-3 -->
|
||||
<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
|
||||
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 dark:bg-gray-700 dark:text-gray-100' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'grid'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||
aria-label="Rasteransicht"
|
||||
>
|
||||
<!-- squares-2x2 -->
|
||||
<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
|
||||
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>
|
||||
@@ -603,9 +679,21 @@
|
||||
{#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 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-blue-500 dark:focus-within:bg-gray-800">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" 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" />
|
||||
<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 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-blue-500 dark:focus-within:bg-gray-800"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||
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"
|
||||
@@ -614,7 +702,10 @@
|
||||
onfocus={(e) => {
|
||||
openAutocomplete();
|
||||
// Push the input above the virtual keyboard so suggestions stay visible.
|
||||
(e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
(e.currentTarget as HTMLInputElement).scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}}
|
||||
onclick={openAutocomplete}
|
||||
oninput={openAutocomplete}
|
||||
@@ -623,11 +714,19 @@
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => { searchQuery = ''; }}
|
||||
onclick={() => {
|
||||
searchQuery = '';
|
||||
}}
|
||||
class="shrink-0 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
aria-label="Suche löschen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
@@ -663,8 +762,18 @@
|
||||
onclick={() => selectSuggestion(item)}
|
||||
>
|
||||
{#if item.type === 'user'}
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" 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
|
||||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||
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 dark:text-gray-100">{item.value}</span>
|
||||
{:else}
|
||||
@@ -680,18 +789,33 @@
|
||||
<!-- Active filter chips -->
|
||||
{#if activeFilters.length > 0}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{#each activeFilters as filter}
|
||||
<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 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{#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 dark:bg-blue-900/40 dark:text-blue-200"
|
||||
>
|
||||
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
||||
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100" aria-label="Filter entfernen">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<button
|
||||
onclick={() => removeFilter(filter)}
|
||||
class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100"
|
||||
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 dark:text-gray-500 dark:hover:text-gray-300">
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
Alle löschen
|
||||
</button>
|
||||
{/if}
|
||||
@@ -705,12 +829,12 @@
|
||||
{#if initialLoading && uploads.length === 0}
|
||||
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
|
||||
{#if viewMode === 'list'}
|
||||
{#each Array(3) as _}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton variant="card" />
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="grid grid-cols-3 gap-0.5">
|
||||
{#each Array(9) as _}
|
||||
{#each Array(9) as _, i (i)}
|
||||
<Skeleton variant="tile" />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -726,7 +850,7 @@
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<VirtualFeed
|
||||
mode="list"
|
||||
uploads={uploads}
|
||||
{uploads}
|
||||
{myUserId}
|
||||
onlike={handleLike}
|
||||
oncomment={openComments}
|
||||
@@ -739,11 +863,19 @@
|
||||
<div class="mx-auto max-w-2xl">
|
||||
{#if displayUploads.length === 0}
|
||||
<div class="py-16 text-center">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Keine Treffer für die gewählten Filter.</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Keine Treffer für die gewählten Filter.
|
||||
</p>
|
||||
{#if nextCursor}
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.
|
||||
</p>
|
||||
{/if}
|
||||
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400">Filter zurücksetzen</button>
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>Filter zurücksetzen</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<VirtualFeed
|
||||
@@ -764,7 +896,9 @@
|
||||
<div bind:this={sentinel} class="h-4"></div>
|
||||
{#if loadingMore}
|
||||
<div class="py-4 text-center">
|
||||
<div class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"></div>
|
||||
<div
|
||||
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user