Skip to content

Commit 66d5401

Browse files
committed
feat: overview page
1 parent 6e45933 commit 66d5401

File tree

11 files changed

+362
-4
lines changed

11 files changed

+362
-4
lines changed

app/(admin)/_components/header.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use client";
2+
3+
import { useUser } from "@/providers/use-user";
4+
5+
export function Header() {
6+
const { user } = useUser();
7+
8+
return (
9+
<div className="flex items-center justify-between space-y-2">
10+
<div>
11+
<h2 className="text-2xl font-bold tracking-tight">哈囉,{user.name}</h2>
12+
<p className="text-muted-foreground">
13+
這裡可以快速總覽所有統計資料,也可以點選左邊的側邊來進行資料管理。
14+
</p>
15+
</div>
16+
</div>
17+
);
18+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use client";
2+
3+
import { graphql } from "@/gql";
4+
import { SubmissionStatus, type SubmissionWhereInput } from "@/gql/graphql";
5+
import { useLazyQuery } from "@apollo/client/react";
6+
import { useEffect, useState } from "react";
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9+
import { Checkbox } from "@/components/ui/checkbox";
10+
import { Label } from "@/components/ui/label";
11+
import { Skeleton } from "@/components/ui/skeleton";
12+
13+
const LOGIN_TOTAL_COUNT_QUERY = graphql(`
14+
query LoginTotalCount($where: EventWhereInput!) {
15+
events(where: $where) {
16+
totalCount
17+
}
18+
}
19+
`);
20+
21+
type TimeRange = "daily" | "weekly" | "all";
22+
23+
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
24+
daily: "今日",
25+
weekly: "本週",
26+
all: "全部",
27+
};
28+
29+
export default function LoginTotalCount() {
30+
const [timeRange, setTimeRange] = useState<TimeRange>("daily");
31+
32+
const [getLoginTotalCount, { data, loading }] = useLazyQuery(LOGIN_TOTAL_COUNT_QUERY);
33+
34+
useEffect(() => {
35+
const now = new Date();
36+
37+
const timeRangeWhere: Record<TimeRange, SubmissionWhereInput> = {
38+
daily: {
39+
submittedAtGTE: new Date(now.setDate(now.getDate() - 1)).toISOString(),
40+
},
41+
weekly: {
42+
submittedAtGTE: new Date(now.setDate(now.getDate() - 7)).toISOString(),
43+
},
44+
all: {},
45+
};
46+
47+
const where = {
48+
...timeRangeWhere[timeRange],
49+
type: "login",
50+
}
51+
52+
getLoginTotalCount({ variables: { where } });
53+
}, [timeRange, getLoginTotalCount]);
54+
55+
return (
56+
<Card>
57+
<CardHeader>
58+
<CardTitle>登入總數</CardTitle>
59+
<CardDescription>
60+
所有使用者在這段期間的總登入次數。
61+
</CardDescription>
62+
</CardHeader>
63+
<CardContent className="space-y-4">
64+
<Tabs value={timeRange} onValueChange={(value) => setTimeRange(value as TimeRange)}>
65+
<TabsList>
66+
{Object.entries(TIME_RANGE_LABELS).map(([value, label]) => (
67+
<TabsTrigger key={value} value={value}>
68+
{label}
69+
</TabsTrigger>
70+
))}
71+
</TabsList>
72+
<TabsContent value={timeRange} className="mt-4">
73+
<div className="flex items-end gap-2 text-3xl font-bold">
74+
{!loading && (data?.events.totalCount?.toLocaleString('zh-TW') ?? 0)}
75+
{loading && <Skeleton className="h-8 w-24" />}
76+
</div>
77+
<p className="mt-1 text-sm text-muted-foreground">
78+
{TIME_RANGE_LABELS[timeRange]}的登入次數
79+
</p>
80+
</TabsContent>
81+
</Tabs>
82+
</CardContent>
83+
</Card>
84+
);
85+
}

app/(admin)/_components/rank.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import DataTablePagination from "@/components/data-table/pagination";
2424
import { Badge } from "@/components/ui/badge";
2525

26-
const OVERVIEW_RANKING = graphql(`
26+
const OVERVIEW_RANKING_QUERY = graphql(`
2727
query OverviewRanking($filter: RankingFilter!, $first: Int!, $after: Cursor) {
2828
ranking(filter: $filter, first: $first, after: $after) {
2929
edges {
@@ -88,7 +88,7 @@ const RANKING_PERIOD_LABELS: Record<RankingPeriod, string> = {
8888
[RankingPeriod.Weekly]: "本週",
8989
};
9090

91-
export function OverviewRanking() {
91+
export default function OverviewRanking() {
9292
const PAGE_SIZE = 20;
9393

9494
const [rankingBy, setRankingBy] = useState<RankingBy>(RankingBy.Points);
@@ -100,7 +100,7 @@ export function OverviewRanking() {
100100
);
101101
const [cursors, setCursors] = useState<string[]>([]);
102102

103-
const { data } = useSuspenseQuery(OVERVIEW_RANKING, {
103+
const { data } = useSuspenseQuery(OVERVIEW_RANKING_QUERY, {
104104
variables: {
105105
filter: { by: rankingBy, order: rankingOrder, period: rankingPeriod },
106106
first: PAGE_SIZE,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"use client";
2+
3+
import { graphql } from "@/gql";
4+
import { SubmissionStatus, type SubmissionWhereInput } from "@/gql/graphql";
5+
import { useLazyQuery } from "@apollo/client/react";
6+
import { useEffect, useState } from "react";
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9+
import { Checkbox } from "@/components/ui/checkbox";
10+
import { Label } from "@/components/ui/label";
11+
import { Skeleton } from "@/components/ui/skeleton";
12+
13+
const SUBMISSIONS_TOTAL_COUNT_QUERY = graphql(`
14+
query SubmissionsTotalCount($where: SubmissionWhereInput!) {
15+
submissions(where: $where) {
16+
totalCount
17+
}
18+
}
19+
`);
20+
21+
type TimeRange = "daily" | "weekly" | "all";
22+
23+
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
24+
daily: "今日",
25+
weekly: "本週",
26+
all: "全部",
27+
};
28+
29+
export default function SubmissionsTotalCount() {
30+
const [showSuccessOnly, setShowSuccessOnly] = useState(false);
31+
const [timeRange, setTimeRange] = useState<TimeRange>("daily");
32+
33+
const [getSubmissionsTotalCount, { data, loading }] = useLazyQuery(SUBMISSIONS_TOTAL_COUNT_QUERY);
34+
35+
useEffect(() => {
36+
const now = new Date();
37+
38+
const timeRangeWhere: Record<TimeRange, SubmissionWhereInput> = {
39+
daily: {
40+
submittedAtGTE: new Date(now.setDate(now.getDate() - 1)).toISOString(),
41+
},
42+
weekly: {
43+
submittedAtGTE: new Date(now.setDate(now.getDate() - 7)).toISOString(),
44+
},
45+
all: {},
46+
};
47+
48+
const where: SubmissionWhereInput = showSuccessOnly
49+
? {
50+
...timeRangeWhere[timeRange],
51+
status: SubmissionStatus.Success,
52+
}
53+
: timeRangeWhere[timeRange];
54+
55+
getSubmissionsTotalCount({ variables: { where } });
56+
}, [showSuccessOnly, timeRange, getSubmissionsTotalCount]);
57+
58+
return (
59+
<Card>
60+
<CardHeader>
61+
<CardTitle>提交總數</CardTitle>
62+
<CardDescription>
63+
所有使用者在這段期間的總提交數量。
64+
</CardDescription>
65+
</CardHeader>
66+
<CardContent className="space-y-4">
67+
<Tabs value={timeRange} onValueChange={(value) => setTimeRange(value as TimeRange)}>
68+
<TabsList>
69+
{Object.entries(TIME_RANGE_LABELS).map(([value, label]) => (
70+
<TabsTrigger key={value} value={value}>
71+
{label}
72+
</TabsTrigger>
73+
))}
74+
</TabsList>
75+
<TabsContent value={timeRange} className="mt-4">
76+
<div className="flex items-end gap-2 text-3xl font-bold">
77+
{!loading && (data?.submissions.totalCount?.toLocaleString('zh-TW') ?? 0)}
78+
{loading && <Skeleton className="h-8 w-24" />}
79+
</div>
80+
<p className="mt-1 text-sm text-muted-foreground">
81+
{TIME_RANGE_LABELS[timeRange]}的提交數量
82+
</p>
83+
</TabsContent>
84+
</Tabs>
85+
<div className="flex items-center space-x-2">
86+
<Checkbox
87+
id="success-only"
88+
checked={showSuccessOnly}
89+
onCheckedChange={(checked) => setShowSuccessOnly(checked === true)}
90+
/>
91+
<Label
92+
htmlFor="success-only"
93+
className="cursor-pointer text-sm font-normal"
94+
>
95+
只顯示成功的提交
96+
</Label>
97+
</div>
98+
</CardContent>
99+
</Card>
100+
);
101+
}

app/(admin)/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { SiteHeader } from "@/components/site-header";
22
import type { Metadata } from "next";
33
import { Suspense } from "react";
4-
import { OverviewRanking } from "./_components/rank";
4+
import OverviewRanking from "./_components/rank";
55
import { Skeleton } from "@/components/ui/skeleton";
6+
import SubmissionsTotalCount from "./_components/submit-count";
7+
import LoginTotalCount from "./_components/login-count";
8+
import { Header } from "./_components/header";
69

710
export const metadata: Metadata = {
811
title: "概覽",
@@ -18,6 +21,14 @@ export default function Home() {
1821
md:p-8
1922
`}
2023
>
24+
<Header />
25+
<div className={`
26+
grid grid-cols-1 gap-4
27+
md:grid-cols-2
28+
`}>
29+
<SubmissionsTotalCount />
30+
<LoginTotalCount />
31+
</div>
2132
<Suspense fallback={<Skeleton className="h-72 w-full" />}>
2233
<OverviewRanking />
2334
</Suspense>

components/ui/spinner.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Loader2Icon } from "lucide-react"
2+
3+
import { cn } from "@/lib/utils"
4+
5+
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
6+
return (
7+
<Loader2Icon
8+
role="status"
9+
aria-label="Loading"
10+
className={cn("size-4 animate-spin", className)}
11+
{...props}
12+
/>
13+
)
14+
}
15+
16+
export { Spinner }

components/ui/tabs.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as TabsPrimitive from "@radix-ui/react-tabs"
5+
6+
import { cn } from "@/lib/utils"
7+
8+
function Tabs({
9+
className,
10+
...props
11+
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
12+
return (
13+
<TabsPrimitive.Root
14+
data-slot="tabs"
15+
className={cn("flex flex-col gap-2", className)}
16+
{...props}
17+
/>
18+
)
19+
}
20+
21+
function TabsList({
22+
className,
23+
...props
24+
}: React.ComponentProps<typeof TabsPrimitive.List>) {
25+
return (
26+
<TabsPrimitive.List
27+
data-slot="tabs-list"
28+
className={cn(
29+
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
30+
className
31+
)}
32+
{...props}
33+
/>
34+
)
35+
}
36+
37+
function TabsTrigger({
38+
className,
39+
...props
40+
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
41+
return (
42+
<TabsPrimitive.Trigger
43+
data-slot="tabs-trigger"
44+
className={cn(
45+
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
46+
className
47+
)}
48+
{...props}
49+
/>
50+
)
51+
}
52+
53+
function TabsContent({
54+
className,
55+
...props
56+
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
57+
return (
58+
<TabsPrimitive.Content
59+
data-slot="tabs-content"
60+
className={cn("flex-1 outline-none", className)}
61+
{...props}
62+
/>
63+
)
64+
}
65+
66+
export { Tabs, TabsList, TabsTrigger, TabsContent }

gql/gql.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ type Documents = {
6262
"\n query UserById($id: ID!) {\n user(id: $id) {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n": typeof types.UserByIdDocument,
6363
"\n query GroupList {\n groups {\n id\n name\n }\n }\n": typeof types.GroupListDocument,
6464
"\n query UsersTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n users(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": typeof types.UsersTableDocument,
65+
"\n query LoginTotalCount($where: EventWhereInput!) {\n events(where: $where) {\n totalCount\n }\n }\n": typeof types.LoginTotalCountDocument,
6566
"\n query OverviewRanking($filter: RankingFilter!, $first: Int!, $after: Cursor) {\n ranking(filter: $filter, first: $first, after: $after) {\n edges {\n node {\n id\n name\n }\n ...ScoreCell\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n": typeof types.OverviewRankingDocument,
6667
"\n fragment ScoreCell on RankingEdge {\n ...UserCompletedQuestions\n ...UserTotalPoints\n ...RankingFragment\n }\n": typeof types.ScoreCellFragmentDoc,
6768
"\n fragment UserCompletedQuestions on RankingEdge {\n node {\n submissionStatistics {\n solvedQuestions\n }\n }\n }\n": typeof types.UserCompletedQuestionsFragmentDoc,
6869
"\n fragment UserTotalPoints on RankingEdge {\n node {\n totalPoints\n }\n }\n": typeof types.UserTotalPointsFragmentDoc,
6970
"\n fragment RankingFragment on RankingEdge {\n score\n }\n": typeof types.RankingFragmentFragmentDoc,
71+
"\n query SubmissionsTotalCount($where: SubmissionWhereInput!) {\n submissions(where: $where) {\n totalCount\n }\n }\n": typeof types.SubmissionsTotalCountDocument,
7072
"\n mutation MeUpdateUserInfo($input: UpdateUserInput!) {\n updateMe(input: $input) {\n id\n }\n }\n": typeof types.MeUpdateUserInfoDocument,
7173
"\n query MeUserInfo {\n me {\n id\n name\n avatar\n }\n }\n": typeof types.MeUserInfoDocument,
7274
"\n query BasicUserInfo {\n me {\n id\n name\n email\n avatar\n\n group {\n name\n }\n }\n }\n": typeof types.BasicUserInfoDocument,
@@ -120,11 +122,13 @@ const documents: Documents = {
120122
"\n query UserById($id: ID!) {\n user(id: $id) {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n": types.UserByIdDocument,
121123
"\n query GroupList {\n groups {\n id\n name\n }\n }\n": types.GroupListDocument,
122124
"\n query UsersTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n users(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n": types.UsersTableDocument,
125+
"\n query LoginTotalCount($where: EventWhereInput!) {\n events(where: $where) {\n totalCount\n }\n }\n": types.LoginTotalCountDocument,
123126
"\n query OverviewRanking($filter: RankingFilter!, $first: Int!, $after: Cursor) {\n ranking(filter: $filter, first: $first, after: $after) {\n edges {\n node {\n id\n name\n }\n ...ScoreCell\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n": types.OverviewRankingDocument,
124127
"\n fragment ScoreCell on RankingEdge {\n ...UserCompletedQuestions\n ...UserTotalPoints\n ...RankingFragment\n }\n": types.ScoreCellFragmentDoc,
125128
"\n fragment UserCompletedQuestions on RankingEdge {\n node {\n submissionStatistics {\n solvedQuestions\n }\n }\n }\n": types.UserCompletedQuestionsFragmentDoc,
126129
"\n fragment UserTotalPoints on RankingEdge {\n node {\n totalPoints\n }\n }\n": types.UserTotalPointsFragmentDoc,
127130
"\n fragment RankingFragment on RankingEdge {\n score\n }\n": types.RankingFragmentFragmentDoc,
131+
"\n query SubmissionsTotalCount($where: SubmissionWhereInput!) {\n submissions(where: $where) {\n totalCount\n }\n }\n": types.SubmissionsTotalCountDocument,
128132
"\n mutation MeUpdateUserInfo($input: UpdateUserInput!) {\n updateMe(input: $input) {\n id\n }\n }\n": types.MeUpdateUserInfoDocument,
129133
"\n query MeUserInfo {\n me {\n id\n name\n avatar\n }\n }\n": types.MeUserInfoDocument,
130134
"\n query BasicUserInfo {\n me {\n id\n name\n email\n avatar\n\n group {\n name\n }\n }\n }\n": types.BasicUserInfoDocument,
@@ -336,6 +340,10 @@ export function graphql(source: "\n query GroupList {\n groups {\n id\n
336340
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
337341
*/
338342
export function graphql(source: "\n query UsersTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n users(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"): (typeof documents)["\n query UsersTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n ) {\n users(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n email\n avatar\n createdAt\n updatedAt\n group {\n id\n name\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n endCursor\n startCursor\n }\n }\n }\n"];
343+
/**
344+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
345+
*/
346+
export function graphql(source: "\n query LoginTotalCount($where: EventWhereInput!) {\n events(where: $where) {\n totalCount\n }\n }\n"): (typeof documents)["\n query LoginTotalCount($where: EventWhereInput!) {\n events(where: $where) {\n totalCount\n }\n }\n"];
339347
/**
340348
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
341349
*/
@@ -356,6 +364,10 @@ export function graphql(source: "\n fragment UserTotalPoints on RankingEdge {\n
356364
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
357365
*/
358366
export function graphql(source: "\n fragment RankingFragment on RankingEdge {\n score\n }\n"): (typeof documents)["\n fragment RankingFragment on RankingEdge {\n score\n }\n"];
367+
/**
368+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
369+
*/
370+
export function graphql(source: "\n query SubmissionsTotalCount($where: SubmissionWhereInput!) {\n submissions(where: $where) {\n totalCount\n }\n }\n"): (typeof documents)["\n query SubmissionsTotalCount($where: SubmissionWhereInput!) {\n submissions(where: $where) {\n totalCount\n }\n }\n"];
359371
/**
360372
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
361373
*/

0 commit comments

Comments
 (0)