Skip to content

Commit 2f8c129

Browse files
authored
Merge pull request #3 from database-playground/pan93412/dbp-34-questions-list-page
DBP-34: Questions List page
2 parents 6b34e91 + 8bb2e1f commit 2f8c129

File tree

28 files changed

+2706
-632
lines changed

28 files changed

+2706
-632
lines changed

app/(app)/challenges/[id]/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Metadata } from "next";
2+
3+
export const metadata: Metadata = {
4+
title: "挑戰題目",
5+
};
6+
7+
export default function ChallengePage(/* _props: { params: Promise<{ id: string }> } */) {
8+
return <div>ChallengePage</div>;
9+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"use client";
2+
3+
import SearchFilterSection from "./search";
4+
import TagFilterSection, { type TagState } from "./tag";
5+
6+
export interface FilterSectionProps {
7+
search: string;
8+
setSearch: (search: string) => void;
9+
tags: TagState;
10+
setTags: (tags: TagState) => void;
11+
}
12+
13+
export default function FilterSection({
14+
search,
15+
setSearch,
16+
tags,
17+
setTags,
18+
}: FilterSectionProps) {
19+
return (
20+
<aside
21+
className={`
22+
flex min-h-[50dvh] w-[25%] flex-col gap-8 rounded bg-gray-100 p-6
23+
`}
24+
>
25+
<SearchFilterSection value={search} onChange={setSearch} />
26+
<TagFilterSection value={tags} onChange={setTags} />
27+
</aside>
28+
);
29+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Input } from "@/components/ui/input";
2+
import { SearchIcon } from "lucide-react";
3+
4+
export interface SearchFilterSectionProps {
5+
value: string;
6+
onChange: (value: string) => void;
7+
}
8+
9+
export default function SearchFilterSection({ value, onChange }: SearchFilterSectionProps) {
10+
return (
11+
<div className="space-y-2">
12+
<label className="flex items-center gap-2 font-bold">
13+
<SearchIcon className="size-4" />
14+
搜尋
15+
</label>
16+
<Input type="text" placeholder="搜尋" value={value} onChange={(e) => onChange(e.target.value)} />
17+
<p className="text-sm text-muted-foreground">
18+
可以搜尋題目標題、題幹內容,或者是類別。
19+
</p>
20+
</div>
21+
);
22+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Checkbox } from "@/components/ui/checkbox";
2+
import { Label } from "@/components/ui/label";
3+
import { QuestionDifficulty } from "@/gql/graphql";
4+
import { FilterIcon } from "lucide-react";
5+
import { type Difficulty, difficultyTranslation, type SolvedStatus, solvedStatusTranslation } from "../model";
6+
7+
export interface TagState {
8+
solvedStatus: SolvedStatus[];
9+
difficulty: Difficulty[];
10+
}
11+
12+
export interface TagFilterSectionProps {
13+
value: TagState;
14+
onChange: (tags: TagState) => void;
15+
}
16+
17+
export default function TagFilterSection({
18+
value,
19+
onChange,
20+
}: TagFilterSectionProps) {
21+
const getSolvedStatus = (solvedStatus: SolvedStatus) => {
22+
return value.solvedStatus.includes(solvedStatus);
23+
};
24+
25+
const getDifficulty = (difficulty: Difficulty) => {
26+
return value.difficulty.includes(difficulty);
27+
};
28+
29+
const handleDifficultyChange = (difficulty: Difficulty) => {
30+
return (checked: boolean) => {
31+
onChange({
32+
...value,
33+
difficulty: checked
34+
? [...value.difficulty, difficulty]
35+
: value.difficulty.filter((d) => d !== difficulty),
36+
});
37+
};
38+
};
39+
40+
const handleSolvedStatusChange = (status: SolvedStatus) => {
41+
return (checked: boolean) => {
42+
onChange({
43+
...value,
44+
solvedStatus: checked
45+
? [...value.solvedStatus, status]
46+
: value.solvedStatus.filter((s) => s !== status),
47+
});
48+
};
49+
};
50+
51+
return (
52+
<div className="space-y-3">
53+
<label className="flex items-center gap-2 font-bold">
54+
<FilterIcon className="size-4" />
55+
標籤
56+
</label>
57+
58+
<div className="mb-4 space-y-2 text-sm text-muted-foreground">
59+
解題狀態
60+
<div className="mt-2 space-y-2">
61+
<TagCheckbox
62+
tag="solved"
63+
checked={getSolvedStatus("solved")}
64+
onChange={handleSolvedStatusChange("solved")}
65+
translation={solvedStatusTranslation}
66+
/>
67+
<TagCheckbox
68+
tag="unsolved"
69+
checked={getSolvedStatus("unsolved")}
70+
onChange={handleSolvedStatusChange("unsolved")}
71+
translation={solvedStatusTranslation}
72+
/>
73+
<TagCheckbox
74+
tag="not-tried"
75+
checked={getSolvedStatus("not-tried")}
76+
onChange={handleSolvedStatusChange("not-tried")}
77+
translation={solvedStatusTranslation}
78+
/>
79+
</div>
80+
</div>
81+
82+
<div className="space-y-2 text-sm text-muted-foreground">
83+
難度
84+
<div className="mt-2 space-y-2">
85+
<TagCheckbox
86+
tag={QuestionDifficulty.Easy}
87+
checked={getDifficulty(QuestionDifficulty.Easy)}
88+
onChange={handleDifficultyChange(QuestionDifficulty.Easy)}
89+
translation={difficultyTranslation}
90+
/>
91+
<TagCheckbox
92+
tag={QuestionDifficulty.Medium}
93+
checked={getDifficulty(QuestionDifficulty.Medium)}
94+
onChange={handleDifficultyChange(QuestionDifficulty.Medium)}
95+
translation={difficultyTranslation}
96+
/>
97+
<TagCheckbox
98+
tag={QuestionDifficulty.Hard}
99+
checked={getDifficulty(QuestionDifficulty.Hard)}
100+
onChange={handleDifficultyChange(QuestionDifficulty.Hard)}
101+
translation={difficultyTranslation}
102+
/>
103+
<TagCheckbox
104+
tag={QuestionDifficulty.Unspecified}
105+
checked={getDifficulty(QuestionDifficulty.Unspecified)}
106+
onChange={handleDifficultyChange(QuestionDifficulty.Unspecified)}
107+
translation={difficultyTranslation}
108+
/>
109+
</div>
110+
</div>
111+
</div>
112+
);
113+
}
114+
115+
function TagCheckbox<T extends string>({
116+
tag,
117+
checked,
118+
onChange,
119+
translation,
120+
}: {
121+
tag: T;
122+
checked: boolean;
123+
onChange: (checked: boolean) => void;
124+
translation: Record<T, string>;
125+
}) {
126+
return (
127+
<div className="flex items-center gap-2">
128+
<Checkbox id={tag} checked={checked} onCheckedChange={onChange} />
129+
<Label htmlFor={tag}>{translation[tag]}</Label>
130+
</div>
131+
);
132+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"use client";
2+
3+
import { GridProgress } from "@/components/ui/grid-progress";
4+
import { graphql } from "@/gql";
5+
import { useSuspenseQuery } from "@apollo/client/react";
6+
7+
const CHALLENGE_STATISTICS_QUERY = graphql(`
8+
query ChallengeStatisticsQuery {
9+
me {
10+
submissionStatistics {
11+
totalQuestions
12+
solvedQuestions
13+
attemptedQuestions
14+
}
15+
}
16+
}
17+
`);
18+
19+
export default function Header() {
20+
const { data } = useSuspenseQuery(CHALLENGE_STATISTICS_QUERY);
21+
22+
const totalQuestions = data.me.submissionStatistics.totalQuestions;
23+
const totalSolvedQuestions = data.me.submissionStatistics.solvedQuestions;
24+
const totalAttemptedQuestions = data.me.submissionStatistics.attemptedQuestions;
25+
26+
return (
27+
<header className="flex items-center justify-between pb-6">
28+
<div className="space-y-1 tracking-wide">
29+
<h1 className="text-xl font-bold">
30+
<HeaderTitle
31+
totalQuestions={totalQuestions}
32+
totalSolvedQuestions={totalSolvedQuestions}
33+
/>
34+
</h1>
35+
<p>
36+
<HeaderDescription
37+
totalSolvedQuestions={totalSolvedQuestions}
38+
totalAttemptedQuestions={totalAttemptedQuestions}
39+
/>
40+
</p>
41+
</div>
42+
43+
<div
44+
className={`
45+
hidden
46+
md:block
47+
`}
48+
>
49+
<GridProgress
50+
variant="primary"
51+
cols={10}
52+
rows={4}
53+
progress={(totalSolvedQuestions / totalQuestions) * 100}
54+
/>
55+
</div>
56+
</header>
57+
);
58+
}
59+
60+
export function HeaderTitle({
61+
totalQuestions,
62+
totalSolvedQuestions,
63+
}: {
64+
totalQuestions: number;
65+
totalSolvedQuestions: number;
66+
}) {
67+
const remainingQuestions = totalQuestions - totalSolvedQuestions;
68+
69+
if (remainingQuestions === 0) {
70+
return <>你成功挑戰了所有題目!</>;
71+
}
72+
73+
if (remainingQuestions === 1) {
74+
return <>剩下最後一題就能全數通關!</>;
75+
}
76+
77+
return <>繼續挑戰接下來的 {remainingQuestions} 題題目吧!</>;
78+
}
79+
80+
export function HeaderDescription({
81+
totalSolvedQuestions,
82+
totalAttemptedQuestions,
83+
}: {
84+
totalSolvedQuestions: number;
85+
totalAttemptedQuestions: number;
86+
}) {
87+
if (totalAttemptedQuestions > 0 && totalSolvedQuestions === totalAttemptedQuestions) {
88+
return <>你現在百戰百勝,繼續加油!</>;
89+
}
90+
91+
if (totalSolvedQuestions > 0) {
92+
return (
93+
<>
94+
你目前已經嘗試作答了 {totalAttemptedQuestions} 題,其中攻克了 {totalSolvedQuestions} 題!
95+
</>
96+
);
97+
}
98+
99+
if (totalAttemptedQuestions > 0) {
100+
return <>你目前已經嘗試作答了 {totalAttemptedQuestions} 題,祝你成功攻克題目!</>;
101+
}
102+
103+
return <>你尚未嘗試作答任何題目,快點試試看你有興趣的題目吧!</>;
104+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Skeleton } from "@/components/ui/skeleton";
2+
3+
export default function HeaderSkeleton() {
4+
return (
5+
<div className="flex items-center justify-between">
6+
<div className="space-y-2">
7+
<Skeleton className="h-8 w-64" />
8+
<Skeleton className="h-4 w-86" />
9+
</div>
10+
11+
<Skeleton
12+
className={`
13+
hidden h-16 w-40
14+
md:block
15+
`}
16+
/>
17+
</div>
18+
);
19+
}

0 commit comments

Comments
 (0)