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,224 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { ArrowUp, ChevronLeft, ChevronRight, List, Search as SearchIcon, X } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import type { Heading } from '~/lib/content.server'
|
|
5
|
+
import { TocLinks, tocHeadings } from '~/components/Toc'
|
|
6
|
+
import FindOnPage from '~/components/FindOnPage'
|
|
7
|
+
import { cn } from '~/lib/utils'
|
|
8
|
+
|
|
9
|
+
/** Shared tab class — IDENTICAL to MobileBottomBar's `tab`, so this row reads as
|
|
10
|
+
* a second deck of the same bar (stacked size-5 icon + 0.625rem label). */
|
|
11
|
+
const tab =
|
|
12
|
+
'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-lg py-1.5 text-[0.625rem] font-medium text-muted-foreground transition-colors'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Page-specific action menu (mobile only), styled as a SECOND DECK of the global
|
|
16
|
+
* MobileBottomBar: same pill chrome, same tab sizing (size-5 icons, 0.625rem
|
|
17
|
+
* labels), sitting directly above the global bar so the two read as one stacked
|
|
18
|
+
* control. The global bar itself is left untouched — this is a separate
|
|
19
|
+
* page-scoped strip.
|
|
20
|
+
*
|
|
21
|
+
* - Collapsed: a slim bar in the same chrome with one centred trigger tab
|
|
22
|
+
* ("Действия" + chevron).
|
|
23
|
+
* - Tapped: it swaps to the full action row — "Наверх" (scroll to top) and
|
|
24
|
+
* "Содержание" (open the TOC sheet) — as bottom-bar-style tabs. A trailing
|
|
25
|
+
* close tab (chevron-down) collapses it; tapping an action also collapses.
|
|
26
|
+
*
|
|
27
|
+
* Quiet by default: HIDDEN on the first screen, fading in once scrolled ~1
|
|
28
|
+
* viewport (at the top there's nothing to scroll up to and the intro stays
|
|
29
|
+
* clean), and dimmed until reached for.
|
|
30
|
+
*
|
|
31
|
+
* Layering: same inset-x-3 width as the global bar, stacked just above it, at
|
|
32
|
+
* z-80 — below the bar (z-100) AND below the mobile overlays (Sidebar/files and
|
|
33
|
+
* Projects panel, both z-90) so those cover it just like they cover each other.
|
|
34
|
+
* The TOC sheet it opens uses the modal tier (z-[200]).
|
|
35
|
+
*/
|
|
36
|
+
export default function PageFloatingMenu({ headings }: { headings: Heading[] }) {
|
|
37
|
+
const shown = tocHeadings(headings)
|
|
38
|
+
const [visible, setVisible] = useState(true)
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
const [tocOpen, setTocOpen] = useState(false)
|
|
41
|
+
const [findOpen, setFindOpen] = useState(false)
|
|
42
|
+
const navRef = useRef<HTMLElement>(null)
|
|
43
|
+
|
|
44
|
+
// While the deck is open, any pointer/focus landing outside it collapses it —
|
|
45
|
+
// standard dropdown dismissal. pointerdown fires before click so a tap-away
|
|
46
|
+
// closes immediately; focusin covers keyboard/AT moving focus elsewhere.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return
|
|
49
|
+
const onAway = (e: Event) => {
|
|
50
|
+
if (!navRef.current?.contains(e.target as Node)) setOpen(false)
|
|
51
|
+
}
|
|
52
|
+
document.addEventListener('pointerdown', onAway)
|
|
53
|
+
document.addEventListener('focusin', onAway)
|
|
54
|
+
return () => {
|
|
55
|
+
document.removeEventListener('pointerdown', onAway)
|
|
56
|
+
document.removeEventListener('focusin', onAway)
|
|
57
|
+
}
|
|
58
|
+
}, [open])
|
|
59
|
+
|
|
60
|
+
// Visibility for the collapsed trigger: show it at the very top, at the very
|
|
61
|
+
// bottom, or while scrolling UP. Hide it while scrolling down, and — once you
|
|
62
|
+
// scroll up and stop — auto-hide after a short idle so it doesn't linger over
|
|
63
|
+
// the content. (The expanded row ignores all this; see the `open` guards.)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (open) return // While interacting, stay put — don't react to scroll.
|
|
66
|
+
let lastY = window.scrollY
|
|
67
|
+
let idle: ReturnType<typeof setTimeout> | undefined
|
|
68
|
+
|
|
69
|
+
const evaluate = () => {
|
|
70
|
+
clearTimeout(idle)
|
|
71
|
+
const y = window.scrollY
|
|
72
|
+
const max = document.documentElement.scrollHeight - window.innerHeight
|
|
73
|
+
const atTop = y <= 4
|
|
74
|
+
const atBottom = y >= max - 4
|
|
75
|
+
const scrollingUp = y < lastY
|
|
76
|
+
lastY = y
|
|
77
|
+
|
|
78
|
+
if (atTop || atBottom) {
|
|
79
|
+
setVisible(true) // Persistent edges — stay shown, no idle timer.
|
|
80
|
+
} else if (scrollingUp) {
|
|
81
|
+
setVisible(true)
|
|
82
|
+
// Scrolled up then stopped → hide after the scrolling settles.
|
|
83
|
+
idle = setTimeout(() => setVisible(false), 2000)
|
|
84
|
+
} else {
|
|
85
|
+
setVisible(false) // Scrolling down.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
evaluate()
|
|
90
|
+
window.addEventListener('scroll', evaluate, { passive: true })
|
|
91
|
+
return () => {
|
|
92
|
+
clearTimeout(idle)
|
|
93
|
+
window.removeEventListener('scroll', evaluate)
|
|
94
|
+
}
|
|
95
|
+
}, [open])
|
|
96
|
+
|
|
97
|
+
const scrollTop = () => {
|
|
98
|
+
setOpen(false)
|
|
99
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const openToc = () => {
|
|
103
|
+
setOpen(false)
|
|
104
|
+
setTocOpen(true)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const openFind = () => {
|
|
108
|
+
setOpen(false)
|
|
109
|
+
setFindOpen(true)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
{open ? (
|
|
115
|
+
/* Expanded: full-width deck matching the global bar, stacked above it. */
|
|
116
|
+
<nav
|
|
117
|
+
ref={navRef}
|
|
118
|
+
className={cn(
|
|
119
|
+
'fixed inset-x-3 bottom-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+1.5rem)] z-80 md:hidden',
|
|
120
|
+
'flex items-stretch gap-1 rounded-2xl border bg-sidebar/95 px-1.5 py-1 shadow-lg backdrop-blur',
|
|
121
|
+
)}
|
|
122
|
+
aria-label="Действия страницы"
|
|
123
|
+
>
|
|
124
|
+
<button type="button" onClick={scrollTop} className={tab} aria-label="Наверх">
|
|
125
|
+
<ArrowUp className="size-5" />
|
|
126
|
+
<span>Наверх</span>
|
|
127
|
+
</button>
|
|
128
|
+
<button type="button" onClick={openFind} className={tab} aria-label="Найти на странице">
|
|
129
|
+
<SearchIcon className="size-5" />
|
|
130
|
+
<span>Найти</span>
|
|
131
|
+
</button>
|
|
132
|
+
<button type="button" onClick={openToc} className={tab} aria-label="Содержание">
|
|
133
|
+
<List className="size-5" />
|
|
134
|
+
<span>Содержание</span>
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={() => setOpen(false)}
|
|
139
|
+
className={tab}
|
|
140
|
+
aria-label="Скрыть"
|
|
141
|
+
aria-expanded
|
|
142
|
+
>
|
|
143
|
+
<ChevronRight className="size-5" />
|
|
144
|
+
<span>Скрыть</span>
|
|
145
|
+
</button>
|
|
146
|
+
</nav>
|
|
147
|
+
) : (
|
|
148
|
+
/* Collapsed: a small centred pill, shown per the scroll rules above. */
|
|
149
|
+
<div
|
|
150
|
+
className={cn(
|
|
151
|
+
'fixed inset-x-3 bottom-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+1.5rem)] z-80 flex justify-end md:hidden',
|
|
152
|
+
'transition-opacity duration-200',
|
|
153
|
+
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => setOpen(true)}
|
|
159
|
+
className="flex items-center gap-1 rounded-xl border bg-sidebar/95 px-3 py-2 text-[0.6875rem] font-medium text-muted-foreground shadow-md backdrop-blur transition-colors hover:text-foreground"
|
|
160
|
+
aria-label="Действия страницы"
|
|
161
|
+
aria-expanded={false}
|
|
162
|
+
>
|
|
163
|
+
<ChevronLeft className="size-3.5" />
|
|
164
|
+
На странице
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{tocOpen && <TocSheet shown={shown} onClose={() => setTocOpen(false)} />}
|
|
170
|
+
{findOpen && <FindOnPage onClose={() => setFindOpen(false)} />}
|
|
171
|
+
</>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bottom-sheet TOC modal. Slides up from the bottom, backdrop dismiss + Escape +
|
|
177
|
+
* body-scroll-lock, mirroring the Search modal conventions. Tapping a heading
|
|
178
|
+
* link closes the sheet (the link's hash navigation scrolls to the heading).
|
|
179
|
+
*/
|
|
180
|
+
function TocSheet({ shown, onClose }: { shown: Heading[]; onClose: () => void }) {
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const onKey = (e: KeyboardEvent) => {
|
|
183
|
+
if (e.key === 'Escape') onClose()
|
|
184
|
+
}
|
|
185
|
+
document.addEventListener('keydown', onKey)
|
|
186
|
+
const prev = document.body.style.overflow
|
|
187
|
+
document.body.style.overflow = 'hidden'
|
|
188
|
+
return () => {
|
|
189
|
+
document.removeEventListener('keydown', onKey)
|
|
190
|
+
document.body.style.overflow = prev
|
|
191
|
+
}
|
|
192
|
+
}, [onClose])
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
className="fixed inset-0 z-[200] flex flex-col justify-end bg-background/40 backdrop-blur-sm md:hidden"
|
|
197
|
+
onClick={onClose}
|
|
198
|
+
role="dialog"
|
|
199
|
+
aria-modal="true"
|
|
200
|
+
aria-label="Содержание"
|
|
201
|
+
>
|
|
202
|
+
<div
|
|
203
|
+
className="flex h-[75vh] flex-col overflow-hidden rounded-t-2xl border-t bg-popover pb-[env(safe-area-inset-bottom)]"
|
|
204
|
+
onClick={(e) => e.stopPropagation()}
|
|
205
|
+
>
|
|
206
|
+
<div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
|
|
207
|
+
<span className="text-sm font-semibold text-foreground">Содержание</span>
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={onClose}
|
|
211
|
+
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
212
|
+
aria-label="Закрыть"
|
|
213
|
+
>
|
|
214
|
+
<X className="size-5" />
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
{/* onClick on the list closes after a heading is tapped (links bubble up). */}
|
|
218
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-3 pb-6 text-sm" onClick={onClose}>
|
|
219
|
+
<TocLinks shown={shown} />
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useNavigate } from '@remix-run/react'
|
|
3
|
+
import { Check, ChevronsUpDown } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { getProjects, getProject, type Project } from '~/lib/projects'
|
|
6
|
+
import { t } from '~/lib/site'
|
|
7
|
+
import { cn } from '~/lib/utils'
|
|
8
|
+
|
|
9
|
+
/** Logo + height tuned to dodge the unlayered `img{height:auto}` cascade (inline style wins). */
|
|
10
|
+
function ProjectLogo({ project, className }: { project: Project; className?: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<img
|
|
13
|
+
src={project.logo}
|
|
14
|
+
alt=""
|
|
15
|
+
aria-hidden
|
|
16
|
+
width={20}
|
|
17
|
+
height={20}
|
|
18
|
+
style={{ height: 20, width: 20 }}
|
|
19
|
+
className={cn('shrink-0 rounded', className)}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** Id of the active project, or null when none is selected (e.g. `/`). */
|
|
26
|
+
activeId: string | null
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Project selector shown in the top bar, just right of the logo. Displays the
|
|
32
|
+
* current project (logo + name); clicking opens a dropdown of all projects, each
|
|
33
|
+
* with its logo. Choosing one navigates to that project's landing doc — which
|
|
34
|
+
* re-derives the active project and swaps the sidebar to its menu.
|
|
35
|
+
*/
|
|
36
|
+
export default function ProjectSwitcher({ activeId, className }: Props) {
|
|
37
|
+
const navigate = useNavigate()
|
|
38
|
+
const projects = getProjects()
|
|
39
|
+
// null when no project is selected (e.g. on `/`) — the trigger shows a
|
|
40
|
+
// neutral "choose a project" label instead of defaulting to the first.
|
|
41
|
+
const active = activeId ? (getProject(activeId) ?? null) : null
|
|
42
|
+
|
|
43
|
+
const [open, setOpen] = useState(false)
|
|
44
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
|
|
46
|
+
// Close on outside click or Escape.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return
|
|
49
|
+
const onDown = (e: MouseEvent) => {
|
|
50
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
51
|
+
}
|
|
52
|
+
const onKey = (e: KeyboardEvent) => {
|
|
53
|
+
if (e.key === 'Escape') setOpen(false)
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener('mousedown', onDown)
|
|
56
|
+
document.addEventListener('keydown', onKey)
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener('mousedown', onDown)
|
|
59
|
+
document.removeEventListener('keydown', onKey)
|
|
60
|
+
}
|
|
61
|
+
}, [open])
|
|
62
|
+
|
|
63
|
+
const select = (p: Project) => {
|
|
64
|
+
setOpen(false)
|
|
65
|
+
if (p.id !== active?.id) navigate(p.landing)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div ref={rootRef} className={cn('relative', className)}>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
aria-haspopup="listbox"
|
|
73
|
+
aria-expanded={open}
|
|
74
|
+
onClick={() => setOpen((o) => !o)}
|
|
75
|
+
className={cn(
|
|
76
|
+
'flex items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm',
|
|
77
|
+
'transition-colors hover:bg-sidebar-accent',
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{active ? (
|
|
81
|
+
<>
|
|
82
|
+
<ProjectLogo project={active} />
|
|
83
|
+
<span className="max-w-[10rem] truncate font-medium text-foreground">{active.name}</span>
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
<span className="max-w-[10rem] truncate font-medium text-muted-foreground">
|
|
87
|
+
{t('selectProject')}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
{open && (
|
|
94
|
+
<ul
|
|
95
|
+
role="listbox"
|
|
96
|
+
className={cn(
|
|
97
|
+
'absolute left-0 top-[calc(100%+4px)] z-50 min-w-[14rem] overflow-hidden rounded-md border bg-popover p-1 shadow-md',
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{projects.map((p) => {
|
|
101
|
+
const isActive = p.id === active?.id
|
|
102
|
+
return (
|
|
103
|
+
<li key={p.id} role="option" aria-selected={isActive}>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => select(p)}
|
|
107
|
+
className={cn(
|
|
108
|
+
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
|
|
109
|
+
'transition-colors hover:bg-sidebar-accent',
|
|
110
|
+
isActive && 'bg-sidebar-accent',
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<ProjectLogo project={p} />
|
|
114
|
+
<span className="min-w-0 flex-1 truncate text-foreground">{p.name}</span>
|
|
115
|
+
{isActive && <Check className="size-4 shrink-0 text-foreground" />}
|
|
116
|
+
</button>
|
|
117
|
+
</li>
|
|
118
|
+
)
|
|
119
|
+
})}
|
|
120
|
+
</ul>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|