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,278 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
|
|
6
|
+
type SearchResult = {
|
|
7
|
+
slug: string
|
|
8
|
+
title: string
|
|
9
|
+
description?: string
|
|
10
|
+
excerpt: string
|
|
11
|
+
group?: string
|
|
12
|
+
section?: string
|
|
13
|
+
roles: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ROLE_FILTERS = [
|
|
17
|
+
{ label: "Editor", value: "editor" },
|
|
18
|
+
{ label: "Admin", value: "admin" },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const DocIcon = () => (
|
|
22
|
+
<svg className="w-4 h-4 shrink-0 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
23
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
24
|
+
</svg>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export function SearchModal() {
|
|
28
|
+
const [open, setOpen] = useState(false)
|
|
29
|
+
const [query, setQuery] = useState("")
|
|
30
|
+
const [results, setResults] = useState<SearchResult[]>([])
|
|
31
|
+
const [groups, setGroups] = useState<string[]>([])
|
|
32
|
+
const [activeGroup, setActiveGroup] = useState<string>("")
|
|
33
|
+
const [activeRole, setActiveRole] = useState<string>("")
|
|
34
|
+
const [selected, setSelected] = useState(0)
|
|
35
|
+
const router = useRouter()
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const handler = (e: KeyboardEvent) => {
|
|
39
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
setOpen((o) => !o)
|
|
42
|
+
}
|
|
43
|
+
if (e.key === "Escape") setOpen(false)
|
|
44
|
+
}
|
|
45
|
+
window.addEventListener("keydown", handler)
|
|
46
|
+
return () => window.removeEventListener("keydown", handler)
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
const search = useCallback(async (q: string, group: string, role: string) => {
|
|
50
|
+
let data: { results: SearchResult[]; groups: string[] }
|
|
51
|
+
|
|
52
|
+
// Try the live API; fall back to the pre-built static index (offline builds)
|
|
53
|
+
try {
|
|
54
|
+
const params = new URLSearchParams()
|
|
55
|
+
if (q) params.set("q", q)
|
|
56
|
+
if (group) params.set("group", group)
|
|
57
|
+
if (role) params.set("role", role)
|
|
58
|
+
const res = await fetch(`/api/search?${params}`)
|
|
59
|
+
if (!res.ok) throw new Error("api unavailable")
|
|
60
|
+
data = await res.json()
|
|
61
|
+
} catch {
|
|
62
|
+
const allDocs: (SearchResult & { body: string })[] = await fetch("/search-index.json").then((r) => r.json())
|
|
63
|
+
const terms = q.toLowerCase().trim().split(/\s+/).filter(Boolean)
|
|
64
|
+
const uniqueGroups = [...new Set(allDocs.map((d) => d.group).filter((g): g is string => Boolean(g)))]
|
|
65
|
+
|
|
66
|
+
if (!terms.length) {
|
|
67
|
+
data = { results: [], groups: uniqueGroups }
|
|
68
|
+
} else {
|
|
69
|
+
const scored = allDocs
|
|
70
|
+
.filter((doc) => {
|
|
71
|
+
if (group && doc.group !== group) return false
|
|
72
|
+
if (role && !doc.roles.includes(role) && doc.roles.length > 0) return false
|
|
73
|
+
return true
|
|
74
|
+
})
|
|
75
|
+
.map((doc) => {
|
|
76
|
+
let score = 0
|
|
77
|
+
for (const term of terms) {
|
|
78
|
+
if (doc.title.toLowerCase().includes(term)) score += 10
|
|
79
|
+
if ((doc.description ?? "").toLowerCase().includes(term)) score += 5
|
|
80
|
+
if (doc.body.toLowerCase().includes(term)) score += 1
|
|
81
|
+
}
|
|
82
|
+
const idx = doc.body.toLowerCase().indexOf(terms[0])
|
|
83
|
+
const excerpt = doc.description || (idx >= 0 ? doc.body.slice(Math.max(0, idx - 30), idx + 100).trim() : "")
|
|
84
|
+
return { ...doc, score, excerpt }
|
|
85
|
+
})
|
|
86
|
+
.filter((d) => d.score > 0)
|
|
87
|
+
.sort((a, b) => b.score - a.score)
|
|
88
|
+
.slice(0, 10)
|
|
89
|
+
data = { results: scored, groups: uniqueGroups }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setResults(data.results ?? [])
|
|
94
|
+
if (data.groups) setGroups(data.groups)
|
|
95
|
+
setSelected(0)
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const t = setTimeout(() => search(query, activeGroup, activeRole), 150)
|
|
100
|
+
return () => clearTimeout(t)
|
|
101
|
+
}, [query, activeGroup, activeRole, search])
|
|
102
|
+
|
|
103
|
+
// Load groups on open
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (open) search("", "", "")
|
|
106
|
+
}, [open, search])
|
|
107
|
+
|
|
108
|
+
const navigate = (slug: string) => {
|
|
109
|
+
router.push(slug)
|
|
110
|
+
setOpen(false)
|
|
111
|
+
setQuery("")
|
|
112
|
+
setActiveGroup("")
|
|
113
|
+
setActiveRole("")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const toggleGroup = (g: string) => setActiveGroup((prev) => prev === g ? "" : g)
|
|
117
|
+
const toggleRole = (r: string) => setActiveRole((prev) => prev === r ? "" : r)
|
|
118
|
+
|
|
119
|
+
if (!open) return null
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]"
|
|
124
|
+
style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(4px)" }}
|
|
125
|
+
onClick={() => setOpen(false)}
|
|
126
|
+
>
|
|
127
|
+
<div
|
|
128
|
+
className="w-full max-w-2xl rounded-xl overflow-hidden shadow-2xl"
|
|
129
|
+
style={{ backgroundColor: "#1a1d23", border: "1px solid #2e3340" }}
|
|
130
|
+
onClick={(e) => e.stopPropagation()}
|
|
131
|
+
>
|
|
132
|
+
{/* Search input */}
|
|
133
|
+
<div className="flex items-center gap-3 px-4 py-3" style={{ borderBottom: "1px solid #2e3340" }}>
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => setOpen(false)}
|
|
136
|
+
className="text-gray-500 hover:text-gray-300 transition-colors p-1 rounded"
|
|
137
|
+
>
|
|
138
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
139
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
140
|
+
</svg>
|
|
141
|
+
</button>
|
|
142
|
+
<input
|
|
143
|
+
autoFocus
|
|
144
|
+
className="flex-1 text-sm outline-none bg-transparent text-gray-100 placeholder-gray-500"
|
|
145
|
+
placeholder="Search documentation..."
|
|
146
|
+
value={query}
|
|
147
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
148
|
+
onKeyDown={(e) => {
|
|
149
|
+
if (e.key === "ArrowDown") setSelected((s) => Math.min(s + 1, results.length - 1))
|
|
150
|
+
if (e.key === "ArrowUp") setSelected((s) => Math.max(s - 1, 0))
|
|
151
|
+
if (e.key === "Enter" && results[selected]) navigate(results[selected].slug)
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
<kbd className="text-xs text-gray-500 border border-gray-600 rounded px-1.5 py-0.5">esc</kbd>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Filter chips */}
|
|
158
|
+
<div className="flex items-center gap-2 px-4 py-2.5 flex-wrap" style={{ borderBottom: "1px solid #2e3340" }}>
|
|
159
|
+
{/* Section filters */}
|
|
160
|
+
{groups.map((g) => (
|
|
161
|
+
<button
|
|
162
|
+
key={g}
|
|
163
|
+
onClick={() => toggleGroup(g)}
|
|
164
|
+
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors"
|
|
165
|
+
style={{
|
|
166
|
+
backgroundColor: activeGroup === g ? "#2d5a4e" : "#252830",
|
|
167
|
+
color: activeGroup === g ? "#6ee7b7" : "#9ca3af",
|
|
168
|
+
border: activeGroup === g ? "1px solid #34d399" : "1px solid #374151",
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{g}
|
|
172
|
+
{activeGroup === g && <span className="text-xs opacity-70">×</span>}
|
|
173
|
+
</button>
|
|
174
|
+
))}
|
|
175
|
+
|
|
176
|
+
{/* Divider */}
|
|
177
|
+
{groups.length > 0 && (
|
|
178
|
+
<span className="text-gray-700 text-xs">|</span>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{/* Role filters */}
|
|
182
|
+
{ROLE_FILTERS.map((r) => (
|
|
183
|
+
<button
|
|
184
|
+
key={r.value}
|
|
185
|
+
onClick={() => toggleRole(r.value)}
|
|
186
|
+
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors"
|
|
187
|
+
style={{
|
|
188
|
+
backgroundColor: activeRole === r.value ? "#1e3a5f" : "#252830",
|
|
189
|
+
color: activeRole === r.value ? "#93c5fd" : "#9ca3af",
|
|
190
|
+
border: activeRole === r.value ? "1px solid #3b82f6" : "1px solid #374151",
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{r.label}
|
|
194
|
+
{activeRole === r.value && <span className="text-xs opacity-70">×</span>}
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Results */}
|
|
200
|
+
<div className="max-h-96 overflow-y-auto">
|
|
201
|
+
{results.length > 0 ? (
|
|
202
|
+
<>
|
|
203
|
+
<p className="px-4 pt-3 pb-1 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
|
204
|
+
Results
|
|
205
|
+
</p>
|
|
206
|
+
<ul>
|
|
207
|
+
{results.map((r, i) => (
|
|
208
|
+
<li key={r.slug}>
|
|
209
|
+
<button
|
|
210
|
+
className="w-full text-left px-4 py-3 flex items-center gap-3 transition-colors"
|
|
211
|
+
style={{ backgroundColor: i === selected ? "#252830" : "transparent" }}
|
|
212
|
+
onClick={() => navigate(r.slug)}
|
|
213
|
+
onMouseEnter={() => setSelected(i)}
|
|
214
|
+
>
|
|
215
|
+
<DocIcon />
|
|
216
|
+
<div className="flex-1 min-w-0">
|
|
217
|
+
<p className="text-sm font-medium text-gray-100 truncate">{r.title}</p>
|
|
218
|
+
{(r.group || r.section) && (
|
|
219
|
+
<p className="text-xs text-gray-500 mt-0.5 flex items-center gap-1">
|
|
220
|
+
{r.group && <span>{r.group}</span>}
|
|
221
|
+
{r.group && r.section && r.section !== r.title && (
|
|
222
|
+
<>
|
|
223
|
+
<span className="text-gray-700">›</span>
|
|
224
|
+
<span>{r.section}</span>
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
</p>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
<span
|
|
231
|
+
className="shrink-0 text-xs px-2 py-0.5 rounded"
|
|
232
|
+
style={{ backgroundColor: "#252830", color: "#6b7280", border: "1px solid #374151" }}
|
|
233
|
+
>
|
|
234
|
+
Guide
|
|
235
|
+
</span>
|
|
236
|
+
</button>
|
|
237
|
+
</li>
|
|
238
|
+
))}
|
|
239
|
+
</ul>
|
|
240
|
+
</>
|
|
241
|
+
) : query ? (
|
|
242
|
+
<p className="px-4 py-8 text-sm text-gray-500 text-center">
|
|
243
|
+
No results for “{query}”
|
|
244
|
+
</p>
|
|
245
|
+
) : (
|
|
246
|
+
<p className="px-4 py-8 text-xs text-gray-600 text-center">
|
|
247
|
+
Type to search, or use filters to browse by section or role
|
|
248
|
+
</p>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{/* Footer hints */}
|
|
253
|
+
<div className="px-4 py-2 flex gap-4 text-xs text-gray-600" style={{ borderTop: "1px solid #2e3340" }}>
|
|
254
|
+
<span><kbd className="border border-gray-700 rounded px-1 text-gray-500">↑↓</kbd> navigate</span>
|
|
255
|
+
<span><kbd className="border border-gray-700 rounded px-1 text-gray-500">↵</kbd> select</span>
|
|
256
|
+
<span><kbd className="border border-gray-700 rounded px-1 text-gray-500">esc</kbd> close</span>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function SearchTrigger() {
|
|
264
|
+
return (
|
|
265
|
+
<button
|
|
266
|
+
onClick={() => {
|
|
267
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }))
|
|
268
|
+
}}
|
|
269
|
+
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-400 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-500 transition-colors"
|
|
270
|
+
>
|
|
271
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
272
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
273
|
+
</svg>
|
|
274
|
+
<span>Search</span>
|
|
275
|
+
<kbd className="text-xs border border-gray-600 rounded px-1">⌘K</kbd>
|
|
276
|
+
</button>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import type { NavEntry } from "@/lib/nav-types"
|
|
3
|
+
|
|
4
|
+
export function SectionCards({ entry }: { entry: NavEntry }) {
|
|
5
|
+
if (!entry.section || entry.section.length === 0) return null
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-800">
|
|
9
|
+
<h2 className="text-base font-semibold text-gray-700 dark:text-gray-300 mb-4 uppercase tracking-wide text-xs">In this section</h2>
|
|
10
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
11
|
+
{entry.section.map((child) => (
|
|
12
|
+
<Link
|
|
13
|
+
key={child.slug}
|
|
14
|
+
href={child.slug}
|
|
15
|
+
className="group flex flex-col gap-1 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900 transition-all"
|
|
16
|
+
>
|
|
17
|
+
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">
|
|
18
|
+
{child.label}
|
|
19
|
+
</span>
|
|
20
|
+
{child.children && child.children.length > 0 && (
|
|
21
|
+
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
22
|
+
{child.children.length} article{child.children.length !== 1 ? "s" : ""}
|
|
23
|
+
</span>
|
|
24
|
+
)}
|
|
25
|
+
<span className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
26
|
+
View →
|
|
27
|
+
</span>
|
|
28
|
+
</Link>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import Link from "next/link"
|
|
5
|
+
import { NavEntry, NavGroup, NavChild } from "@/lib/nav-types"
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
activeGroup?: NavGroup | null
|
|
9
|
+
currentSlug: string
|
|
10
|
+
userRoles: string[]
|
|
11
|
+
authEnabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canSee(roles: string[], userRoles: string[], authEnabled: boolean) {
|
|
15
|
+
if (!authEnabled) return true
|
|
16
|
+
return roles.length === 0 || roles.some((r) => userRoles.includes(r))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Doc page link (leaf node inside a section)
|
|
20
|
+
function DocLink({ item, currentSlug }: { item: NavChild; currentSlug: string }) {
|
|
21
|
+
const isActive = currentSlug === item.slug
|
|
22
|
+
return (
|
|
23
|
+
<li>
|
|
24
|
+
<Link
|
|
25
|
+
href={item.slug}
|
|
26
|
+
style={isActive ? { borderColor: "var(--cm-active-border)", color: "var(--cm-active)" } : {}}
|
|
27
|
+
className={`block py-1.5 pl-4 pr-3 text-sm border-l-2 transition-colors ${
|
|
28
|
+
isActive
|
|
29
|
+
? "font-medium bg-black/5 dark:bg-white/10"
|
|
30
|
+
: "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:border-gray-300 dark:hover:border-gray-600"
|
|
31
|
+
}`}
|
|
32
|
+
>
|
|
33
|
+
{item.label}
|
|
34
|
+
</Link>
|
|
35
|
+
</li>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Collapsible section row (e.g. "DoW Deployment") — collapsed by default
|
|
40
|
+
function SectionRow({
|
|
41
|
+
section,
|
|
42
|
+
currentSlug,
|
|
43
|
+
userRoles,
|
|
44
|
+
authEnabled = false,
|
|
45
|
+
}: {
|
|
46
|
+
section: NavChild
|
|
47
|
+
currentSlug: string
|
|
48
|
+
userRoles: string[]
|
|
49
|
+
authEnabled?: boolean
|
|
50
|
+
}) {
|
|
51
|
+
const docs = (section.children ?? []).filter((c) => canSee(c.roles, userRoles, authEnabled))
|
|
52
|
+
const hasChildren = docs.length > 0
|
|
53
|
+
|
|
54
|
+
// Open only if the current page lives inside this section
|
|
55
|
+
const containsCurrent =
|
|
56
|
+
currentSlug === section.slug ||
|
|
57
|
+
docs.some((d) => currentSlug === d.slug)
|
|
58
|
+
|
|
59
|
+
const [open, setOpen] = useState(containsCurrent)
|
|
60
|
+
|
|
61
|
+
// No children — render as a plain link, no arrow
|
|
62
|
+
if (!hasChildren) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="mb-0.5">
|
|
65
|
+
<Link
|
|
66
|
+
href={section.slug}
|
|
67
|
+
style={currentSlug === section.slug ? { borderColor: "var(--cm-active-border)", color: "var(--cm-active)" } : {}}
|
|
68
|
+
className={`block py-1.5 pl-4 pr-2 text-sm border-l-2 transition-colors ${
|
|
69
|
+
currentSlug === section.slug
|
|
70
|
+
? "font-medium bg-black/5 dark:bg-white/10"
|
|
71
|
+
: "border-transparent text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:border-gray-300 dark:hover:border-gray-600"
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
{section.label}
|
|
75
|
+
</Link>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="mb-0.5">
|
|
82
|
+
{/* Section header — full-width click area toggles collapse */}
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setOpen((o) => !o)}
|
|
85
|
+
className="w-full flex items-center justify-between py-1.5 px-2 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors rounded"
|
|
86
|
+
>
|
|
87
|
+
<span>{section.label}</span>
|
|
88
|
+
<svg
|
|
89
|
+
className={`w-3.5 h-3.5 shrink-0 text-gray-400 transition-transform ${open ? "rotate-0" : "rotate-180"}`}
|
|
90
|
+
fill="none"
|
|
91
|
+
viewBox="0 0 24 24"
|
|
92
|
+
stroke="currentColor"
|
|
93
|
+
strokeWidth={2.5}
|
|
94
|
+
>
|
|
95
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
96
|
+
</svg>
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
{open && (
|
|
100
|
+
<ul className="mt-0.5 mb-1">
|
|
101
|
+
{docs.map((doc) => (
|
|
102
|
+
<DocLink key={doc.slug} item={doc} currentSlug={currentSlug} />
|
|
103
|
+
))}
|
|
104
|
+
</ul>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Category block (e.g. "AUTHORIZATION PATH") — label only, no arrow
|
|
111
|
+
function CategoryBlock({
|
|
112
|
+
entry,
|
|
113
|
+
currentSlug,
|
|
114
|
+
userRoles,
|
|
115
|
+
authEnabled = false,
|
|
116
|
+
}: {
|
|
117
|
+
entry: NavEntry
|
|
118
|
+
currentSlug: string
|
|
119
|
+
userRoles: string[]
|
|
120
|
+
authEnabled?: boolean
|
|
121
|
+
}) {
|
|
122
|
+
const sections = (entry.section ?? []).filter((s) => canSee(s.roles, userRoles, authEnabled))
|
|
123
|
+
const isCategoryActive = currentSlug === entry.slug
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="mb-4">
|
|
127
|
+
{/* Category label — ALL CAPS, highlighted when on the category landing page */}
|
|
128
|
+
<Link
|
|
129
|
+
href={entry.slug}
|
|
130
|
+
style={isCategoryActive ? { color: "var(--cm-active)" } : {}}
|
|
131
|
+
className={`block px-2 pb-1 text-xs font-semibold uppercase tracking-widest transition-colors select-none ${
|
|
132
|
+
isCategoryActive ? "" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
|
133
|
+
}`}
|
|
134
|
+
>
|
|
135
|
+
{entry.label}
|
|
136
|
+
</Link>
|
|
137
|
+
|
|
138
|
+
{sections.map((section) => (
|
|
139
|
+
<SectionRow
|
|
140
|
+
key={section.slug}
|
|
141
|
+
section={section}
|
|
142
|
+
currentSlug={currentSlug}
|
|
143
|
+
userRoles={userRoles}
|
|
144
|
+
authEnabled={authEnabled}
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function GroupSidebar({
|
|
152
|
+
group,
|
|
153
|
+
currentSlug,
|
|
154
|
+
userRoles,
|
|
155
|
+
authEnabled = false,
|
|
156
|
+
}: {
|
|
157
|
+
group: NavGroup
|
|
158
|
+
currentSlug: string
|
|
159
|
+
userRoles: string[]
|
|
160
|
+
authEnabled?: boolean
|
|
161
|
+
}) {
|
|
162
|
+
const visibleItems = group.items.filter((i) => canSee(i.roles, userRoles, authEnabled))
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<aside className="hidden md:block w-72 shrink-0 border-r border-gray-200 dark:border-gray-800 overflow-y-auto bg-white dark:bg-gray-950">
|
|
166
|
+
<div className="px-4 py-4">
|
|
167
|
+
{visibleItems.map((entry) => (
|
|
168
|
+
<CategoryBlock
|
|
169
|
+
key={entry.slug}
|
|
170
|
+
entry={entry}
|
|
171
|
+
currentSlug={currentSlug}
|
|
172
|
+
userRoles={userRoles}
|
|
173
|
+
authEnabled={authEnabled}
|
|
174
|
+
/>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
</aside>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Sidebar({ activeGroup, currentSlug, userRoles, authEnabled = false }: Props) {
|
|
182
|
+
if (activeGroup) {
|
|
183
|
+
return (
|
|
184
|
+
<GroupSidebar
|
|
185
|
+
group={activeGroup}
|
|
186
|
+
currentSlug={currentSlug}
|
|
187
|
+
userRoles={userRoles}
|
|
188
|
+
authEnabled={authEnabled}
|
|
189
|
+
/>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<aside className="hidden md:block w-72 shrink-0 border-r border-gray-200 dark:border-gray-800 p-4 overflow-y-auto bg-white dark:bg-gray-950" />
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import type { TocEntry } from "@/lib/mdx"
|
|
5
|
+
|
|
6
|
+
export function Toc({ entries }: { entries: TocEntry[] }) {
|
|
7
|
+
const [activeId, setActiveId] = useState<string>("")
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (entries.length === 0) return
|
|
11
|
+
|
|
12
|
+
const observer = new IntersectionObserver(
|
|
13
|
+
(obs) => {
|
|
14
|
+
const visible = obs.filter((e) => e.isIntersecting)
|
|
15
|
+
if (visible.length > 0) setActiveId(visible[0].target.id)
|
|
16
|
+
},
|
|
17
|
+
{ rootMargin: "0px 0px -60% 0px", threshold: 0 }
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
entries.forEach(({ id }) => {
|
|
21
|
+
const el = document.getElementById(id)
|
|
22
|
+
if (el) observer.observe(el)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return () => observer.disconnect()
|
|
26
|
+
}, [entries])
|
|
27
|
+
|
|
28
|
+
if (entries.length === 0) return null
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<aside className="hidden xl:block w-56 shrink-0 pl-6 py-8 self-start sticky top-8">
|
|
32
|
+
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
33
|
+
On this page
|
|
34
|
+
</p>
|
|
35
|
+
<ul className="border-l border-gray-200 dark:border-gray-700">
|
|
36
|
+
{entries.map((entry) => (
|
|
37
|
+
<li key={entry.id}>
|
|
38
|
+
<a
|
|
39
|
+
href={`#${entry.id}`}
|
|
40
|
+
onClick={() => setActiveId(entry.id)}
|
|
41
|
+
style={activeId === entry.id ? { borderColor: "var(--cm-active-border)", color: "var(--cm-active)" } : {}}
|
|
42
|
+
className={`block text-sm leading-snug py-1 transition-colors border-l-2 -ml-px ${
|
|
43
|
+
entry.level === 3 ? "pl-5" : "pl-3"
|
|
44
|
+
} ${
|
|
45
|
+
activeId === entry.id
|
|
46
|
+
? "font-semibold"
|
|
47
|
+
: "border-transparent text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
48
|
+
}`}
|
|
49
|
+
>
|
|
50
|
+
{entry.text}
|
|
51
|
+
</a>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
</aside>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
import { usePathname } from "next/navigation"
|
|
5
|
+
|
|
6
|
+
export function ZoomImages() {
|
|
7
|
+
const pathname = usePathname()
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const article = document.querySelector("article")
|
|
11
|
+
if (!article) return
|
|
12
|
+
|
|
13
|
+
const imgs = Array.from(article.querySelectorAll<HTMLImageElement>("img"))
|
|
14
|
+
|
|
15
|
+
function toggle(this: HTMLImageElement) {
|
|
16
|
+
this.classList.toggle("zoomed")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
imgs.forEach((img) => img.addEventListener("click", toggle))
|
|
20
|
+
return () => imgs.forEach((img) => img.removeEventListener("click", toggle))
|
|
21
|
+
}, [pathname])
|
|
22
|
+
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Icon } from "./Icon"
|
|
2
|
+
|
|
3
|
+
type CalloutType = "tip" | "warning" | "important" | "note" | "danger" | "success"
|
|
4
|
+
|
|
5
|
+
const labels: Record<CalloutType, string> = {
|
|
6
|
+
tip: "Tip",
|
|
7
|
+
warning: "Warning",
|
|
8
|
+
important: "Important",
|
|
9
|
+
note: "Note",
|
|
10
|
+
danger: "Danger",
|
|
11
|
+
success: "Success",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const defaultIcons: Record<CalloutType, string> = {
|
|
15
|
+
tip: "lightbulb",
|
|
16
|
+
warning: "triangle-alert",
|
|
17
|
+
important: "info",
|
|
18
|
+
note: "notebook-pen",
|
|
19
|
+
danger: "circle-x",
|
|
20
|
+
success: "circle-check",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Callout({
|
|
24
|
+
type = "note",
|
|
25
|
+
icon,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
type?: CalloutType
|
|
29
|
+
icon?: string
|
|
30
|
+
children: React.ReactNode
|
|
31
|
+
}) {
|
|
32
|
+
const iconName = icon ?? defaultIcons[type]
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={`callout ${type} my-4`}>
|
|
36
|
+
<div className="callout-header flex items-center gap-1.5">
|
|
37
|
+
<Icon name={iconName} size={13} />
|
|
38
|
+
{labels[type]}
|
|
39
|
+
</div>
|
|
40
|
+
<div className="callout-body">{children}</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|