Skip to content

Commit

Permalink
Merge pull request #302 from Southclaws/thread-feed-pagination
Browse files Browse the repository at this point in the history
implement infinite-scroll-paginated thread feed
  • Loading branch information
Southclaws authored Nov 21, 2024
2 parents 711949c + 502d631 commit 0e203b2
Show file tree
Hide file tree
Showing 14 changed files with 532 additions and 120 deletions.
20 changes: 18 additions & 2 deletions web/src/app/(dashboard)/d/[category]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from "zod";

import { UnreadyBanner } from "src/components/site/Unready";

import { categoryList } from "@/api/openapi-server/categories";
Expand All @@ -8,20 +10,34 @@ type Props = {
params: Promise<{
category: string;
}>;
searchParams: Promise<Query>;
};

export default async function Page(props: Props) {
const slug = (await props.params).category;
export const dynamic = "force-dynamic";

const QuerySchema = z.object({
page: z
.string()
.transform((v) => parseInt(v, 10))
.optional(),
});
type Query = z.infer<typeof QuerySchema>;

export default async function Page({ params, searchParams }: Props) {
try {
const { category: slug } = await params;
const { page } = QuerySchema.parse(await searchParams);

const { data: categoryListData } = await categoryList();

const { data: threadListData } = await threadList({
categories: [slug],
page: page?.toString(),
});

return (
<CategoryScreen
initialPage={page ?? 1}
slug={slug}
initialCategoryList={categoryListData}
initialThreadList={threadListData}
Expand Down
28 changes: 26 additions & 2 deletions web/src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { z } from "zod";

import { UnreadyBanner } from "src/components/site/Unready";

import { getServerSession } from "@/auth/server-session";
import { getSettings } from "@/lib/settings/settings-server";
import { FeedScreen } from "@/screens/feed/FeedScreen";

export default async function Page() {
type Props = {
searchParams: Promise<Query>;
};

export const dynamic = "force-dynamic";

const QuerySchema = z.object({
page: z
.string()
.transform((v) => parseInt(v, 10))
.optional(),
});
type Query = z.infer<typeof QuerySchema>;

export default async function Page({ searchParams }: Props) {
try {
const session = await getServerSession();
const settings = await getSettings();

return <FeedScreen initialSession={session} initialSettings={settings} />;
const { page } = QuerySchema.parse(await searchParams);

return (
<FeedScreen
page={page ?? 1}
initialSession={session}
initialSettings={settings}
/>
);
} catch (error) {
return <UnreadyBanner error={error} />;
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function Providers({ children }: PropsWithChildren) {
<SWRConfig
value={{
keepPreviousData: true,
provider: provider,
// provider: provider,
}}
>
<Toaster />
Expand Down
55 changes: 55 additions & 0 deletions web/src/components/site/PaginationBubble/PaginationBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { parseAsInteger, useQueryState } from "nuqs";

import { Box, CardBox } from "@/styled-system/jsx";

import { PaginationControls } from "../PaginationControls/PaginationControls";

type Props = {
path: string;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
};

export function PaginationBubble({
path,
totalPages,
pageSize,
onPageChange,
}: Props) {
const [page, setPage] = useQueryState("page", {
...parseAsInteger,
defaultValue: 1,
clearOnDefault: true,
});

function handlePage(page: number) {
setPage(page);
onPageChange(page);
}

const isFirst = page === 1;

return (
<Box
style={{
display: isFirst ? "none" : "block",
}}
position="fixed"
bottom={{
base: "24",
md: "8",
}}
>
<CardBox borderRadius="lg" p="1">
<PaginationControls
path={path}
currentPage={page}
pageSize={pageSize}
totalPages={totalPages}
onClick={handlePage}
/>
</CardBox>
</Box>
);
}
15 changes: 12 additions & 3 deletions web/src/components/site/PaginationControls/PaginationControls.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { range } from "lodash";
import { MouseEvent } from "react";

import { LinkButton } from "@/components/ui/link-button";
import { HStack, styled } from "@/styled-system/jsx";
Expand Down Expand Up @@ -59,6 +60,14 @@ export function PaginationControls({

const targetPages = getPages();

const clickHandler =
(page: number) => (event: MouseEvent<HTMLAnchorElement>) => {
if (onClick) {
event.preventDefault();
onClick(page);
}
};

return (
<HStack w="min" p="1">
{needStartJump && (
Expand All @@ -70,7 +79,7 @@ export function PaginationControls({
...params,
page: "1",
}).toString()}`}
onClick={() => onClick?.(1)}
onClick={clickHandler(1)}
>
{1}
</LinkButton>
Expand All @@ -92,7 +101,7 @@ export function PaginationControls({
size="xs"
key={v}
href={`${path}?${withPage.toString()}`}
onClick={() => onClick?.(v)}
onClick={clickHandler(v)}
>
{pageName}
</LinkButton>
Expand All @@ -109,7 +118,7 @@ export function PaginationControls({
...params,
page: lastPage.toString(),
}).toString()}`}
onClick={() => onClick?.(lastPage)}
onClick={clickHandler(lastPage)}
>
{lastPage}
</LinkButton>
Expand Down
7 changes: 6 additions & 1 deletion web/src/components/ui/link-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export function LinkButton({
const target = isExternal ? "_blank" : undefined;

return (
<NextLink className={cn} href={href} target={target}>
<NextLink
className={cn}
href={href}
target={target}
onClick={props.onClick}
>
<styled.span
display="flex"
// Supports overflowing children and text ellipsis
Expand Down
23 changes: 23 additions & 0 deletions web/src/lib/feed/mutation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { Arguments, MutatorCallback, useSWRConfig } from "swr";
import { SWRInfiniteKeyLoader } from "swr/infinite";

import { cleanQuery } from "@/api/common";
import { getThreadListKey, threadDelete } from "@/api/openapi-client/threads";
import { Identifier, ThreadListOKResponse } from "@/api/openapi-schema";

type QueryParams = Record<string, string | undefined>;

export const getThreadListPageKey =
(parameters?: QueryParams): SWRInfiniteKeyLoader<ThreadListOKResponse> =>
(pageIndex: number, previousPageData: ThreadListOKResponse | null) => {
if (previousPageData && previousPageData.next_page === undefined) {
return null;
}

const pageNumber = pageIndex + 1;

const [path, params] = getThreadListKey({
page: pageNumber.toString(),
...parameters,
});

const key = path + cleanQuery(params);

return key;
};

export function useFeedMutations() {
const { mutate } = useSWRConfig();

Expand Down
83 changes: 10 additions & 73 deletions web/src/screens/category/CategoryScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,25 @@
"use client";

import { useCategoryList } from "@/api/openapi-client/categories";
import { useThreadList } from "@/api/openapi-client/threads";
import {
CategoryListOKResponse,
Permission,
ThreadListOKResponse,
} from "@/api/openapi-schema";
import { useSession } from "@/auth";
import { CategoryMenu } from "@/components/category/CategoryMenu/CategoryMenu";
import { Unready } from "@/components/site/Unready";
import { Heading } from "@/components/ui/heading";
import { LStack, WStack, styled } from "@/styled-system/jsx";
import { hasPermission } from "@/utils/permissions";

import { ThreadFeedScreen } from "../feed/ThreadFeedScreen";
import { ThreadFeedScreen } from "../feed/ThreadFeedScreen/ThreadFeedScreen";

export type Props = {
initialCategoryList: CategoryListOKResponse;
initialThreadList: ThreadListOKResponse;
slug: string;
};
import { Props, useCategoryScreen } from "./useCategoryScreen";

export function useCategoryScreen({
initialCategoryList,
initialThreadList,
slug,
}: Props) {
const session = useSession();
type ScreenProps = {
initialPage: number;
} & Props;

const { data: categoryListData, error: categoryListError } = useCategoryList({
swr: { fallbackData: initialCategoryList },
});

const { data: threadListData, error: threadListError } = useThreadList(
{ categories: [slug] },
{
swr: { fallbackData: initialThreadList },
},
);

if (!categoryListData) {
return {
ready: false as const,
error: categoryListError,
};
}
if (!threadListData) {
return {
ready: false as const,
error: threadListError,
};
}

const category = categoryListData.categories.find((c) => c.slug === slug);
if (!category) {
return {
ready: false as const,
error: new Error("Category not found"),
};
}

const canEditCategory = hasPermission(session, Permission.MANAGE_CATEGORIES);

const threads = threadListData;

return {
ready: true as const,
data: {
canEditCategory,
category,
threads,
},
};
}

export function CategoryScreen(props: Props) {
export function CategoryScreen(props: ScreenProps) {
const { ready, data, error } = useCategoryScreen(props);
if (!ready) {
return <Unready error={error} />;
}

const { category, threads } = data;
const { category } = data;

return (
<LStack>
Expand All @@ -96,10 +34,9 @@ export function CategoryScreen(props: Props) {
</LStack>

<ThreadFeedScreen
params={{
categories: [props.slug],
}}
initialData={threads}
initialPage={props.initialPage}
initialPageData={[props.initialThreadList]}
category={category}
/>
</LStack>
);
Expand Down
2 changes: 1 addition & 1 deletion web/src/screens/category/CategoryScreenContextPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { cva } from "@/styled-system/css";
import { HStack, LStack, styled } from "@/styled-system/jsx";
import { button } from "@/styled-system/recipes";

import { Props, useCategoryScreen } from "./CategoryScreen";
import { Props, useCategoryScreen } from "./useCategoryScreen";

const valueStyles = cva({
base: {},
Expand Down
Loading

0 comments on commit 0e203b2

Please sign in to comment.