Skip to content

Commit 17c0654

Browse files
ouiliameclaude
andcommitted
docs: add Academy learning surface
Adds the Academy section to the docs: video-first lessons (self-hosted MP4 on Vercel Blob), organized into Workflows, Agents, Tables, Files, and Knowledge Bases, each linking back to the reference docs. Lessons use a course layout (hero video with chapter seek, "what you'll learn", block diagrams). Docs only — no runtime or auth changes. The content may move to a separate CMS or its own site (academy.sim.ai) later; the docs are a starting point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1c3c7f commit 17c0654

24 files changed

Lines changed: 1476 additions & 7 deletions

apps/docs/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ next-env.d.ts
3838
# Fumadocs
3939
/.source/
4040
.plans/
41+
42+
# fumadocs generates .source dirs anywhere a source.config sits
43+
**/.source/

apps/docs/app/[lang]/[[...slug]]/page.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,22 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
7575
}
7676
const isOpenAPI = '_openapi' in data && data._openapi != null
7777
const isApiReference = slug?.some((s) => s === 'api-reference') ?? false
78+
// Academy lessons are video-first: drop the "On this page" TOC and go full
79+
// width so the lesson hero/video gets the room (chapters live in-page instead).
80+
const isAcademy = slug?.[0] === 'academy'
7881

7982
const pageTreeRecord = source.pageTree as Record<string, Root>
8083
const pageTree = pageTreeRecord[lang] ?? pageTreeRecord.en ?? Object.values(pageTreeRecord)[0]
8184
const rawNeighbours = pageTree ? findNeighbour(pageTree, page.url) : null
82-
const neighbours = isApiReference
85+
// Academy and API Reference are self-contained sections; keep prev/next inside
86+
// the section instead of spilling into the main documentation tree.
87+
const sectionPrefix = isApiReference ? '/api-reference/' : isAcademy ? '/academy/' : null
88+
const neighbours = sectionPrefix
8389
? {
84-
previous: rawNeighbours?.previous?.url.includes('/api-reference/')
90+
previous: rawNeighbours?.previous?.url.includes(sectionPrefix)
8591
? rawNeighbours.previous
8692
: undefined,
87-
next: rawNeighbours?.next?.url.includes('/api-reference/') ? rawNeighbours.next : undefined,
93+
next: rawNeighbours?.next?.url.includes(sectionPrefix) ? rawNeighbours.next : undefined,
8894
}
8995
: rawNeighbours
9096

@@ -197,18 +203,18 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
197203
/>
198204
<DocsPage
199205
toc={data.toc}
200-
full={data.full}
206+
full={data.full || isAcademy}
201207
breadcrumb={{
202208
enabled: false,
203209
}}
204210
tableOfContent={{
205211
style: 'clerk',
206-
enabled: true,
212+
enabled: !isAcademy,
207213
single: false,
208214
}}
209215
tableOfContentPopover={{
210216
style: 'clerk',
211-
enabled: true,
217+
enabled: !isAcademy,
212218
}}
213219
footer={{
214220
enabled: true,

apps/docs/components/navbar/navbar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ const NAV_TABS = [
1313
{
1414
label: 'Documentation',
1515
href: '/introduction',
16-
match: (p: string) => !p.includes('/api-reference'),
16+
match: (p: string) => !p.includes('/api-reference') && !p.includes('/academy'),
17+
external: false,
18+
},
19+
{
20+
label: 'Academy',
21+
href: '/academy',
22+
match: (p: string) => p.includes('/academy'),
1723
external: false,
1824
},
1925
{
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { BookOpen, Check, CirclePlay, Clock } from 'lucide-react'
2+
import { cn } from '@/lib/utils'
3+
4+
interface Lesson {
5+
title: string
6+
/** e.g. "4:12". Omit to show "View" instead. */
7+
duration?: string
8+
/** Highlights the current lesson. */
9+
active?: boolean
10+
/** Renders a completed checkmark. */
11+
done?: boolean
12+
}
13+
14+
interface CourseProgressProps {
15+
/** Course name shown as the panel heading. */
16+
course: string
17+
lessons: Lesson[]
18+
/** e.g. "Approx. 18 min". */
19+
durationLabel?: string
20+
/** Completion percentage 0–100. */
21+
progress?: number
22+
className?: string
23+
}
24+
25+
/** Right-rail course panel: lesson count, duration, progress bar, and lesson list. */
26+
export function CourseProgress({
27+
course,
28+
lessons,
29+
durationLabel,
30+
progress = 0,
31+
className,
32+
}: CourseProgressProps) {
33+
return (
34+
<aside className={cn('rounded-xl border border-fd-border bg-fd-card/40 p-5', className)}>
35+
<h2 className='mt-0 mb-3 font-semibold text-fd-foreground text-lg'>{course}</h2>
36+
37+
<div className='flex flex-wrap items-center gap-2 border-fd-border border-b pb-4 text-fd-muted-foreground text-xs'>
38+
<span className='inline-flex items-center gap-1.5 rounded-md border border-fd-border px-2 py-1'>
39+
<BookOpen className='size-3.5' />
40+
{lessons.length} lessons
41+
</span>
42+
{durationLabel && (
43+
<span className='inline-flex items-center gap-1.5 rounded-md border border-fd-border px-2 py-1'>
44+
<Clock className='size-3.5' />
45+
{durationLabel}
46+
</span>
47+
)}
48+
</div>
49+
50+
<div className='py-4'>
51+
<div className='mb-2 flex items-center justify-between text-sm'>
52+
<span className='text-fd-foreground'>Your progress</span>
53+
<span className='text-fd-muted-foreground'>{Math.min(100, Math.max(0, progress))}%</span>
54+
</div>
55+
<div className='h-1.5 w-full overflow-hidden rounded-full bg-fd-muted'>
56+
<div
57+
className='h-full rounded-full bg-[#33c482] transition-all'
58+
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
59+
/>
60+
</div>
61+
</div>
62+
63+
<ul className='m-0 flex list-none flex-col gap-0.5 p-0'>
64+
{lessons.map((lesson) => (
65+
<li
66+
key={lesson.title}
67+
className={cn(
68+
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm',
69+
lesson.active
70+
? 'bg-fd-accent text-fd-foreground'
71+
: 'text-fd-muted-foreground hover:bg-fd-accent/50'
72+
)}
73+
>
74+
{lesson.done ? (
75+
<Check className='size-4 shrink-0 text-[#33c482]' />
76+
) : (
77+
<CirclePlay
78+
className={cn('size-4 shrink-0', lesson.active && 'text-fd-foreground')}
79+
/>
80+
)}
81+
<span className={cn('flex-1 truncate', lesson.active && 'font-medium')}>
82+
{lesson.title}
83+
</span>
84+
<span className='shrink-0 text-fd-muted-foreground text-xs tabular-nums'>
85+
{lesson.duration ?? 'View'}
86+
</span>
87+
</li>
88+
))}
89+
</ul>
90+
</aside>
91+
)
92+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { CirclePlay } from 'lucide-react'
5+
import { cn } from '@/lib/utils'
6+
7+
/** Parse a chapter timestamp ("M:SS" or "H:MM:SS") into seconds. */
8+
function parseTime(time: string): number {
9+
const parts = time.split(':').map(Number)
10+
if (parts.some(Number.isNaN)) return 0
11+
return parts.reduce((acc, n) => acc * 60 + n, 0)
12+
}
13+
14+
interface Chapter {
15+
/** Chapter label. */
16+
title: string
17+
/** Timestamp, e.g. "0:45". */
18+
time?: string
19+
}
20+
21+
interface VideoChaptersProps {
22+
/** Panel heading. Defaults to "Chapters". */
23+
title?: string
24+
chapters: Chapter[]
25+
className?: string
26+
}
27+
28+
/**
29+
* Right-rail panel listing the current video's chapters, styled to match the
30+
* Academy's course panels. Rows are skip-to controls; they activate once the
31+
* lesson's video is recorded.
32+
*/
33+
export function VideoChapters({ title = 'Chapters', chapters, className }: VideoChaptersProps) {
34+
// Chapters only seek when a VideoPlaceholder with a real video is on the page.
35+
// Handshake so the rows stay inert (not falsely clickable) on video-less lessons.
36+
const [hasVideo, setHasVideo] = useState(false)
37+
useEffect(() => {
38+
const onReady = () => setHasVideo(true)
39+
window.addEventListener('academy:video-ready', onReady)
40+
window.dispatchEvent(new Event('academy:video-query'))
41+
return () => window.removeEventListener('academy:video-ready', onReady)
42+
}, [])
43+
44+
return (
45+
<aside
46+
className={cn('not-prose rounded-xl border border-fd-border bg-fd-card/40 p-5', className)}
47+
>
48+
<h2 className='mt-0 mb-3 font-semibold text-fd-foreground text-lg'>{title}</h2>
49+
<ul className='m-0 flex list-none flex-col gap-0.5 p-0'>
50+
{chapters.map((chapter) => (
51+
<li key={chapter.title}>
52+
<button
53+
type='button'
54+
disabled={!hasVideo || chapter.time == null}
55+
onClick={() => {
56+
if (chapter.time == null) return
57+
window.dispatchEvent(
58+
new CustomEvent('academy:seek', { detail: { time: parseTime(chapter.time) } })
59+
)
60+
}}
61+
className='flex w-full cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 text-left text-fd-muted-foreground text-sm transition-colors hover:bg-fd-accent/50 disabled:cursor-default disabled:hover:bg-transparent'
62+
>
63+
<CirclePlay className='mt-0.5 size-4 shrink-0' />
64+
<span className='min-w-0 flex-1 break-words'>{chapter.title}</span>
65+
{chapter.time && (
66+
<span className='mt-0.5 shrink-0 text-fd-muted-foreground text-xs tabular-nums'>
67+
{chapter.time}
68+
</span>
69+
)}
70+
</button>
71+
</li>
72+
))}
73+
</ul>
74+
</aside>
75+
)
76+
}

0 commit comments

Comments
 (0)