Skip to content

Commit e9fd409

Browse files
authored
Merge pull request #247 from dotCMS/courses
Add /learn course viewer with chapter navigation
2 parents 40d487f + db7f783 commit e9fd409

10 files changed

Lines changed: 237 additions & 21 deletions

File tree

app/learn/[slug]/ChapterFooter.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Link from "next/link";
2+
3+
export default function ChapterFooter({ courseSlug, currentIndex, chapters }) {
4+
const isLast = currentIndex === chapters.length - 1;
5+
6+
const nextIndex = currentIndex + 1;
7+
const nextChapter = chapters[nextIndex];
8+
const nextHref = nextChapter ? `/learn/${courseSlug}/chapter-${nextIndex + 1}` : null;
9+
10+
return (
11+
<div className="mt-16 space-y-4">
12+
{/* Next up */}
13+
{!isLast && nextChapter && (
14+
<div className="flex items-center justify-between rounded-lg border border-white/10 px-5 py-4">
15+
<div>
16+
<p className="text-xs text-white/40 mb-0.5">Next up</p>
17+
<p className="text-sm font-medium text-white">Chapter {nextIndex + 1}: {nextChapter.title}</p>
18+
</div>
19+
<Link
20+
href={nextHref}
21+
className="ml-6 shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
22+
>
23+
Continue →
24+
</Link>
25+
</div>
26+
)}
27+
28+
{isLast && (
29+
<div className="flex items-center justify-between rounded-lg border border-white/10 px-5 py-4">
30+
<p className="text-sm font-medium text-white">You&apos;ve completed the course!</p>
31+
<Link
32+
href={`/learn/${courseSlug}`}
33+
className="ml-6 shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
34+
>
35+
Back to Overview
36+
</Link>
37+
</div>
38+
)}
39+
</div>
40+
);
41+
}

app/learn/[slug]/CourseSidebar.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
6+
export default function CourseSidebar({ course, courseSlug }) {
7+
const pathname = usePathname();
8+
9+
const introActive = pathname === `/learn/${courseSlug}`;
10+
11+
return (
12+
<aside className="w-72 shrink-0 border-r border-border p-6 overflow-y-auto">
13+
<p className="text-sm text-muted-foreground mb-4">{course.title}</p>
14+
<nav>
15+
<ol className="space-y-1">
16+
<li>
17+
<Link
18+
href={`/learn/${courseSlug}`}
19+
className={`flex w-full items-center gap-3 rounded px-2 py-1.5 transition-colors hover:bg-accent ${introActive ? "bg-primary/20" : ""}`}
20+
>
21+
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs transition-colors ${introActive ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"}`}>
22+
0
23+
</span>
24+
<span className={`text-sm transition-colors ${introActive ? "text-foreground font-medium" : "text-muted-foreground"}`}>
25+
Introduction
26+
</span>
27+
</Link>
28+
</li>
29+
{course.chapters.map((chapter, index) => {
30+
const chapterSlug = `chapter-${index + 1}`;
31+
const isActive = pathname === `/learn/${courseSlug}/${chapterSlug}`;
32+
return (
33+
<li key={index}>
34+
<Link
35+
href={`/learn/${courseSlug}/${chapterSlug}`}
36+
className={`flex w-full items-center gap-3 rounded px-2 py-1.5 transition-colors hover:bg-accent ${isActive ? "bg-primary/20" : ""}`}
37+
>
38+
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs transition-colors ${isActive ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"}`}>
39+
{index + 1}
40+
</span>
41+
<span className={`text-sm transition-colors ${isActive ? "text-foreground font-medium" : "text-muted-foreground"}`}>
42+
{chapter.title}
43+
</span>
44+
</Link>
45+
</li>
46+
);
47+
})}
48+
</ol>
49+
</nav>
50+
</aside>
51+
);
52+
}

app/learn/[slug]/[chapter]/page.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getCourseDetail } from "@/services/courses/getCourse";
2+
import MarkdownContent from "@/components/MarkdownContent";
3+
import { notFound } from "next/navigation";
4+
import ChapterFooter from "../ChapterFooter";
5+
6+
export default async function ChapterPage({ params }) {
7+
const { slug, chapter } = await params;
8+
const { course } = await getCourseDetail({ slug });
9+
if (!course) notFound();
10+
11+
const match = chapter.match(/^chapter-(\d+)$/);
12+
if (!match) notFound();
13+
14+
const index = parseInt(match[1], 10) - 1;
15+
const chapterData = course.chapters[index];
16+
if (!chapterData) notFound();
17+
18+
return (
19+
<>
20+
<p className="text-sm text-white/50 mb-2">{course.title}</p>
21+
<h1 className="text-4xl font-bold mb-8">{chapterData.title}</h1>
22+
<MarkdownContent content={chapterData.content} />
23+
<ChapterFooter
24+
courseSlug={slug}
25+
currentIndex={index}
26+
chapters={course.chapters}
27+
/>
28+
</>
29+
);
30+
}

app/learn/[slug]/layout.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getCourseDetail } from "@/services/courses/getCourse";
2+
import CourseSidebar from "./CourseSidebar";
3+
import Header from "@/components/header/header";
4+
import { notFound } from "next/navigation";
5+
6+
export default async function CourseLayout({ children, params }) {
7+
const { slug } = await params;
8+
const { course } = await getCourseDetail({ slug });
9+
if (!course) notFound();
10+
11+
return (
12+
<div className="flex flex-col h-screen overflow-hidden">
13+
<Header />
14+
<div className="flex flex-1 overflow-hidden">
15+
<CourseSidebar course={course} courseSlug={slug} />
16+
<main className="flex-1 overflow-y-auto">
17+
<div className="mx-auto max-w-3xl px-16 py-10">
18+
{children}
19+
</div>
20+
</main>
21+
</div>
22+
</div>
23+
);
24+
}

app/learn/[slug]/page.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getCourseDetail } from "@/services/courses/getCourse";
2+
import { DotCMSBlockEditorRenderer } from "@dotcms/react";
3+
import ChapterFooter from "./ChapterFooter";
4+
import { notFound } from "next/navigation";
5+
6+
export default async function CoursePage({ params }) {
7+
const { slug } = await params;
8+
const { course } = await getCourseDetail({ slug });
9+
if (!course) notFound();
10+
11+
return (
12+
<>
13+
<p className="text-sm text-white/50 mb-2">Introduction</p>
14+
<h1 className="text-4xl font-bold mb-8">{course.title}</h1>
15+
<DotCMSBlockEditorRenderer
16+
className="prose dark:prose-invert"
17+
blocks={course.introduction.json}
18+
/>
19+
<ChapterFooter courseSlug={slug} currentIndex={-1} chapters={course.chapters} />
20+
</>
21+
);
22+
}

app/learn/page.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getCourseDetail } from "@/services/courses/getCourse";
2+
3+
import { redirect } from "next/navigation";
4+
5+
export default function Courses() {
6+
redirect("/learn/headless");
7+
return null;
8+
}

components/MarkdownContent.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@ import remarkGfm from 'remark-gfm'
99
import SyntaxHighlighter from 'react-syntax-highlighter'
1010
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
1111
import { Components } from 'react-markdown'
12-
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
1312
import { smoothScroll } from '@/util/smoothScroll'
14-
import Video from '@/components/mdx/Video'
1513
import { CopyButton } from './chat/CopyButton'
16-
import { a11yLight, dark, docco, a11yDark, vs } from 'react-syntax-highlighter/dist/cjs/styles/hljs'
14+
import { a11yLight, a11yDark } from 'react-syntax-highlighter/dist/cjs/styles/hljs'
1715
import { useTheme } from "next-themes"
1816
import { Include } from '@/components/mdx/Include'
1917
import { remarkCustomId } from '@/util/remarkCustomId'
@@ -34,18 +32,8 @@ type ExtendedComponents = Components & {
3432
// Block components are added dynamically
3533
}
3634

37-
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
38-
level: number;
39-
children: React.ReactNode;
40-
}
41-
42-
interface ChildrenProps {
43-
children: ReactNode;
44-
}
45-
4635
const HEADER_HEIGHT = 80;
4736
const BREADCRUMB_HEIGHT = 48; // 24px height + 24px bottom margin
48-
const TOTAL_OFFSET = HEADER_HEIGHT + BREADCRUMB_HEIGHT;
4937

5038
// Context to track if we're inside a list item
5139
const ListItemContext = createContext(false);
@@ -54,7 +42,7 @@ const ListItemContext = createContext(false);
5442
const HeadingContext = createContext(false);
5543

5644
const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, className, disableBlockComponents = false }) => {
57-
const { theme } = useTheme();
45+
const { resolvedTheme } = useTheme();
5846
const [mounted, setMounted] = useState(false);
5947

6048
useEffect(() => {
@@ -264,14 +252,14 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, className, d
264252
<SyntaxHighlighter
265253
language={highlight}
266254
PreTag="div"
267-
style={theme === 'dark' ? a11yDark : a11yLight}
255+
style={resolvedTheme === 'dark' ? { ...a11yDark, hljs: { ...a11yDark.hljs, color: '#f8f8f2' } } : a11yLight}
268256
className="rounded-lg py-2 [&>pre]:!m-0 border border-border [&>pre]:!bg-muted"
269257
customStyle={{
270258
padding: '1rem',
271259
paddingTop: '2rem',
272260
paddingBottom: '2rem',
273261
fontSize: '14px',
274-
backgroundColor: 'transparent', // Use transparent to let CSS handle the background
262+
backgroundColor: 'transparent',
275263
}}
276264
{...props}
277265
>{String(children).replace(/\n$/, '')}</SyntaxHighlighter>

package-lock.json

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"dependencies": {
1212
"@dotcms/analytics": "1.2.5-next.6",
1313
"@dotcms/client": "next",
14-
"@dotcms/react": "next",
14+
"@dotcms/react": "^1.2.6-next.2",
1515
"@dotcms/types": "next",
1616
"@hookform/resolvers": "^3.9.0",
1717
"@mdx-js/mdx": "^3.1.0",
@@ -54,6 +54,7 @@
5454
"class-variance-authority": "^0.7.0",
5555
"clsx": "^2.1.1",
5656
"cmdk": "^1.0.0",
57+
"core-js": "^3.49.0",
5758
"date-fns": "^3.6.0",
5859
"embla-carousel-react": "^8.3.0",
5960
"eslint": "8.49.0",

services/courses/getCourse.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { graphqlResults } from "@/services/gql";
2+
import { logRequest } from "@/util/logRequest";
3+
4+
function escapeLuceneValue(value) {
5+
return String(value).replace(/([+\-!(){}[\]^"~*?:\\/])/g, "\\$1");
6+
}
7+
8+
export async function getCourseDetail({ slug }) {
9+
const safeSlug = escapeLuceneValue(slug);
10+
const query = `query ContentAPI {
11+
CourseE2eCollection(query: "+CourseE2e.urlTitle:${safeSlug}", limit: 1) {
12+
title,
13+
urlTitle
14+
introduction {
15+
json
16+
}
17+
chapters {
18+
title
19+
content
20+
}
21+
}
22+
}`;
23+
24+
const result = await logRequest(
25+
async () => graphqlResults(query),
26+
"getCourseDetail",
27+
);
28+
29+
if (result.errors && result.errors.length > 0) {
30+
console.error("GraphQL errors in getCourseDetail:", result.errors);
31+
throw new Error(result.errors[0].message);
32+
}
33+
34+
const collection = result?.data?.CourseE2eCollection;
35+
const course = Array.isArray(collection) && collection.length > 0 ? collection[0] : null;
36+
37+
return { course };
38+
}

0 commit comments

Comments
 (0)