1'use client'
2
3import { Content } from '@/lib/content'
4import { Link } from 'nextjs13-progress'
5import { usePathname } from 'next/navigation'
6import Layout from './Layout'
7import { useEffect, useState, useCallback } from 'react'
8import { VideoPlayer } from '@/components/media/VideoPlayer'
9import { ImageViewer } from '@/components/media/ImageViewer'
10import { createRoot } from 'react-dom/client'
11
12interface Props {
13 children: React.ReactNode
14 guides: Content[]
15 currentGuide?: Content
16}
17
18interface TocItem {
19 id: string
20 level: number
21 text: string
22 children: TocItem[]
23}
24
25function slugify(text: string): string {
26 return text
27 .toLowerCase()
28 .replace(/[^a-z0-9]+/g, '-')
29 .replace(/^-+|-+$/g, '')
30}
31
32function generateToc(content: string): TocItem[] {
33 const container = document.createElement('div')
34 container.innerHTML = content
35 const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6')
36 const toc: TocItem[] = []
37 const stack: TocItem[] = []
38
39 headings.forEach((heading) => {
40 const level = parseInt(heading.tagName[1])
41 const id = heading.id || slugify(heading.textContent || '')
42 const text = heading.textContent || ''
43 const item: TocItem = { id, level, text, children: [] }
44
45 while (stack.length > 0 && stack[stack.length - 1].level >= level) {
46 stack.pop()
47 }
48
49 if (stack.length === 0) {
50 toc.push(item)
51 } else {
52 stack[stack.length - 1].children.push(item)
53 }
54 stack.push(item)
55 })
56
57 return toc
58}
59
60function TocItems({
61 items,
62 level = 0,
63 activeId,
64}: {
65 items: TocItem[]
66 level?: number
67 activeId?: string
68}) {
69 const [expanded, setExpanded] = useState<Record<string, boolean>>({})
70
71 const toggleExpand = (id: string) => {
72 setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
73 }
74
75 return (
76 <ul className={`space-y-2 ${level > 0 ? 'ml-4 mt-2' : ''}`}>
77 {items.map((item) => (
78 <li key={item.id}>
79 <div className="flex items-center gap-2">
80 {item.children.length > 0 && (
81 <button
82 onClick={() => toggleExpand(item.id)}
83 className="p-1 hover:text-purple-400"
84 >
85 <svg
86 className={`h-3 w-3 transform transition-transform ${
87 expanded[item.id] ? 'rotate-90' : ''
88 }`}
89 fill="none"
90 stroke="currentColor"
91 viewBox="0 0 24 24"
92 >
93 <path
94 strokeLinecap="round"
95 strokeLinejoin="round"
96 strokeWidth={2}
97 d="M9 5l7 7-7 7"
98 />
99 </svg>
100 </button>
101 )}
102 <a
103 href={`#${item.id}`}
104 className={`transition-colors ${
105 activeId === item.id ?
106 'text-purple-400'
107 : 'text-gray-400 hover:text-purple-400'
108 }`}
109 >
110 {item.text}
111 </a>
112 </div>
113 {expanded[item.id] && item.children.length > 0 && (
114 <TocItems
115 items={item.children}
116 level={level + 1}
117 activeId={activeId}
118 />
119 )}
120 </li>
121 ))}
122 </ul>
123 )
124}
125
126function HeaderLink({
127 id,
128 children,
129}: {
130 id: string
131 children: React.ReactNode
132}) {
133 const [showCheck, setShowCheck] = useState(false)
134
135 const handleCopy = useCallback(() => {
136 const url = `${window.location.origin}${window.location.pathname}#${id}`
137 navigator.clipboard.writeText(url)
138 setShowCheck(true)
139 setTimeout(() => setShowCheck(false), 1000) // Reset after 1 second
140 }, [id])
141
142 return (
143 <span className="group relative">
144 {children}
145 <button
146 onClick={handleCopy}
147 className="absolute -left-5 top-1/2 hidden -translate-y-1/2 text-gray-400 opacity-0 transition-opacity hover:text-purple-400 group-hover:opacity-100 md:block"
148 title="Copy link to section"
149 >
150 {showCheck ? 'ā' : '#'}
151 </button>
152 </span>
153 )
154}
155
156export function GuideLayout({ children, guides, currentGuide }: Props) {
157 const pathname = usePathname()
158 const [tocItems, setTocItems] = useState<TocItem[]>([])
159 const [activeId, setActiveId] = useState<string>()
160
161 useEffect(() => {
162 if (currentGuide?.content) {
163 setTocItems(generateToc(currentGuide.content))
164 }
165 }, [currentGuide?.content])
166
167 // Group guides by category
168 const groupedGuides = guides.reduce(
169 (acc, guide) => {
170 const category = guide.metadata.category || 'Uncategorized'
171 if (!acc[category]) {
172 acc[category] = []
173 }
174 acc[category].push(guide)
175 return acc
176 },
177 {} as Record<string, Content[]>,
178 )
179
180 useEffect(() => {
181 // Process custom video players
182 const videoPlayers = document.querySelectorAll('.custom-video-player')
183 videoPlayers.forEach((player) => {
184 const videoData = player.querySelector('div')
185 if (videoData) {
186 const src = videoData.getAttribute('data-video-src')
187 if (src) {
188 const videoElement = document.createElement('div')
189 const root = createRoot(videoElement)
190 root.render(<VideoPlayer src={src} />)
191 player.replaceWith(videoElement)
192 }
193 }
194 })
195
196 // Process custom image viewers
197 const imageViewers = document.querySelectorAll('.custom-image-viewer')
198 imageViewers.forEach((viewer) => {
199 const imageData = viewer.querySelector('div')
200 if (imageData) {
201 const src = imageData.getAttribute('data-image-src')
202 const alt = imageData.getAttribute('data-image-alt')
203 if (src) {
204 const imageElement = document.createElement('div')
205 const root = createRoot(imageElement)
206 root.render(<ImageViewer src={src} alt={alt || ''} />)
207 viewer.replaceWith(imageElement)
208 }
209 }
210 })
211 }, [currentGuide?.content])
212
213 useEffect(() => {
214 const observer = new IntersectionObserver(
215 (entries) => {
216 entries.forEach((entry) => {
217 if (entry.isIntersecting) {
218 setActiveId(entry.target.id)
219 }
220 })
221 },
222 {
223 rootMargin: '-80px 0px -80% 0px',
224 },
225 )
226
227 // Observe all section headings
228 document
229 .querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')
230 .forEach((element) => {
231 observer.observe(element)
232 })
233
234 return () => {
235 observer.disconnect()
236 }
237 }, [currentGuide?.content])
238
239 useEffect(() => {
240 // Only target headers within the prose content div
241 const contentDiv = document.querySelector('.prose')
242 if (!contentDiv) return
243
244 const headers = contentDiv.querySelectorAll('h1, h2, h3, h4, h5, h6')
245 headers.forEach((header) => {
246 if (!header.id) {
247 header.id = slugify(header.textContent || '')
248 }
249
250 // Wrap header content in our HeaderLink component
251 const headerContent = header.innerHTML
252 const root = createRoot(header)
253 root.render(<HeaderLink id={header.id}>{headerContent}</HeaderLink>)
254 })
255
256 // Add smooth scrolling for hash links
257 const hashLinks = contentDiv.querySelectorAll('a[href^="#"]')
258 hashLinks.forEach((link) => {
259 link.addEventListener('click', (e) => {
260 e.preventDefault()
261 const targetId = link.getAttribute('href')?.slice(1)
262 const targetElement = document.getElementById(targetId || '')
263 if (targetElement) {
264 targetElement.scrollIntoView({ behavior: 'smooth' })
265 // Update URL without scroll
266 window.history.pushState(null, '', `#${targetId}`)
267 }
268 })
269 })
270 }, [currentGuide?.content])
271
272 useEffect(() => {
273 if (window.location.hash) {
274 const targetId = window.location.hash.slice(1)
275 const targetElement = document.getElementById(targetId)
276 if (targetElement) {
277 setTimeout(() => {
278 targetElement.scrollIntoView({ behavior: 'smooth' })
279 }, 100)
280 }
281 }
282 }, [])
283
284 return (
285 <Layout>
286 <div className="container mx-auto px-4 py-20">
287 <div className="flex flex-col gap-8 lg:flex-row">
288 {/* Sidebar with Guides and ToC for smaller screens */}
289 <aside className="lg:w-64 lg:flex-shrink-0">
290 <nav className="sticky top-24 space-y-6">
291 {Object.entries(groupedGuides).map(
292 ([category, categoryGuides]) => (
293 <div key={category}>
294 <h3 className="mb-3 font-semibold text-purple-400">
295 {category}
296 </h3>
297 <ul className="space-y-2">
298 {categoryGuides.map((guide) => (
299 <li key={guide.slug}>
300 <Link
301 href={`/guides/${guide.slug}`}
302 className={`block rounded-lg px-3 py-2 text-sm transition-colors ${
303 pathname === `/guides/${guide.slug}` ?
304 'bg-purple-500/20 text-purple-400'
305 : 'text-gray-400 hover:bg-gray-800/50 hover:text-purple-400'
306 }`}
307 >
308 {guide.metadata.title}
309 </Link>
310 </li>
311 ))}
312 </ul>
313 </div>
314 ),
315 )}
316
317 {/* ToC for smaller screens */}
318 {currentGuide && currentGuide.metadata.toc && (
319 <div className="xl:hidden">
320 <h3 className="mb-3 font-semibold text-purple-400">
321 On this page
322 </h3>
323 <nav className="text-sm text-gray-400">
324 <TocItems items={tocItems} activeId={activeId} />
325 </nav>
326 </div>
327 )}
328 </nav>
329 </aside>
330
331 {/* Main content */}
332 <main className="min-w-0 flex-1">
333 {currentGuide && (
334 <>
335 <header className="mb-8">
336 <h1 className="mb-4 text-4xl font-bold">
337 {currentGuide.metadata.title}
338 </h1>
339 <div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
340 {currentGuide.metadata.date && (
341 <time dateTime={currentGuide.metadata.date}>
342 {new Date(
343 currentGuide.metadata.date,
344 ).toLocaleDateString('en-US', {
345 year: 'numeric',
346 month: 'long',
347 day: 'numeric',
348 })}
349 </time>
350 )}
351 {currentGuide.metadata.category && (
352 <span className="rounded-full bg-purple-500/10 px-3 py-1 text-purple-400">
353 {currentGuide.metadata.category}
354 </span>
355 )}
356 {currentGuide.metadata.readingTime && (
357 <span className="flex items-center gap-1">
358 <svg
359 className="h-4 w-4"
360 viewBox="0 0 24 24"
361 fill="none"
362 stroke="currentColor"
363 >
364 <circle cx="12" cy="12" r="10" strokeWidth="2" />
365 <path d="M12 6v6l4 2" strokeWidth="2" />
366 </svg>
367 {currentGuide.metadata.readingTime} min read
368 </span>
369 )}
370 </div>
371 {currentGuide.metadata.description && (
372 <p className="mt-4 text-lg text-gray-400">
373 {currentGuide.metadata.description}
374 </p>
375 )}
376 </header>
377 <div className="prose prose-invert max-w-none">{children}</div>
378 {/* Add navigation between guides */}
379 <nav className="mt-16 flex justify-between border-t border-gray-800 pt-8">
380 {currentGuide.metadata.prev && (
381 <Link
382 href={`/guides/${currentGuide.metadata.prev}`}
383 className="group flex items-center gap-2 text-gray-400 transition-colors hover:text-purple-400"
384 >
385 <svg
386 className="h-5 w-5 transition-transform group-hover:-translate-x-1"
387 viewBox="0 0 24 24"
388 fill="none"
389 stroke="currentColor"
390 >
391 <path strokeWidth="2" d="M19 12H5M12 19l-7-7 7-7" />
392 </svg>
393 Previous Guide
394 </Link>
395 )}
396 {currentGuide.metadata.next && (
397 <Link
398 href={`/guides/${currentGuide.metadata.next}`}
399 className="group flex items-center gap-2 text-gray-400 transition-colors hover:text-purple-400"
400 >
401 <svg
402 className="h-5 w-5 transition-transform group-hover:translate-x-1"
403 viewBox="0 0 24 24"
404 fill="none"
405 stroke="currentColor"
406 >
407 <path strokeWidth="2" d="M5 12h14M12 5l7 7-7 7" />
408 </svg>
409 Next Guide
410 </Link>
411 )}
412 </nav>
413 </>
414 )}
415 </main>
416
417 {/* ToC for larger screens */}
418 {currentGuide && currentGuide.metadata.toc && (
419 <aside className="hidden xl:block xl:w-64">
420 <div className="sticky top-24 space-y-3">
421 <h3 className="font-semibold text-purple-400">On this page</h3>
422 <nav className="text-sm text-gray-400">
423 <TocItems items={tocItems} activeId={activeId} />
424 </nav>
425 </div>
426 </aside>
427 )}
428 </div>
429 </div>
430 </Layout>
431 )
432}
433