skrypt-ai 0.3.3 → 0.4.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 +1 -1
- package/dist/auth/index.d.ts +0 -1
- package/dist/auth/index.js +3 -5
- package/dist/autofix/index.js +15 -3
- package/dist/cli.js +19 -4
- package/dist/commands/check-links.js +164 -174
- package/dist/commands/deploy.js +5 -2
- package/dist/commands/generate.js +206 -199
- package/dist/commands/i18n.js +3 -20
- package/dist/commands/init.js +47 -40
- package/dist/commands/lint.js +3 -20
- package/dist/commands/mcp.js +125 -122
- package/dist/commands/monitor.js +125 -108
- package/dist/commands/review-pr.js +1 -1
- package/dist/commands/sdk.js +1 -1
- package/dist/config/loader.js +21 -2
- package/dist/generator/organizer.d.ts +3 -0
- package/dist/generator/organizer.js +4 -9
- package/dist/generator/writer.js +2 -10
- package/dist/github/pr-comments.js +21 -8
- package/dist/plugins/index.js +1 -0
- package/dist/scanner/index.js +8 -2
- package/dist/template/docs.json +2 -1
- package/dist/template/next.config.mjs +3 -1
- package/dist/template/package.json +17 -14
- package/dist/template/public/favicon.svg +4 -0
- package/dist/template/public/search-index.json +1 -1
- package/dist/template/scripts/build-search-index.mjs +120 -25
- package/dist/template/src/app/api/chat/route.ts +11 -3
- package/dist/template/src/app/docs/README.md +28 -0
- package/dist/template/src/app/docs/[...slug]/page.tsx +141 -14
- package/dist/template/src/app/docs/auth/page.mdx +589 -0
- package/dist/template/src/app/docs/autofix/page.mdx +624 -0
- package/dist/template/src/app/docs/cli/page.mdx +217 -0
- package/dist/template/src/app/docs/config/page.mdx +428 -0
- package/dist/template/src/app/docs/configuration/page.mdx +86 -0
- package/dist/template/src/app/docs/deployment/page.mdx +112 -0
- package/dist/template/src/app/docs/error.tsx +20 -0
- package/dist/template/src/app/docs/generator/generator.md +504 -0
- package/dist/template/src/app/docs/generator/organizer.md +779 -0
- package/dist/template/src/app/docs/generator/page.mdx +613 -0
- package/dist/template/src/app/docs/github/page.mdx +502 -0
- package/dist/template/src/app/docs/llm/anthropic-client.md +549 -0
- package/dist/template/src/app/docs/llm/index.md +471 -0
- package/dist/template/src/app/docs/llm/page.mdx +428 -0
- package/dist/template/src/app/docs/llms-full.md +256 -0
- package/dist/template/src/app/docs/llms.txt +2971 -0
- package/dist/template/src/app/docs/not-found.tsx +23 -0
- package/dist/template/src/app/docs/page.mdx +0 -3
- package/dist/template/src/app/docs/plugins/page.mdx +1793 -0
- package/dist/template/src/app/docs/pro/page.mdx +121 -0
- package/dist/template/src/app/docs/quickstart/page.mdx +93 -0
- package/dist/template/src/app/docs/scanner/content-type.md +599 -0
- package/dist/template/src/app/docs/scanner/index.md +212 -0
- package/dist/template/src/app/docs/scanner/page.mdx +307 -0
- package/dist/template/src/app/docs/scanner/python.md +469 -0
- package/dist/template/src/app/docs/scanner/python_parser.md +1056 -0
- package/dist/template/src/app/docs/scanner/rust.md +325 -0
- package/dist/template/src/app/docs/scanner/typescript.md +201 -0
- package/dist/template/src/app/error.tsx +3 -3
- package/dist/template/src/app/icon.tsx +29 -0
- package/dist/template/src/app/layout.tsx +57 -7
- package/dist/template/src/app/not-found.tsx +35 -0
- package/dist/template/src/app/page.tsx +95 -11
- package/dist/template/src/components/ai-chat.tsx +26 -21
- package/dist/template/src/components/breadcrumbs.tsx +56 -12
- package/dist/template/src/components/copy-button.tsx +17 -3
- package/dist/template/src/components/docs-layout.tsx +202 -8
- package/dist/template/src/components/feedback.tsx +4 -2
- package/dist/template/src/components/footer.tsx +42 -0
- package/dist/template/src/components/header.tsx +56 -20
- package/dist/template/src/components/mdx/accordion.tsx +17 -13
- package/dist/template/src/components/mdx/callout.tsx +50 -37
- package/dist/template/src/components/mdx/card.tsx +24 -12
- package/dist/template/src/components/mdx/code-block.tsx +17 -3
- package/dist/template/src/components/mdx/code-group.tsx +78 -18
- package/dist/template/src/components/mdx/code-playground.tsx +3 -0
- package/dist/template/src/components/mdx/go-playground.tsx +3 -0
- package/dist/template/src/components/mdx/highlighted-code.tsx +178 -38
- package/dist/template/src/components/mdx/python-playground.tsx +2 -0
- package/dist/template/src/components/mdx/steps.tsx +6 -6
- package/dist/template/src/components/mdx/tabs.tsx +76 -8
- package/dist/template/src/components/page-header.tsx +19 -0
- package/dist/template/src/components/scroll-to-top.tsx +33 -0
- package/dist/template/src/components/search-dialog.tsx +251 -57
- package/dist/template/src/components/sidebar.tsx +137 -77
- package/dist/template/src/components/table-of-contents.tsx +29 -13
- package/dist/template/src/lib/highlight.ts +90 -31
- package/dist/template/src/lib/search.ts +14 -4
- package/dist/template/src/lib/theme-utils.ts +140 -0
- package/dist/template/src/styles/globals.css +397 -84
- package/dist/template/src/types/remark-gfm.d.ts +2 -0
- package/dist/utils/files.d.ts +9 -0
- package/dist/utils/files.js +33 -0
- package/dist/utils/validation.d.ts +4 -0
- package/dist/utils/validation.js +38 -0
- package/package.json +1 -4
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useState, ReactNode } from 'react'
|
|
3
|
+
import { createContext, useContext, useState, useRef, useCallback, useId, ReactNode } from 'react'
|
|
4
4
|
import { cn } from '@/lib/utils'
|
|
5
5
|
|
|
6
6
|
interface TabsContextValue {
|
|
7
7
|
activeTab: string
|
|
8
8
|
setActiveTab: (tab: string) => void
|
|
9
|
+
instanceId: string
|
|
10
|
+
tabRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>
|
|
11
|
+
tabValues: string[]
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const TabsContext = createContext<TabsContextValue | null>(null)
|
|
@@ -17,9 +20,13 @@ interface TabsProps {
|
|
|
17
20
|
|
|
18
21
|
export function Tabs({ defaultValue, children }: TabsProps) {
|
|
19
22
|
const [activeTab, setActiveTab] = useState(defaultValue)
|
|
23
|
+
const instanceId = useId()
|
|
24
|
+
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
|
|
25
|
+
// Collect tab values from children to enable arrow key navigation
|
|
26
|
+
const tabValues = useRef<string[]>([])
|
|
20
27
|
|
|
21
28
|
return (
|
|
22
|
-
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
|
29
|
+
<TabsContext.Provider value={{ activeTab, setActiveTab, instanceId, tabRefs, tabValues: tabValues.current }}>
|
|
23
30
|
<div className="my-6">{children}</div>
|
|
24
31
|
</TabsContext.Provider>
|
|
25
32
|
)
|
|
@@ -30,8 +37,39 @@ interface TabListProps {
|
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export function TabList({ children }: TabListProps) {
|
|
40
|
+
const context = useContext(TabsContext)
|
|
41
|
+
if (!context) throw new Error('TabList must be used within Tabs')
|
|
42
|
+
|
|
43
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
44
|
+
const { tabValues, tabRefs, activeTab, setActiveTab } = context
|
|
45
|
+
if (!tabValues.length) return
|
|
46
|
+
|
|
47
|
+
const currentIndex = tabValues.indexOf(activeTab)
|
|
48
|
+
let nextIndex = -1
|
|
49
|
+
|
|
50
|
+
if (e.key === 'ArrowRight') {
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
nextIndex = (currentIndex + 1) % tabValues.length
|
|
53
|
+
} else if (e.key === 'ArrowLeft') {
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
nextIndex = (currentIndex - 1 + tabValues.length) % tabValues.length
|
|
56
|
+
} else if (e.key === 'Home') {
|
|
57
|
+
e.preventDefault()
|
|
58
|
+
nextIndex = 0
|
|
59
|
+
} else if (e.key === 'End') {
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
nextIndex = tabValues.length - 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (nextIndex >= 0) {
|
|
65
|
+
const nextValue = tabValues[nextIndex]
|
|
66
|
+
setActiveTab(nextValue)
|
|
67
|
+
tabRefs.current.get(nextValue)?.focus()
|
|
68
|
+
}
|
|
69
|
+
}, [context])
|
|
70
|
+
|
|
33
71
|
return (
|
|
34
|
-
<div className="flex border-b border-[var(--color-border)]">
|
|
72
|
+
<div role="tablist" className="flex gap-0.5 border-b border-[var(--color-border)]" onKeyDown={handleKeyDown}>
|
|
35
73
|
{children}
|
|
36
74
|
</div>
|
|
37
75
|
)
|
|
@@ -46,17 +84,36 @@ export function Tab({ value, children }: TabProps) {
|
|
|
46
84
|
const context = useContext(TabsContext)
|
|
47
85
|
if (!context) throw new Error('Tab must be used within Tabs')
|
|
48
86
|
|
|
49
|
-
const { activeTab, setActiveTab } = context
|
|
87
|
+
const { activeTab, setActiveTab, instanceId, tabRefs, tabValues } = context
|
|
50
88
|
const isActive = activeTab === value
|
|
51
89
|
|
|
90
|
+
// Register this tab value for arrow key navigation
|
|
91
|
+
const refCallback = useCallback((el: HTMLButtonElement | null) => {
|
|
92
|
+
if (el) {
|
|
93
|
+
tabRefs.current.set(value, el)
|
|
94
|
+
if (!tabValues.includes(value)) {
|
|
95
|
+
tabValues.push(value)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, [value, tabRefs, tabValues])
|
|
99
|
+
|
|
100
|
+
const tabId = `tab-${instanceId}-${value}`
|
|
101
|
+
const panelId = `panel-${instanceId}-${value}`
|
|
102
|
+
|
|
52
103
|
return (
|
|
53
104
|
<button
|
|
105
|
+
ref={refCallback}
|
|
106
|
+
id={tabId}
|
|
107
|
+
role="tab"
|
|
108
|
+
aria-selected={isActive}
|
|
109
|
+
aria-controls={panelId}
|
|
110
|
+
tabIndex={isActive ? 0 : -1}
|
|
54
111
|
onClick={() => setActiveTab(value)}
|
|
55
112
|
className={cn(
|
|
56
|
-
'px-
|
|
113
|
+
'px-3 py-2 text-[0.8125rem] font-medium transition-colors border-b-2 -mb-px',
|
|
57
114
|
isActive
|
|
58
115
|
? 'border-[var(--color-primary)] text-[var(--color-primary)]'
|
|
59
|
-
: 'border-transparent text-[var(--color-text-
|
|
116
|
+
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
|
60
117
|
)}
|
|
61
118
|
>
|
|
62
119
|
{children}
|
|
@@ -73,9 +130,20 @@ export function TabPanel({ value, children }: TabPanelProps) {
|
|
|
73
130
|
const context = useContext(TabsContext)
|
|
74
131
|
if (!context) throw new Error('TabPanel must be used within Tabs')
|
|
75
132
|
|
|
76
|
-
const { activeTab } = context
|
|
133
|
+
const { activeTab, instanceId } = context
|
|
134
|
+
const tabId = `tab-${instanceId}-${value}`
|
|
135
|
+
const panelId = `panel-${instanceId}-${value}`
|
|
77
136
|
|
|
78
137
|
if (activeTab !== value) return null
|
|
79
138
|
|
|
80
|
-
return
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
id={panelId}
|
|
142
|
+
role="tabpanel"
|
|
143
|
+
aria-labelledby={tabId}
|
|
144
|
+
className="pt-4"
|
|
145
|
+
>
|
|
146
|
+
{children}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
81
149
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface PageHeaderProps {
|
|
2
|
+
title: string
|
|
3
|
+
description?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function PageHeader({ title, description }: PageHeaderProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="mb-8 pb-6 border-b border-[var(--color-border)]">
|
|
9
|
+
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-[var(--color-text)]">
|
|
10
|
+
{title}
|
|
11
|
+
</h1>
|
|
12
|
+
{description && (
|
|
13
|
+
<p className="text-lg text-[var(--color-text-secondary)] mt-3 leading-relaxed max-w-2xl">
|
|
14
|
+
{description}
|
|
15
|
+
</p>
|
|
16
|
+
)}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { ChevronUp } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
export function ScrollToTop() {
|
|
7
|
+
const [visible, setVisible] = useState(false)
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
function handleScroll() {
|
|
11
|
+
setVisible(window.scrollY > 300)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
15
|
+
return () => window.removeEventListener('scroll', handleScroll)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
function scrollToTop() {
|
|
19
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!visible) return null
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
onClick={scrollToTop}
|
|
27
|
+
className="fixed bottom-6 right-24 z-40 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-lg transition-all hover:bg-[var(--color-primary-dark)] hover:scale-105"
|
|
28
|
+
aria-label="Scroll to top"
|
|
29
|
+
>
|
|
30
|
+
<ChevronUp size={20} />
|
|
31
|
+
</button>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -1,19 +1,66 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import React, { useEffect, useState, useCallback, useRef } from 'react'
|
|
4
|
-
import { Search,
|
|
3
|
+
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
|
4
|
+
import { Search, FileText, ArrowRight, Clock, X } from 'lucide-react'
|
|
5
5
|
import Link from 'next/link'
|
|
6
|
+
import { useRouter } from 'next/navigation'
|
|
6
7
|
import { cn } from '@/lib/utils'
|
|
7
8
|
import { search as performSearch, SearchResultWithHighlight } from '@/lib/search'
|
|
8
9
|
|
|
10
|
+
const RECENT_SEARCHES_KEY = 'recent-searches'
|
|
11
|
+
const MAX_RECENT_SEARCHES = 5
|
|
12
|
+
|
|
13
|
+
function getRecentSearches(): string[] {
|
|
14
|
+
try {
|
|
15
|
+
const stored = localStorage.getItem(RECENT_SEARCHES_KEY)
|
|
16
|
+
return stored ? JSON.parse(stored) : []
|
|
17
|
+
} catch {
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveRecentSearch(query: string) {
|
|
23
|
+
try {
|
|
24
|
+
const trimmed = query.trim()
|
|
25
|
+
if (!trimmed) return
|
|
26
|
+
const existing = getRecentSearches()
|
|
27
|
+
const filtered = existing.filter((s) => s !== trimmed)
|
|
28
|
+
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES)
|
|
29
|
+
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated))
|
|
30
|
+
} catch {
|
|
31
|
+
// localStorage unavailable
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function clearRecentSearches() {
|
|
36
|
+
try {
|
|
37
|
+
localStorage.removeItem(RECENT_SEARCHES_KEY)
|
|
38
|
+
} catch {
|
|
39
|
+
// localStorage unavailable
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function SearchSkeletons() {
|
|
44
|
+
return (
|
|
45
|
+
<div className="p-4 space-y-4">
|
|
46
|
+
{[0, 1, 2, 3].map((i) => (
|
|
47
|
+
<div key={i} className="flex items-center gap-3">
|
|
48
|
+
<div className="skeleton w-8 h-8 rounded-full shrink-0" />
|
|
49
|
+
<div className="flex-1 space-y-2">
|
|
50
|
+
<div className="skeleton h-3.5 w-3/4" />
|
|
51
|
+
<div className="skeleton h-3 w-1/2" />
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
9
59
|
interface SearchDialogProps {
|
|
10
60
|
open: boolean
|
|
11
61
|
onClose: () => void
|
|
12
62
|
}
|
|
13
63
|
|
|
14
|
-
/**
|
|
15
|
-
* Highlight search terms in text
|
|
16
|
-
*/
|
|
17
64
|
function highlightTerms(text: string, query: string): React.ReactNode {
|
|
18
65
|
if (!query.trim()) return text
|
|
19
66
|
|
|
@@ -24,7 +71,7 @@ function highlightTerms(text: string, query: string): React.ReactNode {
|
|
|
24
71
|
|
|
25
72
|
return parts.map((part, i) =>
|
|
26
73
|
terms.some(t => part.toLowerCase() === t) ? (
|
|
27
|
-
<mark key={i} className="bg-
|
|
74
|
+
<mark key={i} className="bg-[var(--color-primary)]/20 text-[var(--color-primary)] rounded px-0.5">
|
|
28
75
|
{part}
|
|
29
76
|
</mark>
|
|
30
77
|
) : (
|
|
@@ -33,18 +80,48 @@ function highlightTerms(text: string, query: string): React.ReactNode {
|
|
|
33
80
|
)
|
|
34
81
|
}
|
|
35
82
|
|
|
83
|
+
interface GroupedResults {
|
|
84
|
+
section: string
|
|
85
|
+
results: SearchResultWithHighlight[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function groupResultsBySection(results: SearchResultWithHighlight[]): GroupedResults[] {
|
|
89
|
+
const map = new Map<string, SearchResultWithHighlight[]>()
|
|
90
|
+
const order: string[] = []
|
|
91
|
+
|
|
92
|
+
for (const result of results) {
|
|
93
|
+
const section = result.section || 'Results'
|
|
94
|
+
if (!map.has(section)) {
|
|
95
|
+
map.set(section, [])
|
|
96
|
+
order.push(section)
|
|
97
|
+
}
|
|
98
|
+
map.get(section)!.push(result)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return order.map(section => ({ section, results: map.get(section)! }))
|
|
102
|
+
}
|
|
103
|
+
|
|
36
104
|
export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
|
105
|
+
const router = useRouter()
|
|
37
106
|
const [query, setQuery] = useState('')
|
|
38
107
|
const [results, setResults] = useState<SearchResultWithHighlight[]>([])
|
|
39
108
|
const [isLoading, setIsLoading] = useState(false)
|
|
109
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
110
|
+
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
|
40
111
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
41
112
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
113
|
+
const selectedRef = useRef<HTMLLIElement>(null)
|
|
114
|
+
|
|
115
|
+
const grouped = useMemo(() => groupResultsBySection(results), [results])
|
|
42
116
|
|
|
43
|
-
// Focus trap and restore focus on close
|
|
44
117
|
useEffect(() => {
|
|
45
118
|
if (open) {
|
|
46
119
|
const previouslyFocused = document.activeElement as HTMLElement
|
|
47
120
|
inputRef.current?.focus()
|
|
121
|
+
setQuery('')
|
|
122
|
+
setResults([])
|
|
123
|
+
setSelectedIndex(0)
|
|
124
|
+
setRecentSearches(getRecentSearches())
|
|
48
125
|
|
|
49
126
|
return () => {
|
|
50
127
|
previouslyFocused?.focus()
|
|
@@ -62,6 +139,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
|
|
62
139
|
try {
|
|
63
140
|
const searchResults = await performSearch(q)
|
|
64
141
|
setResults(searchResults)
|
|
142
|
+
setSelectedIndex(0)
|
|
65
143
|
} catch (err) {
|
|
66
144
|
console.error('Search failed:', err)
|
|
67
145
|
setResults([])
|
|
@@ -71,33 +149,74 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
|
|
71
149
|
}, [])
|
|
72
150
|
|
|
73
151
|
useEffect(() => {
|
|
74
|
-
const debounce = setTimeout(() => search(query),
|
|
152
|
+
const debounce = setTimeout(() => search(query), 150)
|
|
75
153
|
return () => clearTimeout(debounce)
|
|
76
154
|
}, [query, search])
|
|
77
155
|
|
|
78
156
|
useEffect(() => {
|
|
79
157
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
80
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
81
|
-
e.preventDefault()
|
|
82
|
-
if (open) onClose()
|
|
83
|
-
else onClose() // This should trigger open, but we don't have that control here
|
|
84
|
-
}
|
|
85
158
|
if (e.key === 'Escape' && open) {
|
|
86
159
|
onClose()
|
|
87
160
|
}
|
|
161
|
+
// Focus trapping within the dialog
|
|
162
|
+
if (e.key === 'Tab' && open && dialogRef.current) {
|
|
163
|
+
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
|
164
|
+
'input, button, a[href], [tabindex]:not([tabindex="-1"])'
|
|
165
|
+
)
|
|
166
|
+
if (focusable.length === 0) return
|
|
167
|
+
const first = focusable[0]
|
|
168
|
+
const last = focusable[focusable.length - 1]
|
|
169
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
170
|
+
e.preventDefault()
|
|
171
|
+
last.focus()
|
|
172
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
first.focus()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
88
177
|
}
|
|
89
178
|
|
|
90
179
|
document.addEventListener('keydown', handleKeyDown)
|
|
91
180
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
92
181
|
}, [open, onClose])
|
|
93
182
|
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
selectedRef.current?.scrollIntoView({ block: 'nearest' })
|
|
185
|
+
}, [selectedIndex])
|
|
186
|
+
|
|
187
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
188
|
+
if (e.key === 'ArrowDown') {
|
|
189
|
+
e.preventDefault()
|
|
190
|
+
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
|
191
|
+
} else if (e.key === 'ArrowUp') {
|
|
192
|
+
e.preventDefault()
|
|
193
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
194
|
+
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
|
195
|
+
saveRecentSearch(query)
|
|
196
|
+
onClose()
|
|
197
|
+
router.push(results[selectedIndex].href)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Build a flat index map for grouped results
|
|
202
|
+
const flatIndexMap = useMemo(() => {
|
|
203
|
+
const map = new Map<string, number>()
|
|
204
|
+
let idx = 0
|
|
205
|
+
for (const group of grouped) {
|
|
206
|
+
for (const result of group.results) {
|
|
207
|
+
map.set(result.href, idx++)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return map
|
|
211
|
+
}, [grouped])
|
|
212
|
+
|
|
94
213
|
if (!open) return null
|
|
95
214
|
|
|
96
215
|
return (
|
|
97
216
|
<div className="fixed inset-0 z-50" role="presentation">
|
|
98
217
|
{/* Backdrop */}
|
|
99
218
|
<div
|
|
100
|
-
className="absolute inset-0 bg-black/
|
|
219
|
+
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-150"
|
|
101
220
|
onClick={onClose}
|
|
102
221
|
aria-hidden="true"
|
|
103
222
|
/>
|
|
@@ -108,69 +227,144 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
|
|
108
227
|
role="dialog"
|
|
109
228
|
aria-modal="true"
|
|
110
229
|
aria-label="Search documentation"
|
|
111
|
-
className="absolute top-[
|
|
230
|
+
className="absolute top-[15%] left-1/2 -translate-x-1/2 w-full max-w-lg px-4 dialog-animate-in"
|
|
112
231
|
>
|
|
113
232
|
<div className="bg-[var(--color-bg)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden">
|
|
114
233
|
{/* Search input */}
|
|
115
234
|
<div className="flex items-center gap-3 px-4 border-b border-[var(--color-border)]">
|
|
116
|
-
<Search size={
|
|
235
|
+
<Search size={16} className="text-[var(--color-text-tertiary)] shrink-0" />
|
|
117
236
|
<input
|
|
118
237
|
ref={inputRef}
|
|
119
238
|
type="text"
|
|
120
239
|
value={query}
|
|
121
240
|
onChange={(e) => setQuery(e.target.value)}
|
|
241
|
+
onKeyDown={handleKeyDown}
|
|
122
242
|
placeholder="Search documentation..."
|
|
123
|
-
className="flex-1 py-
|
|
243
|
+
className="flex-1 py-3.5 bg-transparent outline-none text-[0.875rem] text-[var(--color-text)] placeholder:text-[var(--color-text-tertiary)]"
|
|
124
244
|
aria-label="Search query"
|
|
125
245
|
/>
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
aria-label="Close search"
|
|
130
|
-
>
|
|
131
|
-
<X size={20} className="text-[var(--color-text-tertiary)]" />
|
|
132
|
-
</button>
|
|
246
|
+
<kbd className="hidden sm:inline px-1.5 py-0.5 text-[0.625rem] font-medium text-[var(--color-text-tertiary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded">
|
|
247
|
+
ESC
|
|
248
|
+
</kbd>
|
|
133
249
|
</div>
|
|
134
250
|
|
|
135
251
|
{/* Results */}
|
|
136
|
-
<div className="max-h-
|
|
252
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
137
253
|
{results.length > 0 ? (
|
|
138
|
-
<
|
|
139
|
-
{
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
254
|
+
<div className="p-1.5">
|
|
255
|
+
{grouped.map((group) => (
|
|
256
|
+
<div key={group.section}>
|
|
257
|
+
{/* Section header */}
|
|
258
|
+
<div className="px-3 pt-3 pb-1.5">
|
|
259
|
+
<span className="text-[0.6875rem] font-bold uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
|
260
|
+
{group.section}
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
<ul>
|
|
264
|
+
{group.results.map((result) => {
|
|
265
|
+
const index = flatIndexMap.get(result.href) ?? 0
|
|
266
|
+
return (
|
|
267
|
+
<li key={result.href} ref={index === selectedIndex ? selectedRef : undefined}>
|
|
268
|
+
<Link
|
|
269
|
+
href={result.href}
|
|
270
|
+
onClick={() => { saveRecentSearch(query); onClose() }}
|
|
271
|
+
className={cn(
|
|
272
|
+
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors hover:no-underline group',
|
|
273
|
+
index === selectedIndex
|
|
274
|
+
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
|
275
|
+
: 'hover:bg-[var(--color-bg-secondary)]'
|
|
276
|
+
)}
|
|
277
|
+
>
|
|
278
|
+
<FileText size={16} className={cn(
|
|
279
|
+
'shrink-0',
|
|
280
|
+
index === selectedIndex ? 'text-white/70' : 'text-[var(--color-text-tertiary)]'
|
|
281
|
+
)} />
|
|
282
|
+
<div className="flex-1 min-w-0">
|
|
283
|
+
<div className={cn(
|
|
284
|
+
'text-[0.8125rem] font-medium truncate',
|
|
285
|
+
index === selectedIndex ? 'text-white' : 'text-[var(--color-text)]'
|
|
286
|
+
)}>
|
|
287
|
+
{result.title}
|
|
288
|
+
</div>
|
|
289
|
+
{result.snippet && (
|
|
290
|
+
<div className={cn(
|
|
291
|
+
'text-[0.75rem] truncate mt-0.5',
|
|
292
|
+
index === selectedIndex ? 'text-white/70' : 'text-[var(--color-text-tertiary)]'
|
|
293
|
+
)}>
|
|
294
|
+
{index === selectedIndex ? result.snippet : highlightTerms(result.snippet, query)}
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
<ArrowRight size={14} className={cn(
|
|
299
|
+
'shrink-0 opacity-0 group-hover:opacity-100 transition-opacity',
|
|
300
|
+
index === selectedIndex ? 'text-white/70 opacity-100' : 'text-[var(--color-text-tertiary)]'
|
|
301
|
+
)} />
|
|
302
|
+
</Link>
|
|
303
|
+
</li>
|
|
304
|
+
)
|
|
305
|
+
})}
|
|
306
|
+
</ul>
|
|
307
|
+
</div>
|
|
162
308
|
))}
|
|
163
|
-
</ul>
|
|
164
|
-
) : query ? (
|
|
165
|
-
<div className="p-8 text-center text-[var(--color-text-secondary)]">
|
|
166
|
-
No results found for "{query}"
|
|
167
309
|
</div>
|
|
168
|
-
) : (
|
|
169
|
-
<div className="p-8 text-center text-[var(--color-text-tertiary)]">
|
|
170
|
-
|
|
310
|
+
) : query && !isLoading ? (
|
|
311
|
+
<div className="p-8 text-center text-[0.8125rem] text-[var(--color-text-tertiary)]">
|
|
312
|
+
No results for “{query}”
|
|
171
313
|
</div>
|
|
172
|
-
)
|
|
314
|
+
) : isLoading ? (
|
|
315
|
+
<SearchSkeletons />
|
|
316
|
+
) : !query ? (
|
|
317
|
+
recentSearches.length > 0 ? (
|
|
318
|
+
<div className="p-1.5">
|
|
319
|
+
<div className="px-3 pt-3 pb-1.5 flex items-center justify-between">
|
|
320
|
+
<span className="text-[0.6875rem] font-bold uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
|
321
|
+
Recent
|
|
322
|
+
</span>
|
|
323
|
+
<button
|
|
324
|
+
onClick={() => { clearRecentSearches(); setRecentSearches([]) }}
|
|
325
|
+
className="text-[0.6875rem] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
|
326
|
+
>
|
|
327
|
+
Clear
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
330
|
+
<ul>
|
|
331
|
+
{recentSearches.map((recentQuery) => (
|
|
332
|
+
<li key={recentQuery}>
|
|
333
|
+
<button
|
|
334
|
+
onClick={() => setQuery(recentQuery)}
|
|
335
|
+
className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors hover:bg-[var(--color-bg-secondary)] w-full text-left"
|
|
336
|
+
>
|
|
337
|
+
<Clock size={14} className="text-[var(--color-text-tertiary)] shrink-0" />
|
|
338
|
+
<span className="text-[0.8125rem] text-[var(--color-text-secondary)] truncate">
|
|
339
|
+
{recentQuery}
|
|
340
|
+
</span>
|
|
341
|
+
</button>
|
|
342
|
+
</li>
|
|
343
|
+
))}
|
|
344
|
+
</ul>
|
|
345
|
+
</div>
|
|
346
|
+
) : (
|
|
347
|
+
<div className="p-6 text-center text-[0.8125rem] text-[var(--color-text-tertiary)]">
|
|
348
|
+
Type to search the docs
|
|
349
|
+
</div>
|
|
350
|
+
)
|
|
351
|
+
) : null}
|
|
173
352
|
</div>
|
|
353
|
+
|
|
354
|
+
{/* Footer */}
|
|
355
|
+
{results.length > 0 && (
|
|
356
|
+
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[0.6875rem] text-[var(--color-text-tertiary)]">
|
|
357
|
+
<span className="flex items-center gap-1">
|
|
358
|
+
<kbd className="px-1 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[0.5625rem]">↑</kbd>
|
|
359
|
+
<kbd className="px-1 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[0.5625rem]">↓</kbd>
|
|
360
|
+
navigate
|
|
361
|
+
</span>
|
|
362
|
+
<span className="flex items-center gap-1">
|
|
363
|
+
<kbd className="px-1 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[0.5625rem]">↵</kbd>
|
|
364
|
+
open
|
|
365
|
+
</span>
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
174
368
|
</div>
|
|
175
369
|
</div>
|
|
176
370
|
</div>
|