Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
9 changes: 9 additions & 0 deletions app/(app)/challenges/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "挑戰題目",
};

export default function ChallengePage({ params }: { params: { id: string } }) {

Check warning on line 7 in app/(app)/challenges/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Run Linters

'params' is defined but never used
return <div>ChallengePage</div>;
}
25 changes: 25 additions & 0 deletions app/(app)/challenges/_filter/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import SearchFilterSection from "./search";
import TagFilterSection, { type TagState } from "./tag";

export interface FilterSectionProps {
search: string;
setSearch: (search: string) => void;
tags: TagState;
setTags: (tags: TagState) => void;
}

export default function FilterSection({
search,
setSearch,
tags,
setTags,
}: FilterSectionProps) {
return (
<aside className="flex flex-col gap-8 p-6 bg-gray-100 min-h-[50dvh] w-[25%] rounded">
<SearchFilterSection value={search} onChange={setSearch} />
<TagFilterSection value={tags} onChange={setTags} />
</aside>
);
}
22 changes: 22 additions & 0 deletions app/(app)/challenges/_filter/search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Input } from "@/components/ui/input";
import { SearchIcon } from "lucide-react";

export interface SearchFilterSectionProps {
value: string;
onChange: (value: string) => void;
}

export default function SearchFilterSection({ value, onChange }: SearchFilterSectionProps) {
return (
<div className="space-y-2">
<label className="flex items-center gap-2 font-bold">
<SearchIcon className="size-4" />
搜尋
</label>
<Input type="text" placeholder="搜尋" value={value} onChange={(e) => onChange(e.target.value)} />
<p className="text-sm text-muted-foreground">
可以搜尋題目標題、題幹內容,或者是類別。
</p>
</div>
)
}
137 changes: 137 additions & 0 deletions app/(app)/challenges/_filter/tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { FilterIcon } from "lucide-react";
import {
difficultyTranslation,
solvedStatusTranslation,
type Difficulty,
type SolvedStatus,
} from "../model";
import { QuestionDifficulty } from "@/gql/graphql";

export interface TagState {
solvedStatus: SolvedStatus[];
difficulty: Difficulty[];
}

export interface TagFilterSectionProps {
value: TagState;
onChange: (tags: TagState) => void;
}

export default function TagFilterSection({
value,
onChange,
}: TagFilterSectionProps) {
const getSolvedStatus = (solvedStatus: SolvedStatus) => {
return value.solvedStatus.includes(solvedStatus);
};

const getDifficulty = (difficulty: Difficulty) => {
return value.difficulty.includes(difficulty);
};

const handleDifficultyChange = (difficulty: Difficulty) => {
return (checked: boolean) => {
onChange({
...value,
difficulty: checked
? [...value.difficulty, difficulty]
: value.difficulty.filter((d) => d !== difficulty),
});
};
};

const handleSolvedStatusChange = (status: SolvedStatus) => {
return (checked: boolean) => {
onChange({
...value,
solvedStatus: checked
? [...value.solvedStatus, status]
: value.solvedStatus.filter((s) => s !== status),
});
};
};

return (
<div className="space-y-3">
<label className="flex items-center gap-2 font-bold">
<FilterIcon className="size-4" />
標籤
</label>

<div className="space-y-2 text-sm text-muted-foreground mb-4">
解題狀態
<div className="mt-2 space-y-2">
<TagCheckbox
tag="solved"
checked={getSolvedStatus("solved")}
onChange={handleSolvedStatusChange("solved")}
translation={solvedStatusTranslation}
/>
<TagCheckbox
tag="unsolved"
checked={getSolvedStatus("unsolved")}
onChange={handleSolvedStatusChange("unsolved")}
translation={solvedStatusTranslation}
/>
<TagCheckbox
tag="not-tried"
checked={getSolvedStatus("not-tried")}
onChange={handleSolvedStatusChange("not-tried")}
translation={solvedStatusTranslation}
/>
</div>
</div>

<div className="space-y-2 text-sm text-muted-foreground">
難度
<div className="mt-2 space-y-2">
<TagCheckbox
tag={QuestionDifficulty.Easy}
checked={getDifficulty(QuestionDifficulty.Easy)}
onChange={handleDifficultyChange(QuestionDifficulty.Easy)}
translation={difficultyTranslation}
/>
<TagCheckbox
tag={QuestionDifficulty.Medium}
checked={getDifficulty(QuestionDifficulty.Medium)}
onChange={handleDifficultyChange(QuestionDifficulty.Medium)}
translation={difficultyTranslation}
/>
<TagCheckbox
tag={QuestionDifficulty.Hard}
checked={getDifficulty(QuestionDifficulty.Hard)}
onChange={handleDifficultyChange(QuestionDifficulty.Hard)}
translation={difficultyTranslation}
/>
<TagCheckbox
tag={QuestionDifficulty.Unspecified}
checked={getDifficulty(QuestionDifficulty.Unspecified)}
onChange={handleDifficultyChange(QuestionDifficulty.Unspecified)}
translation={difficultyTranslation}
/>
</div>
</div>
</div>
);
}

function TagCheckbox<T extends string>({
tag,
checked,
onChange,
translation,
}: {
tag: T;
checked: boolean;
onChange: (checked: boolean) => void;
translation: Record<T, string>;
}) {
return (
<div className="flex items-center gap-2">
<Checkbox id={tag} checked={checked} onCheckedChange={onChange} />
<Label htmlFor={tag}>{translation[tag]}</Label>
</div>
);
}
103 changes: 103 additions & 0 deletions app/(app)/challenges/_header/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { useSuspenseQuery } from "@apollo/client/react";
import { GridProgress } from "@/components/ui/grid-progress";
import { graphql } from "@/gql";

const CHALLENGE_STATISTICS_QUERY = graphql(`
query ChallengeStatisticsQuery {
me {
submissionStatistics {
totalQuestions
solvedQuestions
attemptedQuestions
}
}
}
`);

export default function Header() {
const { data } = useSuspenseQuery(CHALLENGE_STATISTICS_QUERY);

const totalQuestions = data.me.submissionStatistics.totalQuestions;
const totalSolvedQuestions = data.me.submissionStatistics.solvedQuestions;
const totalAttemptedQuestions =
data.me.submissionStatistics.attemptedQuestions;

return (
<header className="flex items-center justify-between pb-6">
<div className="space-y-1 tracking-wide">
<h1 className="text-xl font-bold">
<HeaderTitle
totalQuestions={totalQuestions}
totalSolvedQuestions={totalSolvedQuestions}
/>
</h1>
<p>
<HeaderDescription
totalSolvedQuestions={totalSolvedQuestions}
totalAttemptedQuestions={totalAttemptedQuestions}
/>
</p>
</div>

<div className="hidden md:block">
<GridProgress
variant="primary"
cols={10}
rows={4}
progress={(totalSolvedQuestions / totalQuestions) * 100}
/>
</div>
</header>
);
}

export function HeaderTitle({
totalQuestions,
totalSolvedQuestions,
}: {
totalQuestions: number;
totalSolvedQuestions: number;
}) {
const remainingQuestions = totalQuestions - totalSolvedQuestions;

if (remainingQuestions === 0) {
return <>你成功挑戰了所有題目!</>;
}

if (remainingQuestions === 1) {
return <>剩下最後一題就能全數通關!</>;
}

return <>繼續挑戰接下來的 {remainingQuestions} 題題目吧!</>;
}

export function HeaderDescription({
totalSolvedQuestions,
totalAttemptedQuestions,
}: {
totalSolvedQuestions: number;
totalAttemptedQuestions: number;
}) {
if (totalAttemptedQuestions > 0 && totalSolvedQuestions === totalAttemptedQuestions) {
return <>你現在百戰百勝,繼續加油!</>;
}

if (totalSolvedQuestions > 0) {
return (
<>
你目前已經嘗試作答了 {totalAttemptedQuestions} 題,其中攻克了{" "}
{totalSolvedQuestions} 題!
</>
);
}

if (totalAttemptedQuestions > 0) {
return (
<>你目前已經嘗試作答了 {totalAttemptedQuestions} 題,祝你成功攻克題目!</>
);
}

return <>你尚未嘗試作答任何題目,快點試試看你有興趣的題目吧!</>;
}
14 changes: 14 additions & 0 deletions app/(app)/challenges/_header/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";

export default function HeaderSkeleton() {
return (
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-86" />
</div>

<Skeleton className="hidden md:block h-16 w-40" />
</div>
);
}
82 changes: 82 additions & 0 deletions app/(app)/challenges/_question/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Badge } from "@/components/ui/badge";
import { QuestionDifficulty } from "@/gql/graphql";
import { SwordIcon } from "lucide-react";
import Link from "next/link";
import {
difficultyTranslation,
solvedStatusTranslation,
type SolvedStatus,
} from "../model";
import { graphql, readFragment, type FragmentType } from "@/gql";
import { getQuestionSolvedStatus } from "./solved-status";

const QUESTION_CARD_FRAGMENT = graphql(`
fragment QuestionCard on Question {
id
title
description
difficulty
category

...QuestionSolvedStatus
}
`);

const solvedStatusColor: Record<SolvedStatus, string> = {
solved: "bg-green-800",
unsolved: "bg-yellow-800",
"not-tried": "bg-gray-800",
};

const badgeColor: Record<QuestionDifficulty, string> = {
[QuestionDifficulty.Easy]: "bg-green-800",
[QuestionDifficulty.Medium]: "bg-yellow-800",
[QuestionDifficulty.Hard]: "bg-red-800",
[QuestionDifficulty.Unspecified]: "bg-gray-800",
};

export default function QuestionCard({
fragment,
}: {
fragment: FragmentType<typeof QUESTION_CARD_FRAGMENT>;
}) {
const question = readFragment(QUESTION_CARD_FRAGMENT, fragment);
const descriptionFirstLine = question.description.split("\n")[0];
const solvedStatus = getQuestionSolvedStatus(question);

return (
<div className="flex rounded overflow-hidden">
{/* Question Body */}
<div className="space-y-3 bg-white p-4 flex-1">
<div>
<h2 className="font-bold tracking-wider">{question.title}</h2>
<p className="tracking-wide">{descriptionFirstLine}</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge className={solvedStatusColor[solvedStatus]}>
{solvedStatusTranslation[solvedStatus]}
</Badge>
<Badge className={badgeColor[question.difficulty]}>
{difficultyTranslation[question.difficulty]}
</Badge>
<Badge>{question.category}</Badge>
</div>
</div>

{/* Operation Button */}
<OperationButton href={`/challenges/${question.id}`} />
</div>
);
}

function OperationButton({ href }: { href: string }) {
return (
<Link
href={href}
className="bg-gray-100 hover:bg-primary hover:text-white transition-all duration-300 p-2 flex flex-col justify-center items-center gap-2.5"
>
<SwordIcon className="size-4" />
練習
</Link>
);
}
Loading
Loading