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,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 &ldquo;{query}&rdquo;
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
+ }