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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sedokina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # cantip
2
+
3
+ A config-driven **Remix SSR documentation engine**. Ingest Obsidian vaults or
4
+ plain markdown folders and get a fast docs site with a persistent sidebar, tabs,
5
+ full-text search, dark/light theme, canvas rendering, and wikilinks — all driven
6
+ by a single `docs.config.ts`. ("Cantip" — кантип — is Kyrgyz for "how (to)".)
7
+
8
+ ## Quick start
9
+
10
+ ```sh
11
+ npm create cantip my-docs
12
+ cd my-docs
13
+ npm install
14
+ npm run dev
15
+ ```
16
+
17
+ Or add to an existing project:
18
+
19
+ ```sh
20
+ npm install cantip
21
+ ```
22
+
23
+ …then create a `docs.config.ts` and run `npx cantip dev`.
24
+
25
+ ## CLI
26
+
27
+ | Command | What |
28
+ | --- | --- |
29
+ | `cantip generate` | Ingest sources + compile content from `docs.config.ts`. |
30
+ | `cantip dev` | Generate, then start the dev server. |
31
+ | `cantip build` | Generate, then build for production. |
32
+ | `cantip start` | Serve the production build. |
33
+ | `cantip typecheck` | Type-check the engine. |
34
+
35
+ ## Configure
36
+
37
+ Everything lives in `docs.config.ts` (typed via `cantip/config`):
38
+
39
+ ```ts
40
+ import { defineConfig } from 'cantip/config'
41
+
42
+ export default defineConfig({
43
+ site: { title: 'My Docs', lang: 'en', defaultTheme: 'dark' },
44
+ // Loose markdown in ./docs, served at the root:
45
+ general: { enabled: true, source: './docs' },
46
+ // …or named projects, each a folder / submodule / any path:
47
+ // projects: [{ id: 'guide', name: 'Guide', source: './content/guide' }],
48
+ // theme: { colors: { dark: { '--brand': 'oklch(0.7 0.2 250)' } } },
49
+ // components: { Home: './app/MyHome.tsx' },
50
+ })
51
+ ```
52
+
53
+ - **Content sources** — submodule, loose folder, any path, or a `general` bucket
54
+ served at the root with no project concept.
55
+ - **Branding** — title, description, logos, favicon, language, default theme.
56
+ - **Theme** — `theme.colors` OKLCH tokens, no CSS edits.
57
+ - **Components** — swap `Home` / `DocPage` / `TopBar` / `Toc` for your own `.tsx`.
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,62 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /** True when the app is currently in dark mode (the `.dark` class on <html>). */
4
+ function isDark(): boolean {
5
+ return document.documentElement.classList.contains('dark')
6
+ }
7
+
8
+ /**
9
+ * Mounts any `[data-canvas-mount]` containers in the current page using the
10
+ * json-canvas-viewer library (loaded lazily, client-only). Re-runs whenever the
11
+ * given key changes (i.e. on each navigation) so freshly-rendered canvas pages
12
+ * get initialised. Ported 1:1 from the inline script in the old DocsLayout.astro.
13
+ *
14
+ * The viewer ships its own built-in `light`/`dark` palettes (background, cards,
15
+ * borders, dot grid, text) and defaults to `light`. We pass the current app
16
+ * theme on init and keep each mounted viewer in sync with the `.dark` class via
17
+ * a MutationObserver, so the canvas matches the rest of the site when the user
18
+ * toggles the theme.
19
+ */
20
+ export default function CanvasMount({ deps }: { deps: string }) {
21
+ useEffect(() => {
22
+ let cancelled = false
23
+ const viewers: Array<{ changeTheme: (theme?: 'dark' | 'light') => void }> = []
24
+ const containers = document.querySelectorAll<HTMLElement>('[data-canvas-mount]')
25
+ if (containers.length === 0) return
26
+
27
+ ;(async () => {
28
+ const { JSONCanvasViewer, parser, Minimap, Controls } = await import('json-canvas-viewer')
29
+ if (cancelled) return
30
+ const theme = isDark() ? 'dark' : 'light'
31
+ containers.forEach((container) => {
32
+ const dataEl = container.querySelector<HTMLScriptElement>('script[type="application/json"]')
33
+ if (!dataEl) return
34
+ const canvas = JSON.parse(dataEl.textContent || '{"nodes":[],"edges":[]}')
35
+ container.removeAttribute('data-canvas-mount')
36
+ container.innerHTML = ''
37
+ const viewer = new JSONCanvasViewer({ container, canvas, parser, theme }, [
38
+ Minimap,
39
+ Controls,
40
+ ])
41
+ viewers.push(viewer)
42
+ })
43
+ })()
44
+
45
+ // Re-theme mounted viewers whenever the app toggles its `.dark` class.
46
+ const observer = new MutationObserver(() => {
47
+ const theme = isDark() ? 'dark' : 'light'
48
+ viewers.forEach((viewer) => viewer.changeTheme(theme))
49
+ })
50
+ observer.observe(document.documentElement, {
51
+ attributes: true,
52
+ attributeFilter: ['class'],
53
+ })
54
+
55
+ return () => {
56
+ cancelled = true
57
+ observer.disconnect()
58
+ }
59
+ }, [deps])
60
+
61
+ return null
62
+ }
@@ -0,0 +1,78 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /**
4
+ * The document body HTML is rendered server-side and injected via
5
+ * `dangerouslySetInnerHTML`, so code blocks aren't React elements we can give a
6
+ * button declaratively. This component runs after each page render, wraps every
7
+ * `<pre>` in `.body` in a positioning shell, and injects a per-block toggle.
8
+ *
9
+ * Code blocks default to no-wrap with horizontal scroll; the toggle adds
10
+ * `.pre-wrap` to wrap long lines instead. The button lives on the shell (not
11
+ * inside the `<pre>`) so it stays pinned to the block's top-right corner and
12
+ * does not drift when the `<pre>` is scrolled horizontally.
13
+ *
14
+ * The preference is per-block and deliberately NOT persisted — it resets to
15
+ * no-wrap on reload/navigation. Re-runs whenever `deps` changes (pass the doc
16
+ * id) so freshly swapped-in content gets toggles too.
17
+ */
18
+
19
+ const WRAP_ICON =
20
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 0 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg>'
21
+ const NOWRAP_ICON =
22
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'
23
+
24
+ const WRAP_LABEL = 'Переносить строки'
25
+ const NOWRAP_LABEL = 'Не переносить строки'
26
+
27
+ export function CodeWrapToggle({ deps }: { deps?: string }) {
28
+ useEffect(() => {
29
+ const blocks = document.querySelectorAll<HTMLPreElement>('.content .body pre')
30
+ const cleanups: Array<() => void> = []
31
+
32
+ blocks.forEach((pre) => {
33
+ // Guard against double-wrapping if the effect re-runs over DOM that
34
+ // React hasn't replaced (e.g. same content remounting).
35
+ if (pre.parentElement?.classList.contains('pre-wrap-shell')) return
36
+
37
+ const shell = document.createElement('div')
38
+ shell.className = 'pre-wrap-shell'
39
+ pre.replaceWith(shell)
40
+ shell.appendChild(pre)
41
+
42
+ const button = document.createElement('button')
43
+ button.type = 'button'
44
+ button.className = 'pre-wrap-toggle'
45
+
46
+ const sync = () => {
47
+ const wrapped = pre.classList.contains('pre-wrap')
48
+ // Icon shows the action the click performs (the OTHER state).
49
+ button.innerHTML = wrapped ? NOWRAP_ICON : WRAP_ICON
50
+ const label = wrapped ? NOWRAP_LABEL : WRAP_LABEL
51
+ button.setAttribute('aria-label', label)
52
+ button.title = label
53
+ }
54
+
55
+ const onClick = () => {
56
+ pre.classList.toggle('pre-wrap')
57
+ sync()
58
+ }
59
+
60
+ button.addEventListener('click', onClick)
61
+ sync()
62
+ shell.appendChild(button)
63
+
64
+ cleanups.push(() => {
65
+ button.removeEventListener('click', onClick)
66
+ // Unwrap: move the <pre> back out and drop the shell.
67
+ if (shell.parentElement) {
68
+ shell.replaceWith(pre)
69
+ }
70
+ button.remove()
71
+ })
72
+ })
73
+
74
+ return () => cleanups.forEach((fn) => fn())
75
+ }, [deps])
76
+
77
+ return null
78
+ }
@@ -0,0 +1,224 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { ChevronDown, ChevronUp, Search as SearchIcon, X } from 'lucide-react'
3
+
4
+ import { cn } from '~/lib/utils'
5
+
6
+ /**
7
+ * In-page "find on this page" — a built-in replacement for the browser's Ctrl+F,
8
+ * aimed at mobile where the native find bar is awful (tiny, covers content, hard
9
+ * to dismiss). Searches only the article body (`article.content`), highlights
10
+ * every match, and lets the reader step through them with a running "3 / 12"
11
+ * count.
12
+ *
13
+ * Highlighting uses the CSS Custom Highlight API (`CSS.highlights` + the
14
+ * `::highlight()` pseudo, styled in app.css). That paints over Range objects
15
+ * WITHOUT mutating the DOM — essential here because the article HTML is injected
16
+ * via dangerouslySetInnerHTML and React owns nothing inside it; wrapping matches
17
+ * in <mark> would fight React and risk corrupting the content. Ranges are also
18
+ * cheap to discard, so clearing on close is a one-liner.
19
+ *
20
+ * Rendered (mobile-only) by the doc route; opened from the page floating menu.
21
+ */
22
+
23
+ /** Highlight registry names — two layers so the current match reads differently. */
24
+ const HL_ALL = 'find-all'
25
+ const HL_CURRENT = 'find-current'
26
+
27
+ /** Whether the browser supports the CSS Custom Highlight API we rely on. */
28
+ function highlightSupported(): boolean {
29
+ return typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined'
30
+ }
31
+
32
+ /** Drop both highlight layers from the global registry. */
33
+ function clearHighlights() {
34
+ if (!highlightSupported()) return
35
+ CSS.highlights.delete(HL_ALL)
36
+ CSS.highlights.delete(HL_CURRENT)
37
+ }
38
+
39
+ /**
40
+ * Collect every text node under `root`, skipping ones inside <script>/<style>
41
+ * (never visible prose). Each node's full text is searched as one string.
42
+ */
43
+ function textNodesIn(root: Node): Text[] {
44
+ const nodes: Text[] = []
45
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
46
+ acceptNode(node) {
47
+ const parent = node.parentElement
48
+ if (!parent) return NodeFilter.FILTER_REJECT
49
+ const tag = parent.tagName
50
+ if (tag === 'SCRIPT' || tag === 'STYLE') return NodeFilter.FILTER_REJECT
51
+ // Skip pure-whitespace nodes — nothing matchable, keeps the walk lean.
52
+ if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT
53
+ return NodeFilter.FILTER_ACCEPT
54
+ },
55
+ })
56
+ let n = walker.nextNode()
57
+ while (n) {
58
+ nodes.push(n as Text)
59
+ n = walker.nextNode()
60
+ }
61
+ return nodes
62
+ }
63
+
64
+ /**
65
+ * Find every (case-insensitive) occurrence of `query` within the article and
66
+ * return a Range per match. Matches are confined to single text nodes — a query
67
+ * spanning element boundaries (e.g. across a <strong>) won't match, which is the
68
+ * normal, acceptable limitation for this kind of find.
69
+ */
70
+ function findRanges(root: Element, query: string): Range[] {
71
+ const ranges: Range[] = []
72
+ if (!query) return ranges
73
+ const needle = query.toLowerCase()
74
+ for (const node of textNodesIn(root)) {
75
+ const haystack = (node.nodeValue ?? '').toLowerCase()
76
+ let from = 0
77
+ let idx = haystack.indexOf(needle, from)
78
+ while (idx !== -1) {
79
+ const range = document.createRange()
80
+ range.setStart(node, idx)
81
+ range.setEnd(node, idx + needle.length)
82
+ ranges.push(range)
83
+ from = idx + needle.length
84
+ idx = haystack.indexOf(needle, from)
85
+ }
86
+ }
87
+ return ranges
88
+ }
89
+
90
+ export default function FindOnPage({ onClose }: { onClose: () => void }) {
91
+ const [query, setQuery] = useState('')
92
+ const [ranges, setRanges] = useState<Range[]>([])
93
+ const [current, setCurrent] = useState(0)
94
+ const inputRef = useRef<HTMLInputElement>(null)
95
+
96
+ const supported = useMemo(highlightSupported, [])
97
+
98
+ // Focus the field on open so the reader can type straight away.
99
+ useEffect(() => {
100
+ inputRef.current?.focus()
101
+ }, [])
102
+
103
+ // Recompute matches whenever the query changes. Reset to the first match each
104
+ // time so the count + current highlight stay in sync with what's typed.
105
+ useEffect(() => {
106
+ if (!supported) return
107
+ const root = document.querySelector('article.content')
108
+ const q = query.trim()
109
+ const found = root && q ? findRanges(root, q) : []
110
+ setRanges(found)
111
+ setCurrent(0)
112
+ }, [query, supported])
113
+
114
+ // Paint the "all matches" layer whenever the match set changes, and the
115
+ // "current match" layer whenever the set OR the cursor changes. Two separate
116
+ // Highlight objects so the active match can be styled distinctly.
117
+ useEffect(() => {
118
+ if (!supported) return
119
+ if (ranges.length === 0) {
120
+ clearHighlights()
121
+ return
122
+ }
123
+ CSS.highlights.set(HL_ALL, new Highlight(...ranges))
124
+ const active = ranges[current]
125
+ if (active) CSS.highlights.set(HL_CURRENT, new Highlight(active))
126
+ else CSS.highlights.delete(HL_CURRENT)
127
+ }, [ranges, current, supported])
128
+
129
+ // Scroll the active match into view as the reader steps through.
130
+ useEffect(() => {
131
+ const active = ranges[current]
132
+ if (!active) return
133
+ // Range has no scrollIntoView; use its bounding rect's nearest element.
134
+ const target =
135
+ active.startContainer.parentElement ?? (active.commonAncestorContainer as Element | null)
136
+ target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
137
+ }, [ranges, current])
138
+
139
+ // Always clear highlights when the bar unmounts (close), so matches don't
140
+ // linger painted over the page after the reader is done.
141
+ useEffect(() => clearHighlights, [])
142
+
143
+ const step = useCallback(
144
+ (delta: number) => {
145
+ setCurrent((c) => {
146
+ if (ranges.length === 0) return 0
147
+ // Wrap around both ends so prev from the first lands on the last.
148
+ return (c + delta + ranges.length) % ranges.length
149
+ })
150
+ },
151
+ [ranges.length],
152
+ )
153
+
154
+ const onKeyDown = (e: React.KeyboardEvent) => {
155
+ if (e.key === 'Enter') {
156
+ e.preventDefault()
157
+ step(e.shiftKey ? -1 : 1)
158
+ } else if (e.key === 'Escape') {
159
+ e.preventDefault()
160
+ onClose()
161
+ }
162
+ }
163
+
164
+ const has = ranges.length > 0
165
+ const q = query.trim()
166
+
167
+ return (
168
+ <div
169
+ className={cn(
170
+ 'fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] z-[110] md:hidden',
171
+ 'flex items-center gap-1 rounded-2xl border bg-sidebar/95 px-2 py-1.5 shadow-lg backdrop-blur',
172
+ )}
173
+ role="search"
174
+ aria-label="Найти на странице"
175
+ >
176
+ <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
177
+ <input
178
+ ref={inputRef}
179
+ value={query}
180
+ onChange={(e) => setQuery(e.target.value)}
181
+ onKeyDown={onKeyDown}
182
+ placeholder="Найти на странице…"
183
+ className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
184
+ autoComplete="off"
185
+ spellCheck={false}
186
+ enterKeyHint="search"
187
+ />
188
+
189
+ {/* Match counter: "3 / 12", or "0" when nothing matches a non-empty query. */}
190
+ {q && (
191
+ <span className="shrink-0 px-1 text-xs tabular-nums text-muted-foreground">
192
+ {has ? `${current + 1} / ${ranges.length}` : '0'}
193
+ </span>
194
+ )}
195
+
196
+ <button
197
+ type="button"
198
+ onClick={() => step(-1)}
199
+ disabled={!has}
200
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
201
+ aria-label="Предыдущее совпадение"
202
+ >
203
+ <ChevronUp className="size-4" />
204
+ </button>
205
+ <button
206
+ type="button"
207
+ onClick={() => step(1)}
208
+ disabled={!has}
209
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
210
+ aria-label="Следующее совпадение"
211
+ >
212
+ <ChevronDown className="size-4" />
213
+ </button>
214
+ <button
215
+ type="button"
216
+ onClick={onClose}
217
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
218
+ aria-label="Закрыть поиск по странице"
219
+ >
220
+ <X className="size-4" />
221
+ </button>
222
+ </div>
223
+ )
224
+ }
@@ -0,0 +1,93 @@
1
+ import { Home, FolderTree, LayoutGrid, Search as SearchIcon } from 'lucide-react'
2
+ import { Link } from '@remix-run/react'
3
+
4
+ import { Search } from '~/components/Search'
5
+ import { t } from '~/lib/site'
6
+ import { cn } from '~/lib/utils'
7
+
8
+ interface Props {
9
+ /** Whether the directory (sidebar) overlay is open — drives the tab's active state. */
10
+ dirsOpen: boolean
11
+ /** Whether the fullscreen projects panel is open. */
12
+ projectsOpen: boolean
13
+ /** Whether there's a file tree to open. False with no active project (e.g. `/`),
14
+ * where the Files tab is disabled since there's nothing to show. */
15
+ filesEnabled: boolean
16
+ onToggleDirs: () => void
17
+ onToggleProjects: () => void
18
+ }
19
+
20
+ /** Shared classes for a tab: stacked icon + label, active = foreground colour. */
21
+ const tab =
22
+ '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'
23
+ const tabActive = 'text-foreground'
24
+
25
+ /**
26
+ * Classic mobile floating bottom navigation: a detached, rounded pill anchored
27
+ * above the bottom safe area. Four tabs, in order — Home (link to `/`), Projects
28
+ * (the fullscreen project switcher, which also houses the theme toggle), Files
29
+ * (the desktop sidebar tree, opened as a mobile overlay), and Search (reuses the
30
+ * Pagefind Search modal). No standalone theme button — theme lives inside Projects.
31
+ *
32
+ * Only the floating-bar chrome lives here; the panels it toggles (sidebar overlay,
33
+ * projects panel) are rendered by the root so they can sit behind this bar.
34
+ */
35
+ export default function MobileBottomBar({
36
+ dirsOpen,
37
+ projectsOpen,
38
+ filesEnabled,
39
+ onToggleDirs,
40
+ onToggleProjects,
41
+ }: Props) {
42
+ return (
43
+ <nav
44
+ className={cn(
45
+ 'fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] z-100 md:hidden',
46
+ 'flex items-stretch gap-1 rounded-2xl border bg-sidebar/95 px-1.5 py-1 shadow-lg backdrop-blur',
47
+ )}
48
+ aria-label="Навигация"
49
+ >
50
+ <Link to="/" className={tab} aria-label={t('home')}>
51
+ <Home className="size-5" />
52
+ <span>{t('home')}</span>
53
+ </Link>
54
+
55
+ <button
56
+ type="button"
57
+ onClick={onToggleProjects}
58
+ className={cn(tab, projectsOpen && tabActive)}
59
+ aria-label={t('projects')}
60
+ aria-expanded={projectsOpen}
61
+ >
62
+ <LayoutGrid className="size-5" />
63
+ <span>{t('projects')}</span>
64
+ </button>
65
+
66
+ <button
67
+ type="button"
68
+ onClick={onToggleDirs}
69
+ disabled={!filesEnabled}
70
+ className={cn(tab, dirsOpen && tabActive, 'disabled:pointer-events-none disabled:opacity-40')}
71
+ aria-label={t('files')}
72
+ aria-expanded={dirsOpen}
73
+ >
74
+ <FolderTree className="size-5" />
75
+ <span>{t('files')}</span>
76
+ </button>
77
+
78
+ {/* Reuse the Pagefind Search modal, but render our own stacked tab as its
79
+ trigger so it matches the other tabs (icon over label). The desktop
80
+ TopBar's instance owns ⌘K, so disable the shortcut here to avoid
81
+ double-opening. */}
82
+ <Search
83
+ enableShortcut={false}
84
+ trigger={(open) => (
85
+ <button type="button" onClick={open} className={tab} aria-label={t('search')}>
86
+ <SearchIcon className="size-5" />
87
+ <span>{t('search')}</span>
88
+ </button>
89
+ )}
90
+ />
91
+ </nav>
92
+ )
93
+ }
@@ -0,0 +1,113 @@
1
+ import { useEffect } from 'react'
2
+ import { useNavigate } from '@remix-run/react'
3
+ import { Check, X } from 'lucide-react'
4
+
5
+ import { getProjects, getProject, type Project } from '~/lib/projects'
6
+ import { ThemeToggle } from '~/components/theme-toggle'
7
+ import { t } from '~/lib/site'
8
+ import { cn } from '~/lib/utils'
9
+
10
+ /** Logo sized via inline style to win the unlayered `img{height:auto}` cascade. */
11
+ function ProjectLogo({ project }: { project: Project }) {
12
+ return (
13
+ <img
14
+ src={project.logo}
15
+ alt=""
16
+ aria-hidden
17
+ width={24}
18
+ height={24}
19
+ style={{ height: 24, width: 24 }}
20
+ className="shrink-0 rounded"
21
+ />
22
+ )
23
+ }
24
+
25
+ interface Props {
26
+ /** Id of the active project, or null when none is selected (e.g. `/`). */
27
+ activeId: string | null
28
+ open: boolean
29
+ onClose: () => void
30
+ }
31
+
32
+ /**
33
+ * Fullscreen mobile "side menu": the project list rendered as tappable items
34
+ * (not a dropdown), the active one ticked, plus a theme-toggle row at the bottom.
35
+ * Picking a project navigates to its landing doc — the root loader then re-derives
36
+ * the active project and swaps the sidebar. Lives above the floating bottom bar
37
+ * (which stays visible) so the Projects tab can re-tap to close. Self-contained
38
+ * Escape + body-scroll-lock, mirroring the Search/ProjectSwitcher pattern.
39
+ */
40
+ export default function MobileProjectsPanel({ activeId, open, onClose }: Props) {
41
+ const navigate = useNavigate()
42
+ const projects = getProjects()
43
+ const active = activeId ? (getProject(activeId) ?? null) : null
44
+
45
+ // Escape closes; lock background scroll while open.
46
+ useEffect(() => {
47
+ if (!open) return
48
+ const onKey = (e: KeyboardEvent) => {
49
+ if (e.key === 'Escape') onClose()
50
+ }
51
+ document.addEventListener('keydown', onKey)
52
+ const prev = document.body.style.overflow
53
+ document.body.style.overflow = 'hidden'
54
+ return () => {
55
+ document.removeEventListener('keydown', onKey)
56
+ document.body.style.overflow = prev
57
+ }
58
+ }, [open, onClose])
59
+
60
+ if (!open) return null
61
+
62
+ const select = (p: Project) => {
63
+ onClose()
64
+ if (p.id !== active?.id) navigate(p.landing)
65
+ }
66
+
67
+ return (
68
+ // Sits below the floating bar (bottom inset clears it) so the bar's Projects
69
+ // tab stays tappable to toggle the panel shut.
70
+ <div className="fixed inset-x-0 bottom-0 top-0 z-90 flex flex-col bg-popover pb-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+3rem)] md:hidden">
71
+ <div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
72
+ <span className="text-sm font-semibold text-foreground">{t('projects')}</span>
73
+ <button
74
+ type="button"
75
+ onClick={onClose}
76
+ className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
77
+ aria-label={t('close')}
78
+ >
79
+ <X className="size-5" />
80
+ </button>
81
+ </div>
82
+
83
+ <ul className="min-h-0 flex-1 overflow-y-auto p-2">
84
+ {projects.map((p) => {
85
+ const isActive = p.id === active?.id
86
+ return (
87
+ <li key={p.id}>
88
+ <button
89
+ type="button"
90
+ onClick={() => select(p)}
91
+ className={cn(
92
+ 'flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left',
93
+ 'transition-colors hover:bg-sidebar-accent',
94
+ isActive && 'bg-sidebar-accent',
95
+ )}
96
+ >
97
+ <ProjectLogo project={p} />
98
+ <span className="min-w-0 flex-1 truncate text-base text-foreground">{p.name}</span>
99
+ {isActive && <Check className="size-5 shrink-0 text-foreground" />}
100
+ </button>
101
+ </li>
102
+ )
103
+ })}
104
+ </ul>
105
+
106
+ {/* Theme toggle row — a normal "settings" row at the bottom of the menu. */}
107
+ <div className="flex shrink-0 items-center justify-between border-t px-4 py-3">
108
+ <span className="text-sm text-foreground">Тема оформления</span>
109
+ <ThemeToggle className="text-muted-foreground" />
110
+ </div>
111
+ </div>
112
+ )
113
+ }