implement trollboard UI (#1301)

* implement trollboard UI

* remove unneeded code

* add link to user

* user link in leaderboard
This commit is contained in:
notmd
2023-02-08 11:49:00 +07:00
committed by GitHub
parent 59dbfea48f
commit a46e4a2bb1
13 changed files with 389 additions and 85 deletions
+14 -14
View File
@@ -12332,11 +12332,11 @@
}
},
"node_modules/@tanstack/react-table": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.6.tgz",
"integrity": "sha512-/QijmMFeP7wDLBnr0MQ/5MlbXePbIL/1nOtkxBC9zvmBu4gDKJEDBqipUyM7Wc/iBpSd0IFyqBlvZvTPD9FYDA==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.9.tgz",
"integrity": "sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==",
"dependencies": {
"@tanstack/table-core": "8.7.6"
"@tanstack/table-core": "8.7.9"
},
"engines": {
"node": ">=12"
@@ -12351,9 +12351,9 @@
}
},
"node_modules/@tanstack/table-core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.6.tgz",
"integrity": "sha512-sqiNTMzB6cpyL8DFH6/VqW48SwiflLqxQqYpo2wNock7rdVGvlm0BLNI8vZUJbr1+fmmWmHwBvi5OMgZw8n1DA==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.9.tgz",
"integrity": "sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw==",
"engines": {
"node": ">=12"
},
@@ -47665,17 +47665,17 @@
}
},
"@tanstack/react-table": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.6.tgz",
"integrity": "sha512-/QijmMFeP7wDLBnr0MQ/5MlbXePbIL/1nOtkxBC9zvmBu4gDKJEDBqipUyM7Wc/iBpSd0IFyqBlvZvTPD9FYDA==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.9.tgz",
"integrity": "sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==",
"requires": {
"@tanstack/table-core": "8.7.6"
"@tanstack/table-core": "8.7.9"
}
},
"@tanstack/table-core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.6.tgz",
"integrity": "sha512-sqiNTMzB6cpyL8DFH6/VqW48SwiflLqxQqYpo2wNock7rdVGvlm0BLNI8vZUJbr1+fmmWmHwBvi5OMgZw8n1DA=="
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.9.tgz",
"integrity": "sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw=="
},
"@testing-library/dom": {
"version": "8.19.1",
+2 -1
View File
@@ -8,5 +8,6 @@
"users": "Users",
"users_dashboard": "Users Dashboard",
"status": "Status",
"status_dashboard": "Status Dashboard"
"status_dashboard": "Status Dashboard",
"trollboard": "Trollboard"
}
+5
View File
@@ -74,6 +74,11 @@ export const getAdminLayout = (page: React.ReactElement) => (
pathname: "/admin",
icon: Users,
},
{
labelID: "trollboard",
pathname: "/admin/trollboard",
icon: BarChart2,
},
{
labelID: "status",
pathname: "/admin/status",
@@ -1,15 +1,16 @@
import { Box, CircularProgress, Flex, useColorModeValue, useToken } from "@chakra-ui/react";
import { Box, CircularProgress, Flex, Link, useColorModeValue } from "@chakra-ui/react";
import { createColumnHelper } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import NextLink from "next/link";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import React, { useCallback, useMemo, useState } from "react";
import { get } from "src/lib/api";
import { colors } from "src/styles/Theme/colors";
import React, { useMemo } from "react";
import { LeaderboardEntity, LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard";
import useSWRImmutable from "swr/immutable";
import { DataTable, DataTableColumnDef, DataTableRowPropsCallback } from "../DataTable";
import { DataTable, DataTableColumnDef } from "../DataTable";
import { useBoardPagination } from "./useBoardPagination";
import { useBoardRowProps } from "./useBoardRowProps";
import { useFetchBoard } from "./useFetchBoard";
type WindowLeaderboardEntity = LeaderboardEntity & { isSpaceRow?: boolean };
const columnHelper = createColumnHelper<WindowLeaderboardEntity>();
@@ -34,10 +35,13 @@ export const LeaderboardTable = ({
data: reply,
isLoading,
error,
} = useSWRImmutable<LeaderboardReply & { user_stats_window?: LeaderboardReply["leaderboard"] }>(
`/api/leaderboard?time_frame=${timeFrame}&limit=${limit}&includeUserStats=${!hideCurrentUserRanking}`,
get
lastUpdated,
} = useFetchBoard<LeaderboardReply & { user_stats_window?: LeaderboardReply["leaderboard"] }>(
`/api/leaderboard?time_frame=${timeFrame}&limit=${limit}&includeUserStats=${!hideCurrentUserRanking}`
);
const { data: session } = useSession();
const isAdmin = session?.user?.role === "admin";
const columns: DataTableColumnDef<WindowLeaderboardEntity>[] = useMemo(
() => [
{
@@ -49,6 +53,14 @@ export const LeaderboardTable = ({
},
columnHelper.accessor("display_name", {
header: t("user"),
cell: ({ getValue, row }) =>
isAdmin ? (
<Link as={NextLink} href={`/admin/manage_user/${row.original.user_id}`}>
{getValue()}
</Link>
) : (
getValue()
),
}),
columnHelper.accessor("leader_score", {
header: t("score"),
@@ -63,40 +75,32 @@ export const LeaderboardTable = ({
header: t("label"),
}),
],
[t]
[isAdmin, t]
);
const lastUpdated = useMemo(() => {
const val = new Date(reply?.last_updated);
return t("last_updated_at", { val, formatParams: { val: { dateStyle: "full", timeStyle: "short" } } });
}, [t, reply?.last_updated]);
const [page, setPage] = useState(1);
const {
data: paginatedData,
end,
...pagnationProps
} = useBoardPagination({ rowPerPage, data: reply?.leaderboard, limit });
const data: WindowLeaderboardEntity[] = useMemo(() => {
if (!reply) {
return [];
}
const start = (page - 1) * rowPerPage;
const end = start + rowPerPage;
const leaderBoardEntities = reply.leaderboard.slice(start, end);
if (hideCurrentUserRanking || !reply.user_stats_window) {
return leaderBoardEntities;
if (hideCurrentUserRanking || !reply?.user_stats_window) {
return paginatedData;
}
const userStatsWindow: WindowLeaderboardEntity[] = reply.user_stats_window;
const userStats = userStatsWindow.find((stats) => stats.highlighted);
if (userStats.rank > end) {
leaderBoardEntities.push(
if (userStats && userStats.rank > end) {
paginatedData.push(
{ isSpaceRow: true } as WindowLeaderboardEntity,
...reply.user_stats_window.filter(
(stats) =>
leaderBoardEntities.findIndex((leaderBoardEntity) => leaderBoardEntity.user_id === stats.user_id) === -1
(stats) => paginatedData.findIndex((leaderBoardEntity) => leaderBoardEntity.user_id === stats.user_id) === -1
) // filter to avoid duplicated row
);
}
return leaderBoardEntities;
}, [page, rowPerPage, reply, hideCurrentUserRanking]);
return paginatedData;
}, [hideCurrentUserRanking, reply?.user_stats_window, end, paginatedData]);
const rowProps = useLeaderboardRowProps();
const rowProps = useBoardRowProps<WindowLeaderboardEntity>();
if (isLoading) {
return <CircularProgress isIndeterminate></CircularProgress>;
@@ -106,19 +110,13 @@ export const LeaderboardTable = ({
return <span>Unable to load leaderboard</span>;
}
const maxPage = Math.ceil(reply.leaderboard.length / rowPerPage);
return (
<DataTable
<DataTable<WindowLeaderboardEntity>
data={data}
columns={columns}
caption={lastUpdated}
disablePagination={limit <= rowPerPage}
disableNext={page >= maxPage}
disablePrevious={page === 1}
onNextClick={() => setPage((p) => p + 1)}
onPreviousClick={() => setPage((p) => p - 1)}
rowProps={rowProps}
{...pagnationProps}
></DataTable>
);
};
@@ -131,32 +129,3 @@ const SpaceRow = () => {
</Flex>
);
};
const useLeaderboardRowProps = () => {
const borderColor = useToken("colors", useColorModeValue(colors.light.active, colors.dark.active));
return useCallback<DataTableRowPropsCallback<WindowLeaderboardEntity>>(
(row) => {
const rowData = row.original;
return rowData.highlighted
? {
sx: {
// https://stackoverflow.com/questions/37963524/how-to-apply-border-radius-to-tr-in-bootstrap
position: "relative",
"td:first-of-type:before": {
borderLeft: `6px solid ${borderColor}`,
content: `""`,
display: "block",
width: "10px",
height: "100%",
left: 0,
top: 0,
borderRadius: "6px 0 0 6px",
position: "absolute",
},
},
}
: {};
},
[borderColor]
);
};
@@ -0,0 +1,124 @@
import { Box, CircularProgress, Flex, Link } from "@chakra-ui/react";
import { createColumnHelper } from "@tanstack/react-table";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import NextLink from "next/link";
import { FetchTrollBoardResponse, TrollboardEntity, TrollboardTimeFrame } from "src/types/Trollboard";
import { DataTable } from "../DataTable";
import { useBoardPagination } from "./useBoardPagination";
import { useBoardRowProps } from "./useBoardRowProps";
import { useFetchBoard } from "./useFetchBoard";
const columnHelper = createColumnHelper<TrollboardEntity>();
const toPercentage = (num: number) => `${Math.round(num * 100)}%`;
const columns = [
columnHelper.accessor("rank", {}),
columnHelper.accessor("display_name", {
header: "Display name",
cell: ({ getValue, row }) => (
<Link as={NextLink} href={`/admin/manage_user/${row.original.user_id}`}>
{getValue()}
</Link>
),
}),
columnHelper.accessor("troll_score", {
header: "Troll score",
}),
columnHelper.accessor("red_flags", {
header: "Red flags",
}),
columnHelper.accessor((row) => [row.upvotes, row.downvotes] as const, {
id: "vote",
cell: ({ getValue }) => {
const [up, down] = getValue();
return (
<Flex gap={2} justifyItems="center" alignItems="center">
<ThumbsUp></ThumbsUp>
{up}
<ThumbsDown></ThumbsDown>
{down}
</Flex>
);
},
}),
columnHelper.accessor((row) => row.spam + row.spam_prompts, {
header: "Spam",
}),
columnHelper.accessor("lang_mismach", {
header: "Lang mismach",
}),
columnHelper.accessor("not_appropriate", {
header: "Not appropriate",
}),
columnHelper.accessor("pii", {}),
columnHelper.accessor("hate_speech", {
header: "Hate speech",
}),
columnHelper.accessor("sexual_content", {
header: "Sexual Content",
}),
columnHelper.accessor("political_content", {
header: "Political Content",
}),
columnHelper.accessor("quality", {
cell: ({ getValue }) => toPercentage(getValue()),
}),
columnHelper.accessor("helpfulness", {
cell: ({ getValue }) => toPercentage(getValue()),
}),
columnHelper.accessor("humor", {
cell: ({ getValue }) => toPercentage(getValue()),
}),
columnHelper.accessor("violence", {
cell: ({ getValue }) => toPercentage(getValue()),
}),
columnHelper.accessor("toxicity", {
cell: ({ getValue }) => toPercentage(getValue()),
}),
];
export const TrollboardTable = ({
limit,
rowPerPage,
timeFrame,
}: {
timeFrame: TrollboardTimeFrame;
limit: number;
rowPerPage: number;
}) => {
const {
data: trollboardRes,
isLoading,
error,
lastUpdated,
} = useFetchBoard<FetchTrollBoardResponse>(`/api/admin/trollboard?time_frame=${timeFrame}&limit=${limit}`);
const { data, ...paginationProps } = useBoardPagination({ rowPerPage, data: trollboardRes?.trollboard, limit });
const rowProps = useBoardRowProps<TrollboardEntity>();
if (isLoading) {
return <CircularProgress isIndeterminate></CircularProgress>;
}
if (error) {
return <span>Unable to load leaderboard</span>;
}
return (
<Box
sx={{
"th,td": {
px: 2,
},
}}
>
<DataTable<TrollboardEntity>
data={data}
columns={columns}
caption={lastUpdated}
rowProps={rowProps}
{...paginationProps}
></DataTable>
</Box>
);
};
@@ -0,0 +1,34 @@
import { useState } from "react";
export const useBoardPagination = <T>({
rowPerPage,
limit,
...res
}: {
rowPerPage: number;
data?: T[];
limit: number;
}) => {
const data = res.data || [];
const [page, setPage] = useState(1);
const maxPage = data ? Math.ceil(data.length / rowPerPage) : 0;
const disablePagination = limit <= rowPerPage;
const disableNext = page >= maxPage;
const disablePrevious = page === 1;
const onNextClick = () => setPage((p) => p + 1);
const onPreviousClick = () => setPage((p) => p - 1);
const start = (page - 1) * rowPerPage;
const end = start + rowPerPage;
const entities = data.slice(start, end);
return {
page,
data: entities,
end,
disablePrevious,
disableNext,
disablePagination,
onNextClick,
onPreviousClick,
};
};
@@ -0,0 +1,34 @@
import { useColorModeValue, useToken } from "@chakra-ui/react";
import { useCallback } from "react";
import { colors } from "src/styles/Theme/colors";
import { DataTableRowPropsCallback } from "../DataTable";
export const useBoardRowProps = <T extends { highlighted: boolean }>() => {
const borderColor = useToken("colors", useColorModeValue(colors.light.active, colors.dark.active));
return useCallback<DataTableRowPropsCallback<T>>(
(row) => {
const rowData = row.original;
return rowData.highlighted
? {
sx: {
// https://stackoverflow.com/questions/37963524/how-to-apply-border-radius-to-tr-in-bootstrap
position: "relative",
"td:first-of-type:before": {
borderLeft: `6px solid ${borderColor}`,
content: `""`,
display: "block",
width: "10px",
height: "100%",
left: 0,
top: 0,
borderRadius: "6px 0 0 6px",
position: "absolute",
},
},
}
: {};
},
[borderColor]
);
};
@@ -0,0 +1,19 @@
import { useTranslation } from "next-i18next";
import { useMemo } from "react";
import { get } from "src/lib/api";
import useSWRImmutable from "swr/immutable";
export const useFetchBoard = <T extends { last_updated: string }>(url: string) => {
const { t } = useTranslation("leaderboard");
const res = useSWRImmutable<T>(url, get);
const lastUpdated = useMemo(() => {
const val = res.data ? new Date(res.data.last_updated) : new Date();
return t("last_updated_at", { val, formatParams: { val: { dateStyle: "full", timeStyle: "short" } } });
}, [res.data, t]);
return {
...res,
lastUpdated,
};
};
+7
View File
@@ -1,6 +1,7 @@
import type { EmojiOp, Message } from "src/types/Conversation";
import { LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard";
import type { AvailableTasks } from "src/types/Task";
import { FetchTrollBoardResponse, TrollboardTimeFrame } from "src/types/Trollboard";
import type { BackendUser, BackendUserCore, FetchUsersParams, FetchUsersResponse } from "src/types/Users";
export class OasstError {
@@ -350,4 +351,10 @@ export class OasstApiClient {
fetch_frontend_user(user: BackendUserCore) {
return this.get<BackendUser>(`/api/v1/frontend_users/${user.auth_method}/${user.id}`);
}
fetch_trollboard(time_frame: TrollboardTimeFrame, { limit }: { limit?: number }) {
return this.get<FetchTrollBoardResponse>(`/api/v1/trollboards/${time_frame}`, {
max_count: limit,
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Box, Card, CardBody, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import Head from "next/head";
import { useTranslation } from "next-i18next";
export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props";
import { AdminArea } from "src/components/AdminArea";
import { getAdminLayout } from "src/components/Layout";
import { TrollboardTable } from "src/components/LeaderboardTable/TrollboardTable";
import { TrollboardTimeFrame } from "src/types/Trollboard";
const Leaderboard = () => {
const { t } = useTranslation(["leaderboard", "common"]);
return (
<>
<Head>
<title>{`Trollboard - ${t("common:title")}`}</title>
<meta name="description" content="Admin Trollboard" charSet="UTF-8" />
</Head>
<AdminArea>
<Box display="flex" flexDirection="column">
<Heading fontSize="2xl" fontWeight="bold" pb="4">
{t("leaderboard")}
</Heading>
<Card>
<CardBody>
<Tabs isFitted isLazy>
<TabList mb={4}>
<Tab>{t("daily")}</Tab>
<Tab>{t("weekly")}</Tab>
<Tab>{t("monthly")}</Tab>
<Tab>{t("overall")}</Tab>
</TabList>
<TabPanels>
<TabPanel p="0">
<TrollboardTable timeFrame={TrollboardTimeFrame.day} limit={100} rowPerPage={20} />
</TabPanel>
<TabPanel p="0">
<TrollboardTable timeFrame={TrollboardTimeFrame.week} limit={100} rowPerPage={20} />
</TabPanel>
<TabPanel p="0">
<TrollboardTable timeFrame={TrollboardTimeFrame.month} limit={100} rowPerPage={20} />
</TabPanel>
<TabPanel p="0">
<TrollboardTable timeFrame={TrollboardTimeFrame.total} limit={100} rowPerPage={20} />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
</Box>
</AdminArea>
</>
);
};
Leaderboard.getLayout = getAdminLayout;
export default Leaderboard;
+13
View File
@@ -0,0 +1,13 @@
import { withRole } from "src/lib/auth";
import { createApiClient } from "src/lib/oasst_client_factory";
import { TrollboardTimeFrame } from "src/types/Trollboard";
export default withRole("admin", async (req, res, token) => {
const client = await createApiClient(token);
const trollboard = await client.fetch_trollboard(req.query.time_frame as TrollboardTimeFrame, {
limit: req.query.limit as unknown as number,
});
return res.status(200).json(trollboard);
});
+1 -1
View File
@@ -4,7 +4,7 @@ export interface LeaderboardEntry {
score: number;
}
export const enum LeaderboardTimeFrame {
export enum LeaderboardTimeFrame {
day = "day",
week = "week",
month = "month",
+41
View File
@@ -0,0 +1,41 @@
export enum TrollboardTimeFrame {
day = "day",
week = "week",
month = "month",
total = "total",
}
export type FetchTrollBoardResponse = {
time_frame: TrollboardTimeFrame;
last_updated: string;
trollboard: TrollboardEntity[];
};
export type TrollboardEntity = {
rank: number;
user_id: string;
highlighted: boolean;
username: string;
auth_method: string;
display_name: string;
last_activity_date: string | null;
troll_score: number;
base_date: string;
modified_date: string;
red_flags: number;
upvotes: number;
downvotes: number;
spam_prompts: 0;
quality: number | null;
humor: number | null;
toxicity: number | null;
violence: number | null;
helpfulness: number | null;
spam: number;
lang_mismach: number;
not_appropriate: number;
pii: number;
hate_speech: number;
sexual_content: number;
political_content: number;
};