Skip to content

Commit a11ef50

Browse files
authored
Menu side bar for Challenges (#352)
1 parent 54e5acf commit a11ef50

File tree

5 files changed

+250
-82
lines changed

5 files changed

+250
-82
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { XMarkIcon } from "@heroicons/react/24/outline";
5+
import { useGlobalState } from "~~/services/store/store";
6+
import type { Heading } from "~~/utils/challenges";
7+
8+
type ChallengeSidebarProps = {
9+
headings: Heading[];
10+
};
11+
12+
export function ChallengeSidebar({ headings }: ChallengeSidebarProps) {
13+
const [activeId, setActiveId] = useState<string>("");
14+
const { sidebarIsOpen, setSidebarIsOpen } = useGlobalState();
15+
16+
useEffect(() => {
17+
const observer = new IntersectionObserver(
18+
entries => {
19+
const visibleEntries = entries.filter(entry => entry.isIntersecting);
20+
if (visibleEntries.length > 0) {
21+
const topEntry = visibleEntries.reduce((prev, curr) => {
22+
return prev.boundingClientRect.top < curr.boundingClientRect.top ? prev : curr;
23+
});
24+
setActiveId(topEntry.target.id);
25+
}
26+
},
27+
{
28+
rootMargin: "-80px 0px -70% 0px",
29+
threshold: 0,
30+
},
31+
);
32+
33+
headings.forEach(heading => {
34+
const element = document.getElementById(heading.id);
35+
if (element) {
36+
observer.observe(element);
37+
}
38+
});
39+
40+
return () => {
41+
observer.disconnect();
42+
};
43+
}, [headings]);
44+
45+
const handleClick = (id: string) => {
46+
const element = document.getElementById(id);
47+
if (element) {
48+
element.scrollIntoView({ behavior: "smooth" });
49+
setActiveId(id);
50+
setSidebarIsOpen(false);
51+
}
52+
};
53+
54+
if (headings.length === 0) {
55+
return null;
56+
}
57+
58+
return (
59+
<>
60+
{sidebarIsOpen && (
61+
<div className="lg:hidden fixed inset-0 bg-black/50 z-40" onClick={() => setSidebarIsOpen(false)} />
62+
)}
63+
64+
<nav
65+
className={`
66+
fixed left-0 top-0 h-full w-72 pt-4 z-40
67+
bg-base-100 border-r border-base-300 overflow-y-auto
68+
transition-transform duration-300 ease-in-out
69+
lg:sticky lg:top-0 lg:h-screen lg:w-64 lg:shrink-0 lg:translate-x-0 lg:border-r-0 lg:bg-transparent
70+
${sidebarIsOpen ? "translate-x-0" : "-translate-x-full"}
71+
`}
72+
>
73+
<button
74+
onClick={() => setSidebarIsOpen(false)}
75+
className="lg:hidden absolute top-4 right-4 btn btn-circle btn-sm btn-ghost"
76+
aria-label="Close navigation menu"
77+
>
78+
<XMarkIcon className="w-5 h-5" />
79+
</button>
80+
81+
<div className="p-4">
82+
<h3 className="font-semibold text-sm uppercase tracking-wider text-base-content/60 mb-4">On this page</h3>
83+
<ul className="space-y-1">
84+
{headings.map(heading => (
85+
<li key={heading.id}>
86+
<button
87+
onClick={() => handleClick(heading.id)}
88+
className={`
89+
block w-full text-left px-3 py-2 text-sm rounded-lg transition-colors border-l-2
90+
hover:bg-primary/20 hover:text-primary
91+
${
92+
activeId === heading.id
93+
? "bg-primary/10 text-primary border-primary"
94+
: "text-base-content/70 border-transparent"
95+
}
96+
`}
97+
>
98+
{heading.text}
99+
</button>
100+
</li>
101+
))}
102+
</ul>
103+
</div>
104+
</nav>
105+
</>
106+
);
107+
}
Lines changed: 100 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createElement } from "react";
2-
import type { ComponentPropsWithoutRef } from "react";
2+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
33
import { notFound } from "next/navigation";
44
import { ChallengeHeader } from "./_components/ChallengeHeader";
5+
import { ChallengeSidebar } from "./_components/ChallengeSidebar";
56
import { ConnectAndRegisterBanner } from "./_components/ConnectAndRegisterBanner";
67
import { SubmitChallengeButton } from "./_components/SubmitChallengeButton";
78
import { MDXRemote } from "next-mdx-remote/rsc";
@@ -15,7 +16,7 @@ import {
1516
getCountOfCompletedChallenge,
1617
} from "~~/services/database/repositories/challenges";
1718
import { fetchGithubChallengeReadme, parseGithubUrl, splitChallengeReadme } from "~~/services/github";
18-
import { CHALLENGE_METADATA } from "~~/utils/challenges";
19+
import { CHALLENGE_METADATA, extractHeadings, generateHeadingId } from "~~/utils/challenges";
1920
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
2021

2122
export async function generateStaticParams() {
@@ -58,90 +59,107 @@ export default async function ChallengePage(props: { params: Promise<{ challenge
5859
const { headerImageMdx, restMdx } = splitChallengeReadme(challengeReadme);
5960
const { owner, repo, branch } = parseGithubUrl(challenge.github);
6061

62+
// Extract headings for the sidebar navigation
63+
const headings = extractHeadings(restMdx);
64+
65+
// Custom h2 component that adds IDs for anchor navigation
66+
const createH2WithId = ({ children, ...props }: { children?: ReactNode }) => {
67+
const text = String(children);
68+
const id = generateHeadingId(text);
69+
return createElement("h2", { ...props, id, style: { scrollMarginTop: "80px" } }, children);
70+
};
71+
6172
return (
62-
<div className="flex flex-col items-center py-8 px-5 xl:p-12 relative max-w-[100vw]">
63-
{challengeReadme ? (
64-
<>
65-
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
66-
<MDXRemote
67-
source={headerImageMdx}
68-
options={{
69-
mdxOptions: {
70-
rehypePlugins: [rehypeRaw],
71-
remarkPlugins: [remarkGfm],
72-
format: "md",
73-
},
74-
}}
75-
/>
76-
</div>
77-
<ChallengeHeader
78-
skills={staticMetadata?.skills}
79-
skillLevel={staticMetadata?.skillLevel}
80-
timeToComplete={staticMetadata?.timeToComplete}
81-
helpfulLinks={staticMetadata?.helpfulLinks}
82-
completedByCount={countOfCompletedChallenge}
83-
/>
84-
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
85-
<MDXRemote
86-
source={restMdx}
87-
components={{
88-
a: (props: ComponentPropsWithoutRef<"a">) =>
89-
createElement("a", { ...props, target: "_blank", rel: "noopener" }),
90-
}}
91-
options={{
92-
mdxOptions: {
93-
rehypePlugins: [rehypeRaw],
94-
remarkPlugins: [remarkGfm],
95-
format: "md",
96-
},
97-
}}
98-
/>
99-
</div>
73+
<div className="flex relative max-w-[100vw]">
74+
{/* Sidebar Navigation */}
75+
<ChallengeSidebar headings={headings} />
10076

101-
<a
102-
href={`https://github.com/${owner}/${repo}/tree/${branch}`}
103-
className="block mt-2"
104-
target="_blank"
105-
rel="noopener noreferrer"
106-
>
107-
<button className="btn btn-outline btn-sm sm:btn-md">
108-
<span className="text-xs sm:text-sm">View on GitHub</span>
109-
<ArrowTopRightOnSquareIcon className="w-3 h-3 sm:w-4 sm:h-4" />
110-
</button>
111-
</a>
112-
{guides && guides.length > 0 && (
113-
<div className="max-w-[850px] w-full mx-auto">
114-
<div className="mt-16 mb-4 font-semibold text-left">Related guides</div>
115-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2 mb-2">
116-
{guides.map(guide => (
117-
<div key={guide.url} className="p-4 border rounded bg-base-300">
118-
<a href={guide.url} className="text-primary underline font-semibold">
119-
{guide.title}
120-
</a>
121-
</div>
122-
))}
123-
</div>
77+
{/* Main Content */}
78+
<div className="flex-1 flex flex-col items-center py-8 px-5 xl:p-12">
79+
{challengeReadme ? (
80+
<>
81+
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
82+
<MDXRemote
83+
source={headerImageMdx}
84+
options={{
85+
mdxOptions: {
86+
rehypePlugins: [rehypeRaw],
87+
remarkPlugins: [remarkGfm],
88+
format: "md",
89+
},
90+
}}
91+
/>
92+
</div>
93+
<ChallengeHeader
94+
skills={staticMetadata?.skills}
95+
skillLevel={staticMetadata?.skillLevel}
96+
timeToComplete={staticMetadata?.timeToComplete}
97+
helpfulLinks={staticMetadata?.helpfulLinks}
98+
completedByCount={countOfCompletedChallenge}
99+
/>
100+
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
101+
<MDXRemote
102+
source={restMdx}
103+
components={{
104+
a: (props: ComponentPropsWithoutRef<"a">) =>
105+
createElement("a", { ...props, target: "_blank", rel: "noopener" }),
106+
h2: createH2WithId,
107+
}}
108+
options={{
109+
mdxOptions: {
110+
rehypePlugins: [rehypeRaw],
111+
remarkPlugins: [remarkGfm],
112+
format: "md",
113+
},
114+
}}
115+
/>
124116
</div>
125-
)}
126-
</>
127-
) : (
128-
<div>Failed to load challenge content</div>
129-
)}
130-
{challenge.autograding && (
131-
<>
132-
<ConnectAndRegisterBanner />
133-
<SubmitChallengeButton challengeId={challenge.id} />
134-
</>
135-
)}
136-
{challenge.externalLink && (
137-
<div className="fixed bottom-8 inset-x-0 mx-auto w-fit">
138-
<button className="btn btn-sm sm:btn-md btn-primary text-secondary px-3 sm:px-4 mt-2 text-xs sm:text-sm">
139-
<a href={challenge.externalLink.link} target="_blank" rel="noopener noreferrer">
140-
{challenge.externalLink.claim}
117+
118+
<a
119+
href={`https://github.com/${owner}/${repo}/tree/${branch}`}
120+
className="block mt-2"
121+
target="_blank"
122+
rel="noopener noreferrer"
123+
>
124+
<button className="btn btn-outline btn-sm sm:btn-md">
125+
<span className="text-xs sm:text-sm">View on GitHub</span>
126+
<ArrowTopRightOnSquareIcon className="w-3 h-3 sm:w-4 sm:h-4" />
127+
</button>
141128
</a>
142-
</button>
143-
</div>
144-
)}
129+
{guides && guides.length > 0 && (
130+
<div className="max-w-[850px] w-full mx-auto">
131+
<div className="mt-16 mb-4 font-semibold text-left">Related guides</div>
132+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2 mb-2">
133+
{guides.map(guide => (
134+
<div key={guide.url} className="p-4 border rounded bg-base-300">
135+
<a href={guide.url} className="text-primary underline font-semibold">
136+
{guide.title}
137+
</a>
138+
</div>
139+
))}
140+
</div>
141+
</div>
142+
)}
143+
</>
144+
) : (
145+
<div>Failed to load challenge content</div>
146+
)}
147+
{challenge.autograding && (
148+
<>
149+
<ConnectAndRegisterBanner />
150+
<SubmitChallengeButton challengeId={challenge.id} />
151+
</>
152+
)}
153+
{challenge.externalLink && (
154+
<div className="fixed bottom-8 inset-x-0 mx-auto w-fit">
155+
<button className="btn btn-sm sm:btn-md btn-primary text-secondary px-3 sm:px-4 mt-2 text-xs sm:text-sm">
156+
<a href={challenge.externalLink.link} target="_blank" rel="noopener noreferrer">
157+
{challenge.externalLink.claim}
158+
</a>
159+
</button>
160+
</div>
161+
)}
162+
</div>
145163
</div>
146164
);
147165
}

packages/nextjs/components/Header.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { usePathname } from "next/navigation";
66
import Logo from "./Logo";
77
import clsx from "clsx";
88
import { useAccount } from "wagmi";
9+
import { Bars3Icon } from "@heroicons/react/24/outline";
910
import { RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
1011
import { useUser } from "~~/hooks/useUser";
1112
import { UserRole } from "~~/services/database/config/types";
1213
import { UserByAddress } from "~~/services/database/repositories/users";
14+
import { useGlobalState } from "~~/services/store/store";
1315

1416
type HeaderMenuLink = {
1517
label: string;
@@ -92,9 +94,11 @@ export const Header = () => {
9294
const pathname = usePathname();
9395
const isHomepage = pathname === "/";
9496
const isStartPage = pathname === "/start";
97+
const isChallengePage = pathname.startsWith("/challenge/");
9598

9699
const { address: connectedAddress } = useAccount();
97100
const { data: user } = useUser(connectedAddress);
101+
const { sidebarIsOpen, setSidebarIsOpen } = useGlobalState();
98102

99103
return (
100104
<div
@@ -110,6 +114,15 @@ export const Header = () => {
110114
<Logo className="w-36 lg:w-48" />
111115
</Link>
112116
)}
117+
{isChallengePage && !sidebarIsOpen && (
118+
<button
119+
onClick={() => setSidebarIsOpen(true)}
120+
className="lg:hidden ml-4 btn btn-circle btn-sm btn-primary shadow-lg"
121+
aria-label="Toggle navigation menu"
122+
>
123+
<Bars3Icon className="w-5 h-5" />
124+
</button>
125+
)}
113126
<ul className="hidden lg:flex flex-nowrap px-1 gap-2">
114127
<HeaderMenuLinks hideItemsByLabel={["Home"]} user={user} />
115128
</ul>

packages/nextjs/services/store/store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type GlobalState = {
2020
setIsNativeCurrencyFetching: (newIsNativeCurrencyFetching: boolean) => void;
2121
targetNetwork: ChainWithAttributes;
2222
setTargetNetwork: (newTargetNetwork: ChainWithAttributes) => void;
23+
sidebarIsOpen: boolean;
24+
setSidebarIsOpen: (open: boolean) => void;
2325
};
2426

2527
export const useGlobalState = create<GlobalState>(set => ({
@@ -33,4 +35,6 @@ export const useGlobalState = create<GlobalState>(set => ({
3335
set(state => ({ nativeCurrency: { ...state.nativeCurrency, isFetching: newValue } })),
3436
targetNetwork: scaffoldConfig.targetNetworks[0],
3537
setTargetNetwork: (newTargetNetwork: ChainWithAttributes) => set(() => ({ targetNetwork: newTargetNetwork })),
38+
sidebarIsOpen: false,
39+
setSidebarIsOpen: (open: boolean) => set({ sidebarIsOpen: open }),
3640
}));

0 commit comments

Comments
 (0)