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
3 changes: 2 additions & 1 deletion app/(app)/challenges/_filter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export default function FilterSection({
return (
<aside
className={`
flex min-h-[50dvh] w-[25%] flex-col gap-8 rounded bg-gray-100 p-6
flex flex-col gap-8 rounded bg-gray-100 p-6
lg:min-h-[50dvh] lg:w-[25%]
`}
>
<SearchFilterSection value={search} onChange={setSearch} />
Expand Down
7 changes: 6 additions & 1 deletion app/(app)/challenges/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ export default function ChallengePageContent() {
};

return (
<div className="flex w-full gap-4">
<div
className={`
flex w-full flex-col-reverse gap-4
lg:flex-row
`}
>
<FilterSection
search={search}
setSearch={setSearch}
Expand Down
33 changes: 23 additions & 10 deletions app/(app)/statistics/board/completed-questions.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
"use client";

import { graphql } from "@/gql";
import { useSuspenseQuery } from "@apollo/client/react";
import { useIsMobile } from "@/hooks/use-mobile";
import { skipToken, useSuspenseQuery } from "@apollo/client/react";

const COMPLETED_QUESTIONS = graphql(`
query CompletedQuestions {
me {
submissionStatistics {
totalQuestions
solvedQuestions
}
}
query CompletedQuestions {
me {
submissionStatistics {
totalQuestions
solvedQuestions
}
}
}
`);

export default function CompletedQuestionsPercentage() {
const { data } = useSuspenseQuery(COMPLETED_QUESTIONS);
const isMobile = useIsMobile();

const { data } = useSuspenseQuery(
COMPLETED_QUESTIONS,
isMobile ? skipToken : undefined,
);

if (!data) return null;

const totalQuestions = data.me.submissionStatistics.totalQuestions;
const solvedQuestions = data.me.submissionStatistics.solvedQuestions;
const completedPercentage = (solvedQuestions / totalQuestions) * 100;

return <>{solvedQuestions}/{totalQuestions} ({completedPercentage.toFixed(2)}%)</>;
return (
<>
{solvedQuestions}/{totalQuestions} ({completedPercentage.toFixed(2)}%)
</>
);
}
9 changes: 7 additions & 2 deletions app/(app)/statistics/board/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function Board() {
return (
<section
className={`
relative mb-6 flex h-42 w-full items-end justify-between rounded
relative mb-6 flex min-h-42 w-full items-end justify-between rounded
bg-primary/20 px-6 py-4
`}
>
Expand All @@ -21,7 +21,12 @@ export default function Board() {
</div>

<Suspense>
<div className="flex flex-col items-end leading-none">
<div
className={`
hidden flex-col items-end leading-none
lg:flex
`}
>
<p className="text-sm text-muted-foreground">完成題數</p>
<p className="text-xl font-bold">
<CompletedQuestionsPercentage />
Expand Down
137 changes: 102 additions & 35 deletions components/app-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import useUser from "@/hooks/use-user";
import { cn } from "@/lib/utils";
import { BarChart3, BookOpen, ChevronDown, MessageSquare, Swords } from "lucide-react";
import { BarChart3, BookOpen, ChevronDown, Menu, MessageSquare, Swords, X } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { Suspense, useState } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";

interface NavItemProps {
Expand All @@ -19,7 +20,11 @@ function NavItem({ icon, label, active = false }: NavItemProps) {
<Button
variant="ghost"
className={cn(
"flex h-auto items-center gap-3 rounded px-3 py-2 text-sm",
`
flex h-auto w-full items-center justify-start gap-3 rounded px-3 py-2
text-sm
md:w-auto md:justify-center
`,
active && "bg-primary text-primary-foreground",
)}
>
Expand Down Expand Up @@ -56,45 +61,107 @@ function UserMenu() {

export default function AppNavbar({ path }: { path: string }) {
const navItemLabel = getActiveNavItemLabel(path);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);

return (
<nav className="border-b border-stone-200 bg-stone-50">
<div className="flex items-center justify-between px-6 py-0">
{/* Left Section */}
<div className="flex items-center gap-4">
{/* Logo and Title */}
<Link href="/">
<div className="flex items-center gap-3 px-3 py-4">
<Logo className="h-4 w-4" />
<span className="whitespace-nowrap text-stone-900">
資料庫練功房
</span>
<Collapsible open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
<nav className="border-b border-stone-200 bg-stone-50">
<div className="flex items-center justify-between px-6 py-0">
{/* Left Section */}
<div className="flex items-center gap-4">
{/* Logo and Title */}
<Link href="/">
<div className="flex items-center gap-3 px-3 py-4">
<Logo className="h-4 w-4" />
<span className="whitespace-nowrap text-stone-900">
資料庫練功房
</span>
</div>
</Link>

{/* Desktop Navigation Items - Hidden on mobile */}
<div
className={`
hidden items-center gap-1
md:flex
`}
>
{navItems.map((item) => (
<Link href={item.pathPrefix} key={item.label}>
<NavItem
key={item.label}
icon={item.icon}
label={item.label}
active={navItemLabel === item.label}
/>
</Link>
))}
</div>
</Link>
</div>

{/* Navigation Items */}
<div className="flex items-center gap-1">
{navItems.map((item) => (
<Link href={item.pathPrefix} key={item.label}>
<NavItem
key={item.label}
icon={item.icon}
label={item.label}
active={navItemLabel === item.label}
/>
</Link>
))}
{/* Right Section */}
<div className="flex items-center gap-2">
{/* Desktop User Menu - Hidden on mobile */}
<div
className={`
hidden
md:block
`}
>
<Suspense>
<UserMenu />
</Suspense>
</div>

{/* Mobile Menu Button - Only visible on mobile */}
<CollapsibleTrigger asChild className="md:hidden">
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
aria-label="Toggle mobile menu"
>
{isMobileMenuOpen ? <X className="h-5 w-5" /> : (
<Menu
className={`h-5 w-5`}
/>
)}
</Button>
</CollapsibleTrigger>
</div>
</div>

{/* Right Section - User Menu */}
<div className="flex items-center">
<Suspense>
<UserMenu />
</Suspense>
</div>
</div>
</nav>
{/* Mobile Navigation Menu */}
<CollapsibleContent className="md:hidden">
<div className="border-t border-stone-200 bg-stone-50">
<div className="flex flex-col space-y-1 px-6 py-4">
{/* Mobile Navigation Items */}
{navItems.map((item) => (
<Link
href={item.pathPrefix}
key={item.label}
onClick={() => setIsMobileMenuOpen(false)}
>
<NavItem
key={item.label}
icon={item.icon}
label={item.label}
active={navItemLabel === item.label}
/>
</Link>
))}

{/* Mobile User Menu */}
<div className="mt-4 border-t border-stone-200 pt-4">
<Suspense>
<UserMenu />
</Suspense>
</div>
</div>
</div>
</CollapsibleContent>
</nav>
</Collapsible>
);
}

Expand Down
12 changes: 9 additions & 3 deletions gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@ type Documents = {
"\n fragment QuestionCard on Question {\n id\n title\n description\n difficulty\n category\n\n ...QuestionSolvedStatus\n }\n": typeof types.QuestionCardFragmentDoc,
"\n fragment QuestionSolvedStatus on Question {\n solved\n attempted\n }\n": typeof types.QuestionSolvedStatusFragmentDoc,
"\n query ListQuestions($where: QuestionWhereInput, $after: Cursor) {\n questions(where: $where, first: 10, after: $after) {\n edges {\n node {\n id\n ...QuestionCard\n ...QuestionSolvedStatus\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.ListQuestionsDocument,
"\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n": typeof types.CompletedQuestionsDocument,
"\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n": typeof types.CompletedQuestionsDocument,
"\n query Points {\n me {\n totalPoints\n\n points(first: 5) {\n edges {\n node {\n id\n ...PointFragment\n }\n }\n }\n }\n }\n": typeof types.PointsDocument,
"\n fragment PointFragment on Point {\n description\n points\n }\n": typeof types.PointFragmentFragmentDoc,
"\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n": typeof types.CompletedQuestionsDocument,
"\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,
};
const documents: Documents = {
"\n query ChallengeStatisticsQuery {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n attemptedQuestions\n }\n }\n }\n": types.ChallengeStatisticsQueryDocument,
"\n fragment QuestionCard on Question {\n id\n title\n description\n difficulty\n category\n\n ...QuestionSolvedStatus\n }\n": types.QuestionCardFragmentDoc,
"\n fragment QuestionSolvedStatus on Question {\n solved\n attempted\n }\n": types.QuestionSolvedStatusFragmentDoc,
"\n query ListQuestions($where: QuestionWhereInput, $after: Cursor) {\n questions(where: $where, first: 10, after: $after) {\n edges {\n node {\n id\n ...QuestionCard\n ...QuestionSolvedStatus\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.ListQuestionsDocument,
"\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n": types.CompletedQuestionsDocument,
"\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n": types.CompletedQuestionsDocument,
"\n query Points {\n me {\n totalPoints\n\n points(first: 5) {\n edges {\n node {\n id\n ...PointFragment\n }\n }\n }\n }\n }\n": types.PointsDocument,
"\n fragment PointFragment on Point {\n description\n points\n }\n": types.PointFragmentFragmentDoc,
"\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n": types.CompletedQuestionsDocument,
"\n query BasicUserInfo {\n me {\n id\n name\n email\n avatar\n\n group {\n name\n }\n }\n }\n": types.BasicUserInfoDocument,
};

Expand Down Expand Up @@ -67,7 +69,7 @@ export function graphql(source: "\n query ListQuestions($where: QuestionWhereIn
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n"): (typeof documents)["\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n"];
export function graphql(source: "\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n"): (typeof documents)["\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand All @@ -76,6 +78,10 @@ export function graphql(source: "\n query Points {\n me {\n
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment PointFragment on Point {\n description\n points\n }\n"): (typeof documents)["\n fragment PointFragment on Point {\n description\n points\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n"): (typeof documents)["\n query CompletedQuestions {\n me {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down