Skip to content
Open
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,126 @@
"use client";

import { useEffect, useState } from "react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { useSidebar } from "~~/contexts/SidebarContext";

export type Heading = {
id: string;
text: string;
};

type ChallengeSidebarProps = {
headings: Heading[];
};

export function ChallengeSidebar({ headings }: ChallengeSidebarProps) {
const [activeId, setActiveId] = useState<string>("");
const sidebar = useSidebar();
const isOpen = sidebar?.isOpen ?? false;

const setIsOpen = (open: boolean) => {
sidebar?.setIsOpen(open);
};

// Register sidebar on mount, unregister on unmount
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove all of these comments from the PR. At least the ones that don't contribute anything to the understanding of the code.

useEffect(() => {
sidebar?.setHasSidebar(true);
return () => {
sidebar?.setHasSidebar(false);
};
}, [sidebar]);

useEffect(() => {
const observer = new IntersectionObserver(
entries => {
const visibleEntries = entries.filter(entry => entry.isIntersecting);
if (visibleEntries.length > 0) {
// Sort by their position in the document and take the topmost one
const topEntry = visibleEntries.reduce((prev, curr) => {
return prev.boundingClientRect.top < curr.boundingClientRect.top ? prev : curr;
});
setActiveId(topEntry.target.id);
}
},
{
rootMargin: "-80px 0px -70% 0px",
threshold: 0,
},
);

headings.forEach(heading => {
const element = document.getElementById(heading.id);
if (element) {
observer.observe(element);
}
});

return () => {
observer.disconnect();
};
}, [headings]);

const handleClick = (id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
setActiveId(id);
setIsOpen(false);
}
};

if (headings.length === 0) {
return null;
}

return (
<>
{/* Mobile overlay */}
{isOpen && <div className="lg:hidden fixed inset-0 bg-black/50 z-40" onClick={() => setIsOpen(false)} />}

{/* Sidebar */}
<nav
className={`
fixed left-0 top-0 h-full w-72 pt-4 z-40
bg-base-100 border-r border-base-300 overflow-y-auto
transition-transform duration-300 ease-in-out
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
${isOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
{/* Close button for mobile */}
<button
onClick={() => setIsOpen(false)}
className="lg:hidden absolute top-4 right-4 btn btn-circle btn-sm btn-ghost"
aria-label="Close navigation menu"
>
<XMarkIcon className="w-5 h-5" />
</button>

<div className="p-4">
<h3 className="font-semibold text-sm uppercase tracking-wider text-base-content/60 mb-4">On this page</h3>
<ul className="space-y-1">
{headings.map(heading => (
<li key={heading.id}>
<button
onClick={() => handleClick(heading.id)}
className={`
block w-full text-left px-3 py-2 text-sm rounded-lg transition-colors border-l-2
hover:bg-primary/20 hover:text-primary
${
activeId === heading.id
? "bg-primary/10 text-primary border-primary"
: "text-base-content/70 border-transparent"
}
`}
>
{heading.text}
</button>
</li>
))}
</ul>
</div>
</nav>
</>
);
}
201 changes: 120 additions & 81 deletions packages/nextjs/app/challenge/[challengeId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createElement } from "react";
import type { ComponentPropsWithoutRef } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { notFound } from "next/navigation";
import { ChallengeHeader } from "./_components/ChallengeHeader";
import { ChallengeSidebar, Heading } from "./_components/ChallengeSidebar";
import { ConnectAndRegisterBanner } from "./_components/ConnectAndRegisterBanner";
import { SubmitChallengeButton } from "./_components/SubmitChallengeButton";
import { MDXRemote } from "next-mdx-remote/rsc";
Expand All @@ -18,6 +19,27 @@ import { fetchGithubChallengeReadme, parseGithubUrl, splitChallengeReadme } from
import { CHALLENGE_METADATA } from "~~/utils/challenges";
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";

function generateHeadingId(text: string): string {
return text
.toLowerCase()
.replace(/[\u{1F300}-\u{1F9FF}]/gu, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}

function extractHeadings(markdown: string): Heading[] {
const h2Regex = /^##\s+(.+)$/gm;
const headings: Heading[] = [];
let match;
while ((match = h2Regex.exec(markdown)) !== null) {
const text = match[1];
const id = generateHeadingId(text);
headings.push({ id, text });
}
return headings;
}

Comment on lines +22 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can go into utils/

export async function generateStaticParams() {
const challenges = await getAllChallenges();

Expand Down Expand Up @@ -58,90 +80,107 @@ export default async function ChallengePage(props: { params: Promise<{ challenge
const { headerImageMdx, restMdx } = splitChallengeReadme(challengeReadme);
const { owner, repo, branch } = parseGithubUrl(challenge.github);

// Extract headings for the sidebar navigation
const headings = extractHeadings(restMdx);

// Custom h2 component that adds IDs for anchor navigation
const createH2WithId = ({ children, ...props }: { children?: ReactNode }) => {
const text = String(children);
const id = generateHeadingId(text);
return createElement("h2", { ...props, id, style: { scrollMarginTop: "80px" } }, children);
};

return (
<div className="flex flex-col items-center py-8 px-5 xl:p-12 relative max-w-[100vw]">
{challengeReadme ? (
<>
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
<MDXRemote
source={headerImageMdx}
options={{
mdxOptions: {
rehypePlugins: [rehypeRaw],
remarkPlugins: [remarkGfm],
format: "md",
},
}}
/>
</div>
<ChallengeHeader
skills={staticMetadata?.skills}
skillLevel={staticMetadata?.skillLevel}
timeToComplete={staticMetadata?.timeToComplete}
helpfulLinks={staticMetadata?.helpfulLinks}
completedByCount={countOfCompletedChallenge}
/>
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
<MDXRemote
source={restMdx}
components={{
a: (props: ComponentPropsWithoutRef<"a">) =>
createElement("a", { ...props, target: "_blank", rel: "noopener" }),
}}
options={{
mdxOptions: {
rehypePlugins: [rehypeRaw],
remarkPlugins: [remarkGfm],
format: "md",
},
}}
/>
</div>
<div className="flex relative max-w-[100vw]">
{/* Sidebar Navigation */}
<ChallengeSidebar headings={headings} />

<a
href={`https://github.com/${owner}/${repo}/tree/${branch}`}
className="block mt-2"
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-outline btn-sm sm:btn-md">
<span className="text-xs sm:text-sm">View on GitHub</span>
<ArrowTopRightOnSquareIcon className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
</a>
{guides && guides.length > 0 && (
<div className="max-w-[850px] w-full mx-auto">
<div className="mt-16 mb-4 font-semibold text-left">Related guides</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2 mb-2">
{guides.map(guide => (
<div key={guide.url} className="p-4 border rounded bg-base-300">
<a href={guide.url} className="text-primary underline font-semibold">
{guide.title}
</a>
</div>
))}
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col items-center py-8 px-5 xl:p-12">
{challengeReadme ? (
<>
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
<MDXRemote
source={headerImageMdx}
options={{
mdxOptions: {
rehypePlugins: [rehypeRaw],
remarkPlugins: [remarkGfm],
format: "md",
},
}}
/>
</div>
<ChallengeHeader
skills={staticMetadata?.skills}
skillLevel={staticMetadata?.skillLevel}
timeToComplete={staticMetadata?.timeToComplete}
helpfulLinks={staticMetadata?.helpfulLinks}
completedByCount={countOfCompletedChallenge}
/>
<div className="prose dark:prose-invert max-w-fit break-words lg:max-w-[850px]">
<MDXRemote
source={restMdx}
components={{
a: (props: ComponentPropsWithoutRef<"a">) =>
createElement("a", { ...props, target: "_blank", rel: "noopener" }),
h2: createH2WithId,
}}
options={{
mdxOptions: {
rehypePlugins: [rehypeRaw],
remarkPlugins: [remarkGfm],
format: "md",
},
}}
/>
</div>
)}
</>
) : (
<div>Failed to load challenge content</div>
)}
{challenge.autograding && (
<>
<ConnectAndRegisterBanner />
<SubmitChallengeButton challengeId={challenge.id} />
</>
)}
{challenge.externalLink && (
<div className="fixed bottom-8 inset-x-0 mx-auto w-fit">
<button className="btn btn-sm sm:btn-md btn-primary text-secondary px-3 sm:px-4 mt-2 text-xs sm:text-sm">
<a href={challenge.externalLink.link} target="_blank" rel="noopener noreferrer">
{challenge.externalLink.claim}

<a
href={`https://github.com/${owner}/${repo}/tree/${branch}`}
className="block mt-2"
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-outline btn-sm sm:btn-md">
<span className="text-xs sm:text-sm">View on GitHub</span>
<ArrowTopRightOnSquareIcon className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
</a>
</button>
</div>
)}
{guides && guides.length > 0 && (
<div className="max-w-[850px] w-full mx-auto">
<div className="mt-16 mb-4 font-semibold text-left">Related guides</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2 mb-2">
{guides.map(guide => (
<div key={guide.url} className="p-4 border rounded bg-base-300">
<a href={guide.url} className="text-primary underline font-semibold">
{guide.title}
</a>
</div>
))}
</div>
</div>
)}
</>
) : (
<div>Failed to load challenge content</div>
)}
{challenge.autograding && (
<>
<ConnectAndRegisterBanner />
<SubmitChallengeButton challengeId={challenge.id} />
</>
)}
{challenge.externalLink && (
<div className="fixed bottom-8 inset-x-0 mx-auto w-fit">
<button className="btn btn-sm sm:btn-md btn-primary text-secondary px-3 sm:px-4 mt-2 text-xs sm:text-sm">
<a href={challenge.externalLink.link} target="_blank" rel="noopener noreferrer">
{challenge.externalLink.claim}
</a>
</button>
</div>
)}
</div>
</div>
);
}
7 changes: 5 additions & 2 deletions packages/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PlausibleProvider from "next-plausible";
import AcquisitionTracker from "~~/components/AcquisitionTracker";
import { ScaffoldEthAppWithProviders } from "~~/components/ScaffoldEthAppWithProviders";
import { ThemeProvider } from "~~/components/ThemeProvider";
import { SidebarProvider } from "~~/contexts/SidebarContext";
import "~~/styles/globals.css";
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";

Expand All @@ -25,8 +26,10 @@ const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
</head>
<body>
<ThemeProvider enableSystem>
<AcquisitionTracker />
<ScaffoldEthAppWithProviders>{children}</ScaffoldEthAppWithProviders>
<SidebarProvider>
<AcquisitionTracker />
<ScaffoldEthAppWithProviders>{children}</ScaffoldEthAppWithProviders>
</SidebarProvider>
</ThemeProvider>
</body>
</html>
Expand Down
Loading
Loading