Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client";

import AppAvatar from "@/components/avatar";
import { CardLayout } from "@/components/card-layout";
import { Badge } from "@/components/ui/badge";
import { StyledLink } from "@/components/ui/link";
import { type FragmentType, graphql, useFragment } from "@/gql";
import { useSuspenseQuery } from "@apollo/client/react";

const CHEAT_RECORD_DETAILS_QUERY = graphql(`
query CheatRecordDetails($id: ID!) {
cheatRecord(id: $id) {
...CheatRecordDetailsCard
id
}
}
`);

const CHEAT_RECORD_DETAILS_FRAGMENT = graphql(`
fragment CheatRecordDetailsCard on CheatRecord {
id
cheatedAt
reason
resolvedAt
resolvedReason
user {
id
avatar
email
name
}
}
`);

export function CheatRecordDetails({ id }: { id: string }) {
const { data } = useSuspenseQuery(CHEAT_RECORD_DETAILS_QUERY, {
variables: { id },
});

const fragment = data.cheatRecord;

return (
<div
className={`
grid grid-cols-1 gap-4
lg:grid-cols-2
`}
>
<DetailsCard fragment={fragment} />
</div>
);
}

function DetailsCard({
fragment,
}: {
fragment: FragmentType<typeof CHEAT_RECORD_DETAILS_FRAGMENT>;
}) {
const {
reason,
cheatedAt,
resolvedAt,
resolvedReason,
user,
} = useFragment(CHEAT_RECORD_DETAILS_FRAGMENT, fragment);

const isResolved = !!resolvedAt;

return (
<CardLayout title="記錄詳情" description="作弊記錄的詳細資訊。">
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">狀態</p>
{isResolved
? (
<Badge
variant="secondary"
className={`mt-1 bg-green-100 text-green-800`}
>
已解決
</Badge>
)
: <Badge variant="destructive" className="mt-1">未解決</Badge>}
</div>

<div>
<p className="text-sm font-medium text-muted-foreground">使用者</p>
<div className="mt-1 flex items-center gap-2">
<AppAvatar src={user.avatar} name={user.name} />
<div>
<StyledLink href={`/users/${user.id}`}>
{user.name}
</StyledLink>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</div>

<div>
<p className="text-sm font-medium text-muted-foreground">原因</p>
<p className="mt-1">{reason}</p>
</div>

<div>
<p className="text-sm font-medium text-muted-foreground">作弊時間</p>
<p className="mt-1">
{new Date(cheatedAt).toLocaleString("zh-TW", {
timeZone: "Asia/Taipei",
})}
</p>
</div>

{isResolved && (
<>
<div>
<p className="text-sm font-medium text-muted-foreground">解決時間</p>
<p className="mt-1">
{new Date(resolvedAt).toLocaleString("zh-TW", {
timeZone: "Asia/Taipei",
})}
</p>
</div>

{resolvedReason && (
<div>
<p className="text-sm font-medium text-muted-foreground">解決原因</p>
<p className="mt-1">{resolvedReason}</p>
</div>
)}
</>
)}
</div>
</CardLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import AppAvatar from "@/components/avatar";
import PageHeader, { PageHeaderSkeleton } from "@/components/page-header";
import { graphql } from "@/gql";
import { useSuspenseQuery } from "@apollo/client/react";
import { Suspense } from "react";

const CHEAT_RECORD_HEADER_QUERY = graphql(`
query CheatRecordHeader($id: ID!) {
cheatRecord(id: $id) {
id
reason
user {
id
avatar
email
name
}
}
}
`);

export function Header({ id }: { id: string }) {
return (
<Suspense fallback={<HeaderSkeleton />}>
<HeaderMain id={id} />
</Suspense>
);
}

function HeaderMain({ id }: { id: string }) {
const { data } = useSuspenseQuery(CHEAT_RECORD_HEADER_QUERY, {
variables: { id },
});

return (
<div className="flex items-center gap-4">
<AppAvatar
src={data.cheatRecord.user.avatar}
name={data.cheatRecord.user.name}
className="h-12 w-12"
/>
<PageHeader
title={`作弊記錄「${data.cheatRecord.reason}」`}
description={`使用者:${data.cheatRecord.user.name} (${data.cheatRecord.user.email})`}
/>
</div>
);
}

function HeaderSkeleton() {
return (
<div className="flex items-center gap-4">
<AppAvatar name="Loading" className="h-12 w-12" />
<PageHeaderSkeleton description="Loading..." />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { graphql } from "@/gql";
import { useSuspenseQuery } from "@apollo/client/react";
import { ResolveCheatRecordButtonTrigger } from "../../_components/resolve";

const CHEAT_RECORD_RESOLVE_BUTTON_QUERY = graphql(`
query CheatRecordResolveButton($id: ID!) {
cheatRecord(id: $id) {
id
resolvedAt
}
}
`);

export function ResolveButton({ id }: { id: string }) {
const { data } = useSuspenseQuery(CHEAT_RECORD_RESOLVE_BUTTON_QUERY, {
variables: { id },
});

if (data.cheatRecord.resolvedAt) {
return null;
}

return <ResolveCheatRecordButtonTrigger id={id} />;
}
48 changes: 48 additions & 0 deletions app/(admin)/(activity-management)/cheat-records/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { SiteHeader } from "@/components/site-header";
import type { Metadata } from "next";
import { Suspense } from "react";
import { CheatRecordDetails } from "./_components/details";
import { Header } from "./_components/header";
import { ResolveButton } from "./_components/resolve-button";

export const metadata: Metadata = {
title: "作弊記錄詳情",
};

export default async function CheatRecordPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;

return (
<>
<SiteHeader title="作弊記錄詳情" hasBackButton />
<main
className={`
flex-1 space-y-4 p-4 pt-6
md:p-8
`}
>
<div
className={`
flex flex-col items-center justify-between space-y-2
lg:flex-row
`}
>
<Header id={id} />

<div className="flex items-center gap-2">
<Suspense>
<ResolveButton id={id} />
</Suspense>
</div>
</div>
<Suspense>
<CheatRecordDetails id={id} />
</Suspense>
</main>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"use client";

import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { graphql } from "@/gql";
import { skipToken, useQuery } from "@apollo/client/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useDebouncedValue } from "foxact/use-debounced-value";
import { useEffect } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";

const createCheatRecordFormSchema = z.object({
reason: z.string().min(1, "請輸入原因"),
userID: z.string().min(1, "請輸入使用者 ID"),
});

const CREATE_CHEAT_RECORD_FORM_USER_INFO_QUERY = graphql(`
query CreateCheatRecordFormUserInfo($id: ID!) {
user(id: $id) {
id
email
name
}
}
`);

export type CreateCheatRecordFormData = z.infer<
typeof createCheatRecordFormSchema
>;

export function CreateCheatRecordForm({
defaultValues,
onSubmit,
onFormStateChange,
}: {
defaultValues: CreateCheatRecordFormData;
onSubmit: (data: CreateCheatRecordFormData) => void;
onFormStateChange?: (isDirty: boolean) => void;
}) {
const form = useForm<CreateCheatRecordFormData>({
resolver: zodResolver(createCheatRecordFormSchema),
defaultValues,
});

const isDirty = form.formState.isDirty;

useEffect(() => {
onFormStateChange?.(isDirty);
}, [isDirty, onFormStateChange]);

const userID = useWatch({ control: form.control, name: "userID" });
const userIDDebounced = useDebouncedValue(userID, 200);

const { data: userInfoData, loading } = useQuery(
CREATE_CHEAT_RECORD_FORM_USER_INFO_QUERY,
userIDDebounced
? {
variables: {
id: userIDDebounced,
},
errorPolicy: "ignore",
}
: skipToken,
);

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="userID"
render={({ field }) => (
<FormItem>
<FormLabel>使用者 ID</FormLabel>
<FormControl>
<Input {...field} placeholder="請輸入使用者 ID" />
</FormControl>
<FormDescription>
<div>
選擇要新增作弊記錄的使用者。可以到使用者管理頁面確認對應代號。
</div>

<div className="flex items-center gap-4">
{loading ? <Spinner className="mr-4 inline-block size-4" /> : null}
{userInfoData?.user
? `您正要為:${userInfoData.user.name} (${userInfoData.user.email}) 新增作弊記錄`
: userIDDebounced
? "您輸入的使用者 ID 不存在。"
: ""}
</div>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>原因</FormLabel>
<FormControl>
<Textarea
placeholder="請輸入作弊原因..."
{...field}
/>
</FormControl>
<FormDescription>
請說明為什麼要新增這個作弊記錄。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<div className="flex justify-end gap-2">
<Button type="submit">新增記錄</Button>
</div>
</form>
</Form>
);
}
Loading