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.
- package/README.md +57 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +499 -0
- package/package.json +48 -0
- package/template/LICENSE +21 -0
- package/template/README.md +278 -0
- package/template/__tests__/components/callout.test.tsx +46 -0
- package/template/__tests__/components/card.test.tsx +59 -0
- package/template/__tests__/components/tabs.test.tsx +61 -0
- package/template/__tests__/theme-config.test.ts +49 -0
- package/template/__tests__/utils.test.ts +25 -0
- package/template/app/api/og/route.tsx +90 -0
- package/template/app/api/search/route.ts +6 -0
- package/template/app/components/docs/docs-pager.tsx +41 -0
- package/template/app/components/docs/docs-sidebar.tsx +143 -0
- package/template/app/components/docs/docs-toc.tsx +61 -0
- package/template/app/components/docs/mdx/accordion.tsx +54 -0
- package/template/app/components/docs/mdx/callout.tsx +102 -0
- package/template/app/components/docs/mdx/card.tsx +110 -0
- package/template/app/components/docs/mdx/code-block.tsx +42 -0
- package/template/app/components/docs/mdx/frame.tsx +14 -0
- package/template/app/components/docs/mdx/index.tsx +167 -0
- package/template/app/components/docs/mdx/pre.tsx +82 -0
- package/template/app/components/docs/mdx/steps.tsx +59 -0
- package/template/app/components/docs/mdx/tabs.tsx +60 -0
- package/template/app/components/docs/mdx/youtube.tsx +18 -0
- package/template/app/components/docs/search-dialog.tsx +281 -0
- package/template/app/components/docs/theme-toggle.tsx +35 -0
- package/template/app/docs/[[...slug]]/page.tsx +139 -0
- package/template/app/docs/layout.tsx +98 -0
- package/template/app/globals.css +151 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/page.tsx +5 -0
- package/template/app/providers/theme-provider.tsx +8 -0
- package/template/content/docs/components.mdx +82 -0
- package/template/content/docs/customization.mdx +34 -0
- package/template/content/docs/deployment.mdx +28 -0
- package/template/content/docs/index.mdx +91 -0
- package/template/content/docs/meta.json +13 -0
- package/template/content/docs/quickstart.mdx +110 -0
- package/template/content/docs/theming.mdx +41 -0
- package/template/lib/docs-source.ts +7 -0
- package/template/lib/theme-config.ts +89 -0
- package/template/lib/utils.ts +6 -0
- package/template/next.config.mjs +10 -0
- package/template/package-lock.json +10695 -0
- package/template/package.json +45 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/logo.png +0 -0
- package/template/public/logo.svg +9 -0
- package/template/public/logo.txt +1 -0
- package/template/source.config.ts +22 -0
- package/template/tailwind.config.ts +34 -0
- package/template/tsconfig.json +33 -0
- 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 "{search}"</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
|
+
}
|