feat(frontend): lazy infinite scroll on tags/categories/pools lists
These three lists used a manual "Load more" button while files and trash already lazy-loaded on scroll. Wire them to the shared InfiniteScroll component for consistent behaviour: the offset-based load() now also runs a viewport-fill pass (keep paging until the content overflows so the sentinel sits below the fold), and the button + its now-unused spinner CSS are removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, ApiError } from '$lib/api/client';
|
import { api, ApiError } from '$lib/api/client';
|
||||||
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
||||||
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 100;
|
const LIMIT = 100;
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
let categories = $state<Category[]>([]);
|
let categories = $state<Category[]>([]);
|
||||||
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let offset = $state(0);
|
let offset = $state(0);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -61,6 +64,12 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
initialLoaded = true;
|
initialLoaded = true;
|
||||||
}
|
}
|
||||||
|
// Keep loading until the content fills the viewport so the infinite-scroll
|
||||||
|
// sentinel ends up below the fold; then stop.
|
||||||
|
await tick();
|
||||||
|
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasMore = $derived(categories.length < total);
|
let hasMore = $derived(categories.length < total);
|
||||||
@@ -125,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main bind:this={scrollContainer}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -142,15 +151,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
<InfiniteScroll {loading} {hasMore} onLoadMore={load} />
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasMore && !loading}
|
|
||||||
<button class="load-more" onclick={load}>Load more</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loading && categories.length === 0}
|
{#if !loading && categories.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -330,40 +331,6 @@
|
|||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
display: block;
|
|
||||||
margin: 16px auto 0;
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, ApiError } from '$lib/api/client';
|
import { api, ApiError } from '$lib/api/client';
|
||||||
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
|
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
|
||||||
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import type { Pool, PoolOffsetPage } from '$lib/api/types';
|
import type { Pool, PoolOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
@@ -12,6 +14,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
let pools = $state<Pool[]>([]);
|
let pools = $state<Pool[]>([]);
|
||||||
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let offset = $state(0);
|
let offset = $state(0);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -60,6 +63,12 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
initialLoaded = true;
|
initialLoaded = true;
|
||||||
}
|
}
|
||||||
|
// Keep loading until the content fills the viewport so the infinite-scroll
|
||||||
|
// sentinel ends up below the fold; then stop.
|
||||||
|
await tick();
|
||||||
|
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasMore = $derived(pools.length < total);
|
let hasMore = $derived(pools.length < total);
|
||||||
@@ -128,7 +137,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main bind:this={scrollContainer}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -159,15 +168,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
<InfiniteScroll {loading} {hasMore} onLoadMore={load} />
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasMore && !loading}
|
|
||||||
<button class="load-more" onclick={load}>Load more</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loading && pools.length === 0}
|
{#if !loading && pools.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -393,40 +394,6 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
display: block;
|
|
||||||
margin: 16px auto 0;
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { api, ApiError } from '$lib/api/client';
|
import { api, ApiError } from '$lib/api/client';
|
||||||
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
||||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||||
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 100;
|
const LIMIT = 100;
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
let tags = $state<Tag[]>([]);
|
let tags = $state<Tag[]>([]);
|
||||||
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let offset = $state(0);
|
let offset = $state(0);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -66,6 +69,12 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
initialLoaded = true;
|
initialLoaded = true;
|
||||||
}
|
}
|
||||||
|
// Keep loading until the content fills the viewport so the infinite-scroll
|
||||||
|
// sentinel ends up below the fold; then stop.
|
||||||
|
await tick();
|
||||||
|
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch(e: Event) {
|
function onSearch(e: Event) {
|
||||||
@@ -136,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main bind:this={scrollContainer}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -147,15 +156,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
<InfiniteScroll {loading} {hasMore} onLoadMore={load} />
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasMore && !loading}
|
|
||||||
<button class="load-more" onclick={load}>Load more</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loading && tags.length === 0}
|
{#if !loading && tags.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -315,41 +316,6 @@
|
|||||||
align-content: flex-start;
|
align-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
display: block;
|
|
||||||
margin: 16px auto 0;
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user