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.
- package/dist/index.js +142 -0
- package/package.json +51 -0
- package/template/_env.example +16 -0
- package/template/_gitignore +32 -0
- package/template/_package.json +39 -0
- package/template/app/[...slug]/page.tsx +150 -0
- package/template/app/api/auth/callback/route.ts +47 -0
- package/template/app/api/auth/login/route.ts +39 -0
- package/template/app/api/auth/logout/route.ts +8 -0
- package/template/app/api/auth/signin/route.ts +64 -0
- package/template/app/api/download/route.ts +67 -0
- package/template/app/api/raw/route.ts +43 -0
- package/template/app/api/search/route.ts +138 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +524 -0
- package/template/app/home/page.tsx +136 -0
- package/template/app/icon.svg +29 -0
- package/template/app/layout.tsx +44 -0
- package/template/app/login/LoginForm.tsx +105 -0
- package/template/app/login/page.tsx +9 -0
- package/template/app/page.tsx +5 -0
- package/template/camelmind.config.ts +33 -0
- package/template/components/Breadcrumbs/Breadcrumbs.tsx +41 -0
- package/template/components/Code/CodeBlock.tsx +55 -0
- package/template/components/DocActions/DocActions.tsx +62 -0
- package/template/components/Nav/MobileDrawer.tsx +221 -0
- package/template/components/Nav/ThemeToggle.tsx +62 -0
- package/template/components/Nav/TopNav.tsx +173 -0
- package/template/components/Nav/VersionSelector.tsx +107 -0
- package/template/components/PageNav/PageNav.tsx +68 -0
- package/template/components/Search/SearchModal.tsx +278 -0
- package/template/components/SectionCards/SectionCards.tsx +33 -0
- package/template/components/Sidebar/Sidebar.tsx +196 -0
- package/template/components/Toc/Toc.tsx +57 -0
- package/template/components/ZoomImages/ZoomImages.tsx +24 -0
- package/template/components/mdx/Callout.tsx +43 -0
- package/template/components/mdx/Details.tsx +65 -0
- package/template/components/mdx/Icon.tsx +25 -0
- package/template/components/mdx/Steps.tsx +33 -0
- package/template/components/mdx/Tabs.tsx +69 -0
- package/template/components/mdx/index.tsx +34 -0
- package/template/content/getting-started/installation.mdx +51 -0
- package/template/content/getting-started/overview.mdx +39 -0
- package/template/content/guides/writing-docs.mdx +72 -0
- package/template/eslint.config.mjs +18 -0
- package/template/lib/auth-providers/dev-mock.ts +45 -0
- package/template/lib/auth-providers/oidc.ts +95 -0
- package/template/lib/auth-roles.ts +28 -0
- package/template/lib/auth.ts +76 -0
- package/template/lib/config-types.ts +30 -0
- package/template/lib/config.ts +29 -0
- package/template/lib/mdx.ts +53 -0
- package/template/lib/nav-types.ts +31 -0
- package/template/lib/nav.ts +125 -0
- package/template/lib/versions.ts +61 -0
- package/template/nav/nav.yml +20 -0
- package/template/next.config.ts +35 -0
- package/template/postcss.config.mjs +7 -0
- package/template/proxy.ts +50 -0
- package/template/public/2f-logo.png +0 -0
- package/template/public/favicon.png +0 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/images/gwa-app-central-main.png +0 -0
- package/template/public/next.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/scripts/build-offline.sh +189 -0
- package/template/scripts/build-pdf.sh +41 -0
- package/template/scripts/build-search-index.ts +90 -0
- package/template/scripts/generate-master-pdf.ts +260 -0
- package/template/scripts/generate-pdfs.ts +185 -0
- package/template/tsconfig.json +34 -0
- 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"><🐪></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"><</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">></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
|
+
}
|