Compare commits
4 Commits
c0acbd6ddc
...
5a5f7bdbbd
Author | SHA1 | Date | |
---|---|---|---|
5a5f7bdbbd | |||
d58aa4ba6e | |||
43f5423cf9 | |||
9dfe9bfe81 |
@ -22,6 +22,7 @@
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@reduxjs/toolkit": "^2.2.1",
|
||||
"@tanstack/react-table": "^8.13.2",
|
||||
"ace-builds": "^1.32.7",
|
||||
"framer-motion": "^11.0.13",
|
||||
"github-markdown-css": "^5.5.1",
|
||||
|
@ -26,6 +26,9 @@ dependencies:
|
||||
'@reduxjs/toolkit':
|
||||
specifier: ^2.2.1
|
||||
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:
|
||||
specifier: ^1.32.7
|
||||
version: 1.32.7
|
||||
@ -2374,6 +2377,23 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
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:
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
dependencies:
|
||||
|
@ -15,6 +15,6 @@ const baseQuery = fetchBaseQuery({
|
||||
|
||||
export const api = createApi({
|
||||
baseQuery: retry(baseQuery, { maxRetries: 2 }),
|
||||
tagTypes: ["User", "Status", "Submission"],
|
||||
tagTypes: ["User", "Status", "Submission", "ProblemInfo", "TaskInfo"],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
98
src/app/services/problem.ts
Normal file
98
src/app/services/problem.ts
Normal 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
152
src/components/Table.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
111
src/pages/ProblemListPage.tsx
Normal file
111
src/pages/ProblemListPage.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -13,10 +13,10 @@ const SkeletonPage = () => (
|
||||
);
|
||||
|
||||
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 />
|
||||
|
||||
<Container w="100%" flex="1" maxW="container.lg" py={2}>
|
||||
<Container w="100%" flex="1" maxW="container.xl" py={2}>
|
||||
<React.Suspense fallback={<SkeletonPage />}>
|
||||
<Outlet />
|
||||
</React.Suspense>
|
||||
|
@ -8,6 +8,7 @@ import { ErrorPage } from "./pages/ErrorPage";
|
||||
const HomePage = lazy(() => import("./pages/HomePage"));
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage"));
|
||||
const LogoutPage = lazy(() => import("./pages/LogoutPage"));
|
||||
const ProblemListPage = lazy(() => import("./pages/ProblemListPage"));
|
||||
|
||||
export const router: RouteObject[] = [
|
||||
{
|
||||
@ -31,6 +32,10 @@ export const router: RouteObject[] = [
|
||||
path: "logout",
|
||||
element: <LogoutPage />,
|
||||
},
|
||||
{
|
||||
path: "problem",
|
||||
element: <ProblemListPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user