Skip to content

Commit ea36239

Browse files
authored
Add article pagination with category filter (#561)
1 parent 03bb63a commit ea36239

File tree

12 files changed

+255
-15
lines changed

12 files changed

+255
-15
lines changed

src/components/rates/upgrades/data-table-faceted-filter.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import * as Popover from '$ui/popover';
1212
import { Separator } from '$ui/separator';
1313
import Check from '@lucide/svelte/icons/check';
14-
import CirclePlus from '@lucide/svelte/icons/circle-plus';
14+
import Funnel from '@lucide/svelte/icons/funnel';
1515
import type { Column } from '@tanstack/table-core';
1616
import { SvelteSet } from 'svelte/reactivity';
1717
@@ -37,7 +37,7 @@
3737
<Popover.Trigger>
3838
{#snippet child({ props })}
3939
<Button {...props} variant="outline" size="sm" class="h-8 border-dashed">
40-
<CirclePlus />
40+
<Funnel />
4141
{title}
4242
{#if selectedValues.size > 0}
4343
<Separator orientation="vertical" class="mx-2 h-4" />

src/components/stats/ranks/data-table-faceted-filter.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import * as Popover from '$ui/popover';
1212
import Check from '@lucide/svelte/icons/check';
1313
import CircleMinus from '@lucide/svelte/icons/circle-minus';
14-
import CirclePlus from '@lucide/svelte/icons/circle-plus';
14+
import Funnel from '@lucide/svelte/icons/funnel';
1515
import type { Column } from '@tanstack/table-core';
1616
import { SvelteSet } from 'svelte/reactivity';
1717
@@ -43,7 +43,7 @@
4343
{#if radio}
4444
<CircleMinus />
4545
{:else}
46-
<CirclePlus />
46+
<Funnel />
4747
{/if}
4848
<div class="flex space-x-1">
4949
{#if selectedValues.size > 2}
@@ -63,7 +63,7 @@
6363
{/if}
6464
</div>
6565
{:else}
66-
<CirclePlus />
66+
<Funnel />
6767
{title}
6868
{/if}
6969
</Button>

src/lib/api/cms.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,34 @@ export async function fetchBusinessInfo() {
216216
return { name: 'Placeholder Name', contact: 'Placeholder Contact', footer: 'Placeholder Footer' };
217217
}
218218
}
219+
220+
export async function fetchAllArticleCategories() {
221+
const query = qs.stringify(
222+
{
223+
sort: ['name:asc'],
224+
fields: ['name', 'slug'],
225+
// Check that only categories with at least one article are returned
226+
filters: { articles: { id: { $notNull: true } } },
227+
populate: {
228+
articles: { fields: ['id'] },
229+
},
230+
},
231+
{
232+
encodeValuesOnly: true,
233+
}
234+
);
235+
236+
const data = await fetchCmsData<{ data: unknown }>(`/categories?${query}`);
237+
238+
if (!data?.data) {
239+
return null;
240+
}
241+
242+
const parsed = z.array(flatTaxonomyItemSchema).safeParse(data.data);
243+
if (!parsed.success) {
244+
console.error('Failed to parse article categories:', parsed.error);
245+
throw new Error('Failed to parse article categories');
246+
}
247+
248+
return parsed.data;
249+
}

src/lib/servercache.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
type SkyblockGemShopsResponse,
2323
type WeightStyleWithDataDto,
2424
} from './api';
25-
import { fetchBusinessInfo } from './api/cms';
25+
import { fetchAllArticleCategories, fetchBusinessInfo } from './api/cms';
2626
import { parseLeaderboards } from './constants/leaderboards';
2727
import { mdToHtml } from './md';
2828

@@ -123,6 +123,12 @@ const cacheEntries = {
123123
return await fetchBusinessInfo();
124124
},
125125
},
126+
categories: {
127+
data: [] as Awaited<ReturnType<typeof fetchAllArticleCategories>>,
128+
update: async () => {
129+
return await fetchAllArticleCategories();
130+
},
131+
},
126132
};
127133

128134
export const cache = {
@@ -162,6 +168,9 @@ export const cache = {
162168
get businessInfo() {
163169
return cacheEntries.businessInfo.data;
164170
},
171+
get categories() {
172+
return cacheEntries.categories.data;
173+
},
165174
};
166175

167176
let intervals: (number | NodeJS.Timeout)[] = [];

src/routes/(main)/(auth)/guild/[id=snowflake]/event/[eventId=snowflake]/data-table-faceted-filter.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import * as Popover from '$ui/popover';
1212
import { Separator } from '$ui/separator';
1313
import Check from '@lucide/svelte/icons/check';
14-
import CirclePlus from '@lucide/svelte/icons/circle-plus';
14+
import Funnel from '@lucide/svelte/icons/funnel';
1515
import type { Column } from '@tanstack/table-core';
1616
import { SvelteSet } from 'svelte/reactivity';
1717
@@ -37,7 +37,7 @@
3737
<Popover.Trigger>
3838
{#snippet child({ props })}
3939
<Button {...props} variant="outline" size="sm" class="h-8 border-dashed">
40-
<CirclePlus />
40+
<Funnel />
4141
{title}
4242
{#if selectedValues.size > 0}
4343
<Separator orientation="vertical" class="mx-2 h-4" />
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { fetchArticlesPaginated } from '$lib/api/cms';
2+
import { cache } from '$lib/servercache';
23
import type { PageServerLoad } from './$types';
34

45
export const load = (async ({ url }) => {
56
const page = url.searchParams.get('page') ? parseInt(url.searchParams.get('page')!) : 1;
6-
const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!) : 15;
7+
const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!) : 12;
78
const sort = url.searchParams.get('sort') || 'desc';
89
const catgeory = url.searchParams.get('category') || undefined;
910

1011
const defaultArticles = await fetchArticlesPaginated(page, limit, sort === 'asc', catgeory);
1112

12-
return { defaultArticles: defaultArticles?.data ?? [], defaultMeta: defaultArticles?.meta ?? {} };
13+
return {
14+
defaultArticles: defaultArticles?.data ?? [],
15+
defaultMeta: defaultArticles?.meta,
16+
categories: cache.categories,
17+
};
1318
}) satisfies PageServerLoad;

src/routes/(main)/articles/+page.svelte

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import Head from '$comp/head.svelte';
33
import type { PageProps } from './$types';
44
import ArticlePill from './article-pill.svelte';
5+
import ArticlesFilter from './articles-filter.svelte';
6+
import ArticlesPagination from './articles-pagination.svelte';
57
68
let { data }: PageProps = $props();
79
@@ -15,9 +17,32 @@
1517

1618
<main class="@container flex flex-col items-center">
1719
<h1 class="my-16 text-4xl font-bold">Articles</h1>
20+
<div
21+
class="mb-1 flex w-full max-w-[24rem] flex-col items-center justify-between gap-2 p-1 @3xl:max-w-198 @3xl:flex-row @6xl:max-w-300"
22+
>
23+
<ArticlesFilter
24+
query="category"
25+
options={data.categories?.map((cat) => ({ label: cat.name, value: cat.slug })) || []}
26+
title="Category"
27+
/>
28+
<ArticlesPagination
29+
currentPage={data.defaultMeta?.pagination?.page ?? 1}
30+
maxPage={data.defaultMeta?.pagination?.pageCount ?? 1}
31+
/>
32+
</div>
1833
<div class="mx-4 grid grid-cols-1 gap-6 @3xl:grid-cols-2 @6xl:grid-cols-3">
1934
{#each articles as article (article.slug)}
2035
<ArticlePill {article} />
36+
{:else}
37+
<p class="col-span-full text-center text-muted-foreground my-32">No articles found!</p>
2138
{/each}
2239
</div>
40+
<div
41+
class="mb-1 flex w-full max-w-[24rem] flex-row items-center justify-center p-1 @3xl:max-w-198 @3xl:justify-end @6xl:max-w-300"
42+
>
43+
<ArticlesPagination
44+
currentPage={data.defaultMeta?.pagination?.page ?? 1}
45+
maxPage={data.defaultMeta?.pagination?.pageCount ?? 1}
46+
/>
47+
</div>
2348
</main>

src/routes/(main)/articles/article-pill.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,10 @@
2727

2828
<h3 class="text-xl font-semibold sm:text-2xl">{article.title}</h3>
2929
<p class="text-sm">{article.summary}</p>
30+
<div class="flex flex-row items-center gap-1">
31+
{#each article.categories as c (c.slug)}
32+
<span class="bg-muted inline-block rounded-sm px-2 py-0.5 text-xs font-semibold">{c.name}</span>
33+
{/each}
34+
</div>
3035
</article>
3136
</a>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<script lang="ts">
2+
import { goto } from '$app/navigation';
3+
import { page } from '$app/state';
4+
import { cn } from '$lib/utils.js';
5+
import { Badge } from '$ui/badge';
6+
import { Button } from '$ui/button';
7+
import * as Command from '$ui/command';
8+
import * as Popover from '$ui/popover';
9+
import { Separator } from '$ui/separator';
10+
import Check from '@lucide/svelte/icons/check';
11+
import Funnel from '@lucide/svelte/icons/funnel';
12+
import { SvelteURLSearchParams } from 'svelte/reactivity';
13+
14+
type Props = {
15+
query: string;
16+
title: string;
17+
options: {
18+
label: string;
19+
value: string;
20+
// This should be `Component` after @lucide/svelte updates types
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
icon?: any;
23+
}[];
24+
};
25+
26+
let { query, title, options }: Props = $props();
27+
28+
let selectedValue = $derived(page.url.searchParams.get(query) ?? '');
29+
</script>
30+
31+
<Popover.Root>
32+
<Popover.Trigger>
33+
{#snippet child({ props })}
34+
<Button {...props} variant="outline" size="sm" class="h-8 border-dashed">
35+
<Funnel />
36+
{title}
37+
{#if selectedValue}
38+
<Separator orientation="vertical" class="mx-2 h-4" />
39+
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">
40+
{selectedValue.length}
41+
</Badge>
42+
<div class="hidden space-x-1 lg:flex">
43+
{#each options.filter((opt) => selectedValue === opt.value) as option (option)}
44+
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
45+
{option.label}
46+
</Badge>
47+
{/each}
48+
</div>
49+
{/if}
50+
</Button>
51+
{/snippet}
52+
</Popover.Trigger>
53+
<Popover.Content class="w-[200px] p-0" align="start">
54+
<Command.Root>
55+
<!-- <Command.Input placeholder={title} /> -->
56+
<Command.List>
57+
<Command.Empty>No results found.</Command.Empty>
58+
<Command.Group>
59+
{#each options as option (option)}
60+
{@const isSelected = selectedValue.includes(option.value)}
61+
<Command.Item
62+
onSelect={() => {
63+
if (isSelected) {
64+
selectedValue = '';
65+
const newQuery = new SvelteURLSearchParams(page.url.searchParams);
66+
newQuery.delete(query);
67+
goto(`${page.url.pathname}?${newQuery}`);
68+
} else {
69+
selectedValue = option.value;
70+
goto(
71+
`${page.url.pathname}?${new SvelteURLSearchParams({
72+
...Object.fromEntries(page.url.searchParams),
73+
[query]: option.value,
74+
})}`
75+
);
76+
}
77+
}}
78+
>
79+
<div
80+
class={cn(
81+
'border-primary mr-2 flex size-4 items-center justify-center rounded-full border',
82+
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible'
83+
)}
84+
>
85+
<Check class="mt-0.5 size-3" />
86+
</div>
87+
{#if option.icon}
88+
{@const Icon = option.icon}
89+
<Icon class="text-muted-foreground" />
90+
{/if}
91+
92+
<span>{option.label}</span>
93+
</Command.Item>
94+
{/each}
95+
</Command.Group>
96+
</Command.List>
97+
</Command.Root>
98+
</Popover.Content>
99+
</Popover.Root>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script lang="ts">
2+
import { page } from '$app/state';
3+
import { Button } from '$ui/button';
4+
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
5+
import ChevronRight from '@lucide/svelte/icons/chevron-right';
6+
import ChevronsLeft from '@lucide/svelte/icons/chevrons-left';
7+
import ChevronsRight from '@lucide/svelte/icons/chevrons-right';
8+
import { SvelteURLSearchParams } from 'svelte/reactivity';
9+
10+
interface Props {
11+
currentPage: number;
12+
maxPage: number;
13+
}
14+
15+
let { currentPage, maxPage }: Props = $props();
16+
17+
const queryParams = $derived.by(() => {
18+
const params = new SvelteURLSearchParams(page.url.searchParams);
19+
params.delete('page');
20+
return params;
21+
});
22+
</script>
23+
24+
<div class="flex flex-col items-center gap-2 @3xl:flex-row">
25+
<div class="order-3 flex items-center justify-center text-sm font-medium whitespace-nowrap lg:order-1">
26+
<span
27+
>Page <strong>{currentPage.toLocaleString()}</strong> of
28+
<strong>{maxPage.toLocaleString()}</strong></span
29+
>
30+
</div>
31+
<div class="order-2 flex items-center space-x-2">
32+
<Button
33+
variant="outline"
34+
class="size-8 p-0"
35+
href="/articles?{queryParams ? queryParams + '&' : ''}page=1"
36+
disabled={currentPage === 1}
37+
>
38+
<span class="sr-only">Go to first page</span>
39+
<ChevronsLeft />
40+
</Button>
41+
<Button
42+
variant="outline"
43+
class="h-8 w-12 p-0"
44+
href="/articles?{queryParams ? queryParams + '&' : ''}page={currentPage - 1}"
45+
disabled={currentPage === 1}
46+
>
47+
<span class="sr-only">Go to previous page</span>
48+
<ChevronLeft />
49+
</Button>
50+
<Button
51+
variant="outline"
52+
class="h-8 w-12 p-0"
53+
href="/articles?{queryParams ? queryParams + '&' : ''}page={currentPage + 1}"
54+
disabled={currentPage === maxPage}
55+
>
56+
<span class="sr-only">Go to next page</span>
57+
<ChevronRight />
58+
</Button>
59+
<Button
60+
variant="outline"
61+
class="size-8 p-0"
62+
href="/articles?{queryParams ? queryParams + '&' : ''}page={maxPage}"
63+
disabled={currentPage === maxPage}
64+
>
65+
<span class="sr-only">Go to last page</span>
66+
<ChevronsRight />
67+
</Button>
68+
</div>
69+
</div>

0 commit comments

Comments
 (0)