Skip to content

Commit ceebe26

Browse files
committed
feat: implement cheat records page
1 parent 7f5a4cc commit ceebe26

20 files changed

Lines changed: 1824 additions & 6 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"use client";
2+
3+
import { CardLayout } from "@/components/card-layout";
4+
import { Badge } from "@/components/ui/badge";
5+
import { StyledLink } from "@/components/ui/link";
6+
import { graphql, useFragment, type FragmentType } from "@/gql";
7+
import { useSuspenseQuery } from "@apollo/client/react";
8+
import AppAvatar from "@/components/avatar";
9+
10+
const CHEAT_RECORD_DETAILS_QUERY = graphql(`
11+
query CheatRecordDetails($id: ID!) {
12+
cheatRecord(id: $id) {
13+
...CheatRecordDetailsCard
14+
id
15+
}
16+
}
17+
`);
18+
19+
const CHEAT_RECORD_DETAILS_FRAGMENT = graphql(`
20+
fragment CheatRecordDetailsCard on CheatRecord {
21+
id
22+
reason
23+
cheatedAt
24+
resolvedAt
25+
resolvedReason
26+
user {
27+
id
28+
name
29+
email
30+
avatar
31+
}
32+
}
33+
`);
34+
35+
export function CheatRecordDetails({ id }: { id: string }) {
36+
const { data } = useSuspenseQuery(CHEAT_RECORD_DETAILS_QUERY, {
37+
variables: { id },
38+
});
39+
40+
const fragment = data.cheatRecord;
41+
42+
return (
43+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
44+
<DetailsCard fragment={fragment} />
45+
</div>
46+
);
47+
}
48+
49+
function DetailsCard({
50+
fragment,
51+
}: {
52+
fragment: FragmentType<typeof CHEAT_RECORD_DETAILS_FRAGMENT>;
53+
}) {
54+
const {
55+
reason,
56+
cheatedAt,
57+
resolvedAt,
58+
resolvedReason,
59+
user,
60+
} = useFragment(CHEAT_RECORD_DETAILS_FRAGMENT, fragment);
61+
62+
const isResolved = !!resolvedAt;
63+
64+
return (
65+
<CardLayout title="記錄詳情" description="作弊記錄的詳細資訊。">
66+
<div className="space-y-4">
67+
<div>
68+
<p className="text-sm font-medium text-muted-foreground">狀態</p>
69+
{isResolved ? (
70+
<Badge variant="secondary" className="mt-1 bg-green-100 text-green-800">
71+
已解決
72+
</Badge>
73+
) : (
74+
<Badge variant="destructive" className="mt-1">未解決</Badge>
75+
)}
76+
</div>
77+
78+
<div>
79+
<p className="text-sm font-medium text-muted-foreground">使用者</p>
80+
<div className="mt-1 flex items-center gap-2">
81+
<AppAvatar src={user.avatar} name={user.name} />
82+
<div>
83+
<StyledLink
84+
href={`/users/${user.id}`}
85+
className="font-medium"
86+
>
87+
{user.name}
88+
</StyledLink>
89+
<p className="text-xs text-muted-foreground">{user.email}</p>
90+
</div>
91+
</div>
92+
</div>
93+
94+
<div>
95+
<p className="text-sm font-medium text-muted-foreground">原因</p>
96+
<p className="mt-1">{reason}</p>
97+
</div>
98+
99+
<div>
100+
<p className="text-sm font-medium text-muted-foreground">作弊時間</p>
101+
<p className="mt-1">
102+
{new Date(cheatedAt).toLocaleString("zh-TW", {
103+
timeZone: "Asia/Taipei",
104+
})}
105+
</p>
106+
</div>
107+
108+
{isResolved && (
109+
<>
110+
<div>
111+
<p className="text-sm font-medium text-muted-foreground">解決時間</p>
112+
<p className="mt-1">
113+
{new Date(resolvedAt).toLocaleString("zh-TW", {
114+
timeZone: "Asia/Taipei",
115+
})}
116+
</p>
117+
</div>
118+
119+
{resolvedReason && (
120+
<div>
121+
<p className="text-sm font-medium text-muted-foreground">解決原因</p>
122+
<p className="mt-1">{resolvedReason}</p>
123+
</div>
124+
)}
125+
</>
126+
)}
127+
</div>
128+
</CardLayout>
129+
);
130+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import AppAvatar from "@/components/avatar";
4+
import PageHeader, { PageHeaderSkeleton } from "@/components/page-header";
5+
import { graphql } from "@/gql";
6+
import { useSuspenseQuery } from "@apollo/client/react";
7+
import { Suspense } from "react";
8+
9+
const CHEAT_RECORD_HEADER_QUERY = graphql(`
10+
query CheatRecordHeader($id: ID!) {
11+
cheatRecord(id: $id) {
12+
id
13+
reason
14+
user {
15+
id
16+
name
17+
email
18+
avatar
19+
}
20+
}
21+
}
22+
`);
23+
24+
export function Header({ id }: { id: string }) {
25+
return (
26+
<Suspense fallback={<HeaderSkeleton />}>
27+
<HeaderMain id={id} />
28+
</Suspense>
29+
);
30+
}
31+
32+
function HeaderMain({ id }: { id: string }) {
33+
const { data } = useSuspenseQuery(CHEAT_RECORD_HEADER_QUERY, {
34+
variables: { id },
35+
});
36+
37+
return (
38+
<div className="flex items-center gap-4">
39+
<AppAvatar
40+
src={data.cheatRecord.user.avatar}
41+
name={data.cheatRecord.user.name}
42+
className="h-12 w-12"
43+
/>
44+
<PageHeader
45+
title={`作弊記錄「${data.cheatRecord.reason}」`}
46+
description={`使用者:${data.cheatRecord.user.name} (${data.cheatRecord.user.email})`}
47+
/>
48+
</div>
49+
);
50+
}
51+
52+
function HeaderSkeleton() {
53+
return (
54+
<div className="flex items-center gap-4">
55+
<AppAvatar name="Loading" className="h-12 w-12" />
56+
<PageHeaderSkeleton description="Loading..." />
57+
</div>
58+
);
59+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client";
2+
3+
import { graphql, useFragment, type FragmentType } from "@/gql";
4+
import { useSuspenseQuery } from "@apollo/client/react";
5+
import { ResolveCheatRecordButtonTrigger } from "../../_components/resolve";
6+
7+
const CHEAT_RECORD_RESOLVE_BUTTON_QUERY = graphql(`
8+
query CheatRecordResolveButton($id: ID!) {
9+
cheatRecord(id: $id) {
10+
...CheatRecordResolveButtonFragment
11+
id
12+
}
13+
}
14+
`);
15+
16+
const CHEAT_RECORD_RESOLVE_BUTTON_FRAGMENT = graphql(`
17+
fragment CheatRecordResolveButtonFragment on CheatRecord {
18+
id
19+
resolvedAt
20+
}
21+
`);
22+
23+
export function ResolveButton({ id }: { id: string }) {
24+
const { data } = useSuspenseQuery(CHEAT_RECORD_RESOLVE_BUTTON_QUERY, {
25+
variables: { id },
26+
});
27+
28+
const fragment = data.cheatRecord;
29+
const { resolvedAt } = useFragment(
30+
CHEAT_RECORD_RESOLVE_BUTTON_FRAGMENT,
31+
fragment,
32+
);
33+
34+
if (resolvedAt) {
35+
return null;
36+
}
37+
38+
return <ResolveCheatRecordButtonTrigger id={id} />;
39+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { SiteHeader } from "@/components/site-header";
2+
import type { Metadata } from "next";
3+
import { Suspense } from "react";
4+
import { Header } from "./_components/header";
5+
import { CheatRecordDetails } from "./_components/details";
6+
import { ResolveButton } from "./_components/resolve-button";
7+
8+
export const metadata: Metadata = {
9+
title: "作弊記錄詳情",
10+
};
11+
12+
export default async function CheatRecordPage({
13+
params,
14+
}: {
15+
params: Promise<{ id: string }>;
16+
}) {
17+
const { id } = await params;
18+
19+
return (
20+
<>
21+
<SiteHeader title="作弊記錄詳情" hasBackButton />
22+
<main
23+
className={`
24+
flex-1 space-y-4 p-4 pt-6
25+
md:p-8
26+
`}
27+
>
28+
<div
29+
className={`
30+
flex flex-col items-center justify-between space-y-2
31+
lg:flex-row
32+
`}
33+
>
34+
<Header id={id as string} />
35+
36+
<div className="flex items-center gap-2">
37+
<Suspense>
38+
<ResolveButton id={id as string} />
39+
</Suspense>
40+
</div>
41+
</div>
42+
<Suspense>
43+
<CheatRecordDetails id={id as string} />
44+
</Suspense>
45+
</main>
46+
</>
47+
);
48+
}

0 commit comments

Comments
 (0)