guide-layout.tsx

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