Skip to content

Commit

Permalink
feat: games page
Browse files Browse the repository at this point in the history
  • Loading branch information
lance10030 committed Jul 30, 2024
1 parent 80c5680 commit 53c4249
Show file tree
Hide file tree
Showing 9 changed files with 466 additions and 12 deletions.
72 changes: 72 additions & 0 deletions src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { ChevronUpDownIcon } from "@heroicons/react/24/outline";

export type DropdownProps = {
items: (string | number)[];
selected: string | number;
width?: string;
onChange(newValue: string | number): void;
};

const DEFAULT_WIDTH = "w-32";

export const Dropdown: React.FC<DropdownProps> = function ({
items,
selected,
width,
onChange,
}) {
return (
<Listbox value={selected} onChange={onChange}>
<div className="relative">
<Listbox.Button
className={`relative h-9 ${width ?? DEFAULT_WIDTH
} cursor-pointer rounded-lg border border-transparent bg-controlBackground-light pl-2 pr-8 text-left text-sm shadow-md hover:border hover:border-controlBackground-light focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white active:border-controlBorderHighlight-dark ui-open:border-controlActive-light dark:bg-controlBackground-dark dark:hover:border-controlBorderHighlight-dark dark:ui-open:border-controlActive-dark`}
>
<span className="block truncate align-middle font-normal">
{selected}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-icon-light dark:text-icon-dark"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-controlBackground-light py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-controlBackground-dark sm:text-sm">
{items.map((item, personIdx) => (
<Listbox.Option
key={personIdx}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-4 pr-4 font-normal ${active
? "bg-controlActive-light dark:bg-controlActive-dark dark:text-content-dark"
: "text-contentSecondary-light dark:text-contentSecondary-dark"
}`
}
value={item}
>
{({ selected }) => (
<>
<span
className={`block truncate text-sm ${selected ? "font-bold" : ""
}`}
>
{item}
</span>
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
};
5 changes: 5 additions & 0 deletions src/components/Layouts/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { FC, ReactNode } from "react";

export const Header: FC<{ children: ReactNode }> = function ({ children }) {
return <div className="truncate text-2xl font-bold">{children}</div>;
};
132 changes: 132 additions & 0 deletions src/components/Layouts/PaginatedListLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Fragment, useCallback } from "react";
import type { FC, ReactNode } from "react";
import { useRouter } from "next/router";

import { Card } from "../Cards/Card";
import { Dropdown } from "../Dropdown";
import type { DropdownProps } from "../Dropdown";
import { Pagination } from "../Pagination";
import type { PaginationProps } from "../Pagination";
import { Header } from "./Header";

export type PaginatedListLayoutProps = {
header?: ReactNode;
title?: ReactNode;
items?: ReactNode[];
totalItems?: number;
page: number;
pageSize: number;
itemSkeleton: ReactNode;
emptyState?: ReactNode;
};

const PAGE_SIZES = [10, 25, 50, 100];

export const PaginatedListLayout: FC<PaginatedListLayoutProps> = function ({
header,
title,
items,
totalItems,
page,
pageSize,
itemSkeleton,
emptyState = "No items",
}) {
const router = useRouter();
const pages =
totalItems !== undefined
? totalItems === 0
? 1
: Math.ceil(totalItems / pageSize)
: undefined;
const hasItems = !items || items.length;

const handlePageSizeSelection = useCallback<DropdownProps["onChange"]>(
(newPageSize: number) =>
void router.push({
pathname: router.pathname,
query: {
...router.query,
/**
* Update the selected page to a lower value if we require less pages to show the
* new amount of elements per page.
*/
p: Math.min(Math.ceil(totalItems ?? 0 / newPageSize), page),
ps: newPageSize,
},
}),
[page, totalItems, router]
);

const handlePageSelection = useCallback<PaginationProps["onChange"]>(
(newPage) =>
void router.push({
pathname: router.pathname,
query: {
...router.query,
p: newPage,
ps: pageSize,
},
}),
[pageSize, router]
);

return (
<>
<Header>{header}</Header>
<Card
header={
hasItems ? (
<div
className={`flex flex-col ${title ? "justify-between" : "justify-end"
} md:flex-row`}
>
{title && <div>{title}</div>}
<div className="w-full self-center sm:w-auto">
<Pagination
selected={page}
pages={pages}
onChange={handlePageSelection}
/>
</div>
</div>
) : undefined
}
emptyState={emptyState}
>
{hasItems ? (
<div className="flex flex-col gap-6">
<div className="space-y-4">
{!items
? Array.from({ length: 4 }).map((_, i) => (
<Fragment key={i}>{itemSkeleton}</Fragment>
))
: (items ?? []).map((item, i) => (
<Fragment key={i}>{item}</Fragment>
))}
</div>
<div className="flex w-full flex-col items-center gap-3 text-sm md:flex-row md:justify-between">
<div className="flex items-center justify-start gap-2">
Displayed items:
<Dropdown
items={PAGE_SIZES}
selected={pageSize}
width="w-full"
onChange={handlePageSizeSelection}
/>
</div>
<div className="w-full sm:w-auto">
<Pagination
selected={page}
pages={pages}
inverseCompact
onChange={handlePageSelection}
/>
</div>
</div>
</div>
) : undefined}
</Card>
</>
);
};
161 changes: 161 additions & 0 deletions src/components/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useEffect, useState } from "react";
import type { FC } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";

import "react-loading-skeleton/dist/skeleton.css";
import Skeleton from "react-loading-skeleton";

import { Button } from "./Button";
import { Input } from "./Input";

type NavigationButton = {
disabled?: boolean;
onChange(page: number): void;
};

export type PaginationProps = {
pages?: number;
selected: number;
inverseCompact?: boolean;
onChange(page: number): void;
};

// eslint-disable-next-line @typescript-eslint/no-empty-function
const NOOP = () => { };

const FirstButton: FC<NavigationButton> = function ({ disabled, onChange }) {
return (
<Button
disabled={disabled}
variant="outline"
size="sm"
label="First"
className="w-full"
onClick={() => {
onChange(1);
}}
/>
);
};

const LastButton: FC<NavigationButton & { lastPage: number }> = function ({
disabled,
onChange,
lastPage,
}) {
return (
<Button
className="w-full"
disabled={disabled}
variant="outline"
size="sm"
label="Last"
onClick={() => onChange(lastPage)}
/>
);
};

export const Pagination: FC<PaginationProps> = function ({
pages,
selected,
onChange,
inverseCompact = false,
}) {
const [pageInput, setPageInput] = useState(selected);
const isUndefined = pages === undefined;
const disableFirst = selected === 1 || isUndefined;
const disableLast = selected === pages || isUndefined;

// Keep inner page input value in sync
useEffect(() => {
setPageInput(selected);
}, [selected]);

return (
<form
onSubmit={(e) => {
e.preventDefault();
onChange(pageInput);
}}
>
<nav
className={`flex gap-2 ${inverseCompact ? "flex-col-reverse" : "flex-col"
}`}
>
<div className="block sm:hidden">
<div className="flex justify-between gap-2">
<FirstButton disabled={disableFirst} onChange={onChange} />
<LastButton
disabled={disableLast}
lastPage={pages ?? 0}
onChange={onChange}
/>
</div>
</div>
<div className="flex w-full justify-between gap-2 align-middle">
<div className="hidden sm:block">
<FirstButton disabled={disableFirst} onChange={onChange} />
</div>
<Button
disabled={disableFirst}
variant="outline"
size="sm"
icon={
<div>
<span className="sr-only">Previous</span>
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true" />
</div>
}
onClick={() => onChange(Math.max(1, selected - 1))}
/>
<div className="flex items-center gap-2 text-sm text-contentSecondary-light dark:text-contentSecondary-dark">
<div className="w-20 font-light">
<Input
disabled={isUndefined}
className="text-sm"
type="number"
min={1}
max={pages}
step={1}
value={pageInput}
onChange={(e) => setPageInput(Number(e.target.value))}
/>
</div>
<div className="self-center font-normal">
{" "}
of{" "}
{isUndefined ? (
<span>
<Skeleton width={33} />
</span>
) : (
pages
)}
</div>
</div>
<Button
disabled={disableLast}
variant="outline"
size="sm"
icon={
<div>
<span className="sr-only">Next</span>
<ChevronRightIcon className="h-5 w-5" aria-hidden="true" />
</div>
}
onClick={
pages ? () => onChange(Math.min(pages, selected + 1)) : NOOP
}
/>
<div className="hidden sm:block">
<LastButton
disabled={disableLast}
lastPage={pages ?? 0}
onChange={onChange}
/>
</div>
</div>
</nav>
</form>
);
};
Loading

0 comments on commit 53c4249

Please sign in to comment.