camelmind 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/index.js +142 -0
  2. package/package.json +51 -0
  3. package/template/_env.example +16 -0
  4. package/template/_gitignore +32 -0
  5. package/template/_package.json +39 -0
  6. package/template/app/[...slug]/page.tsx +150 -0
  7. package/template/app/api/auth/callback/route.ts +47 -0
  8. package/template/app/api/auth/login/route.ts +39 -0
  9. package/template/app/api/auth/logout/route.ts +8 -0
  10. package/template/app/api/auth/signin/route.ts +64 -0
  11. package/template/app/api/download/route.ts +67 -0
  12. package/template/app/api/raw/route.ts +43 -0
  13. package/template/app/api/search/route.ts +138 -0
  14. package/template/app/favicon.ico +0 -0
  15. package/template/app/globals.css +524 -0
  16. package/template/app/home/page.tsx +136 -0
  17. package/template/app/icon.svg +29 -0
  18. package/template/app/layout.tsx +44 -0
  19. package/template/app/login/LoginForm.tsx +105 -0
  20. package/template/app/login/page.tsx +9 -0
  21. package/template/app/page.tsx +5 -0
  22. package/template/camelmind.config.ts +33 -0
  23. package/template/components/Breadcrumbs/Breadcrumbs.tsx +41 -0
  24. package/template/components/Code/CodeBlock.tsx +55 -0
  25. package/template/components/DocActions/DocActions.tsx +62 -0
  26. package/template/components/Nav/MobileDrawer.tsx +221 -0
  27. package/template/components/Nav/ThemeToggle.tsx +62 -0
  28. package/template/components/Nav/TopNav.tsx +173 -0
  29. package/template/components/Nav/VersionSelector.tsx +107 -0
  30. package/template/components/PageNav/PageNav.tsx +68 -0
  31. package/template/components/Search/SearchModal.tsx +278 -0
  32. package/template/components/SectionCards/SectionCards.tsx +33 -0
  33. package/template/components/Sidebar/Sidebar.tsx +196 -0
  34. package/template/components/Toc/Toc.tsx +57 -0
  35. package/template/components/ZoomImages/ZoomImages.tsx +24 -0
  36. package/template/components/mdx/Callout.tsx +43 -0
  37. package/template/components/mdx/Details.tsx +65 -0
  38. package/template/components/mdx/Icon.tsx +25 -0
  39. package/template/components/mdx/Steps.tsx +33 -0
  40. package/template/components/mdx/Tabs.tsx +69 -0
  41. package/template/components/mdx/index.tsx +34 -0
  42. package/template/content/getting-started/installation.mdx +51 -0
  43. package/template/content/getting-started/overview.mdx +39 -0
  44. package/template/content/guides/writing-docs.mdx +72 -0
  45. package/template/eslint.config.mjs +18 -0
  46. package/template/lib/auth-providers/dev-mock.ts +45 -0
  47. package/template/lib/auth-providers/oidc.ts +95 -0
  48. package/template/lib/auth-roles.ts +28 -0
  49. package/template/lib/auth.ts +76 -0
  50. package/template/lib/config-types.ts +30 -0
  51. package/template/lib/config.ts +29 -0
  52. package/template/lib/mdx.ts +53 -0
  53. package/template/lib/nav-types.ts +31 -0
  54. package/template/lib/nav.ts +125 -0
  55. package/template/lib/versions.ts +61 -0
  56. package/template/nav/nav.yml +20 -0
  57. package/template/next.config.ts +35 -0
  58. package/template/postcss.config.mjs +7 -0
  59. package/template/proxy.ts +50 -0
  60. package/template/public/2f-logo.png +0 -0
  61. package/template/public/favicon.png +0 -0
  62. package/template/public/file.svg +1 -0
  63. package/template/public/globe.svg +1 -0
  64. package/template/public/images/gwa-app-central-main.png +0 -0
  65. package/template/public/next.svg +1 -0
  66. package/template/public/window.svg +1 -0
  67. package/template/scripts/build-offline.sh +189 -0
  68. package/template/scripts/build-pdf.sh +41 -0
  69. package/template/scripts/build-search-index.ts +90 -0
  70. package/template/scripts/generate-master-pdf.ts +260 -0
  71. package/template/scripts/generate-pdfs.ts +185 -0
  72. package/template/tsconfig.json +34 -0
  73. package/template/versions.yml +5 -0
@@ -0,0 +1,221 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import Link from "next/link"
5
+ import { usePathname } from "next/navigation"
6
+ import { useRouter } from "next/navigation"
7
+ import { X, ChevronDown } from "lucide-react"
8
+ import { isNavGroup } from "@/lib/nav-types"
9
+ import type { NavEntry, NavGroup } from "@/lib/nav-types"
10
+ import type { Version } from "@/lib/versions"
11
+
12
+ type Props = {
13
+ open: boolean
14
+ onClose: () => void
15
+ nav: (NavEntry | NavGroup)[]
16
+ userRoles: string[]
17
+ userName: string | null
18
+ authEnabled?: boolean
19
+ versions: Version[]
20
+ currentVersionId: string | null
21
+ currentSlug: string
22
+ versionSlugs: Record<string, string[]>
23
+ }
24
+
25
+ export function MobileDrawer({
26
+ open,
27
+ onClose,
28
+ nav,
29
+ userRoles,
30
+ userName,
31
+ authEnabled = false,
32
+ versions,
33
+ currentVersionId,
34
+ currentSlug,
35
+ versionSlugs,
36
+ }: Props) {
37
+ const pathname = usePathname()
38
+ const router = useRouter()
39
+
40
+ // Find which group contains the current page and expand it by default
41
+ const defaultExpanded = nav.find((item) => {
42
+ if (!isNavGroup(item) || !item.items) return false
43
+ return item.items.some((child) => {
44
+ if (currentSlug === child.slug) return true
45
+ if (child.section) return child.section.some((s) => currentSlug === s.slug || (s.children ?? []).some((d) => currentSlug === d.slug))
46
+ return false
47
+ })
48
+ })?.label ?? null
49
+
50
+ const [expandedGroup, setExpandedGroup] = useState<string | null>(defaultExpanded)
51
+
52
+ // Close on navigation
53
+ useEffect(() => { onClose() }, [pathname]) // eslint-disable-line react-hooks/exhaustive-deps
54
+
55
+ // Lock body scroll when open
56
+ useEffect(() => {
57
+ document.body.style.overflow = open ? "hidden" : ""
58
+ return () => { document.body.style.overflow = "" }
59
+ }, [open])
60
+
61
+ const canSee = (roles: string[]) =>
62
+ !authEnabled || roles.length === 0 || roles.some((r) => userRoles.includes(r))
63
+
64
+ async function signOut() {
65
+ await fetch("/api/auth/logout", { method: "POST" })
66
+ window.location.href = authEnabled ? "/login" : "/home"
67
+ }
68
+
69
+ function navigateToVersion(targetId: string) {
70
+ const bare = currentVersionId
71
+ ? currentSlug.replace(new RegExp(`^/${currentVersionId}`), "") || "/"
72
+ : currentSlug
73
+ const targetSlug = `/${targetId}${bare}`
74
+ const available = versionSlugs[targetId] ?? []
75
+ router.push(available.includes(targetSlug) ? targetSlug : (available[0] ?? `/${targetId}`))
76
+ }
77
+
78
+ if (!open) return null
79
+
80
+ return (
81
+ <>
82
+ {/* Backdrop */}
83
+ <div
84
+ className="fixed inset-0 bg-black/50 z-40 md:hidden"
85
+ onClick={onClose}
86
+ />
87
+
88
+ {/* Drawer */}
89
+ <div className="fixed inset-y-0 left-0 w-80 max-w-[85vw] bg-gray-900 text-white z-50 flex flex-col md:hidden overflow-y-auto">
90
+ {/* Header */}
91
+ <div className="flex items-center justify-between px-4 py-4 border-b border-gray-700">
92
+ <Link href="/home" className="flex items-center gap-2" onClick={onClose}>
93
+ <span className="text-[var(--cm-oasis-teal)] font-mono font-bold">&lt;🐪&gt;</span>
94
+ <span className="font-bold text-base">CamelMind</span>
95
+ </Link>
96
+ <button onClick={onClose} className="p-1 text-gray-400 hover:text-white">
97
+ <X size={20} />
98
+ </button>
99
+ </div>
100
+
101
+ {/* Nav items */}
102
+ <nav className="flex-1 px-3 py-4 space-y-1">
103
+ {nav.map((item) => {
104
+ if (isNavGroup(item)) {
105
+ if ((item.noDropdown || !item.items) && item.slug) {
106
+ return (
107
+ <Link
108
+ key={item.label}
109
+ href={item.slug}
110
+ className="block px-3 py-2.5 text-sm font-medium text-gray-200 hover:text-white hover:bg-gray-800 rounded-lg"
111
+ >
112
+ {item.label}
113
+ </Link>
114
+ )
115
+ }
116
+
117
+ const visibleItems = (item.items ?? []).filter((i) => canSee(i.roles))
118
+ if (visibleItems.length === 0) return null
119
+ const isExpanded = expandedGroup === item.label
120
+
121
+ return (
122
+ <div key={item.label}>
123
+ <button
124
+ onClick={() => setExpandedGroup(isExpanded ? null : item.label)}
125
+ className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-gray-200 hover:text-white hover:bg-gray-800 rounded-lg"
126
+ >
127
+ {item.label}
128
+ <ChevronDown
129
+ size={16}
130
+ className={`text-gray-400 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
131
+ />
132
+ </button>
133
+ {isExpanded && (
134
+ <div className="mt-1 ml-3 border-l border-gray-700 pl-3 space-y-0.5">
135
+ {visibleItems.map((child) => {
136
+ const isActive = currentSlug === child.slug ||
137
+ (child.section ?? []).some((s) => currentSlug === s.slug || (s.children ?? []).some((d) => currentSlug === d.slug))
138
+ return (
139
+ <Link
140
+ key={child.slug}
141
+ href={child.slug}
142
+ className={`block px-2 py-2 text-sm rounded-lg transition-colors ${
143
+ isActive
144
+ ? "bg-gray-700 text-white font-medium"
145
+ : "text-gray-400 hover:text-white hover:bg-gray-800"
146
+ }`}
147
+ >
148
+ {child.label}
149
+ </Link>
150
+ )
151
+ })}
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ }
157
+
158
+ const entry = item as NavEntry
159
+ if (!canSee(entry.roles)) return null
160
+ return (
161
+ <Link
162
+ key={entry.slug}
163
+ href={entry.slug}
164
+ className="block px-3 py-2.5 text-sm font-medium text-gray-200 hover:text-white hover:bg-gray-800 rounded-lg"
165
+ >
166
+ {entry.label}
167
+ </Link>
168
+ )
169
+ })}
170
+ </nav>
171
+
172
+ {/* Footer: version + user */}
173
+ <div className="border-t border-gray-700 px-4 py-4 space-y-3">
174
+ {/* Version selector */}
175
+ <div>
176
+ <p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Version</p>
177
+ <div className="space-y-1">
178
+ {versions.map((v) => (
179
+ <button
180
+ key={v.id}
181
+ onClick={() => navigateToVersion(v.id)}
182
+ className={`w-full text-left flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
183
+ v.id === currentVersionId
184
+ ? "bg-gray-700 text-white"
185
+ : "text-gray-300 hover:bg-gray-800 hover:text-white"
186
+ }`}
187
+ >
188
+ <span>{v.label}</span>
189
+ {v.badge && (
190
+ <span className="text-xs bg-gray-600 text-gray-300 px-1.5 py-0.5 rounded-full">{v.badge}</span>
191
+ )}
192
+ </button>
193
+ ))}
194
+ </div>
195
+ </div>
196
+
197
+ {/* User — only when auth is enabled */}
198
+ {authEnabled && (
199
+ <div className="border-t border-gray-700 pt-3">
200
+ {userName ? (
201
+ <div className="flex items-center justify-between">
202
+ <span className="text-sm text-gray-400">{userName}</span>
203
+ <button
204
+ onClick={signOut}
205
+ className="text-xs text-gray-400 hover:text-white border border-gray-700 px-2 py-1 rounded"
206
+ >
207
+ Sign out
208
+ </button>
209
+ </div>
210
+ ) : (
211
+ <Link href="/login" className="block text-sm text-gray-400 hover:text-white">
212
+ Sign in →
213
+ </Link>
214
+ )}
215
+ </div>
216
+ )}
217
+ </div>
218
+ </div>
219
+ </>
220
+ )
221
+ }
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import { useTheme } from "next-themes"
4
+ import { useEffect, useState, useRef } from "react"
5
+ import { Sun, Moon, Monitor } from "lucide-react"
6
+
7
+ const OPTIONS = [
8
+ { value: "light", label: "Light", Icon: Sun },
9
+ { value: "dark", label: "Dark", Icon: Moon },
10
+ { value: "system", label: "System", Icon: Monitor },
11
+ ]
12
+
13
+ export function ThemeToggle() {
14
+ const { theme, setTheme, resolvedTheme } = useTheme()
15
+ const [mounted, setMounted] = useState(false)
16
+ const [open, setOpen] = useState(false)
17
+ const ref = useRef<HTMLDivElement>(null)
18
+
19
+ useEffect(() => { setMounted(true) }, [])
20
+ useEffect(() => {
21
+ function onClick(e: MouseEvent) {
22
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
23
+ }
24
+ document.addEventListener("mousedown", onClick)
25
+ return () => document.removeEventListener("mousedown", onClick)
26
+ }, [])
27
+
28
+ if (!mounted) return <div className="w-8 h-8" />
29
+
30
+ const Icon = resolvedTheme === "dark" ? Moon : Sun
31
+
32
+ return (
33
+ <div ref={ref} className="relative">
34
+ <button
35
+ onClick={() => setOpen((o) => !o)}
36
+ className="flex items-center justify-center w-8 h-8 rounded text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
37
+ aria-label="Toggle theme"
38
+ >
39
+ <Icon size={16} />
40
+ </button>
41
+
42
+ {open && (
43
+ <div className="absolute right-0 top-full mt-1 w-36 bg-white dark:bg-gray-800 rounded shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1">
44
+ {OPTIONS.map(({ value, label, Icon: ItemIcon }) => (
45
+ <button
46
+ key={value}
47
+ onClick={() => { setTheme(value); setOpen(false) }}
48
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors ${
49
+ theme === value
50
+ ? "text-gray-900 dark:text-white font-medium bg-gray-100 dark:bg-gray-700"
51
+ : "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
52
+ }`}
53
+ >
54
+ <ItemIcon size={13} />
55
+ {label}
56
+ </button>
57
+ ))}
58
+ </div>
59
+ )}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,173 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect, useRef } from "react"
4
+ import Link from "next/link"
5
+ import { Menu } from "lucide-react"
6
+ import { NavEntry, NavGroup, isNavGroup } from "@/lib/nav-types"
7
+ import { VersionSelector } from "./VersionSelector"
8
+ import { MobileDrawer } from "./MobileDrawer"
9
+ import { SearchTrigger } from "@/components/Search/SearchModal"
10
+ import { ThemeToggle } from "./ThemeToggle"
11
+ import type { Version } from "@/lib/versions"
12
+
13
+ type Props = {
14
+ nav: (NavEntry | NavGroup)[]
15
+ userRoles: string[]
16
+ userName?: string | null
17
+ authEnabled?: boolean
18
+ versions: Version[]
19
+ currentVersionId: string | null
20
+ currentSlug: string
21
+ versionSlugs: Record<string, string[]>
22
+ }
23
+
24
+ export function TopNav({ nav, userRoles, userName, authEnabled = false, versions, currentVersionId, currentSlug, versionSlugs }: Props) {
25
+ const [openDropdown, setOpenDropdown] = useState<string | null>(null)
26
+ const [drawerOpen, setDrawerOpen] = useState(false)
27
+ const navRef = useRef<HTMLElement>(null)
28
+
29
+ useEffect(() => {
30
+ function handleClickOutside(e: MouseEvent) {
31
+ if (navRef.current && !navRef.current.contains(e.target as Node)) {
32
+ setOpenDropdown(null)
33
+ }
34
+ }
35
+ document.addEventListener("mousedown", handleClickOutside)
36
+ return () => document.removeEventListener("mousedown", handleClickOutside)
37
+ }, [])
38
+
39
+ async function signOut() {
40
+ await fetch("/api/auth/logout", { method: "POST" })
41
+ window.location.href = authEnabled ? "/login" : "/home"
42
+ }
43
+
44
+ const canSee = (roles: string[]) =>
45
+ !authEnabled || roles.length === 0 || roles.some((r) => userRoles.includes(r))
46
+
47
+ return (
48
+ <>
49
+ <nav ref={navRef} className="bg-[var(--cm-night-dune)] text-[var(--cm-parchment)] px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6 border-b border-[var(--cm-border)]">
50
+ {/* Logo */}
51
+ <Link href="/home" className="flex items-center gap-2 mr-0 md:mr-4 shrink-0">
52
+ <span className="text-[var(--cm-oasis-teal)] font-mono text-lg font-bold">&lt;</span>
53
+ <span className="text-[var(--cm-camel-gold)] text-xl" aria-hidden="true">🐪</span>
54
+ <span className="text-[var(--cm-oasis-teal)] font-mono text-lg font-bold">&gt;</span>
55
+ <span className="font-bold text-lg text-[var(--cm-parchment)] ml-1">CamelMind</span>
56
+ </Link>
57
+
58
+ {/* Desktop nav items */}
59
+ <div className="hidden md:flex items-center gap-6 flex-1">
60
+ {nav.map((item) => {
61
+ if (isNavGroup(item)) {
62
+ if ((item.noDropdown || !item.items) && item.slug) {
63
+ return (
64
+ <Link key={item.label} href={item.slug} className="text-sm font-medium hover:text-gray-300">
65
+ {item.label}
66
+ </Link>
67
+ )
68
+ }
69
+
70
+ const visibleItems = (item.items ?? []).filter((i) => canSee(i.roles))
71
+ if (visibleItems.length === 0) return null
72
+
73
+ return (
74
+ <div key={item.label} className="relative">
75
+ <button
76
+ className="flex items-center gap-1 hover:text-gray-300 text-sm font-medium"
77
+ onClick={() => setOpenDropdown(openDropdown === item.label ? null : item.label)}
78
+ >
79
+ {item.label}
80
+ <span className="text-xs">▾</span>
81
+ </button>
82
+
83
+ {openDropdown === item.label && (
84
+ <div className="absolute top-full left-0 mt-1 w-56 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded shadow-lg z-50 py-1 border border-gray-200 dark:border-gray-700">
85
+ {visibleItems.map((child) => (
86
+ <Link
87
+ key={child.slug}
88
+ href={child.slug}
89
+ className="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
90
+ onClick={() => setOpenDropdown(null)}
91
+ >
92
+ {child.label}
93
+ </Link>
94
+ ))}
95
+ </div>
96
+ )}
97
+ </div>
98
+ )
99
+ }
100
+
101
+ const entry = item as NavEntry
102
+ if (!canSee(entry.roles)) return null
103
+ return (
104
+ <Link key={entry.slug} href={entry.slug} className="text-sm font-medium hover:text-gray-300">
105
+ {entry.label}
106
+ </Link>
107
+ )
108
+ })}
109
+ </div>
110
+
111
+ {/* Right side */}
112
+ <div className="ml-auto flex items-center gap-3">
113
+ <SearchTrigger />
114
+
115
+ {/* Desktop: version selector + user + theme toggle */}
116
+ <div className="hidden md:flex items-center gap-4">
117
+ <VersionSelector
118
+ versions={versions}
119
+ currentVersionId={currentVersionId}
120
+ currentSlug={currentSlug}
121
+ versionSlugs={versionSlugs}
122
+ isLoggedIn={!!userName}
123
+ />
124
+ {authEnabled && (
125
+ userName ? (
126
+ <div className="flex items-center gap-2 border-l border-[var(--cm-border)] pl-4">
127
+ <span className="text-xs text-[var(--cm-text-muted)]">{userName}</span>
128
+ <button
129
+ onClick={signOut}
130
+ className="text-xs text-[var(--cm-text-muted)] hover:text-[var(--cm-parchment)] border border-[var(--cm-border)] hover:border-[var(--cm-oasis-teal)] px-2 py-1 rounded transition-colors"
131
+ >
132
+ Sign out
133
+ </button>
134
+ </div>
135
+ ) : (
136
+ <a href="/login" className="text-xs text-[var(--cm-text-muted)] hover:text-[var(--cm-parchment)] border border-[var(--cm-border)] hover:border-[var(--cm-oasis-teal)] px-2 py-1 rounded transition-colors ml-2">
137
+ Sign in
138
+ </a>
139
+ )
140
+ )}
141
+ <ThemeToggle />
142
+ </div>
143
+
144
+ {/* Mobile: theme toggle + hamburger */}
145
+ <div className="md:hidden flex items-center gap-1">
146
+ <ThemeToggle />
147
+ <button
148
+ className="p-2 text-gray-300 hover:text-white"
149
+ onClick={() => setDrawerOpen(true)}
150
+ aria-label="Open menu"
151
+ >
152
+ <Menu size={22} />
153
+ </button>
154
+ </div>
155
+ </div>
156
+ </nav>
157
+
158
+ {/* Mobile drawer */}
159
+ <MobileDrawer
160
+ open={drawerOpen}
161
+ onClose={() => setDrawerOpen(false)}
162
+ nav={nav}
163
+ userRoles={userRoles}
164
+ userName={userName ?? null}
165
+ authEnabled={authEnabled}
166
+ versions={versions}
167
+ currentVersionId={currentVersionId}
168
+ currentSlug={currentSlug}
169
+ versionSlugs={versionSlugs}
170
+ />
171
+ </>
172
+ )
173
+ }
@@ -0,0 +1,107 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { Download, FileText } from "lucide-react"
6
+ import type { Version } from "@/lib/versions"
7
+
8
+ type Props = {
9
+ versions: Version[]
10
+ currentVersionId: string | null
11
+ currentSlug: string
12
+ versionSlugs: Record<string, string[]>
13
+ isLoggedIn?: boolean
14
+ }
15
+
16
+ export function VersionSelector({ versions, currentVersionId, currentSlug, versionSlugs, isLoggedIn }: Props) {
17
+ const [open, setOpen] = useState(false)
18
+ const router = useRouter()
19
+
20
+ const current = versions.find((v) => v.id === currentVersionId) ?? versions[0]
21
+
22
+ function navigateToVersion(targetId: string) {
23
+ setOpen(false)
24
+ const bare = currentVersionId
25
+ ? currentSlug.replace(new RegExp(`^/${currentVersionId}`), "") || "/"
26
+ : currentSlug
27
+ const targetSlug = `/${targetId}${bare}`
28
+ const available = versionSlugs[targetId] ?? []
29
+ if (available.includes(targetSlug)) {
30
+ router.push(targetSlug)
31
+ } else {
32
+ router.push(available[0] ?? `/${targetId}`)
33
+ }
34
+ }
35
+
36
+ return (
37
+ <div className="relative">
38
+ <button
39
+ onClick={() => setOpen(!open)}
40
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-gray-600 text-sm text-white hover:border-gray-400 transition-colors"
41
+ >
42
+ <span>{current?.label ?? "version"}</span>
43
+ {current?.badge && (
44
+ <span className="text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded-full">
45
+ {current.badge}
46
+ </span>
47
+ )}
48
+ <span className="text-xs text-gray-400">▾</span>
49
+ </button>
50
+
51
+ {open && (
52
+ <div className="absolute top-full right-0 mt-1 w-52 bg-white text-gray-900 rounded shadow-lg z-50 py-1 border border-gray-200">
53
+ {versions.map((v) => (
54
+ <div key={v.id} className="flex items-center group">
55
+ <button
56
+ onClick={() => navigateToVersion(v.id)}
57
+ className={`flex-1 text-left flex items-center justify-between px-4 py-2 text-sm hover:bg-gray-100 ${
58
+ v.id === currentVersionId ? "font-semibold text-gray-900" : ""
59
+ }`}
60
+ >
61
+ <span>{v.label}</span>
62
+ <span className="flex items-center gap-1.5">
63
+ {v.badge && (
64
+ <span className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full">
65
+ {v.badge}
66
+ </span>
67
+ )}
68
+ {!v.stable && !v.badge && (
69
+ <span className="text-xs bg-yellow-100 text-yellow-700 px-1.5 py-0.5 rounded-full">
70
+ Unstable
71
+ </span>
72
+ )}
73
+ {v.stable && (
74
+ <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full">
75
+ Stable
76
+ </span>
77
+ )}
78
+ {v.id === currentVersionId && <span className="text-gray-700">✓</span>}
79
+ </span>
80
+ </button>
81
+ {isLoggedIn && v.stable && (
82
+ <div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
83
+ <a
84
+ href={`/api/download?version=${v.id}&type=pdf`}
85
+ title={`Download ${v.label} as PDF`}
86
+ className="px-1.5 py-2 text-gray-400 hover:text-gray-700 transition-colors"
87
+ onClick={(e) => e.stopPropagation()}
88
+ >
89
+ <FileText size={14} />
90
+ </a>
91
+ <a
92
+ href={`/api/download?version=${v.id}`}
93
+ title={`Download ${v.label} for offline use`}
94
+ className="pr-2 pl-1 py-2 text-gray-400 hover:text-gray-700 transition-colors"
95
+ onClick={(e) => e.stopPropagation()}
96
+ >
97
+ <Download size={14} />
98
+ </a>
99
+ </div>
100
+ )}
101
+ </div>
102
+ ))}
103
+ </div>
104
+ )}
105
+ </div>
106
+ )
107
+ }
@@ -0,0 +1,68 @@
1
+ import Link from "next/link"
2
+ import type { NavGroup, NavChild } from "@/lib/nav-types"
3
+
4
+ type PageEntry = { label: string; slug: string }
5
+
6
+ function flattenGroup(group: NavGroup): PageEntry[] {
7
+ const pages: PageEntry[] = []
8
+ for (const entry of group.items) {
9
+ pages.push({ label: entry.label, slug: entry.slug })
10
+ if (entry.section) {
11
+ for (const child of entry.section) {
12
+ pages.push({ label: child.label, slug: child.slug })
13
+ if (child.children) {
14
+ for (const grandchild of child.children) {
15
+ pages.push({ label: grandchild.label, slug: grandchild.slug })
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ return pages
22
+ }
23
+
24
+ type Props = {
25
+ activeGroup?: NavGroup | null
26
+ currentSlug: string
27
+ }
28
+
29
+ export function PageNav({ activeGroup, currentSlug }: Props) {
30
+ if (!activeGroup) return null
31
+
32
+ const pages = flattenGroup(activeGroup)
33
+ const idx = pages.findIndex((p) => p.slug === currentSlug)
34
+ if (idx === -1) return null
35
+
36
+ const prev = idx > 0 ? pages[idx - 1] : null
37
+ const next = idx < pages.length - 1 ? pages[idx + 1] : null
38
+
39
+ if (!prev && !next) return null
40
+
41
+ return (
42
+ <div className="flex justify-between items-center mt-12 pt-6 border-t border-gray-200 dark:border-gray-800">
43
+ {prev ? (
44
+ <Link
45
+ href={prev.slug}
46
+ className="flex flex-col items-start group max-w-[45%]"
47
+ >
48
+ <span className="text-xs text-gray-400 dark:text-gray-500 mb-1 group-hover:text-gray-600 dark:group-hover:text-gray-300">← Previous</span>
49
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">
50
+ {prev.label}
51
+ </span>
52
+ </Link>
53
+ ) : <div />}
54
+
55
+ {next && (
56
+ <Link
57
+ href={next.slug}
58
+ className="flex flex-col items-end group max-w-[45%]"
59
+ >
60
+ <span className="text-xs text-gray-400 dark:text-gray-500 mb-1 group-hover:text-gray-600 dark:group-hover:text-gray-300">Next →</span>
61
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">
62
+ {next.label}
63
+ </span>
64
+ </Link>
65
+ )}
66
+ </div>
67
+ )
68
+ }