create-unmint 1.0.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 (55) hide show
  1. package/README.md +57 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +499 -0
  4. package/package.json +48 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +278 -0
  7. package/template/__tests__/components/callout.test.tsx +46 -0
  8. package/template/__tests__/components/card.test.tsx +59 -0
  9. package/template/__tests__/components/tabs.test.tsx +61 -0
  10. package/template/__tests__/theme-config.test.ts +49 -0
  11. package/template/__tests__/utils.test.ts +25 -0
  12. package/template/app/api/og/route.tsx +90 -0
  13. package/template/app/api/search/route.ts +6 -0
  14. package/template/app/components/docs/docs-pager.tsx +41 -0
  15. package/template/app/components/docs/docs-sidebar.tsx +143 -0
  16. package/template/app/components/docs/docs-toc.tsx +61 -0
  17. package/template/app/components/docs/mdx/accordion.tsx +54 -0
  18. package/template/app/components/docs/mdx/callout.tsx +102 -0
  19. package/template/app/components/docs/mdx/card.tsx +110 -0
  20. package/template/app/components/docs/mdx/code-block.tsx +42 -0
  21. package/template/app/components/docs/mdx/frame.tsx +14 -0
  22. package/template/app/components/docs/mdx/index.tsx +167 -0
  23. package/template/app/components/docs/mdx/pre.tsx +82 -0
  24. package/template/app/components/docs/mdx/steps.tsx +59 -0
  25. package/template/app/components/docs/mdx/tabs.tsx +60 -0
  26. package/template/app/components/docs/mdx/youtube.tsx +18 -0
  27. package/template/app/components/docs/search-dialog.tsx +281 -0
  28. package/template/app/components/docs/theme-toggle.tsx +35 -0
  29. package/template/app/docs/[[...slug]]/page.tsx +139 -0
  30. package/template/app/docs/layout.tsx +98 -0
  31. package/template/app/globals.css +151 -0
  32. package/template/app/layout.tsx +33 -0
  33. package/template/app/page.tsx +5 -0
  34. package/template/app/providers/theme-provider.tsx +8 -0
  35. package/template/content/docs/components.mdx +82 -0
  36. package/template/content/docs/customization.mdx +34 -0
  37. package/template/content/docs/deployment.mdx +28 -0
  38. package/template/content/docs/index.mdx +91 -0
  39. package/template/content/docs/meta.json +13 -0
  40. package/template/content/docs/quickstart.mdx +110 -0
  41. package/template/content/docs/theming.mdx +41 -0
  42. package/template/lib/docs-source.ts +7 -0
  43. package/template/lib/theme-config.ts +89 -0
  44. package/template/lib/utils.ts +6 -0
  45. package/template/next.config.mjs +10 -0
  46. package/template/package-lock.json +10695 -0
  47. package/template/package.json +45 -0
  48. package/template/postcss.config.mjs +7 -0
  49. package/template/public/logo.png +0 -0
  50. package/template/public/logo.svg +9 -0
  51. package/template/public/logo.txt +1 -0
  52. package/template/source.config.ts +22 -0
  53. package/template/tailwind.config.ts +34 -0
  54. package/template/tsconfig.json +33 -0
  55. package/template/vitest.config.ts +16 -0
@@ -0,0 +1,167 @@
1
+ import type { MDXComponents } from 'mdx/types'
2
+ import Link from 'next/link'
3
+ import Image from 'next/image'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ // Component imports
7
+ import { Card, CardGroup } from './card'
8
+ import { Info, Tip, Warning, Note, Check } from './callout'
9
+ import { Steps, Step } from './steps'
10
+ import { Tabs, Tab } from './tabs'
11
+ import { Accordion, AccordionGroup } from './accordion'
12
+ import { CodeBlock } from './code-block'
13
+ import { Frame } from './frame'
14
+ import { YouTube } from './youtube'
15
+ import { Pre } from './pre'
16
+
17
+ // Re-export for direct imports
18
+ export { Card, CardGroup } from './card'
19
+ export { Info, Tip, Warning, Note, Check } from './callout'
20
+ export { Steps, Step } from './steps'
21
+ export { Tabs, Tab } from './tabs'
22
+ export { Accordion, AccordionGroup } from './accordion'
23
+ export { CodeBlock } from './code-block'
24
+ export { Frame } from './frame'
25
+ export { YouTube } from './youtube'
26
+ export { Pre } from './pre'
27
+
28
+ export function getMDXComponents(): MDXComponents {
29
+ return {
30
+ // Custom components
31
+ Card,
32
+ CardGroup,
33
+ Info,
34
+ Tip,
35
+ Warning,
36
+ Note,
37
+ Check,
38
+ Steps,
39
+ Step,
40
+ Tabs,
41
+ Tab,
42
+ Accordion,
43
+ AccordionGroup,
44
+ CodeBlock,
45
+ Frame,
46
+ YouTube,
47
+
48
+ // HTML element overrides
49
+ h1: ({ children, id }) => (
50
+ <h1 id={id} className="scroll-m-20 text-4xl font-bold tracking-tight mt-8 mb-4 first:mt-0">
51
+ {children}
52
+ </h1>
53
+ ),
54
+ h2: ({ children, id }) => (
55
+ <h2 id={id} className="scroll-m-20 text-2xl font-semibold tracking-tight mt-10 mb-4 pb-2 border-b border-border">
56
+ <a href={`#${id}`} className="hover:underline">
57
+ {children}
58
+ </a>
59
+ </h2>
60
+ ),
61
+ h3: ({ children, id }) => (
62
+ <h3 id={id} className="scroll-m-20 text-xl font-semibold tracking-tight mt-8 mb-4">
63
+ <a href={`#${id}`} className="hover:underline">
64
+ {children}
65
+ </a>
66
+ </h3>
67
+ ),
68
+ h4: ({ children, id }) => (
69
+ <h4 id={id} className="scroll-m-20 text-lg font-semibold tracking-tight mt-6 mb-4">
70
+ {children}
71
+ </h4>
72
+ ),
73
+ p: ({ children }) => (
74
+ <p className="leading-7 text-muted-foreground [&:not(:first-child)]:mt-4">
75
+ {children}
76
+ </p>
77
+ ),
78
+ a: ({ href, children }) => {
79
+ const isExternal = href?.startsWith('http')
80
+ if (isExternal) {
81
+ return (
82
+ <a
83
+ href={href}
84
+ target="_blank"
85
+ rel="noopener noreferrer"
86
+ className="text-[var(--accent)] hover:underline"
87
+ >
88
+ {children}
89
+ </a>
90
+ )
91
+ }
92
+ return (
93
+ <Link href={href || ''} className="text-[var(--accent)] hover:underline">
94
+ {children}
95
+ </Link>
96
+ )
97
+ },
98
+ ul: ({ children }) => (
99
+ <ul className="my-4 ml-6 list-disc text-muted-foreground [&>li]:mt-2">
100
+ {children}
101
+ </ul>
102
+ ),
103
+ ol: ({ children }) => (
104
+ <ol className="my-4 ml-6 list-decimal text-muted-foreground [&>li]:mt-2">
105
+ {children}
106
+ </ol>
107
+ ),
108
+ li: ({ children }) => <li className="leading-7">{children}</li>,
109
+ blockquote: ({ children }) => (
110
+ <blockquote className="mt-6 border-l-4 border-border pl-4 italic text-muted-foreground">
111
+ {children}
112
+ </blockquote>
113
+ ),
114
+ hr: () => <hr className="my-8 border-border" />,
115
+ table: ({ children }) => (
116
+ <div className="my-6 w-full overflow-x-auto">
117
+ <table className="w-full border-collapse text-sm">
118
+ {children}
119
+ </table>
120
+ </div>
121
+ ),
122
+ thead: ({ children }) => (
123
+ <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>
124
+ ),
125
+ tbody: ({ children }) => (
126
+ <tbody className="divide-y divide-border">{children}</tbody>
127
+ ),
128
+ tr: ({ children }) => <tr>{children}</tr>,
129
+ th: ({ children }) => (
130
+ <th className="px-4 py-3 text-left font-semibold text-foreground border-b border-border">
131
+ {children}
132
+ </th>
133
+ ),
134
+ td: ({ children }) => (
135
+ <td className="px-4 py-3 text-muted-foreground">{children}</td>
136
+ ),
137
+ pre: Pre,
138
+ code: ({ children, className }) => {
139
+ // Inline code (no className from syntax highlighter)
140
+ if (!className) {
141
+ return (
142
+ <code className="px-1.5 py-0.5 mx-0.5 rounded-md bg-muted border border-border/50 text-sm font-mono text-foreground">
143
+ {children}
144
+ </code>
145
+ )
146
+ }
147
+ // Code block (has className from syntax highlighter)
148
+ return <code className={className}>{children}</code>
149
+ },
150
+ img: ({ src, alt, ...props }) => (
151
+ <span className="block my-6">
152
+ <Image
153
+ src={src || ''}
154
+ alt={alt || ''}
155
+ width={800}
156
+ height={400}
157
+ className="rounded-lg max-w-full h-auto"
158
+ {...props}
159
+ />
160
+ </span>
161
+ ),
162
+ strong: ({ children }) => (
163
+ <strong className="font-semibold text-foreground">{children}</strong>
164
+ ),
165
+ em: ({ children }) => <em className="italic">{children}</em>,
166
+ }
167
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ interface PreProps extends React.HTMLAttributes<HTMLPreElement> {
7
+ children: React.ReactNode
8
+ 'data-language'?: string
9
+ }
10
+
11
+ export function Pre({ children, className, 'data-language': language, ...props }: PreProps) {
12
+ const [copied, setCopied] = useState(false)
13
+ const preRef = useRef<HTMLPreElement>(null)
14
+
15
+ const handleCopy = async () => {
16
+ const code = preRef.current?.textContent
17
+ if (code) {
18
+ await navigator.clipboard.writeText(code)
19
+ setCopied(true)
20
+ setTimeout(() => setCopied(false), 2000)
21
+ }
22
+ }
23
+
24
+ return (
25
+ <div className="group relative my-6">
26
+ {/* Container with border and rounded corners */}
27
+ <div className="relative rounded-lg border border-border bg-muted/30 dark:bg-[#0d0d0f] overflow-hidden">
28
+ {/* Code content */}
29
+ <pre
30
+ ref={preRef}
31
+ className={cn(
32
+ 'overflow-x-auto p-4 text-sm leading-relaxed',
33
+ className
34
+ )}
35
+ {...props}
36
+ >
37
+ {children}
38
+ </pre>
39
+
40
+ {/* Copy button - positioned in top right */}
41
+ <button
42
+ onClick={handleCopy}
43
+ className={cn(
44
+ 'absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all',
45
+ 'text-muted-foreground hover:text-foreground',
46
+ 'bg-background/80 hover:bg-background border border-border/50',
47
+ 'opacity-0 group-hover:opacity-100',
48
+ copied && 'opacity-100 text-green-600 dark:text-green-400'
49
+ )}
50
+ >
51
+ {copied ? (
52
+ <>
53
+ <CheckIcon className="w-3.5 h-3.5" />
54
+ Copied
55
+ </>
56
+ ) : (
57
+ <>
58
+ <CopyIcon className="w-3.5 h-3.5" />
59
+ Copy
60
+ </>
61
+ )}
62
+ </button>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ function CopyIcon({ className }: { className?: string }) {
69
+ return (
70
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
71
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
72
+ </svg>
73
+ )
74
+ }
75
+
76
+ function CheckIcon({ className }: { className?: string }) {
77
+ return (
78
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
79
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
80
+ </svg>
81
+ )
82
+ }
@@ -0,0 +1,59 @@
1
+ import { cn } from '@/lib/utils'
2
+
3
+ interface StepsProps {
4
+ children: React.ReactNode
5
+ }
6
+
7
+ export function Steps({ children }: StepsProps) {
8
+ return (
9
+ <div className="my-8 space-y-0 [counter-reset:step]">
10
+ {children}
11
+ </div>
12
+ )
13
+ }
14
+
15
+ interface StepProps {
16
+ title: string
17
+ children: React.ReactNode
18
+ }
19
+
20
+ export function Step({ title, children }: StepProps) {
21
+ return (
22
+ <div className="relative [counter-increment:step] group">
23
+ {/* Connecting line */}
24
+ <div className="absolute left-[19px] top-12 bottom-0 w-px bg-gradient-to-b from-border to-transparent group-last:hidden" />
25
+
26
+ {/* Step container */}
27
+ <div className="flex gap-4 pb-8 group-last:pb-0">
28
+ {/* Step number badge */}
29
+ <div className="relative flex-shrink-0">
30
+ <div
31
+ className={cn(
32
+ 'w-10 h-10 rounded-xl',
33
+ 'flex items-center justify-center',
34
+ 'bg-gradient-to-br from-[var(--accent)] to-[color-mix(in_oklch,var(--accent),black_20%)]',
35
+ 'text-[var(--accent-foreground)] font-semibold text-sm',
36
+ 'shadow-sm shadow-[var(--accent)]/20',
37
+ 'before:content-[counter(step)]'
38
+ )}
39
+ />
40
+ </div>
41
+
42
+ {/* Step content */}
43
+ <div className="flex-1 pt-1.5 min-w-0">
44
+ <h4 className="font-semibold text-lg text-foreground mb-3 leading-tight">
45
+ {title}
46
+ </h4>
47
+ <div className={cn(
48
+ 'text-muted-foreground leading-relaxed',
49
+ '[&>p]:mb-4 [&>p:last-child]:mb-0',
50
+ '[&>pre]:my-4 [&>pre]:rounded-lg [&>pre]:border [&>pre]:border-border/50',
51
+ '[&>ul]:my-3 [&>ol]:my-3'
52
+ )}>
53
+ {children}
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,60 @@
1
+ 'use client'
2
+
3
+ import { useState, createContext, useContext } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ interface TabsContextValue {
7
+ activeTab: string
8
+ setActiveTab: (tab: string) => void
9
+ }
10
+
11
+ const TabsContext = createContext<TabsContextValue | null>(null)
12
+
13
+ interface TabsProps {
14
+ children: React.ReactNode
15
+ defaultValue?: string
16
+ }
17
+
18
+ export function Tabs({ children, defaultValue }: TabsProps) {
19
+ const [activeTab, setActiveTab] = useState(defaultValue || '')
20
+
21
+ return (
22
+ <TabsContext.Provider value={{ activeTab, setActiveTab }}>
23
+ <div className="my-6">{children}</div>
24
+ </TabsContext.Provider>
25
+ )
26
+ }
27
+
28
+ interface TabProps {
29
+ title: string
30
+ children: React.ReactNode
31
+ }
32
+
33
+ export function Tab({ title, children }: TabProps) {
34
+ const context = useContext(TabsContext)
35
+ if (!context) return null
36
+
37
+ const { activeTab, setActiveTab } = context
38
+ const isActive = activeTab === title || (!activeTab && title)
39
+
40
+ return (
41
+ <>
42
+ <button
43
+ onClick={() => setActiveTab(title)}
44
+ className={cn(
45
+ 'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
46
+ isActive
47
+ ? 'border-[var(--accent)] text-[var(--accent)]'
48
+ : 'border-transparent text-muted-foreground hover:text-foreground'
49
+ )}
50
+ >
51
+ {title}
52
+ </button>
53
+ {isActive && (
54
+ <div className="pt-4 [&>pre]:mt-0">
55
+ {children}
56
+ </div>
57
+ )}
58
+ </>
59
+ )
60
+ }
@@ -0,0 +1,18 @@
1
+ interface YouTubeProps {
2
+ id: string
3
+ title?: string
4
+ }
5
+
6
+ export function YouTube({ id, title = 'YouTube video' }: YouTubeProps) {
7
+ return (
8
+ <div className="my-6 aspect-video rounded-lg overflow-hidden">
9
+ <iframe
10
+ src={`https://www.youtube.com/embed/${id}`}
11
+ title={title}
12
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
13
+ allowFullScreen
14
+ className="w-full h-full"
15
+ />
16
+ </div>
17
+ )
18
+ }
@@ -0,0 +1,281 @@
1
+ 'use client'
2
+
3
+ import { useDocsSearch } from 'fumadocs-core/search/client'
4
+ import { useEffect, useState, useCallback, useRef } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import { useRouter } from 'next/navigation'
7
+ import { cn } from '@/lib/utils'
8
+
9
+ export function SearchTrigger() {
10
+ const [open, setOpen] = useState(false)
11
+ const [mounted, setMounted] = useState(false)
12
+
13
+ useEffect(() => {
14
+ setMounted(true)
15
+ }, [])
16
+
17
+ useEffect(() => {
18
+ const down = (e: KeyboardEvent) => {
19
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
20
+ e.preventDefault()
21
+ setOpen(true)
22
+ }
23
+ }
24
+
25
+ document.addEventListener('keydown', down)
26
+ return () => document.removeEventListener('keydown', down)
27
+ }, [])
28
+
29
+ return (
30
+ <>
31
+ <button
32
+ type="button"
33
+ onClick={() => setOpen(true)}
34
+ className={cn(
35
+ 'flex items-center gap-3 px-4 py-2.5 rounded-lg w-full max-w-md',
36
+ 'bg-muted/50 border border-border/50',
37
+ 'text-sm text-muted-foreground hover:text-foreground hover:bg-muted hover:border-border',
38
+ 'transition-all'
39
+ )}
40
+ >
41
+ <SearchIcon className="w-4 h-4 shrink-0" />
42
+ <span className="flex-1 text-left">Search documentation...</span>
43
+ <kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-background/80 text-xs font-mono text-muted-foreground/60 border border-border/40">
44
+ <span>⌘</span>K
45
+ </kbd>
46
+ </button>
47
+
48
+ {mounted && open && createPortal(
49
+ <SearchDialog onClose={() => setOpen(false)} />,
50
+ document.body
51
+ )}
52
+ </>
53
+ )
54
+ }
55
+
56
+ interface SearchDialogProps {
57
+ onClose: () => void
58
+ }
59
+
60
+ // Highlight matching text in a string
61
+ function HighlightedText({ text, query }: { text: string; query: string }) {
62
+ if (!query.trim()) {
63
+ return <>{text}</>
64
+ }
65
+
66
+ const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'))
67
+
68
+ return (
69
+ <>
70
+ {parts.map((part, i) =>
71
+ part.toLowerCase() === query.toLowerCase() ? (
72
+ <mark key={i} className="bg-yellow-200 dark:bg-yellow-800/50 text-inherit rounded-sm px-0.5">
73
+ {part}
74
+ </mark>
75
+ ) : (
76
+ <span key={i}>{part}</span>
77
+ )
78
+ )}
79
+ </>
80
+ )
81
+ }
82
+
83
+ function SearchDialog({ onClose }: SearchDialogProps) {
84
+ const router = useRouter()
85
+ const { search, setSearch, query } = useDocsSearch({ type: 'fetch' })
86
+ const [selectedIndex, setSelectedIndex] = useState(0)
87
+ const resultsRef = useRef<HTMLUListElement>(null)
88
+
89
+ const results = query.data && query.data !== 'empty' ? query.data : []
90
+
91
+ // Reset selection when results change
92
+ useEffect(() => {
93
+ setSelectedIndex(0)
94
+ }, [results])
95
+
96
+ const handleSelect = useCallback(
97
+ (url: string) => {
98
+ router.push(url)
99
+ onClose()
100
+ },
101
+ [router, onClose]
102
+ )
103
+
104
+ // Keyboard navigation
105
+ useEffect(() => {
106
+ const down = (e: KeyboardEvent) => {
107
+ if (e.key === 'Escape') {
108
+ onClose()
109
+ return
110
+ }
111
+
112
+ if (results.length === 0) return
113
+
114
+ if (e.key === 'ArrowDown') {
115
+ e.preventDefault()
116
+ setSelectedIndex((prev) => (prev + 1) % results.length)
117
+ } else if (e.key === 'ArrowUp') {
118
+ e.preventDefault()
119
+ setSelectedIndex((prev) => (prev - 1 + results.length) % results.length)
120
+ } else if (e.key === 'Enter') {
121
+ e.preventDefault()
122
+ if (results[selectedIndex]) {
123
+ handleSelect(results[selectedIndex].url)
124
+ }
125
+ }
126
+ }
127
+
128
+ document.addEventListener('keydown', down)
129
+ return () => document.removeEventListener('keydown', down)
130
+ }, [onClose, results, selectedIndex, handleSelect])
131
+
132
+ // Scroll selected item into view
133
+ useEffect(() => {
134
+ if (resultsRef.current) {
135
+ const selectedItem = resultsRef.current.children[selectedIndex] as HTMLElement
136
+ if (selectedItem) {
137
+ selectedItem.scrollIntoView({ block: 'nearest' })
138
+ }
139
+ }
140
+ }, [selectedIndex])
141
+
142
+ // Prevent body scroll when modal is open
143
+ useEffect(() => {
144
+ document.body.style.overflow = 'hidden'
145
+ return () => {
146
+ document.body.style.overflow = ''
147
+ }
148
+ }, [])
149
+
150
+ return (
151
+ <div
152
+ className="fixed inset-0 z-[9999] flex items-start justify-center"
153
+ style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, paddingTop: '10%' }}
154
+ >
155
+ {/* Backdrop */}
156
+ <div
157
+ className="fixed inset-0 bg-black/50 dark:bg-black/70"
158
+ style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}
159
+ onClick={onClose}
160
+ aria-hidden="true"
161
+ />
162
+
163
+ {/* Dialog */}
164
+ <div
165
+ className="relative w-full max-w-2xl mx-4"
166
+ role="dialog"
167
+ aria-modal="true"
168
+ aria-labelledby="search-dialog-title"
169
+ >
170
+ <div className="bg-background border border-border rounded-xl shadow-2xl overflow-hidden">
171
+ <h2 id="search-dialog-title" className="sr-only">Search documentation</h2>
172
+ {/* Search input */}
173
+ <div className="flex items-center gap-3 px-4 border-b border-border">
174
+ <SearchIcon className="w-5 h-5 text-muted-foreground shrink-0" aria-hidden="true" />
175
+ <input
176
+ type="text"
177
+ aria-label="Search documentation"
178
+ placeholder="Search..."
179
+ value={search}
180
+ onChange={(e) => setSearch(e.target.value)}
181
+ className="flex-1 py-4 bg-transparent text-foreground placeholder:text-muted-foreground text-base border-none outline-none focus:outline-none focus:ring-0 focus:border-none"
182
+ style={{ outline: 'none', boxShadow: 'none' }}
183
+ autoFocus
184
+ />
185
+ <kbd className="px-2 py-1 rounded bg-muted text-xs text-muted-foreground font-mono border border-border">
186
+ ESC
187
+ </kbd>
188
+ </div>
189
+
190
+ {/* Results */}
191
+ <div className="max-h-[60vh] overflow-y-auto">
192
+ {search.length === 0 ? (
193
+ <div className="p-8 text-center text-muted-foreground">
194
+ <p>Start typing to search...</p>
195
+ </div>
196
+ ) : query.isLoading ? (
197
+ <div className="p-8 text-center text-muted-foreground">
198
+ <p>Searching...</p>
199
+ </div>
200
+ ) : results.length > 0 ? (
201
+ <ul ref={resultsRef} className="py-2">
202
+ {results.map((result, index) => {
203
+ // Build breadcrumb path
204
+ const breadcrumbPath = result.breadcrumbs && result.breadcrumbs.length > 0
205
+ ? `Documentation > ${result.breadcrumbs.join(' > ')}`
206
+ : 'Documentation'
207
+
208
+ return (
209
+ <li key={result.id}>
210
+ <button
211
+ type="button"
212
+ onClick={() => handleSelect(result.url)}
213
+ onMouseEnter={() => setSelectedIndex(index)}
214
+ className={cn(
215
+ 'w-full px-4 py-3 text-left transition-colors focus:outline-none',
216
+ selectedIndex === index
217
+ ? 'bg-gray-100 dark:bg-gray-800'
218
+ : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
219
+ )}
220
+ >
221
+ {/* Title */}
222
+ <div className="font-semibold text-foreground">
223
+ <HighlightedText text={result.content} query={search} />
224
+ </div>
225
+ {/* Breadcrumb path */}
226
+ <div className="text-sm text-muted-foreground mt-0.5">
227
+ {breadcrumbPath}
228
+ </div>
229
+ </button>
230
+ </li>
231
+ )
232
+ })}
233
+ </ul>
234
+ ) : (
235
+ <div className="p-8 text-center text-muted-foreground">
236
+ <p>No results found for &quot;{search}&quot;</p>
237
+ </div>
238
+ )}
239
+ </div>
240
+
241
+ {/* Footer with keyboard hints */}
242
+ {results.length > 0 && (
243
+ <div className="flex items-center gap-4 px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground">
244
+ <span className="flex items-center gap-1">
245
+ <kbd className="px-1.5 py-0.5 rounded bg-muted border border-border font-mono">↑</kbd>
246
+ <kbd className="px-1.5 py-0.5 rounded bg-muted border border-border font-mono">↓</kbd>
247
+ <span className="ml-1">to navigate</span>
248
+ </span>
249
+ <span className="flex items-center gap-1">
250
+ <kbd className="px-1.5 py-0.5 rounded bg-muted border border-border font-mono">↵</kbd>
251
+ <span className="ml-1">to select</span>
252
+ </span>
253
+ <span className="flex items-center gap-1">
254
+ <kbd className="px-1.5 py-0.5 rounded bg-muted border border-border font-mono">esc</kbd>
255
+ <span className="ml-1">to close</span>
256
+ </span>
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+ </div>
262
+ )
263
+ }
264
+
265
+ function SearchIcon({ className }: { className?: string }) {
266
+ return (
267
+ <svg
268
+ className={className}
269
+ fill="none"
270
+ viewBox="0 0 24 24"
271
+ stroke="currentColor"
272
+ strokeWidth={2}
273
+ >
274
+ <path
275
+ strokeLinecap="round"
276
+ strokeLinejoin="round"
277
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
278
+ />
279
+ </svg>
280
+ )
281
+ }