Compare commits

...

4 Commits

Author SHA1 Message Date
5a5f7bdbbd chore: adjust layout width 2024-03-16 00:55:03 +08:00
d58aa4ba6e feat: add ProblemListPage 2024-03-16 00:51:10 +08:00
43f5423cf9 feat: add pagination table 2024-03-15 23:26:18 +08:00
9dfe9bfe81 feat: add basic problem api 2024-03-15 20:02:18 +08:00
8 changed files with 390 additions and 3 deletions

View File

@ -22,6 +22,7 @@
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@reduxjs/toolkit": "^2.2.1", "@reduxjs/toolkit": "^2.2.1",
"@tanstack/react-table": "^8.13.2",
"ace-builds": "^1.32.7", "ace-builds": "^1.32.7",
"framer-motion": "^11.0.13", "framer-motion": "^11.0.13",
"github-markdown-css": "^5.5.1", "github-markdown-css": "^5.5.1",

View File

@ -26,6 +26,9 @@ dependencies:
'@reduxjs/toolkit': '@reduxjs/toolkit':
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.2.1(react-redux@9.1.0)(react@18.2.0) version: 2.2.1(react-redux@9.1.0)(react@18.2.0)
'@tanstack/react-table':
specifier: ^8.13.2
version: 8.13.2(react-dom@18.2.0)(react@18.2.0)
ace-builds: ace-builds:
specifier: ^1.32.7 specifier: ^1.32.7
version: 1.32.7 version: 1.32.7
@ -2374,6 +2377,23 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
dev: false dev: false
/@tanstack/react-table@8.13.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-b6mR3mYkjRtJ443QZh9sc7CvGTce81J35F/XMr0OoWbx0KIM7TTTdyNP2XKObvkLpYnLpCrYDwI3CZnLezWvpg==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
'@tanstack/table-core': 8.13.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tanstack/table-core@8.13.2:
resolution: {integrity: sha512-/2saD1lWBUV6/uNAwrsg2tw58uvMJ07bO2F1IWMxjFRkJiXKQRuc3Oq2aufeobD3873+4oIM/DRySIw7+QsPPw==}
engines: {node: '>=12'}
dev: false
/@types/babel__core@7.20.5: /@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies: dependencies:

View File

@ -15,6 +15,6 @@ const baseQuery = fetchBaseQuery({
export const api = createApi({ export const api = createApi({
baseQuery: retry(baseQuery, { maxRetries: 2 }), baseQuery: retry(baseQuery, { maxRetries: 2 }),
tagTypes: ["User", "Status", "Submission"], tagTypes: ["User", "Status", "Submission", "ProblemInfo", "TaskInfo"],
endpoints: () => ({}), endpoints: () => ({}),
}); });

View File

@ -0,0 +1,98 @@
import type { Meta, WithCount, Wrap } from "./base";
import type { UserProfile } from "./user";
import { api } from "./api";
export interface RuntimeInfo {
MemoryLimit: number;
NProcLimit: number;
TimeLimit: number;
}
export interface TaskInfo {
Languages: {
Lang: string;
Runtime: {
Compile: RuntimeInfo;
Run: RuntimeInfo;
};
}[];
Tasks: { Id: number; Points: number }[];
}
export interface ProblemInfo {
meta: Meta;
title: string;
statement: string;
tags: { Elements: string[] };
provider: UserProfile;
is_enabled: boolean;
}
export interface CreateVersionRequest {
pid: number;
storage_key: string;
}
export interface DetailRequest {
pid: number;
}
export interface DetailResponse {
context: TaskInfo;
problem: ProblemInfo;
}
export interface SearchRequest {
keyword: string;
tag: string;
offset?: number;
limit: number;
}
export type SearchResponse = WithCount<ProblemInfo>;
export interface UpdateRequest {
pid: number;
title: string;
statement: string;
tags: string[];
is_enabled: boolean;
}
export interface UploadResponse {
key: string;
url: string;
}
export const problemApi = api.injectEndpoints({
endpoints: (builder) => ({
search: builder.query<Wrap<SearchResponse>, SearchRequest>({
query: (data: SearchRequest) => ({
url: "/problem/search",
method: "POST",
body: data,
}),
providesTags: (result) =>
result && result.body.count > 0
? [
...result.body.data.map((p: ProblemInfo) => ({
type: "ProblemInfo" as const,
id: p.meta.ID,
})),
{ type: "ProblemInfo", id: "Search" },
]
: [{ type: "ProblemInfo", id: "Search" }],
}),
detail: builder.query<Wrap<DetailResponse>, DetailRequest>({
query: (data: DetailRequest) => ({
url: "/problem/search",
method: "POST",
body: data,
}),
providesTags: (result) => (result ? [{ type: "TaskInfo", id: result.body.problem.meta.ID }] : []),
}),
// TODO: admin endpoints: version, update, upload
}),
});
export const { useSearchQuery, useDetailQuery } = problemApi;

152
src/components/Table.tsx Normal file
View File

@ -0,0 +1,152 @@
import type React from "react";
import {
Card,
CardFooter,
CardHeader,
Flex,
IconButton,
Select,
SkeletonText,
Table as ChakraTable,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useColorModeValue,
} from "@chakra-ui/react";
import type { ColumnDef, PaginationState } from "@tanstack/react-table";
import { flexRender, getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
import { HiChevronDoubleLeft, HiChevronDoubleRight, HiChevronLeft, HiChevronRight } from "react-icons/hi";
interface TableProps<T> {
header?: React.ReactNode;
data: T[];
columns: ColumnDef<T>[];
total: number;
paging: PaginationState;
setPaging: (state: PaginationState | ((old: PaginationState) => PaginationState)) => void;
isLoading: boolean;
}
export default function Table<T>(props: TableProps<T>) {
const bg = useColorModeValue("gray.100", "gray.800");
const table = useReactTable({
data: props.data,
columns: props.columns,
rowCount: props.total,
state: { pagination: props.paging },
onPaginationChange: props.setPaging,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
const { pageSize, pageIndex } = table.getState().pagination;
const pageCount = table.getPageCount().toLocaleString();
return (
<>
<Card flexDirection="column" flex={1} maxWidth="100%" width="100%">
{props.header && <CardHeader>{props.header}</CardHeader>}
<SkeletonText
mx={3}
mb={3}
overflowX="scroll"
noOfLines={6}
spacing="4"
skeletonHeight="3"
isLoaded={!props.isLoading}
>
<ChakraTable>
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Th
key={header.id}
colSpan={header.colSpan}
bg={bg}
py={4}
px={6}
textTransform="none"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</Th>
))}
</Tr>
))}
</Thead>
<Tbody>
{table.getRowModel().rows.map((row) => (
<Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
))}
</Tr>
))}
</Tbody>
</ChakraTable>
<CardFooter justify="space-between" alignItems="center" p={3}>
<Flex flexDirection="row">
<IconButton
aria-label="goto to first page"
size="sm"
ml={4}
mr={2}
icon={<HiChevronDoubleLeft size={20} />}
onClick={() => table.firstPage()}
isDisabled={!table.getCanPreviousPage()}
/>
<IconButton
aria-label="go to previous page"
size="sm"
mr={2}
icon={<HiChevronLeft size={20} />}
onClick={() => table.previousPage()}
isDisabled={!table.getCanPreviousPage()}
/>
</Flex>
<Flex justifyContent="center" alignItems="center">
<Text mr={4}>
Page {pageIndex + 1} of {pageCount}
</Text>
<Select w={28} value={pageSize} onChange={(e) => table.setPageSize(Number(e.target.value))}>
{[5, 10, 15, 20].map((pageSize) => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</Select>
</Flex>
<Flex flexDirection="row">
<IconButton
aria-label="go to next page"
size="sm"
ml={2}
icon={<HiChevronRight size={20} />}
onClick={() => table.nextPage()}
isDisabled={!table.getCanNextPage()}
/>
<IconButton
aria-label="go to last page"
size="sm"
ml={2}
mr={4}
icon={<HiChevronDoubleRight size={20} />}
onClick={() => table.lastPage()}
isDisabled={!table.getCanNextPage()}
/>
</Flex>
</CardFooter>
</SkeletonText>
</Card>
</>
);
}

View File

@ -0,0 +1,111 @@
import { useState } from "react";
import type { ColumnDef, PaginationState } from "@tanstack/react-table";
import {
Badge,
Button,
Input,
InputGroup,
InputLeftElement,
Link,
Stack,
Text,
useColorModeValue,
} from "@chakra-ui/react";
import { LinkIcon, SearchIcon } from "@chakra-ui/icons";
import { Link as ReactRouterLink } from "react-router-dom";
import type { ProblemInfo } from "../app/services/problem";
import { useSearchQuery } from "../app/services/problem";
import Table from "../components/Table";
const Title = (props: { word: string }) => (
<Text as="b" fontSize="lg">
{props.word}{" "}
</Text>
);
const columns: ColumnDef<ProblemInfo>[] = [
{
id: "id",
accessorFn: (i, _) => i.meta.ID,
header: (_) => <Title word="ID" />,
},
{
accessorKey: "title",
header: (_) => <Title word="Problem" />,
},
{
id: "tags",
accessorFn: (i, _) => i.tags.Elements,
header: (_) => <Title word="Tags" />,
cell: ({ cell }) => (
<Stack direction="row">
{(cell.getValue() as string[]).map((i) => (
<Badge key={i} variant="outline" colorScheme="teal">
{i}
</Badge>
))}
</Stack>
),
},
{
id: "action",
accessorFn: (i, _) => i.meta.ID,
header: (_) => <Title word="Function" />,
cell: ({ cell }) => (
<Button variant="outline" colorScheme="teal" size="sm">
<Link as={ReactRouterLink} to={`/problem/${cell.getValue() as number}`}>
Details
</Link>
</Button>
),
},
];
export default function ProblemListPage() {
const [word, setWord] = useState("");
const [tag, setTag] = useState("");
const [paging, setPaging] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: problems, isFetching } = useSearchQuery({
keyword: word,
tag: tag,
offset: paging.pageIndex * paging.pageSize,
limit: paging.pageSize,
});
const iconColor = useColorModeValue("gray.300", "gray.600");
const header = (
<Stack direction={["column", "row"]} spacing={5}>
<InputGroup>
<InputLeftElement>
<SearchIcon color={iconColor} />
</InputLeftElement>
<Input placeholder="Keyword" value={word} onChange={(e) => setWord(e.target.value)} />
</InputGroup>
<InputGroup>
<InputLeftElement>
<LinkIcon color={iconColor} />
</InputLeftElement>
<Input placeholder="Tag" value={tag} onChange={(e) => setTag(e.target.value)} />
</InputGroup>
</Stack>
);
return (
<>
<Table<ProblemInfo>
header={header}
columns={columns}
data={problems?.body.data || []}
total={problems?.body.count || 0}
paging={paging}
setPaging={setPaging}
isLoading={isFetching}
/>
</>
);
}

View File

@ -13,10 +13,10 @@ const SkeletonPage = () => (
); );
export const Root = () => ( export const Root = () => (
<Flex direction="column" align="center" maxW={{ xl: "1200px" }} m="0 auto"> <Flex direction="column" align="center" maxW={{ xl: "1280px" }} m="0 auto">
<Header /> <Header />
<Container w="100%" flex="1" maxW="container.lg" py={2}> <Container w="100%" flex="1" maxW="container.xl" py={2}>
<React.Suspense fallback={<SkeletonPage />}> <React.Suspense fallback={<SkeletonPage />}>
<Outlet /> <Outlet />
</React.Suspense> </React.Suspense>

View File

@ -8,6 +8,7 @@ import { ErrorPage } from "./pages/ErrorPage";
const HomePage = lazy(() => import("./pages/HomePage")); const HomePage = lazy(() => import("./pages/HomePage"));
const LoginPage = lazy(() => import("./pages/LoginPage")); const LoginPage = lazy(() => import("./pages/LoginPage"));
const LogoutPage = lazy(() => import("./pages/LogoutPage")); const LogoutPage = lazy(() => import("./pages/LogoutPage"));
const ProblemListPage = lazy(() => import("./pages/ProblemListPage"));
export const router: RouteObject[] = [ export const router: RouteObject[] = [
{ {
@ -31,6 +32,10 @@ export const router: RouteObject[] = [
path: "logout", path: "logout",
element: <LogoutPage />, element: <LogoutPage />,
}, },
{
path: "problem",
element: <ProblemListPage />,
},
], ],
}, },
]; ];