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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,1049 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useTree } from '@headless-tree/react'
3
+ import {
4
+ syncDataLoaderFeature,
5
+ hotkeysCoreFeature,
6
+ expandAllFeature,
7
+ selectionFeature,
8
+ } from '@headless-tree/core'
9
+ import { Link, useNavigate } from '@remix-run/react'
10
+ import {
11
+ ChevronRight,
12
+ ChevronsDownUp,
13
+ File as FileIcon,
14
+ Folder,
15
+ Image as ImageIcon,
16
+ LayoutDashboard,
17
+ Locate,
18
+ MoreVertical,
19
+ Search,
20
+ } from 'lucide-react'
21
+ import { createPortal } from 'react-dom'
22
+
23
+ import type { FlatSidebarItem, FlatSidebarMap, SidebarNodeType } from '~/lib/sidebar.server'
24
+ import { Button } from '~/components/ui/button'
25
+ import { useTabs } from '~/lib/tabs'
26
+ import { t } from '~/lib/site'
27
+ import { useKeyboardShortcuts, type Shortcut } from '~/lib/useKeyboardShortcuts'
28
+ import { cn } from '~/lib/utils'
29
+
30
+ const WIDTH_STORAGE_KEY = 'sidebar-width'
31
+ const DEFAULT_WIDTH = 280
32
+ // Practically unlimited: a tiny floor so the drag handle is always grabbable, and
33
+ // a ceiling tied to the viewport (set at drag time) so the content column can't be
34
+ // pushed off-screen. These bounds exist only to keep the layout recoverable.
35
+ const MIN_WIDTH = 48
36
+ const MAX_VIEWPORT_GAP = 64 // px kept free on the right so content never vanishes
37
+ const TOC_WIDTH = 250 // width of the right "На этой странице" column (xl+ only)
38
+ const CONTENT_MIN = 640 // hide the TOC once the content column would be narrower
39
+
40
+ /**
41
+ * Inline script injected into <head> (before paint) so the persisted sidebar
42
+ * width — and the derived TOC visibility — are applied to <html> before first
43
+ * render, avoiding a layout shift on load. Sets `--sidebar-width`, `--toc-width`
44
+ * (collapses the TOC column to 0 when content would be too narrow), and toggles a
45
+ * `toc-collapsed` class the Toc component keys off to hide itself. Falls back to
46
+ * the default width when nothing is stored. Logic mirrors `applyLayout` below —
47
+ * keep the two in sync.
48
+ */
49
+ export const sidebarWidthInitScript = `(function(){try{var w=parseInt(localStorage.getItem('${WIDTH_STORAGE_KEY}'),10);if(!w||isNaN(w))w=${DEFAULT_WIDTH};var vw=window.innerWidth;var max=Math.max(${MIN_WIDTH},vw-${MAX_VIEWPORT_GAP});w=Math.min(max,Math.max(${MIN_WIDTH},w));var d=document.documentElement;d.style.setProperty('--sidebar-width',w+'px');var roomForToc=vw-w-${TOC_WIDTH}>=${CONTENT_MIN};d.style.setProperty('--toc-width',roomForToc?'${TOC_WIDTH}px':'0px');d.classList.toggle('toc-collapsed',!roomForToc);}catch(e){var d=document.documentElement;d.style.setProperty('--sidebar-width','${DEFAULT_WIDTH}px');d.style.setProperty('--toc-width','${TOC_WIDTH}px');}})();`
50
+
51
+ /**
52
+ * Clamp a requested sidebar width to the viewport and apply it, plus the derived
53
+ * TOC visibility, to <html>. Returns the clamped width (for persistence). Mirrors
54
+ * `sidebarWidthInitScript` — keep the two in sync. Returns null off the client.
55
+ */
56
+ function applyLayout(requested: number): number | null {
57
+ if (typeof window === 'undefined') return null
58
+ const vw = window.innerWidth
59
+ const max = Math.max(MIN_WIDTH, vw - MAX_VIEWPORT_GAP)
60
+ const w = Math.min(max, Math.max(MIN_WIDTH, requested))
61
+ const d = document.documentElement
62
+ d.style.setProperty('--sidebar-width', `${w}px`)
63
+ const roomForToc = vw - w - TOC_WIDTH >= CONTENT_MIN
64
+ d.style.setProperty('--toc-width', roomForToc ? `${TOC_WIDTH}px` : '0px')
65
+ d.classList.toggle('toc-collapsed', !roomForToc)
66
+ return w
67
+ }
68
+
69
+ const icons: Record<SidebarNodeType, JSX.Element> = {
70
+ directory: <Folder className="size-4" />,
71
+ file: <FileIcon className="size-4" />,
72
+ canvas: <LayoutDashboard className="size-4" />,
73
+ image: <ImageIcon className="size-4" />,
74
+ }
75
+
76
+ const iconColor: Record<SidebarNodeType, string> = {
77
+ directory: 'text-sidebar-foreground/70',
78
+ file: 'text-muted-foreground',
79
+ canvas: 'text-amber-500',
80
+ image: 'text-emerald-500',
81
+ }
82
+
83
+ /** Icon component per node type (for places that need the component, not an element). */
84
+ const iconComponent: Record<SidebarNodeType, typeof FileIcon> = {
85
+ directory: Folder,
86
+ file: FileIcon,
87
+ canvas: LayoutDashboard,
88
+ image: ImageIcon,
89
+ }
90
+
91
+ interface Props {
92
+ data: FlatSidebarMap
93
+ currentPath: string
94
+ open?: boolean
95
+ className?: string
96
+ }
97
+
98
+ const rowBase =
99
+ 'group flex items-center gap-1 rounded-md py-1.5 pr-2 text-sm cursor-pointer select-none transition-colors hover:bg-sidebar-accent'
100
+
101
+ /** Static, non-interactive tree for SSR / pre-hydration (links work without JS).
102
+ * Only renders children of folders in `expanded`, so the initial HTML matches
103
+ * the interactive tree's collapsed state — no expand→collapse flash on hydrate,
104
+ * and a far smaller DOM to send and parse. */
105
+ function FallbackTree({
106
+ data,
107
+ currentPath,
108
+ expanded,
109
+ }: {
110
+ data: FlatSidebarMap
111
+ currentPath: string
112
+ expanded: Set<string>
113
+ }) {
114
+ const cur = normPath(currentPath)
115
+ const renderNodes = (ids: string[], level: number): JSX.Element => (
116
+ <ul className="list-none m-0 p-0">
117
+ {ids.map((id) => {
118
+ const item = data[id]
119
+ if (!item) return null
120
+ const isActive = item.href ? normPath(item.href) === cur : false
121
+ const isFolder = item.children.length > 0
122
+ const isOpen = isFolder && expanded.has(id)
123
+ return (
124
+ <li key={id}>
125
+ <div
126
+ title={item.name}
127
+ className={cn(rowBase, isActive && 'bg-sidebar-accent')}
128
+ style={{ paddingLeft: `${level * 12 + 8}px` }}
129
+ >
130
+ {isFolder ? (
131
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
132
+ <ChevronRight className={cn('size-3.5 transition-transform', isOpen && 'rotate-90')} />
133
+ </span>
134
+ ) : (
135
+ <span className="size-4 shrink-0" />
136
+ )}
137
+ <span className={cn('flex size-4 shrink-0 items-center justify-center', iconColor[item.type])}>
138
+ {icons[item.type] ?? icons.file}
139
+ </span>
140
+ {item.href ? (
141
+ <Link
142
+ to={item.href}
143
+ className={cn(
144
+ 'flex-1 min-w-0 truncate no-underline',
145
+ isActive ? 'text-foreground font-medium' : 'text-sidebar-foreground/80',
146
+ )}
147
+ >
148
+ {item.name}
149
+ </Link>
150
+ ) : (
151
+ <span
152
+ className={cn(
153
+ 'flex-1 min-w-0 truncate',
154
+ level === 0 ? 'font-semibold text-foreground' : 'text-sidebar-foreground/80',
155
+ )}
156
+ >
157
+ {item.name}
158
+ </span>
159
+ )}
160
+ </div>
161
+ {isOpen && renderNodes(item.children, level + 1)}
162
+ </li>
163
+ )
164
+ })}
165
+ </ul>
166
+ )
167
+ return <div className="outline-none">{renderNodes(data['root']?.children ?? [], 0)}</div>
168
+ }
169
+
170
+ /** Normalise a path for active-item comparison: decoded, no trailing slash. */
171
+ function normPath(p?: string): string {
172
+ if (!p) return ''
173
+ return decodeURIComponent(p).replace(/\/+$/, '')
174
+ }
175
+
176
+ function highlightParts(text: string, query: string): JSX.Element {
177
+ if (!query) return <>{text}</>
178
+ const lower = text.toLowerCase()
179
+ const idx = lower.indexOf(query.toLowerCase())
180
+ if (idx === -1) return <>{text}</>
181
+ return (
182
+ <>
183
+ {text.slice(0, idx)}
184
+ <mark className="bg-transparent font-semibold text-foreground">
185
+ {text.slice(idx, idx + query.length)}
186
+ </mark>
187
+ {text.slice(idx + query.length)}
188
+ </>
189
+ )
190
+ }
191
+
192
+ interface SearchHit {
193
+ id: string
194
+ name: string
195
+ /** "Folder › Subfolder" breadcrumb of the ancestors (excludes the item itself). */
196
+ crumb: string
197
+ type: SidebarNodeType
198
+ href?: string
199
+ }
200
+
201
+ /** What the file-search modal matches against. */
202
+ type SearchScope = 'all' | 'files' | 'directories'
203
+ const SCOPE_OPTIONS: { value: SearchScope; label: string }[] = [
204
+ { value: 'all', label: 'Все' },
205
+ { value: 'files', label: 'Файлы' },
206
+ { value: 'directories', label: 'Папки' },
207
+ ]
208
+
209
+ /**
210
+ * WebStorm-style "search by file" modal: a centered overlay with a query input
211
+ * and a keyboard-navigable result list. Opened from the sidebar header icon or
212
+ * Ctrl/Cmd+P. Matches file (and, unless "только файлы" is on, folder) names by
213
+ * substring; picking a result navigates to its doc (folders just reveal in the
214
+ * tree). Self-contained — portal + outside-click + Esc, mirroring RowMenu/
215
+ * ProjectSwitcher rather than pulling in a dialog dependency.
216
+ */
217
+ function FileSearchModal({
218
+ data,
219
+ getAncestorIds,
220
+ onClose,
221
+ onPick,
222
+ }: {
223
+ data: FlatSidebarMap
224
+ getAncestorIds: (id: string) => string[]
225
+ onClose: () => void
226
+ onPick: (id: string) => void
227
+ }) {
228
+ const [query, setQuery] = useState('')
229
+ const [scope, setScope] = useState<SearchScope>('all')
230
+ const [active, setActive] = useState(0)
231
+ const inputRef = useRef<HTMLInputElement>(null)
232
+ const listRef = useRef<HTMLUListElement>(null)
233
+
234
+ // Focus the input on open.
235
+ useEffect(() => {
236
+ inputRef.current?.focus()
237
+ }, [])
238
+
239
+ // Close on Escape / outside click.
240
+ useEffect(() => {
241
+ const onKey = (e: KeyboardEvent) => {
242
+ if (e.key === 'Escape') onClose()
243
+ }
244
+ document.addEventListener('keydown', onKey)
245
+ return () => document.removeEventListener('keydown', onKey)
246
+ }, [onClose])
247
+
248
+ const hits = useMemo<SearchHit[]>(() => {
249
+ const q = query.trim().toLowerCase()
250
+ const out: SearchHit[] = []
251
+ for (const [id, item] of Object.entries(data)) {
252
+ if (id === 'root') continue
253
+ const isDir = item.type === 'directory'
254
+ if (scope === 'files' && isDir) continue
255
+ if (scope === 'directories' && !isDir) continue
256
+ if (q && !item.name.toLowerCase().includes(q)) continue
257
+ const crumb = getAncestorIds(id)
258
+ .reverse()
259
+ .map((a) => data[a]?.name)
260
+ .filter(Boolean)
261
+ .join(' › ')
262
+ out.push({ id, name: item.name, crumb, type: item.type, href: item.href })
263
+ }
264
+ // Prefix matches first, then alphabetical — keeps the best match on top.
265
+ out.sort((a, b) => {
266
+ if (q) {
267
+ const ap = a.name.toLowerCase().startsWith(q)
268
+ const bp = b.name.toLowerCase().startsWith(q)
269
+ if (ap !== bp) return ap ? -1 : 1
270
+ }
271
+ return a.name.localeCompare(b.name, 'ru')
272
+ })
273
+ return out.slice(0, 50)
274
+ }, [query, scope, data, getAncestorIds])
275
+
276
+ // Keep the active index in range as the result set changes, and scroll it in view.
277
+ useEffect(() => {
278
+ setActive(0)
279
+ }, [query, scope])
280
+ useEffect(() => {
281
+ listRef.current
282
+ ?.querySelector('[data-active="true"]')
283
+ ?.scrollIntoView({ block: 'nearest' })
284
+ }, [active, hits])
285
+
286
+ const choose = (i: number) => {
287
+ const hit = hits[i]
288
+ if (hit) onPick(hit.id)
289
+ }
290
+
291
+ const onInputKey = (e: React.KeyboardEvent) => {
292
+ if (e.key === 'ArrowDown') {
293
+ e.preventDefault()
294
+ setActive((a) => Math.min(a + 1, hits.length - 1))
295
+ } else if (e.key === 'ArrowUp') {
296
+ e.preventDefault()
297
+ setActive((a) => Math.max(a - 1, 0))
298
+ } else if (e.key === 'Enter') {
299
+ e.preventDefault()
300
+ choose(active)
301
+ }
302
+ }
303
+
304
+ return createPortal(
305
+ <div
306
+ className="fixed inset-0 z-100 bg-background/40 backdrop-blur-sm md:flex md:items-start md:justify-center md:p-4 md:pt-[12vh]"
307
+ onMouseDown={onClose}
308
+ >
309
+ <div
310
+ className="fixed inset-0 flex flex-col overflow-hidden bg-popover md:static md:max-h-[70vh] md:w-[min(40rem,calc(100vw-2rem))] md:rounded-lg md:border md:shadow-xl"
311
+ onMouseDown={(e) => e.stopPropagation()}
312
+ >
313
+ <div className="flex items-center gap-2 border-b px-3">
314
+ <Search className="size-4 shrink-0 text-muted-foreground" />
315
+ <input
316
+ ref={inputRef}
317
+ type="text"
318
+ value={query}
319
+ onChange={(e) => setQuery(e.target.value)}
320
+ onKeyDown={onInputKey}
321
+ placeholder="Поиск по файлам..."
322
+ autoComplete="off"
323
+ className="h-11 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
324
+ />
325
+ <div className="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5" role="tablist">
326
+ {SCOPE_OPTIONS.map((opt) => (
327
+ <button
328
+ key={opt.value}
329
+ type="button"
330
+ role="tab"
331
+ aria-selected={scope === opt.value}
332
+ tabIndex={-1}
333
+ onClick={() => setScope(opt.value)}
334
+ className={cn(
335
+ 'rounded px-2 py-1 text-xs transition-colors',
336
+ scope === opt.value
337
+ ? 'bg-background font-medium text-foreground shadow-sm'
338
+ : 'text-muted-foreground hover:text-foreground',
339
+ )}
340
+ >
341
+ {opt.label}
342
+ </button>
343
+ ))}
344
+ </div>
345
+ </div>
346
+ <ul ref={listRef} className="min-h-0 flex-1 overflow-y-auto p-1">
347
+ {hits.length === 0 ? (
348
+ <li className="px-3 py-6 text-center text-sm text-muted-foreground">Ничего не найдено</li>
349
+ ) : (
350
+ hits.map((hit, i) => {
351
+ const isActive = i === active
352
+ const Icon = iconComponent[hit.type]
353
+ return (
354
+ <li key={hit.id}>
355
+ <button
356
+ type="button"
357
+ data-active={isActive || undefined}
358
+ onMouseMove={() => setActive(i)}
359
+ onClick={() => choose(i)}
360
+ className={cn(
361
+ 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left',
362
+ isActive && 'bg-sidebar-accent',
363
+ )}
364
+ >
365
+ <Icon className="size-4 shrink-0 text-muted-foreground" />
366
+ <span className="min-w-0 flex-1">
367
+ <span className="block truncate text-sm text-foreground">
368
+ {highlightParts(hit.name, query.trim())}
369
+ </span>
370
+ {hit.crumb && (
371
+ <span className="block truncate text-xs text-muted-foreground">{hit.crumb}</span>
372
+ )}
373
+ </span>
374
+ </button>
375
+ </li>
376
+ )
377
+ })
378
+ )}
379
+ </ul>
380
+ {/* Mobile-only footer: the full-screen sheet has no backdrop to
381
+ tap, so give an explicit way out. Hidden from md up, where the
382
+ backdrop + Escape dismiss the centered card. */}
383
+ <div className="flex justify-end border-t p-3 md:hidden">
384
+ <button
385
+ type="button"
386
+ onClick={onClose}
387
+ className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground"
388
+ >
389
+ {t('close')}
390
+ </button>
391
+ </div>
392
+ </div>
393
+ </div>,
394
+ document.body,
395
+ )
396
+ }
397
+
398
+ interface RowMenuItem {
399
+ label: string
400
+ onSelect: () => void
401
+ }
402
+
403
+ /**
404
+ * Per-row "more actions" menu (the ⋮ button at the end of a row). Hidden until
405
+ * the row is hovered/active (driven by the parent's `group` + `forceShow`), and
406
+ * revealed while its own dropdown is open. The dropdown is rendered in a portal
407
+ * with fixed positioning computed from the button rect, so it isn't clipped by
408
+ * the sidebar's `overflow` containers. Lightweight (outside-click + Escape),
409
+ * mirroring the ProjectSwitcher pattern — no extra dependency. Renders whatever
410
+ * `actions` the caller passes, so files and directories supply their own items.
411
+ */
412
+ function RowMenu({
413
+ forceShow,
414
+ actions,
415
+ }: {
416
+ /** Keep the button visible even when the row isn't hovered (e.g. active row). */
417
+ forceShow?: boolean
418
+ actions: RowMenuItem[]
419
+ }) {
420
+ const [open, setOpen] = useState(false)
421
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
422
+ const btnRef = useRef<HTMLButtonElement>(null)
423
+
424
+ useEffect(() => {
425
+ if (!open) return
426
+ const close = () => setOpen(false)
427
+ const onKey = (e: KeyboardEvent) => {
428
+ if (e.key === 'Escape') setOpen(false)
429
+ }
430
+ // Close on any outside interaction; also close on scroll/resize since the
431
+ // fixed menu would otherwise float away from its (scrolled) row.
432
+ document.addEventListener('mousedown', close)
433
+ document.addEventListener('keydown', onKey)
434
+ window.addEventListener('scroll', close, true)
435
+ window.addEventListener('resize', close)
436
+ return () => {
437
+ document.removeEventListener('mousedown', close)
438
+ document.removeEventListener('keydown', onKey)
439
+ window.removeEventListener('scroll', close, true)
440
+ window.removeEventListener('resize', close)
441
+ }
442
+ }, [open])
443
+
444
+ const toggle = (e: React.MouseEvent) => {
445
+ // Don't let the row's onClick/navigation fire from the menu button.
446
+ e.stopPropagation()
447
+ e.preventDefault()
448
+ if (!open && btnRef.current) {
449
+ const r = btnRef.current.getBoundingClientRect()
450
+ setPos({ top: r.bottom + 2, left: r.right })
451
+ }
452
+ setOpen((o) => !o)
453
+ }
454
+
455
+ return (
456
+ <>
457
+ <button
458
+ ref={btnRef}
459
+ type="button"
460
+ aria-label="Действия"
461
+ aria-haspopup="menu"
462
+ aria-expanded={open}
463
+ // stop dblclick from opening a tab when interacting with the menu button
464
+ onDoubleClick={(e) => {
465
+ e.stopPropagation()
466
+ e.preventDefault()
467
+ }}
468
+ onClick={toggle}
469
+ className={cn(
470
+ 'flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-foreground/10 hover:text-foreground',
471
+ open || forceShow ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
472
+ )}
473
+ >
474
+ <MoreVertical className="size-3.5" />
475
+ </button>
476
+ {open &&
477
+ pos &&
478
+ createPortal(
479
+ <div
480
+ role="menu"
481
+ // stop the document mousedown-to-close from firing for clicks inside
482
+ onMouseDown={(e) => e.stopPropagation()}
483
+ onClick={(e) => e.stopPropagation()}
484
+ style={{ position: 'fixed', top: pos.top, left: pos.left, transform: 'translateX(-100%)' }}
485
+ className="z-[200] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
486
+ >
487
+ {actions.map((action) => (
488
+ <button
489
+ key={action.label}
490
+ type="button"
491
+ role="menuitem"
492
+ onClick={() => {
493
+ setOpen(false)
494
+ action.onSelect()
495
+ }}
496
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-sidebar-accent"
497
+ >
498
+ {action.label}
499
+ </button>
500
+ ))}
501
+ </div>,
502
+ document.body,
503
+ )}
504
+ </>
505
+ )
506
+ }
507
+
508
+ export default function Sidebar({ data, currentPath, open = false, className }: Props) {
509
+ const navigate = useNavigate()
510
+ const { hasTabs, openTab } = useTabs()
511
+
512
+ // --- Parent map + active item (memoised on data/currentPath) ---
513
+ const parentMap = useMemo(() => {
514
+ const m = new Map<string, string>()
515
+ for (const [id, item] of Object.entries(data)) {
516
+ for (const childId of item.children) m.set(childId, id)
517
+ }
518
+ return m
519
+ }, [data])
520
+
521
+ const getAncestorIds = useCallback(
522
+ (targetId: string): string[] => {
523
+ const ancestors: string[] = []
524
+ let cur = parentMap.get(targetId)
525
+ while (cur && cur !== 'root') {
526
+ ancestors.push(cur)
527
+ cur = parentMap.get(cur)
528
+ }
529
+ return ancestors
530
+ },
531
+ [parentMap],
532
+ )
533
+
534
+ const activeId = useMemo(() => {
535
+ const cur = normPath(currentPath)
536
+ for (const [id, item] of Object.entries(data)) {
537
+ if (item.href && normPath(item.href) === cur) return id
538
+ }
539
+ return null
540
+ }, [data, currentPath])
541
+
542
+ // --- Initial expanded state: collapsed by default, revealing only the
543
+ // ancestors of the active page so the current location is visible. ---
544
+ const initialExpanded = useMemo(
545
+ () => (activeId ? getAncestorIds(activeId) : []),
546
+ [activeId, getAncestorIds],
547
+ )
548
+
549
+ // In-tree text filtering is no longer driven by a header input (replaced by the
550
+ // file-search modal), so `search` stays '' and the filter/expand-all paths below
551
+ // are inert. Kept so those code paths don't need unpicking; setSearch('') is still
552
+ // used by locate() as a defensive reset.
553
+ const [search, setSearch] = useState('')
554
+ const [searchModalOpen, setSearchModalOpen] = useState(false)
555
+
556
+ // useTree populates items only after a client-side effect runs, so during SSR
557
+ // (and the first paint before hydration) we render a static fallback tree from
558
+ // the same data. This keeps the nav present in the initial HTML and avoids an
559
+ // empty-sidebar flash; the interactive tree takes over once mounted.
560
+ const [mounted, setMounted] = useState(false)
561
+ useEffect(() => setMounted(true), [])
562
+
563
+ // Ctrl/Cmd+P opens the file-search modal (WebStorm/VS Code quick-open feel).
564
+ useEffect(() => {
565
+ const onKey = (e: KeyboardEvent) => {
566
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'p') {
567
+ e.preventDefault()
568
+ setSearchModalOpen(true)
569
+ }
570
+ }
571
+ window.addEventListener('keydown', onKey)
572
+ return () => window.removeEventListener('keydown', onKey)
573
+ }, [])
574
+
575
+ // Re-evaluate the layout (clamp + TOC visibility) on window resize: a narrower
576
+ // window can squeeze the content column just like a wider sidebar does. Reads
577
+ // the current width from the CSS variable the init script / drag already set.
578
+ useEffect(() => {
579
+ const onResize = () => {
580
+ const cur = parseInt(
581
+ getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'),
582
+ 10,
583
+ )
584
+ applyLayout(Number.isNaN(cur) ? DEFAULT_WIDTH : cur)
585
+ }
586
+ window.addEventListener('resize', onResize)
587
+ return () => window.removeEventListener('resize', onResize)
588
+ }, [])
589
+
590
+ const tree = useTree<FlatSidebarItem>({
591
+ rootItemId: 'root',
592
+ getItemName: (item) => item.getItemData().name,
593
+ isItemFolder: (item) => item.getItemData().children.length > 0,
594
+ dataLoader: {
595
+ getItem: (itemId) => data[itemId],
596
+ getChildren: (itemId) => data[itemId].children,
597
+ },
598
+ initialState: { expandedItems: initialExpanded },
599
+ indent: 12,
600
+ features: [syncDataLoaderFeature, hotkeysCoreFeature, expandAllFeature, selectionFeature],
601
+ })
602
+
603
+ // --- Search: compute matches + visible ancestors ---
604
+ const { matchingIds, visibleIds } = useMemo(() => {
605
+ const matching = new Set<string>()
606
+ const visible = new Set<string>()
607
+ const q = search.trim().toLowerCase()
608
+ if (q) {
609
+ for (const [id, item] of Object.entries(data)) {
610
+ if (id === 'root') continue
611
+ if (item.name.toLowerCase().includes(q)) {
612
+ matching.add(id)
613
+ visible.add(id)
614
+ for (const anc of getAncestorIds(id)) visible.add(anc)
615
+ }
616
+ }
617
+ }
618
+ return { matchingIds: matching, visibleIds: visible }
619
+ }, [search, data, getAncestorIds])
620
+
621
+ const expandedBeforeSearch = useRef<string[] | null>(null)
622
+ useEffect(() => {
623
+ const q = search.trim()
624
+ if (q) {
625
+ if (expandedBeforeSearch.current === null) {
626
+ expandedBeforeSearch.current = [...(tree.getState().expandedItems ?? [])]
627
+ }
628
+ tree.expandAll()
629
+ } else if (expandedBeforeSearch.current !== null) {
630
+ // Restore the pre-search shape: collapse everything search opened, then
631
+ // re-expand only the folders that were open before. Use the instance
632
+ // API (collapseAll + expand) — mutating expandedItems via setState does
633
+ // not reliably re-collapse children in this version. The active item's
634
+ // ancestors get re-revealed by the activeId effect (and clicking a search
635
+ // result makes that result active, opening its folder).
636
+ // Sort shallow→deep so each folder's parent is already expanded (and its
637
+ // instance loaded) before we expand it.
638
+ const toReopen = [...expandedBeforeSearch.current].sort(
639
+ (a, b) => getAncestorIds(a).length - getAncestorIds(b).length,
640
+ )
641
+ expandedBeforeSearch.current = null
642
+ tree.collapseAll()
643
+ for (const id of toReopen) {
644
+ const inst = tree.getItemInstance(id)
645
+ if (inst && !inst.isExpanded()) inst.expand()
646
+ }
647
+ }
648
+ // eslint-disable-next-line react-hooks/exhaustive-deps
649
+ }, [search])
650
+
651
+ const treeContainerRef = useRef<HTMLDivElement>(null)
652
+
653
+ // --- Collapse all: fold every open folder back to the root level. ---
654
+ const collapseAll = useCallback(() => {
655
+ setSearch('')
656
+ tree.collapseAll()
657
+ }, [tree])
658
+
659
+ // --- Locate: expand ancestors of the active item and scroll to it ---
660
+ const locate = useCallback(() => {
661
+ if (!activeId) return
662
+ setSearch('')
663
+ // Expand outermost-first via the item-instance API (see the activeId
664
+ // reveal effect above for why setState on expandedItems isn't enough).
665
+ for (const id of getAncestorIds(activeId).reverse()) {
666
+ const inst = tree.getItemInstance(id)
667
+ if (inst && !inst.isExpanded()) inst.expand()
668
+ }
669
+ requestAnimationFrame(() => {
670
+ const el = treeContainerRef.current?.querySelector('[data-active="true"]')
671
+ if (el) {
672
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' })
673
+ el.classList.add('locate-flash')
674
+ el.addEventListener('animationend', () => el.classList.remove('locate-flash'), { once: true })
675
+ }
676
+ })
677
+ }, [activeId, getAncestorIds, tree])
678
+
679
+ // --- Scroll active item into view on first mount ---
680
+ useEffect(() => {
681
+ if (!activeId) return
682
+ requestAnimationFrame(() => {
683
+ treeContainerRef.current
684
+ ?.querySelector('[data-active="true"]')
685
+ ?.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior })
686
+ })
687
+ // eslint-disable-next-line react-hooks/exhaustive-deps
688
+ }, [])
689
+
690
+ // --- Global keyboard shortcuts for the tree (fire anywhere outside a text
691
+ // field): `l` reveals the current page, `g c` collapses every folder. ---
692
+ const treeShortcuts = useMemo<Shortcut[]>(
693
+ () => [
694
+ { keys: 'l', label: 'Найти текущую страницу в дереве', group: 'Дерево', run: locate },
695
+ { keys: 'c', label: 'Свернуть все папки', group: 'Дерево', run: collapseAll },
696
+ ],
697
+ [locate, collapseAll],
698
+ )
699
+ useKeyboardShortcuts(treeShortcuts)
700
+
701
+ // --- Reveal the active item whenever it changes (e.g. navigating via a tab
702
+ // click after refresh): expand its ancestor folders so it isn't hidden
703
+ // inside a collapsed menu. `initialState` only applies at tree creation,
704
+ // so without this a later navigation leaves the item collapsed away.
705
+ // Skipped while searching, which drives its own expand-all behaviour. ---
706
+ useEffect(() => {
707
+ if (!activeId || search.trim()) return
708
+ // getAncestorIds returns innermost-first; expand outermost-first so each
709
+ // child folder's instance exists (it's only loaded once its parent is
710
+ // expanded). Use the item-instance API — mutating expandedItems via
711
+ // tree.setState does not reliably reveal children in this version.
712
+ for (const id of getAncestorIds(activeId).reverse()) {
713
+ const inst = tree.getItemInstance(id)
714
+ if (inst && !inst.isExpanded()) inst.expand()
715
+ }
716
+ // eslint-disable-next-line react-hooks/exhaustive-deps
717
+ }, [activeId, getAncestorIds])
718
+
719
+ // --- Resize: drag the right edge to set --sidebar-width, persisted. ---
720
+ const [resizing, setResizing] = useState(false)
721
+ const startResize = useCallback((e: React.PointerEvent) => {
722
+ // Only the grid sidebar (md+) is resizable; the mobile overlay is full-width.
723
+ e.preventDefault()
724
+ setResizing(true)
725
+ let lastWidth = DEFAULT_WIDTH
726
+ const onMove = (ev: PointerEvent) => {
727
+ // applyLayout clamps to the viewport and toggles the TOC; remember the
728
+ // clamped result so pointer-up persists the value actually applied.
729
+ lastWidth = applyLayout(ev.clientX) ?? lastWidth
730
+ }
731
+ const onUp = () => {
732
+ window.removeEventListener('pointermove', onMove)
733
+ window.removeEventListener('pointerup', onUp)
734
+ setResizing(false)
735
+ try {
736
+ localStorage.setItem(WIDTH_STORAGE_KEY, String(lastWidth))
737
+ } catch {
738
+ /* ignore */
739
+ }
740
+ }
741
+ window.addEventListener('pointermove', onMove)
742
+ window.addEventListener('pointerup', onUp)
743
+ }, [])
744
+
745
+ // While dragging, suppress text selection and force the resize cursor globally.
746
+ useEffect(() => {
747
+ if (!resizing) return
748
+ const prev = document.body.style.cssText
749
+ document.body.style.userSelect = 'none'
750
+ document.body.style.cursor = 'col-resize'
751
+ return () => {
752
+ document.body.style.cssText = prev
753
+ }
754
+ }, [resizing])
755
+
756
+ const isFiltering = search.trim().length > 0
757
+ const items = tree.getItems()
758
+
759
+ // Open a file from the sidebar, applying the tab rules:
760
+ // - double-click → always open as a tab
761
+ // - single-click → open as a tab only if tabs are already open; otherwise
762
+ // just navigate in place (no tab bar shown — the original behavior).
763
+ // Either way the URL changes, and the route renders the doc; the tab list is
764
+ // a UI layer on top. Returns nothing; callers handle the click guard.
765
+ const openFile = useCallback(
766
+ (href: string, name: string, isDoubleClick: boolean) => {
767
+ if (isDoubleClick || hasTabs) openTab(href, name)
768
+ navigate(href)
769
+ },
770
+ [hasTabs, openTab, navigate],
771
+ )
772
+
773
+ // Pick a result from the file-search modal: reveal the item in the tree
774
+ // (expand its ancestor folders, outermost-first, so its instance exists),
775
+ // focus it (the ring follows the selection), and scroll it into view. For a
776
+ // file we also navigate to its doc and let it expand; a folder just opens.
777
+ const pickSearchResult = useCallback(
778
+ (id: string) => {
779
+ setSearchModalOpen(false)
780
+ const item = data[id]
781
+ if (!item) return
782
+ // Expand ancestors first so getItemInstance(id) resolves to a loaded item.
783
+ for (const anc of getAncestorIds(id).reverse()) {
784
+ const inst = tree.getItemInstance(anc)
785
+ if (inst && !inst.isExpanded()) inst.expand()
786
+ }
787
+ const self = tree.getItemInstance(id)
788
+ if (self) {
789
+ if (!item.href && !self.isExpanded()) self.expand()
790
+ self.setFocused()
791
+ }
792
+ if (item.href) navigate(item.href)
793
+ requestAnimationFrame(() => {
794
+ treeContainerRef.current
795
+ ?.querySelector(`[data-item-id="${CSS.escape(id)}"]`)
796
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' })
797
+ })
798
+ },
799
+ [data, getAncestorIds, navigate, tree],
800
+ )
801
+
802
+ return (
803
+ <aside
804
+ id="sidebar"
805
+ className={cn(
806
+ // Desktop: pin just below the sticky TopBar (h-11 = 2.75rem) and shrink
807
+ // height to match, so the bar never covers the menu and the sidebar
808
+ // doesn't overflow past the viewport bottom.
809
+ 'sticky top-11 flex h-[calc(100vh-2.75rem)] flex-col overflow-hidden border-r bg-sidebar text-sidebar-foreground',
810
+ // Mobile: full-screen overlay toggled by the bottom bar. Runs full
811
+ // height (inset-0); the floating bottom bar (z-100) sits over it, and
812
+ // the tree's bottom padding keeps the last items clear of that bar.
813
+ 'max-md:fixed max-md:inset-0 max-md:z-90 max-md:h-auto',
814
+ open ? 'max-md:flex' : 'max-md:hidden',
815
+ className,
816
+ )}
817
+ >
818
+ {/* Resize handle: drag the right edge to set the sidebar width (md+ only). */}
819
+ <div
820
+ onPointerDown={startResize}
821
+ role="separator"
822
+ aria-orientation="vertical"
823
+ aria-label="Изменить ширину панели"
824
+ className={cn(
825
+ 'absolute right-0 top-0 z-10 hidden h-full w-1.5 cursor-col-resize md:block',
826
+ 'hover:bg-sidebar-accent',
827
+ resizing && 'bg-sidebar-accent',
828
+ )}
829
+ />
830
+ <div className="flex shrink-0 items-center justify-end gap-2 border-b px-1 py-0.5 md:gap-0.5">
831
+ <Button
832
+ type="button"
833
+ variant="ghost"
834
+ size="icon"
835
+ onClick={() => setSearchModalOpen(true)}
836
+ title="Поиск по файлам (Ctrl+P)"
837
+ aria-label="Поиск по файлам"
838
+ className="size-9 text-muted-foreground md:size-6"
839
+ >
840
+ <Search className="size-4 md:size-3.5" />
841
+ </Button>
842
+ <Button
843
+ type="button"
844
+ variant="ghost"
845
+ size="icon"
846
+ onClick={locate}
847
+ title="Найти в дереве (L)"
848
+ aria-label="Найти текущую страницу в дереве"
849
+ className="size-9 text-muted-foreground md:size-6"
850
+ >
851
+ <Locate className="size-4 md:size-3.5" />
852
+ </Button>
853
+ <Button
854
+ type="button"
855
+ variant="ghost"
856
+ size="icon"
857
+ onClick={collapseAll}
858
+ title="Свернуть всё (C)"
859
+ aria-label="Свернуть все папки"
860
+ className="size-9 text-muted-foreground md:size-6"
861
+ >
862
+ <ChevronsDownUp className="size-4 md:size-3.5" />
863
+ </Button>
864
+ </div>
865
+
866
+ {/* Extra bottom padding so the last tree items have breathing room and
867
+ stay comfortably scrollable into view at the bottom of the menu. On
868
+ mobile, clear the floating bottom bar (its height + gap + safe area)
869
+ so the last items aren't hidden behind it. */}
870
+ <div
871
+ className="flex-1 overflow-y-auto px-2 pt-1 pb-14 max-md:pb-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+3rem)]"
872
+ ref={treeContainerRef}
873
+ >
874
+ {!mounted ? (
875
+ <FallbackTree
876
+ data={data}
877
+ currentPath={currentPath}
878
+ expanded={new Set(initialExpanded)}
879
+ />
880
+ ) : (
881
+ <div {...tree.getContainerProps()} className="outline-none">
882
+ {items.map((item) => {
883
+ const id = item.getId()
884
+ if (isFiltering && !visibleIds.has(id)) return null
885
+ const itemData = item.getItemData()
886
+ const isFolder = item.isFolder()
887
+ const isActive = activeId === id
888
+ const isMatch = matchingIds.has(id)
889
+ const level = item.getItemMeta().level
890
+ const rowProps = item.getProps()
891
+
892
+ return (
893
+ <div
894
+ {...rowProps}
895
+ key={id}
896
+ title={itemData.name}
897
+ data-item-id={id}
898
+ data-active={isActive || undefined}
899
+ className={cn(
900
+ rowBase,
901
+ isActive && 'bg-sidebar-accent',
902
+ item.isFocused() && 'relative z-10 ring-1 ring-ring',
903
+ )}
904
+ style={{ paddingLeft: `${level * 12 + 8}px` }}
905
+ onKeyDown={(e) => {
906
+ // Preserve the tree's own key handling (arrow nav, expand/collapse).
907
+ rowProps.onKeyDown?.(e)
908
+ // Shift+Enter triggers the focused row's secondary action, mirroring
909
+ // its RowMenu entry: a file opens in a new tab ("Открыть в новой
910
+ // вкладке"); a folder expands recursively ("Развернуть рекурсивно").
911
+ if (e.key === 'Enter' && e.shiftKey) {
912
+ if (itemData.href) {
913
+ e.preventDefault()
914
+ openFile(itemData.href, itemData.name, true)
915
+ } else if (isFolder) {
916
+ e.preventDefault()
917
+ void item.expandAll()
918
+ }
919
+ }
920
+ }}
921
+ onClick={(e) => {
922
+ // Make the whole row navigate, not just the link text. The chevron
923
+ // stops propagation, and the inner <Link> handles its own clicks
924
+ // (incl. modifier-clicks / new-tab), so we only act on the rest of
925
+ // the row and skip modified clicks here.
926
+ rowProps.onClick?.(e)
927
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
928
+ const target = e.target as HTMLElement
929
+ if (itemData.href && !target.closest('a, [data-chevron]')) {
930
+ openFile(itemData.href, itemData.name, false)
931
+ }
932
+ }}
933
+ onDoubleClick={(e) => {
934
+ // Double-click always opens the file as a tab (regardless of whether
935
+ // any tabs are currently open). Skip the chevron and modifier-clicks.
936
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
937
+ const target = e.target as HTMLElement
938
+ if (itemData.href && !target.closest('[data-chevron]')) {
939
+ e.preventDefault()
940
+ openFile(itemData.href, itemData.name, true)
941
+ }
942
+ }}
943
+ >
944
+ {isFolder ? (
945
+ <button
946
+ type="button"
947
+ data-chevron
948
+ className="flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground"
949
+ onClick={(e) => {
950
+ e.stopPropagation()
951
+ if (item.isExpanded()) item.collapse()
952
+ else item.expand()
953
+ }}
954
+ >
955
+ <ChevronRight
956
+ className={cn('size-3.5 transition-transform', item.isExpanded() && 'rotate-90')}
957
+ />
958
+ </button>
959
+ ) : (
960
+ <span className="size-4 shrink-0" />
961
+ )}
962
+ <span className={cn('flex size-4 shrink-0 items-center justify-center', iconColor[itemData.type])}>
963
+ {icons[itemData.type] ?? icons.file}
964
+ </span>
965
+ {itemData.href ? (
966
+ <Link
967
+ to={itemData.href}
968
+ onClick={(e) => {
969
+ // Let the browser handle modifier-clicks (open in new tab, etc.).
970
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
971
+ // Otherwise drive navigation through the tab rules instead of the
972
+ // Link's default, so a single-click on the text honors hasTabs too.
973
+ e.preventDefault()
974
+ openFile(itemData.href!, itemData.name, false)
975
+ }}
976
+ className={cn(
977
+ 'flex-1 min-w-0 truncate no-underline',
978
+ isActive ? 'text-foreground font-medium' : 'text-sidebar-foreground/80',
979
+ isMatch && 'text-foreground font-semibold',
980
+ )}
981
+ >
982
+ {isMatch ? highlightParts(itemData.name, search.trim()) : itemData.name}
983
+ </Link>
984
+ ) : (
985
+ <span
986
+ className={cn(
987
+ 'flex-1 min-w-0 truncate',
988
+ level === 0 ? 'font-semibold text-foreground' : 'text-sidebar-foreground/80',
989
+ isMatch && 'text-foreground font-semibold',
990
+ )}
991
+ >
992
+ {isMatch ? highlightParts(itemData.name, search.trim()) : itemData.name}
993
+ </span>
994
+ )}
995
+ {/* "More actions" menu. File rows open the doc as an app tab;
996
+ directory rows expand the whole subtree recursively. The ⋮
997
+ button moves focus to its row first (it stops propagation, so
998
+ the tree's own click never runs) — keeping the focus ring on
999
+ the row whose action fired, like a normal row click would. */}
1000
+ {itemData.href ? (
1001
+ <RowMenu
1002
+ actions={[
1003
+ {
1004
+ label: 'Открыть в новой вкладке (Shift+Enter)',
1005
+ onSelect: () => {
1006
+ item.setFocused()
1007
+ openFile(itemData.href!, itemData.name, true)
1008
+ },
1009
+ },
1010
+ ]}
1011
+ />
1012
+ ) : (
1013
+ isFolder && (
1014
+ <RowMenu
1015
+ actions={[
1016
+ {
1017
+ label: 'Развернуть рекурсивно (Shift+Enter)',
1018
+ onSelect: () => {
1019
+ item.setFocused()
1020
+ // expandAll on the item instance opens this folder and
1021
+ // every descendant folder (awaiting child loads as it goes).
1022
+ void item.expandAll()
1023
+ },
1024
+ },
1025
+ ]}
1026
+ />
1027
+ )
1028
+ )}
1029
+ </div>
1030
+ )
1031
+ })}
1032
+ {isFiltering && matchingIds.size === 0 && (
1033
+ <div className="p-4 text-center text-[0.8125rem] text-muted-foreground">Ничего не найдено</div>
1034
+ )}
1035
+ </div>
1036
+ )}
1037
+ </div>
1038
+
1039
+ {searchModalOpen && (
1040
+ <FileSearchModal
1041
+ data={data}
1042
+ getAncestorIds={getAncestorIds}
1043
+ onClose={() => setSearchModalOpen(false)}
1044
+ onPick={pickSearchResult}
1045
+ />
1046
+ )}
1047
+ </aside>
1048
+ )
1049
+ }