cantip 0.1.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/LICENSE +21 -0
- package/README.md +61 -0
- package/app/components/CanvasMount.tsx +62 -0
- package/app/components/CodeWrapToggle.tsx +78 -0
- package/app/components/FindOnPage.tsx +224 -0
- package/app/components/MobileBottomBar.tsx +93 -0
- package/app/components/MobileProjectsPanel.tsx +113 -0
- package/app/components/PageFloatingMenu.tsx +224 -0
- package/app/components/ProjectSwitcher.tsx +124 -0
- package/app/components/Search.tsx +930 -0
- package/app/components/ShortcutsHelp.tsx +113 -0
- package/app/components/Sidebar.tsx +1049 -0
- package/app/components/TabBar.tsx +227 -0
- package/app/components/Toc.tsx +129 -0
- package/app/components/TopBar.tsx +74 -0
- package/app/components/theme-toggle.tsx +71 -0
- package/app/components/ui/button.tsx +56 -0
- package/app/components/ui/card.tsx +55 -0
- package/app/components/ui/dropdown-menu.tsx +156 -0
- package/app/components/ui/input.tsx +21 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +155 -0
- package/app/generated/site.ts +19 -0
- package/app/generated/slots.ts +10 -0
- package/app/generated/theme.generated.css +60 -0
- package/app/lib/config/config.server.ts +50 -0
- package/app/lib/config/defaults.ts +120 -0
- package/app/lib/config/load.ts +82 -0
- package/app/lib/config/schema.ts +131 -0
- package/app/lib/config/site.ts +43 -0
- package/app/lib/content.server.ts +105 -0
- package/app/lib/projects.ts +86 -0
- package/app/lib/sidebar.server.ts +113 -0
- package/app/lib/site.ts +27 -0
- package/app/lib/slots.tsx +33 -0
- package/app/lib/tabs.tsx +128 -0
- package/app/lib/useKeyboardShortcuts.ts +149 -0
- package/app/lib/utils.ts +17 -0
- package/app/root.tsx +171 -0
- package/app/routes/$.tsx +158 -0
- package/app/routes/_index.tsx +60 -0
- package/app/styles/app.css +461 -0
- package/app/styles/obsidian.css +83 -0
- package/app/styles/tailwind.css +227 -0
- package/cli.js +119 -0
- package/components.json +21 -0
- package/dist/config.mjs +87 -0
- package/dist/generate-content.mjs +1665 -0
- package/package.json +112 -0
- package/scripts/build-search-index.ts +129 -0
- package/scripts/canonical.ts +34 -0
- package/scripts/canvas-to-md.ts +73 -0
- package/scripts/compile.ts +242 -0
- package/scripts/emit-config.ts +163 -0
- package/scripts/generate-content.ts +197 -0
- package/scripts/obsidian/files.ts +222 -0
- package/scripts/obsidian/fs.ts +34 -0
- package/scripts/obsidian/generate.ts +36 -0
- package/scripts/obsidian/html.ts +17 -0
- package/scripts/obsidian/logger.ts +10 -0
- package/scripts/obsidian/markdown.ts +56 -0
- package/scripts/obsidian/obsidian.ts +229 -0
- package/scripts/obsidian/path.ts +60 -0
- package/scripts/obsidian/rehype.ts +60 -0
- package/scripts/obsidian/remark.ts +712 -0
- package/scripts/obsidian/types.ts +31 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Link, useLocation } from '@remix-run/react'
|
|
4
|
+
import {
|
|
5
|
+
ChevronRight,
|
|
6
|
+
FileText,
|
|
7
|
+
Folder,
|
|
8
|
+
FolderTree,
|
|
9
|
+
Search as SearchIcon,
|
|
10
|
+
WholeWord,
|
|
11
|
+
X,
|
|
12
|
+
} from 'lucide-react'
|
|
13
|
+
|
|
14
|
+
import { getProject } from '~/lib/projects'
|
|
15
|
+
import { t } from '~/lib/site'
|
|
16
|
+
import { cn } from '~/lib/utils'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Custom shadcn-styled search over Pagefind's JS API.
|
|
20
|
+
*
|
|
21
|
+
* The index + runtime are static assets under `/pagefind/` (generated at build
|
|
22
|
+
* time by scripts/build-search-index.ts). We import `/pagefind/pagefind.js`
|
|
23
|
+
* lazily on first open and call its `search()` directly, then render results
|
|
24
|
+
* with our own markup so they match the rest of the chrome (instead of using
|
|
25
|
+
* Pagefind's prebuilt UI). The same component backs both the desktop top bar
|
|
26
|
+
* and the mobile bottom bar.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
interface PagefindSubResult {
|
|
30
|
+
title: string
|
|
31
|
+
url: string
|
|
32
|
+
excerpt: string
|
|
33
|
+
}
|
|
34
|
+
interface PagefindData {
|
|
35
|
+
url: string
|
|
36
|
+
meta: { title?: string }
|
|
37
|
+
excerpt: string
|
|
38
|
+
sub_results?: PagefindSubResult[]
|
|
39
|
+
}
|
|
40
|
+
interface PagefindResult {
|
|
41
|
+
id: string
|
|
42
|
+
data: () => Promise<PagefindData>
|
|
43
|
+
}
|
|
44
|
+
/** Options accepted by Pagefind's search/debouncedSearch (the bits we use). */
|
|
45
|
+
interface PagefindSearchOptions {
|
|
46
|
+
filters?: Record<string, string | string[]>
|
|
47
|
+
}
|
|
48
|
+
/** `filters()` returns each filter's values mapped to their result counts. */
|
|
49
|
+
type PagefindFilterCounts = Record<string, Record<string, number>>
|
|
50
|
+
interface PagefindApi {
|
|
51
|
+
search: (
|
|
52
|
+
query: string,
|
|
53
|
+
options?: PagefindSearchOptions,
|
|
54
|
+
) => Promise<{ results: PagefindResult[] }>
|
|
55
|
+
debouncedSearch: (
|
|
56
|
+
query: string,
|
|
57
|
+
options?: PagefindSearchOptions,
|
|
58
|
+
debounceMs?: number,
|
|
59
|
+
) => Promise<{ results: PagefindResult[] } | null>
|
|
60
|
+
filters: () => Promise<PagefindFilterCounts>
|
|
61
|
+
preload: (query: string) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let pagefindPromise: Promise<PagefindApi | null> | null = null
|
|
65
|
+
|
|
66
|
+
/** Load and init the Pagefind runtime once. */
|
|
67
|
+
function loadPagefind(): Promise<PagefindApi | null> {
|
|
68
|
+
if (typeof window === 'undefined') return Promise.resolve(null)
|
|
69
|
+
if (pagefindPromise) return pagefindPromise
|
|
70
|
+
pagefindPromise = (async () => {
|
|
71
|
+
try {
|
|
72
|
+
// Pagefind's runtime is a build-time-generated asset under public/.
|
|
73
|
+
// Vite refuses to import files in public/ if it can see the path as a
|
|
74
|
+
// literal, so build the URL at runtime (from the page origin) to keep
|
|
75
|
+
// the import opaque — it stays a plain browser dynamic import().
|
|
76
|
+
const url = `${window.location.origin}/pagefind/pagefind.js`
|
|
77
|
+
const mod = (await import(/* @vite-ignore */ url)) as PagefindApi & {
|
|
78
|
+
init?: () => Promise<void>
|
|
79
|
+
}
|
|
80
|
+
await mod.init?.()
|
|
81
|
+
return mod
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('Failed to load Pagefind', err)
|
|
84
|
+
pagefindPromise = null
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
})()
|
|
88
|
+
return pagefindPromise
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** First path segment of the current route = the project the reader is in. */
|
|
92
|
+
function projectFromPath(pathname: string): string {
|
|
93
|
+
return pathname.replace(/^\/+/, '').split('/')[0] ?? ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Display label for a project id. The id is the vault directory (the value
|
|
98
|
+
* Pagefind filters on); show the human name from the project registry instead,
|
|
99
|
+
* falling back to the raw id for any project not in the registry.
|
|
100
|
+
*/
|
|
101
|
+
function projectLabel(id: string): string {
|
|
102
|
+
return getProject(id)?.name ?? id
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A node in the directory tree built from the flat list of relative paths. */
|
|
106
|
+
interface DirNode {
|
|
107
|
+
name: string // last path segment (display label)
|
|
108
|
+
path: string // full relative path from the project root
|
|
109
|
+
children: DirNode[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Build a nested folder tree from flat relative paths like "a", "a/b", "a/b/c". */
|
|
113
|
+
function buildDirTree(paths: string[]): DirNode[] {
|
|
114
|
+
const roots: DirNode[] = []
|
|
115
|
+
const byPath = new Map<string, DirNode>()
|
|
116
|
+
// Sorting guarantees parents are created before their children.
|
|
117
|
+
for (const full of [...paths].sort()) {
|
|
118
|
+
const segments = full.split('/')
|
|
119
|
+
let prefix = ''
|
|
120
|
+
let siblings = roots
|
|
121
|
+
for (const seg of segments) {
|
|
122
|
+
prefix = prefix ? `${prefix}/${seg}` : seg
|
|
123
|
+
let node = byPath.get(prefix)
|
|
124
|
+
if (!node) {
|
|
125
|
+
node = { name: seg, path: prefix, children: [] }
|
|
126
|
+
byPath.set(prefix, node)
|
|
127
|
+
siblings.push(node)
|
|
128
|
+
}
|
|
129
|
+
siblings = node.children
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return roots
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One row in the inline directory tree; recurses into its children. */
|
|
136
|
+
function DirTreeNode({
|
|
137
|
+
node,
|
|
138
|
+
depth,
|
|
139
|
+
selected,
|
|
140
|
+
onSelect,
|
|
141
|
+
}: {
|
|
142
|
+
node: DirNode
|
|
143
|
+
depth: number
|
|
144
|
+
selected: string
|
|
145
|
+
onSelect: (path: string) => void
|
|
146
|
+
}) {
|
|
147
|
+
const hasChildren = node.children.length > 0
|
|
148
|
+
// Auto-expand any ancestor of the current selection so it's visible on open.
|
|
149
|
+
const [expanded, setExpanded] = useState(
|
|
150
|
+
() => selected === node.path || selected.startsWith(`${node.path}/`),
|
|
151
|
+
)
|
|
152
|
+
const isSelected = selected === node.path
|
|
153
|
+
return (
|
|
154
|
+
<li>
|
|
155
|
+
{/* A single click selects this folder; if it has children it also toggles
|
|
156
|
+
its expansion (open/close), so one click both highlights and drills.
|
|
157
|
+
Leaf folders just get selected. Selection is a draft — committed only
|
|
158
|
+
when the user presses OK in the modal footer. */}
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => {
|
|
162
|
+
onSelect(node.path)
|
|
163
|
+
if (hasChildren) setExpanded((v) => !v)
|
|
164
|
+
}}
|
|
165
|
+
className={cn(
|
|
166
|
+
'flex w-full items-center gap-1 rounded px-1 py-1 text-left hover:bg-accent',
|
|
167
|
+
isSelected && 'bg-accent text-foreground',
|
|
168
|
+
)}
|
|
169
|
+
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
|
170
|
+
>
|
|
171
|
+
<ChevronRight
|
|
172
|
+
className={cn(
|
|
173
|
+
'size-3 shrink-0 transition-transform',
|
|
174
|
+
expanded && 'rotate-90',
|
|
175
|
+
!hasChildren && 'invisible',
|
|
176
|
+
)}
|
|
177
|
+
/>
|
|
178
|
+
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
|
179
|
+
<span className="truncate">{node.name}</span>
|
|
180
|
+
</button>
|
|
181
|
+
{hasChildren && expanded && (
|
|
182
|
+
<ul>
|
|
183
|
+
{node.children.map((child) => (
|
|
184
|
+
<DirTreeNode
|
|
185
|
+
key={child.path}
|
|
186
|
+
node={child}
|
|
187
|
+
depth={depth + 1}
|
|
188
|
+
selected={selected}
|
|
189
|
+
onSelect={onSelect}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
</ul>
|
|
193
|
+
)}
|
|
194
|
+
</li>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Strip the trailing slash Pagefind preserves from our route URLs for <Link>. */
|
|
199
|
+
function toRoutePath(url: string): string {
|
|
200
|
+
// Pagefind stores the url we indexed ("/krista/.../doc/"); drop any anchor's
|
|
201
|
+
// leading host and keep the path + hash for sub-result deep links.
|
|
202
|
+
return url
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One result row: the page plus its best matching sub-sections. */
|
|
206
|
+
function ResultItem({ data, onNavigate }: { data: PagefindData; onNavigate: () => void }) {
|
|
207
|
+
const title = data.meta.title ?? data.url
|
|
208
|
+
const subs = (data.sub_results ?? []).slice(0, 3)
|
|
209
|
+
return (
|
|
210
|
+
<li className="rounded-md border bg-card">
|
|
211
|
+
<Link
|
|
212
|
+
to={toRoutePath(data.url)}
|
|
213
|
+
onClick={onNavigate}
|
|
214
|
+
className="flex items-start gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
|
215
|
+
>
|
|
216
|
+
<FileText className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
217
|
+
<span className="min-w-0">
|
|
218
|
+
<span className="block truncate text-sm font-medium">{title}</span>
|
|
219
|
+
<span
|
|
220
|
+
className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground [&_mark]:bg-transparent [&_mark]:font-semibold [&_mark]:text-foreground"
|
|
221
|
+
dangerouslySetInnerHTML={{ __html: data.excerpt }}
|
|
222
|
+
/>
|
|
223
|
+
</span>
|
|
224
|
+
</Link>
|
|
225
|
+
{subs.length > 0 && (
|
|
226
|
+
<ul className="border-t px-3 py-1">
|
|
227
|
+
{subs.map((s) => (
|
|
228
|
+
<li key={s.url}>
|
|
229
|
+
<Link
|
|
230
|
+
to={toRoutePath(s.url)}
|
|
231
|
+
onClick={onNavigate}
|
|
232
|
+
className="block truncate rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
233
|
+
>
|
|
234
|
+
{s.title}
|
|
235
|
+
</Link>
|
|
236
|
+
</li>
|
|
237
|
+
))}
|
|
238
|
+
</ul>
|
|
239
|
+
)}
|
|
240
|
+
</li>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function Search({
|
|
245
|
+
className,
|
|
246
|
+
enableShortcut = true,
|
|
247
|
+
trigger,
|
|
248
|
+
}: {
|
|
249
|
+
className?: string
|
|
250
|
+
/**
|
|
251
|
+
* Whether this instance owns the global ⌘K shortcut. The app mounts two
|
|
252
|
+
* Search instances (desktop top bar + mobile bottom bar) at once; only one
|
|
253
|
+
* must handle ⌘K, otherwise both open and the hidden instance's portalled
|
|
254
|
+
* modal (which escapes its `md:hidden` container) covers the visible one with
|
|
255
|
+
* empty state. The mobile instance passes `enableShortcut={false}`.
|
|
256
|
+
*/
|
|
257
|
+
enableShortcut?: boolean
|
|
258
|
+
/**
|
|
259
|
+
* Optional custom trigger. Given an `open` callback, it renders the element
|
|
260
|
+
* that opens the modal — lets callers (e.g. the mobile bottom bar) supply a
|
|
261
|
+
* differently-shaped button than the default bordered "Поиск…" pill while
|
|
262
|
+
* reusing all the modal logic. When omitted, the default trigger renders.
|
|
263
|
+
*/
|
|
264
|
+
trigger?: (open: () => void) => JSX.Element
|
|
265
|
+
}) {
|
|
266
|
+
const [open, setOpen] = useState(false)
|
|
267
|
+
const [query, setQuery] = useState('')
|
|
268
|
+
const [results, setResults] = useState<PagefindData[]>([])
|
|
269
|
+
const [loading, setLoading] = useState(false)
|
|
270
|
+
const [ready, setReady] = useState(false)
|
|
271
|
+
const [mounted, setMounted] = useState(false)
|
|
272
|
+
// Available filter values + counts from Pagefind (projects and directories).
|
|
273
|
+
const [filterCounts, setFilterCounts] = useState<PagefindFilterCounts | null>(null)
|
|
274
|
+
// Search scope. `project` defaults to the project the reader is currently in,
|
|
275
|
+
// '' means all projects. `dir` is a directory path within the scope ('' = the
|
|
276
|
+
// whole project/site). The user can opt out of the current project via these.
|
|
277
|
+
const [project, setProject] = useState('')
|
|
278
|
+
const [dir, setDir] = useState('')
|
|
279
|
+
// What the user types in the directory box — a path RELATIVE to the selected
|
|
280
|
+
// project (e.g. "требования/управление-клиентами"). Kept separate from the
|
|
281
|
+
// committed `dir` (the full "<project>/<path>" filter value) so typing can show
|
|
282
|
+
// hints without committing an invalid scope on every keystroke.
|
|
283
|
+
const [dirInput, setDirInput] = useState('')
|
|
284
|
+
// Whether the directory input is focused — the folder-picker dropdown shows
|
|
285
|
+
// while focused (even with an empty input, to list top-level folders) and
|
|
286
|
+
// hides on blur / selection.
|
|
287
|
+
const [dirFocused, setDirFocused] = useState(false)
|
|
288
|
+
// Screen-space rect of the directory input, used to position the folder
|
|
289
|
+
// dropdown. The dropdown is portalled to <body> (so it escapes the modal's
|
|
290
|
+
// `overflow-hidden`), which means it needs fixed coordinates rather than being
|
|
291
|
+
// positioned relative to the input.
|
|
292
|
+
const [dirRect, setDirRect] = useState<DOMRect | null>(null)
|
|
293
|
+
const [showTree, setShowTree] = useState(false)
|
|
294
|
+
// Draft folder selection inside the tree modal — a relative path. It's only
|
|
295
|
+
// applied to the actual search scope when the user presses OK; Cancel discards
|
|
296
|
+
// it. Seeded from the current scope each time the modal opens.
|
|
297
|
+
const [treeDraft, setTreeDraft] = useState('')
|
|
298
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
299
|
+
const dirInputRef = useRef<HTMLInputElement>(null)
|
|
300
|
+
const location = useLocation()
|
|
301
|
+
const currentProject = projectFromPath(location.pathname)
|
|
302
|
+
|
|
303
|
+
// Portal target only exists on the client; gate the portal on mount.
|
|
304
|
+
useEffect(() => setMounted(true), [])
|
|
305
|
+
|
|
306
|
+
// Kick off loading the runtime as soon as the modal opens, and focus input.
|
|
307
|
+
// If reopening onto a PRESERVED search (a non-empty query survived the last
|
|
308
|
+
// dismiss), keep the query + scope so the user resumes where they left off.
|
|
309
|
+
// Only a fresh open (no query) seeds the scope to the reader's current project.
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (!open) return
|
|
312
|
+
if (!query) {
|
|
313
|
+
setProject(currentProject)
|
|
314
|
+
setDir('')
|
|
315
|
+
setDirInput('')
|
|
316
|
+
}
|
|
317
|
+
setShowTree(false)
|
|
318
|
+
loadPagefind().then(async (api) => {
|
|
319
|
+
setReady(!!api)
|
|
320
|
+
if (api) setFilterCounts(await api.filters())
|
|
321
|
+
})
|
|
322
|
+
inputRef.current?.focus()
|
|
323
|
+
// Read currentProject/query only at open time; we don't want to reset the
|
|
324
|
+
// user's scope mid-session if the route changes underneath the open modal.
|
|
325
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
326
|
+
}, [open])
|
|
327
|
+
|
|
328
|
+
// Run a (debounced) search whenever the query changes.
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
if (!open) return
|
|
331
|
+
const q = query.trim()
|
|
332
|
+
if (!q) {
|
|
333
|
+
setResults([])
|
|
334
|
+
setLoading(false)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
let cancelled = false
|
|
338
|
+
setLoading(true)
|
|
339
|
+
// Scope the search. A selected directory is the most specific filter and
|
|
340
|
+
// already carries its project prefix, so it stands alone; otherwise fall
|
|
341
|
+
// back to the project filter. Neither set → search everything.
|
|
342
|
+
const filters: Record<string, string> = {}
|
|
343
|
+
if (dir) filters.dir = dir
|
|
344
|
+
else if (project) filters.project = project
|
|
345
|
+
const options: PagefindSearchOptions = Object.keys(filters).length ? { filters } : {}
|
|
346
|
+
loadPagefind().then(async (api) => {
|
|
347
|
+
if (!api || cancelled) return
|
|
348
|
+
const search = await api.debouncedSearch(q, options, 200)
|
|
349
|
+
if (cancelled || !search) return // superseded by a newer keystroke
|
|
350
|
+
const data = await Promise.all(search.results.slice(0, 8).map((r) => r.data()))
|
|
351
|
+
if (cancelled) return
|
|
352
|
+
setResults(data)
|
|
353
|
+
setLoading(false)
|
|
354
|
+
})
|
|
355
|
+
return () => {
|
|
356
|
+
cancelled = true
|
|
357
|
+
}
|
|
358
|
+
}, [query, project, dir, open])
|
|
359
|
+
|
|
360
|
+
// Hide the modal but PRESERVE the query + scope, so reopening (top bar / ⌘K)
|
|
361
|
+
// resumes the same search — the common "that result wasn't it, let me keep
|
|
362
|
+
// looking" flow. Only the transient picker UI (tree/dir dropdown) is reset.
|
|
363
|
+
const dismiss = useCallback(() => {
|
|
364
|
+
setOpen(false)
|
|
365
|
+
setDirFocused(false)
|
|
366
|
+
setShowTree(false)
|
|
367
|
+
}, [])
|
|
368
|
+
|
|
369
|
+
// Wipe to a clean state: empty query, no directory, scope back to the reader's
|
|
370
|
+
// current project. Used by the in-field clear (X) button.
|
|
371
|
+
const reset = useCallback(() => {
|
|
372
|
+
setQuery('')
|
|
373
|
+
setResults([])
|
|
374
|
+
setProject(currentProject)
|
|
375
|
+
setDir('')
|
|
376
|
+
setDirInput('')
|
|
377
|
+
setDirFocused(false)
|
|
378
|
+
setShowTree(false)
|
|
379
|
+
inputRef.current?.focus()
|
|
380
|
+
// currentProject changes with the route; reading it here (clear pressed) is
|
|
381
|
+
// intentional — a fresh search should scope to wherever the reader now is.
|
|
382
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
383
|
+
}, [currentProject])
|
|
384
|
+
|
|
385
|
+
// Whether the current input is wrapped in quotes — Pagefind's exact-phrase
|
|
386
|
+
// syntax. Derived from the text itself (not separate state) so the button and
|
|
387
|
+
// the visible input never disagree, even if the user types/edits quotes by hand.
|
|
388
|
+
const isExact = (() => {
|
|
389
|
+
const t = query.trim()
|
|
390
|
+
return t.length >= 2 && t.startsWith('"') && t.endsWith('"')
|
|
391
|
+
})()
|
|
392
|
+
|
|
393
|
+
// Project options (sorted) from the index, for the scope selector.
|
|
394
|
+
const projectOptions = Object.keys(filterCounts?.project ?? {}).sort()
|
|
395
|
+
|
|
396
|
+
// Directory paths within the active project, RELATIVE to it (the "<project>/"
|
|
397
|
+
// prefix stripped). These feed both the type-ahead hints and the tree view.
|
|
398
|
+
// Without a project selected there's nothing to be relative to, so the dir
|
|
399
|
+
// picker is hidden (the project dropdown is the only scope control then).
|
|
400
|
+
const relDirs = (() => {
|
|
401
|
+
if (!project) return [] as string[]
|
|
402
|
+
const prefix = `${project}/`
|
|
403
|
+
return Object.keys(filterCounts?.dir ?? {})
|
|
404
|
+
.filter((d) => d.startsWith(prefix))
|
|
405
|
+
.map((d) => d.slice(prefix.length))
|
|
406
|
+
.filter(Boolean)
|
|
407
|
+
.sort()
|
|
408
|
+
})()
|
|
409
|
+
|
|
410
|
+
// Nested tree of the project's directories, for the inline tree-view picker.
|
|
411
|
+
const dirTree = buildDirTree(relDirs)
|
|
412
|
+
|
|
413
|
+
// Commit a relative directory path as the active scope. Empty → whole project.
|
|
414
|
+
// The committed `dir` filter value is always the full "<project>/<rel>".
|
|
415
|
+
const commitDir = useCallback(
|
|
416
|
+
(rel: string) => {
|
|
417
|
+
// Keep the raw text the user typed verbatim in the box — including any
|
|
418
|
+
// "/" separators they enter to build a nested path. Only normalise
|
|
419
|
+
// (strip leading/trailing slashes) when deriving the committed filter
|
|
420
|
+
// value, so a half-typed "требования/" still searches "требования" but
|
|
421
|
+
// the slash stays visible for the next segment.
|
|
422
|
+
setDirInput(rel)
|
|
423
|
+
const clean = rel.replace(/^\/+|\/+$/g, '')
|
|
424
|
+
setDir(clean && project ? `${project}/${clean}` : '')
|
|
425
|
+
},
|
|
426
|
+
[project],
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
// Measure the directory input's position so the portalled dropdown can be
|
|
430
|
+
// placed right below it in screen space.
|
|
431
|
+
const measureDir = useCallback(() => {
|
|
432
|
+
const el = dirInputRef.current
|
|
433
|
+
setDirRect(el ? el.getBoundingClientRect() : null)
|
|
434
|
+
}, [])
|
|
435
|
+
|
|
436
|
+
// Keep the dropdown anchored while it's open: re-measure on scroll/resize.
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (!dirFocused) return
|
|
439
|
+
measureDir()
|
|
440
|
+
const onMove = () => measureDir()
|
|
441
|
+
// Capture phase catches scrolls inside the modal's overflow containers too.
|
|
442
|
+
window.addEventListener('scroll', onMove, true)
|
|
443
|
+
window.addEventListener('resize', onMove)
|
|
444
|
+
return () => {
|
|
445
|
+
window.removeEventListener('scroll', onMove, true)
|
|
446
|
+
window.removeEventListener('resize', onMove)
|
|
447
|
+
}
|
|
448
|
+
}, [dirFocused, measureDir])
|
|
449
|
+
|
|
450
|
+
// Picking a folder from the hints/tree commits it AND, if it has sub-folders,
|
|
451
|
+
// appends a trailing "/" so the user can keep typing the next segment straight
|
|
452
|
+
// away (leaf folders get no dead slash). The filter value is the same either
|
|
453
|
+
// way — commitDir strips the trailing slash when deriving it.
|
|
454
|
+
const selectDir = useCallback(
|
|
455
|
+
(rel: string) => {
|
|
456
|
+
const hasChildren = relDirs.some((d) => d.startsWith(`${rel}/`))
|
|
457
|
+
commitDir(hasChildren ? `${rel}/` : rel)
|
|
458
|
+
},
|
|
459
|
+
[relDirs, commitDir],
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
// Folder picker entries — the directories on the CURRENT level only (like a
|
|
463
|
+
// file browser), not the whole matching subtree. The typed text is split into
|
|
464
|
+
// a committed parent path (everything up to the last "/") and a partial segment
|
|
465
|
+
// being typed (after it). We list the parent's DIRECT children whose name
|
|
466
|
+
// starts with the partial segment. So: empty/"" → top-level folders;
|
|
467
|
+
// "требования/" → children of требования; "требования/да" → children of
|
|
468
|
+
// требования starting with "да". Each entry is a full relative path (so
|
|
469
|
+
// selecting it commits correctly), but we display only the last segment.
|
|
470
|
+
const dirHints = (() => {
|
|
471
|
+
const raw = dirInput.replace(/^\/+/, '') // keep a trailing slash; drop leading
|
|
472
|
+
const slash = raw.lastIndexOf('/')
|
|
473
|
+
const parent = slash === -1 ? '' : raw.slice(0, slash) // committed dir, no trailing /
|
|
474
|
+
const partial = (slash === -1 ? raw : raw.slice(slash + 1)).toLowerCase()
|
|
475
|
+
const childPrefix = parent ? `${parent}/` : ''
|
|
476
|
+
const seen = new Set<string>()
|
|
477
|
+
const entries: string[] = []
|
|
478
|
+
for (const d of relDirs) {
|
|
479
|
+
if (parent && !d.startsWith(childPrefix)) continue
|
|
480
|
+
const rest = d.slice(childPrefix.length)
|
|
481
|
+
if (!rest) continue
|
|
482
|
+
const name = rest.split('/')[0] // first segment below the parent = this level
|
|
483
|
+
const fullPath = `${childPrefix}${name}`
|
|
484
|
+
if (seen.has(fullPath)) continue
|
|
485
|
+
if (partial && !name.toLowerCase().startsWith(partial)) continue
|
|
486
|
+
seen.add(fullPath)
|
|
487
|
+
entries.push(fullPath)
|
|
488
|
+
}
|
|
489
|
+
return entries.sort().slice(0, 12)
|
|
490
|
+
})()
|
|
491
|
+
|
|
492
|
+
// Normalised relative dir (no surrounding slashes) for exact comparisons such
|
|
493
|
+
// as highlighting the active node in the tree.
|
|
494
|
+
const dirRel = dirInput.trim().replace(/^\/+|\/+$/g, '')
|
|
495
|
+
|
|
496
|
+
// Enter in the directory box "accepts" the best match: take the top hint (or,
|
|
497
|
+
// if the text already exactly names a folder, that folder) and complete it via
|
|
498
|
+
// selectDir — adding the trailing "/" when it has children so the user keeps
|
|
499
|
+
// drilling. No match → leave the typed text as the (free-form) scope.
|
|
500
|
+
const completeDir = useCallback(() => {
|
|
501
|
+
if (!dirRel) return
|
|
502
|
+
const exact = relDirs.find((d) => d.toLowerCase() === dirRel.toLowerCase())
|
|
503
|
+
const best = exact ?? dirHints[0]
|
|
504
|
+
if (best) selectDir(best)
|
|
505
|
+
}, [dirRel, relDirs, dirHints, selectDir])
|
|
506
|
+
|
|
507
|
+
// Reset the directory when the project scope changes — a directory from another
|
|
508
|
+
// project would be empty under the new scope.
|
|
509
|
+
const changeProject = useCallback((next: string) => {
|
|
510
|
+
setProject(next)
|
|
511
|
+
setDir('')
|
|
512
|
+
setDirInput('')
|
|
513
|
+
setShowTree(false)
|
|
514
|
+
inputRef.current?.focus()
|
|
515
|
+
}, [])
|
|
516
|
+
|
|
517
|
+
// Toggle exact match by literally adding/removing the surrounding quotes in the
|
|
518
|
+
// input, so the user sees exactly what's being searched. We trim first so the
|
|
519
|
+
// quotes hug the text, then refocus the input for continued typing.
|
|
520
|
+
const toggleExact = useCallback(() => {
|
|
521
|
+
setQuery((q) => {
|
|
522
|
+
const t = q.trim()
|
|
523
|
+
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
|
|
524
|
+
return t.slice(1, -1)
|
|
525
|
+
}
|
|
526
|
+
return `"${t.replace(/"/g, '')}"`
|
|
527
|
+
})
|
|
528
|
+
inputRef.current?.focus()
|
|
529
|
+
}, [])
|
|
530
|
+
|
|
531
|
+
// Dismiss (not reset) on navigation so the query + scope survive — reopening
|
|
532
|
+
// resumes the same search if the opened result wasn't the right one. Clicking a
|
|
533
|
+
// result fires the <Link>'s onClick (which dismisses), but a result pointing at
|
|
534
|
+
// the current page only changes the hash and a same-tick dismiss can be missed;
|
|
535
|
+
// watching location guarantees the modal hides whenever a result navigates.
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
if (open) dismiss()
|
|
538
|
+
// Only react to location changes, not to `open` toggling on its own.
|
|
539
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
540
|
+
}, [location.pathname, location.hash])
|
|
541
|
+
|
|
542
|
+
// Escape to close + lock background scroll while open. When the folder tree
|
|
543
|
+
// modal is open, Escape dismisses just that (one layer at a time) and leaves
|
|
544
|
+
// the search modal open.
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!open) return
|
|
547
|
+
const onKey = (e: KeyboardEvent) => {
|
|
548
|
+
if (e.key !== 'Escape') return
|
|
549
|
+
if (showTree) setShowTree(false)
|
|
550
|
+
else dismiss()
|
|
551
|
+
}
|
|
552
|
+
document.addEventListener('keydown', onKey)
|
|
553
|
+
const prev = document.body.style.overflow
|
|
554
|
+
document.body.style.overflow = 'hidden'
|
|
555
|
+
return () => {
|
|
556
|
+
document.removeEventListener('keydown', onKey)
|
|
557
|
+
document.body.style.overflow = prev
|
|
558
|
+
}
|
|
559
|
+
}, [open, dismiss, showTree])
|
|
560
|
+
|
|
561
|
+
// Global Cmd/Ctrl-K to open — only on the instance that owns the shortcut, so
|
|
562
|
+
// the two mounted Search instances don't both open on one keypress.
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (!enableShortcut) return
|
|
565
|
+
const onKey = (e: KeyboardEvent) => {
|
|
566
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
567
|
+
e.preventDefault()
|
|
568
|
+
setOpen(true)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
window.addEventListener('keydown', onKey)
|
|
572
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
573
|
+
}, [enableShortcut])
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<>
|
|
577
|
+
{trigger ? (
|
|
578
|
+
trigger(() => setOpen(true))
|
|
579
|
+
) : (
|
|
580
|
+
<button
|
|
581
|
+
type="button"
|
|
582
|
+
onClick={() => setOpen(true)}
|
|
583
|
+
className={cn(
|
|
584
|
+
'inline-flex items-center gap-2 rounded-md border bg-background/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
|
585
|
+
className,
|
|
586
|
+
)}
|
|
587
|
+
aria-label="Поиск"
|
|
588
|
+
>
|
|
589
|
+
<SearchIcon className="size-4 shrink-0" />
|
|
590
|
+
<span className="max-md:hidden">Поиск…</span>
|
|
591
|
+
<kbd className="ml-auto hidden rounded border px-1.5 text-xs md:inline">Ctrl K</kbd>
|
|
592
|
+
</button>
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
{open &&
|
|
596
|
+
mounted &&
|
|
597
|
+
createPortal(
|
|
598
|
+
// Portalled to <body> so the overlay escapes the top bar's
|
|
599
|
+
// `-translate-x-1/2` transform — a transformed ancestor would
|
|
600
|
+
// otherwise become the containing block for this `fixed` element,
|
|
601
|
+
// trapping the backdrop and width inside the narrow centered bar.
|
|
602
|
+
<div
|
|
603
|
+
className="fixed inset-0 z-[200] bg-background/40 backdrop-blur-sm md:flex md:items-start md:justify-center md:p-4 md:pt-[10vh]"
|
|
604
|
+
onMouseDown={dismiss}
|
|
605
|
+
role="dialog"
|
|
606
|
+
aria-modal="true"
|
|
607
|
+
aria-label="Поиск по документации"
|
|
608
|
+
>
|
|
609
|
+
<div
|
|
610
|
+
className="fixed inset-0 flex flex-col overflow-hidden bg-popover md:static md:max-h-[80vh] md:w-full md:max-w-3xl md:rounded-lg md:border md:shadow-xl"
|
|
611
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
612
|
+
>
|
|
613
|
+
{/* Search field */}
|
|
614
|
+
<div className="flex items-center gap-2 border-b px-3">
|
|
615
|
+
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
616
|
+
<input
|
|
617
|
+
ref={inputRef}
|
|
618
|
+
value={query}
|
|
619
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
620
|
+
placeholder="Поиск по документации…"
|
|
621
|
+
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
622
|
+
autoComplete="off"
|
|
623
|
+
spellCheck={false}
|
|
624
|
+
/>
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
onClick={toggleExact}
|
|
628
|
+
className={cn(
|
|
629
|
+
'rounded p-1 hover:bg-accent hover:text-foreground',
|
|
630
|
+
isExact ? 'bg-accent text-foreground' : 'text-muted-foreground',
|
|
631
|
+
)}
|
|
632
|
+
aria-label="Точное совпадение"
|
|
633
|
+
aria-pressed={isExact}
|
|
634
|
+
title="Точное совпадение фразы"
|
|
635
|
+
>
|
|
636
|
+
<WholeWord className="size-4" />
|
|
637
|
+
</button>
|
|
638
|
+
{/* Clear to a clean state (empty query, scope back to the current
|
|
639
|
+
project) without leaving search — for starting a fresh search.
|
|
640
|
+
The modal still closes via Escape, the backdrop, or ⌘K toggling.
|
|
641
|
+
Disabled when there's nothing to clear. */}
|
|
642
|
+
<button
|
|
643
|
+
type="button"
|
|
644
|
+
onClick={reset}
|
|
645
|
+
disabled={!query && !dir}
|
|
646
|
+
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
647
|
+
aria-label="Очистить поиск"
|
|
648
|
+
title="Очистить"
|
|
649
|
+
>
|
|
650
|
+
<X className="size-4" />
|
|
651
|
+
</button>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
{/* Scope: project dropdown + a relative directory path box with
|
|
655
|
+
type-ahead hints and an inline tree-view picker. Defaults to
|
|
656
|
+
the reader's current project; "Все проекты" opts out site-wide. */}
|
|
657
|
+
{projectOptions.length > 0 && (
|
|
658
|
+
<div className="border-b px-3 py-2 text-xs text-muted-foreground">
|
|
659
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
660
|
+
<span className="shrink-0">Искать в:</span>
|
|
661
|
+
<select
|
|
662
|
+
value={project}
|
|
663
|
+
onChange={(e) => changeProject(e.target.value)}
|
|
664
|
+
className="rounded border bg-background px-2 py-1 text-foreground outline-none focus:ring-1 focus:ring-ring"
|
|
665
|
+
aria-label={t('searchProjectFilter')}
|
|
666
|
+
>
|
|
667
|
+
<option value="">Все проекты</option>
|
|
668
|
+
{projectOptions.map((p) => (
|
|
669
|
+
<option key={p} value={p}>
|
|
670
|
+
{projectLabel(p)}
|
|
671
|
+
</option>
|
|
672
|
+
))}
|
|
673
|
+
</select>
|
|
674
|
+
|
|
675
|
+
{/* Directory box only makes sense scoped to one project. */}
|
|
676
|
+
{project && relDirs.length > 0 && (
|
|
677
|
+
<div className="relative flex min-w-0 flex-1 items-center gap-1">
|
|
678
|
+
<div className="relative min-w-0 flex-1">
|
|
679
|
+
<input
|
|
680
|
+
ref={dirInputRef}
|
|
681
|
+
value={dirInput}
|
|
682
|
+
onChange={(e) => commitDir(e.target.value)}
|
|
683
|
+
onFocus={() => setDirFocused(true)}
|
|
684
|
+
onBlur={() => setDirFocused(false)}
|
|
685
|
+
onKeyDown={(e) => {
|
|
686
|
+
// Enter completes the best matching folder (adds "/" if it
|
|
687
|
+
// has children). preventDefault so it doesn't bubble to any
|
|
688
|
+
// form submit / close the modal.
|
|
689
|
+
if (e.key === 'Enter') {
|
|
690
|
+
e.preventDefault()
|
|
691
|
+
completeDir()
|
|
692
|
+
} else if (e.key === 'Escape' && dirFocused) {
|
|
693
|
+
// Let Escape dismiss the folder list first, before it
|
|
694
|
+
// bubbles up and closes the whole search modal.
|
|
695
|
+
e.preventDefault()
|
|
696
|
+
e.stopPropagation()
|
|
697
|
+
setDirFocused(false)
|
|
698
|
+
}
|
|
699
|
+
}}
|
|
700
|
+
placeholder="Папка (напр. требования/управление-клиентами)"
|
|
701
|
+
className="w-full rounded border bg-background px-2 py-1 text-foreground outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring"
|
|
702
|
+
aria-label="Папка"
|
|
703
|
+
autoComplete="off"
|
|
704
|
+
spellCheck={false}
|
|
705
|
+
/>
|
|
706
|
+
{dirInput && (
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
onClick={() => commitDir('')}
|
|
710
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
711
|
+
aria-label="Очистить папку"
|
|
712
|
+
>
|
|
713
|
+
<X className="size-3" />
|
|
714
|
+
</button>
|
|
715
|
+
)}
|
|
716
|
+
{/* Folder picker: the directories on the current level. Shows
|
|
717
|
+
while the input is focused (incl. empty → top-level folders),
|
|
718
|
+
closes on blur (click outside) or picking a leaf folder.
|
|
719
|
+
Portalled to <body> and positioned in screen space so the
|
|
720
|
+
modal's `overflow-hidden` can't clip it. */}
|
|
721
|
+
{dirFocused &&
|
|
722
|
+
dirHints.length > 0 &&
|
|
723
|
+
dirRect &&
|
|
724
|
+
createPortal(
|
|
725
|
+
<ul
|
|
726
|
+
className="fixed z-[220] max-h-48 overflow-y-auto rounded-md border bg-popover py-1 shadow-md"
|
|
727
|
+
style={{
|
|
728
|
+
top: dirRect.bottom + 4,
|
|
729
|
+
left: dirRect.left,
|
|
730
|
+
width: dirRect.width,
|
|
731
|
+
}}
|
|
732
|
+
// Keep focus in the input: stop the list's mousedown from
|
|
733
|
+
// blurring it (the row handlers also preventDefault).
|
|
734
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
735
|
+
>
|
|
736
|
+
{dirHints.map((path) => {
|
|
737
|
+
// Display only this level's folder name; the value is the
|
|
738
|
+
// full relative path so selecting commits correctly.
|
|
739
|
+
const name = path.slice(path.lastIndexOf('/') + 1)
|
|
740
|
+
return (
|
|
741
|
+
<li key={path}>
|
|
742
|
+
<button
|
|
743
|
+
type="button"
|
|
744
|
+
onMouseDown={(e) => {
|
|
745
|
+
// onMouseDown (not onClick) so the input's blur
|
|
746
|
+
// doesn't dismiss the list before we commit.
|
|
747
|
+
e.preventDefault()
|
|
748
|
+
// Folders with children: append "/" and KEEP the list
|
|
749
|
+
// open (now showing the children) so the user drills
|
|
750
|
+
// further. Leaf folders: final pick, so close the list.
|
|
751
|
+
// Either way keep focus on the dir box.
|
|
752
|
+
const hasChildren = relDirs.some((d) =>
|
|
753
|
+
d.startsWith(`${path}/`),
|
|
754
|
+
)
|
|
755
|
+
selectDir(path)
|
|
756
|
+
if (!hasChildren) setDirFocused(false)
|
|
757
|
+
dirInputRef.current?.focus()
|
|
758
|
+
}}
|
|
759
|
+
className="flex w-full items-center gap-1.5 truncate px-2 py-1 text-left text-foreground hover:bg-accent"
|
|
760
|
+
>
|
|
761
|
+
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
|
762
|
+
<span className="truncate">{name}</span>
|
|
763
|
+
</button>
|
|
764
|
+
</li>
|
|
765
|
+
)
|
|
766
|
+
})}
|
|
767
|
+
</ul>,
|
|
768
|
+
document.body,
|
|
769
|
+
)}
|
|
770
|
+
</div>
|
|
771
|
+
<button
|
|
772
|
+
type="button"
|
|
773
|
+
onClick={() => {
|
|
774
|
+
// Seed the draft with the current scope so the modal opens
|
|
775
|
+
// with that folder pre-selected; OK commits, Cancel discards.
|
|
776
|
+
setTreeDraft(dirRel)
|
|
777
|
+
setShowTree(true)
|
|
778
|
+
}}
|
|
779
|
+
className={cn(
|
|
780
|
+
'shrink-0 rounded border p-1 hover:bg-accent hover:text-foreground',
|
|
781
|
+
showTree ? 'bg-accent text-foreground' : 'text-muted-foreground',
|
|
782
|
+
)}
|
|
783
|
+
aria-label="Выбрать папку из дерева"
|
|
784
|
+
aria-pressed={showTree}
|
|
785
|
+
title="Дерево папок"
|
|
786
|
+
>
|
|
787
|
+
<FolderTree className="size-4" />
|
|
788
|
+
</button>
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
)}
|
|
794
|
+
|
|
795
|
+
{/* Results */}
|
|
796
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
797
|
+
{!query.trim() ? (
|
|
798
|
+
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
799
|
+
{ready ? 'Введите запрос для поиска' : 'Загрузка индекса…'}
|
|
800
|
+
</p>
|
|
801
|
+
) : loading && results.length === 0 ? (
|
|
802
|
+
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Поиск…</p>
|
|
803
|
+
) : results.length === 0 ? (
|
|
804
|
+
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
805
|
+
Ничего не найдено по запросу «{query.trim()}»
|
|
806
|
+
</p>
|
|
807
|
+
) : (
|
|
808
|
+
<ul className="flex flex-col gap-1.5">
|
|
809
|
+
{results.map((d) => (
|
|
810
|
+
<ResultItem key={d.url} data={d} onNavigate={dismiss} />
|
|
811
|
+
))}
|
|
812
|
+
</ul>
|
|
813
|
+
)}
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
{/* Mobile-only footer: the full-screen sheet has no backdrop to
|
|
817
|
+
tap, so give an explicit way out. Hidden from md up, where the
|
|
818
|
+
backdrop + Escape dismiss the centered card. */}
|
|
819
|
+
<div className="flex justify-end border-t p-3 md:hidden">
|
|
820
|
+
<button
|
|
821
|
+
type="button"
|
|
822
|
+
onClick={dismiss}
|
|
823
|
+
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground"
|
|
824
|
+
>
|
|
825
|
+
{t('close')}
|
|
826
|
+
</button>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
</div>,
|
|
830
|
+
document.body,
|
|
831
|
+
)}
|
|
832
|
+
|
|
833
|
+
{/* Folder tree picker — its own modal layered above the search modal.
|
|
834
|
+
Opened by the FolderTree button; selecting a folder commits it as
|
|
835
|
+
the scope and closes this modal (the search modal stays open). */}
|
|
836
|
+
{open &&
|
|
837
|
+
mounted &&
|
|
838
|
+
showTree &&
|
|
839
|
+
project &&
|
|
840
|
+
createPortal(
|
|
841
|
+
<div
|
|
842
|
+
className="fixed inset-0 z-[210] bg-background/40 backdrop-blur-sm md:flex md:items-start md:justify-center md:p-4 md:pt-[12vh]"
|
|
843
|
+
onMouseDown={() => setShowTree(false)}
|
|
844
|
+
role="dialog"
|
|
845
|
+
aria-modal="true"
|
|
846
|
+
aria-label="Выбор папки"
|
|
847
|
+
>
|
|
848
|
+
<div
|
|
849
|
+
className="fixed inset-0 flex flex-col overflow-hidden bg-popover md:static md:max-h-[70vh] md:w-full md:max-w-md md:rounded-lg md:border md:shadow-xl"
|
|
850
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
851
|
+
>
|
|
852
|
+
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
|
853
|
+
<span className="flex items-center gap-2 text-sm font-medium">
|
|
854
|
+
<FolderTree className="size-4 shrink-0 text-muted-foreground" />
|
|
855
|
+
Папка в «{projectLabel(project)}»
|
|
856
|
+
</span>
|
|
857
|
+
{/* Top-right X cancels, like the backdrop — no scope change. */}
|
|
858
|
+
<button
|
|
859
|
+
type="button"
|
|
860
|
+
onClick={() => setShowTree(false)}
|
|
861
|
+
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
862
|
+
aria-label={t('close')}
|
|
863
|
+
>
|
|
864
|
+
<X className="size-4" />
|
|
865
|
+
</button>
|
|
866
|
+
</div>
|
|
867
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
868
|
+
{dirTree.length > 0 ? (
|
|
869
|
+
<ul>
|
|
870
|
+
<li>
|
|
871
|
+
{/* Selecting "весь проект" drafts an empty path (whole project). */}
|
|
872
|
+
<button
|
|
873
|
+
type="button"
|
|
874
|
+
onClick={() => setTreeDraft('')}
|
|
875
|
+
className={cn(
|
|
876
|
+
'flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-accent',
|
|
877
|
+
!treeDraft && 'bg-accent text-foreground',
|
|
878
|
+
)}
|
|
879
|
+
>
|
|
880
|
+
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
|
881
|
+
<span className="truncate">{projectLabel(project)} (весь проект)</span>
|
|
882
|
+
</button>
|
|
883
|
+
</li>
|
|
884
|
+
{dirTree.map((node) => (
|
|
885
|
+
<DirTreeNode
|
|
886
|
+
key={node.path}
|
|
887
|
+
node={node}
|
|
888
|
+
depth={1}
|
|
889
|
+
selected={treeDraft}
|
|
890
|
+
onSelect={setTreeDraft}
|
|
891
|
+
/>
|
|
892
|
+
))}
|
|
893
|
+
</ul>
|
|
894
|
+
) : (
|
|
895
|
+
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
896
|
+
В этом проекте нет вложенных папок
|
|
897
|
+
</p>
|
|
898
|
+
)}
|
|
899
|
+
</div>
|
|
900
|
+
{/* Footer: Cancel discards the draft (and closes — the mobile
|
|
901
|
+
sheet's way out, since there's no backdrop to tap); OK applies
|
|
902
|
+
it to the scope. Buttons stretch full-width on mobile for easy
|
|
903
|
+
tapping, compact + right-aligned from md up. */}
|
|
904
|
+
<div className="flex items-center gap-2 border-t px-3 py-2 max-md:py-3 md:justify-end">
|
|
905
|
+
<button
|
|
906
|
+
type="button"
|
|
907
|
+
onClick={() => setShowTree(false)}
|
|
908
|
+
className="flex-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent hover:text-foreground max-md:py-2 md:flex-none"
|
|
909
|
+
>
|
|
910
|
+
Отмена
|
|
911
|
+
</button>
|
|
912
|
+
<button
|
|
913
|
+
type="button"
|
|
914
|
+
onClick={() => {
|
|
915
|
+
commitDir(treeDraft)
|
|
916
|
+
setShowTree(false)
|
|
917
|
+
inputRef.current?.focus()
|
|
918
|
+
}}
|
|
919
|
+
className="flex-1 rounded-md border border-transparent bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 max-md:py-2 md:flex-none"
|
|
920
|
+
>
|
|
921
|
+
ОК
|
|
922
|
+
</button>
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
</div>,
|
|
926
|
+
document.body,
|
|
927
|
+
)}
|
|
928
|
+
</>
|
|
929
|
+
)
|
|
930
|
+
}
|