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,227 @@
|
|
|
1
|
+
import { useNavigate, useLocation } from "@remix-run/react";
|
|
2
|
+
import { MoreVertical, X } from "lucide-react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { DropdownMenu, DropdownMenuItem } from "~/components/ui/dropdown-menu";
|
|
6
|
+
import { useTabs, normTabPath } from "~/lib/tabs";
|
|
7
|
+
import { useKeyboardShortcuts, type Shortcut } from "~/lib/useKeyboardShortcuts";
|
|
8
|
+
import { cn } from "~/lib/utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drives a custom overlay scrollbar over a horizontally-scrolling element whose
|
|
12
|
+
* native scrollbar is hidden (`.scrollbar-none`). Returns a ref to attach to the
|
|
13
|
+
* scroll container, the thumb geometry (as 0–1 fractions of the track), and a
|
|
14
|
+
* flag for whether the content overflows at all.
|
|
15
|
+
*
|
|
16
|
+
* The thumb is positioned/sized from `scrollLeft / scrollWidth / clientWidth`,
|
|
17
|
+
* recomputed on scroll, resize, and whenever the tab set changes.
|
|
18
|
+
*/
|
|
19
|
+
function useOverlayScrollbar(deps: unknown[]) {
|
|
20
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
21
|
+
const [thumb, setThumb] = useState({ width: 0, left: 0 });
|
|
22
|
+
|
|
23
|
+
const measure = useCallback(() => {
|
|
24
|
+
const el = ref.current;
|
|
25
|
+
if (!el) return;
|
|
26
|
+
const { scrollWidth, clientWidth, scrollLeft } = el;
|
|
27
|
+
if (scrollWidth <= clientWidth) {
|
|
28
|
+
setThumb({ width: 0, left: 0 });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setThumb({
|
|
32
|
+
width: clientWidth / scrollWidth,
|
|
33
|
+
left: scrollLeft / scrollWidth,
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
measure();
|
|
39
|
+
const el = ref.current;
|
|
40
|
+
if (!el) return;
|
|
41
|
+
const ro = new ResizeObserver(measure);
|
|
42
|
+
ro.observe(el);
|
|
43
|
+
return () => ro.disconnect();
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, deps);
|
|
46
|
+
|
|
47
|
+
// Translate vertical wheel ticks into horizontal scroll, the way VS Code does.
|
|
48
|
+
const onWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
|
49
|
+
const el = ref.current;
|
|
50
|
+
if (!el || el.scrollWidth <= el.clientWidth) return;
|
|
51
|
+
// Only hijack a predominantly-vertical wheel; let trackpad horizontal
|
|
52
|
+
// gestures (deltaX) scroll natively.
|
|
53
|
+
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
|
54
|
+
el.scrollLeft += e.deltaY;
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ref,
|
|
60
|
+
thumb,
|
|
61
|
+
onScroll: measure,
|
|
62
|
+
onWheel,
|
|
63
|
+
hasOverflow: thumb.width > 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Editor-style tab strip shown **only above the content area** (right of the
|
|
69
|
+
* sidebar). Renders nothing when no tabs are open. The active tab is whichever
|
|
70
|
+
* one matches the current URL. Clicking a tab navigates to it; the × button
|
|
71
|
+
* closes it — closing the active tab activates a neighbor (right, else left).
|
|
72
|
+
* A vertical-dots button at the far end (always visible, outside the scroll
|
|
73
|
+
* region) closes all tabs at once.
|
|
74
|
+
*/
|
|
75
|
+
export default function TabBar() {
|
|
76
|
+
const { tabs, closeTab, closeAll } = useTabs();
|
|
77
|
+
const navigate = useNavigate();
|
|
78
|
+
const location = useLocation();
|
|
79
|
+
const { ref, thumb, onScroll, onWheel, hasOverflow } = useOverlayScrollbar([
|
|
80
|
+
tabs.length,
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const cur = normTabPath(location.pathname);
|
|
84
|
+
|
|
85
|
+
// Close a tab by path: drop it, and if it was the active one, move to a
|
|
86
|
+
// neighbor (prefer the right, else the left). Shared by the × button and the
|
|
87
|
+
// `w` shortcut. Defined before the empty-tabs early return so the hook below
|
|
88
|
+
// it always runs (hooks can't follow a conditional return).
|
|
89
|
+
const closePath = useCallback(
|
|
90
|
+
(path: string) => {
|
|
91
|
+
const norm = normTabPath(path);
|
|
92
|
+
const idx = tabs.findIndex((t) => normTabPath(t.path) === norm);
|
|
93
|
+
const wasActive = norm === cur;
|
|
94
|
+
closeTab(path);
|
|
95
|
+
if (wasActive && tabs.length > 1) {
|
|
96
|
+
const neighbor = tabs[idx + 1] ?? tabs[idx - 1];
|
|
97
|
+
if (neighbor) navigate(neighbor.path);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
[tabs, cur, closeTab, navigate]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// `w` (outside text fields) closes the current tab — bare key, so no clash
|
|
104
|
+
// with the browser's reserved Ctrl/Cmd+W. No-op when no tab matches the URL.
|
|
105
|
+
const tabShortcuts = useMemo<Shortcut[]>(
|
|
106
|
+
() => [
|
|
107
|
+
{
|
|
108
|
+
keys: "w",
|
|
109
|
+
label: "Закрыть текущую вкладку",
|
|
110
|
+
group: "Вкладки",
|
|
111
|
+
run: () => {
|
|
112
|
+
if (tabs.some((t) => normTabPath(t.path) === cur)) closePath(cur);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
[tabs, cur, closePath]
|
|
117
|
+
);
|
|
118
|
+
useKeyboardShortcuts(tabShortcuts);
|
|
119
|
+
|
|
120
|
+
if (tabs.length === 0) return null;
|
|
121
|
+
|
|
122
|
+
const close = (e: React.MouseEvent, path: string) => {
|
|
123
|
+
e.stopPropagation();
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
closePath(path);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Stickiness is handled by the grid cell wrapper in root.tsx (sticky top-11),
|
|
129
|
+
// since a sticky grid item can travel the whole grid height while this inner
|
|
130
|
+
// strip's own parent box is only as tall as the strip.
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
data-tab-strip
|
|
134
|
+
className="flex h-9 items-stretch border-b bg-background max-md:hidden"
|
|
135
|
+
>
|
|
136
|
+
{/* Scrollable tab list wrapper. `group/strip relative` is the positioning
|
|
137
|
+
context for the overlay scrollbar, which sits as a non-scrolling
|
|
138
|
+
sibling pinned to the bottom edge. */}
|
|
139
|
+
<div className="group/strip relative flex min-w-0 flex-1 items-stretch">
|
|
140
|
+
{/* The scroll region itself. The native scrollbar is hidden
|
|
141
|
+
(`scrollbar-none`) so it reserves no vertical space and never
|
|
142
|
+
shifts the tab text. Vertical wheel ticks scroll it horizontally
|
|
143
|
+
(VS Code style) via onWheel. */}
|
|
144
|
+
<div
|
|
145
|
+
ref={ref}
|
|
146
|
+
onScroll={onScroll}
|
|
147
|
+
onWheel={onWheel}
|
|
148
|
+
className="flex min-w-0 flex-1 items-stretch overflow-x-auto scrollbar-none"
|
|
149
|
+
>
|
|
150
|
+
{tabs.map((tab) => {
|
|
151
|
+
const isActive = normTabPath(tab.path) === cur;
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
key={tab.path}
|
|
155
|
+
title={tab.title}
|
|
156
|
+
onClick={() => navigate(tab.path)}
|
|
157
|
+
className={cn(
|
|
158
|
+
"group flex max-w-[12rem] shrink-0 cursor-pointer items-center gap-1.5 border-r pl-3 pr-1.5 text-sm select-none",
|
|
159
|
+
"transition-colors",
|
|
160
|
+
isActive
|
|
161
|
+
? "bg-sidebar-accent text-foreground"
|
|
162
|
+
: "bg-background text-muted-foreground hover:bg-sidebar-accent/60"
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<span className="min-w-0 truncate">{tab.title}</span>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
aria-label={`Закрыть ${tab.title}`}
|
|
169
|
+
// The `w` shortcut closes the *active* tab, so only hint it there.
|
|
170
|
+
title={isActive ? "Закрыть (W)" : undefined}
|
|
171
|
+
onClick={(e) => close(e, tab.path)}
|
|
172
|
+
className={cn(
|
|
173
|
+
"flex size-5 shrink-0 items-center justify-center rounded hover:bg-foreground/10",
|
|
174
|
+
isActive
|
|
175
|
+
? "opacity-100"
|
|
176
|
+
: "opacity-0 group-hover:opacity-100"
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<X className="size-3.5" />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
{/* Overlay scrollbar. Non-scrolling sibling pinned to the bottom edge
|
|
186
|
+
of the strip; only rendered when the tabs overflow. The thumb's
|
|
187
|
+
width/offset are fractions of the track, derived from the scroll
|
|
188
|
+
geometry. It's invisible until the strip is hovered, then fades in
|
|
189
|
+
— so it costs no layout space and stays out of the way. */}
|
|
190
|
+
{hasOverflow && (
|
|
191
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5">
|
|
192
|
+
<div
|
|
193
|
+
className={cn(
|
|
194
|
+
"h-full rounded-full bg-border opacity-0 transition-opacity",
|
|
195
|
+
"group-hover/strip:opacity-100"
|
|
196
|
+
)}
|
|
197
|
+
style={{
|
|
198
|
+
width: `${thumb.width * 100}%`,
|
|
199
|
+
marginLeft: `${thumb.left * 100}%`,
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
{/* Actions menu: pinned at the end, outside the scroll region, so the
|
|
206
|
+
trigger stays visible no matter how far the tab list scrolls. The
|
|
207
|
+
vertical-dots button opens a dropdown with "close all tabs". */}
|
|
208
|
+
<DropdownMenu
|
|
209
|
+
align="end"
|
|
210
|
+
label="Действия с вкладками"
|
|
211
|
+
className={({ open }) =>
|
|
212
|
+
cn(
|
|
213
|
+
"flex h-full w-9 shrink-0 items-center justify-center border-l text-muted-foreground",
|
|
214
|
+
"transition-colors hover:bg-sidebar-accent/60 hover:text-foreground",
|
|
215
|
+
open && "bg-sidebar-accent text-foreground"
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
trigger={() => <MoreVertical className="size-4" />}
|
|
219
|
+
>
|
|
220
|
+
<DropdownMenuItem onSelect={closeAll}>
|
|
221
|
+
<X className="size-4 shrink-0 text-muted-foreground" />
|
|
222
|
+
<span>Закрыть все вкладки ({tabs.length})</span>
|
|
223
|
+
</DropdownMenuItem>
|
|
224
|
+
</DropdownMenu>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useStickyBox } from 'react-sticky-box'
|
|
3
|
+
import type { Heading } from '~/lib/content.server'
|
|
4
|
+
import { t } from '~/lib/site'
|
|
5
|
+
import { cn } from '~/lib/utils'
|
|
6
|
+
|
|
7
|
+
// Headings shown in every TOC: section (h2) and subsection (h3) only.
|
|
8
|
+
export function tocHeadings(headings: Heading[]) {
|
|
9
|
+
return headings.filter((h) => h.depth >= 2 && h.depth <= 3)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Scroll-spy: returns the slug of the heading the reader is currently at.
|
|
13
|
+
//
|
|
14
|
+
// We observe each heading element and keep the set that's intersecting the
|
|
15
|
+
// "active band" — the top 20% of the viewport (rootMargin shrinks the bottom
|
|
16
|
+
// to -80%). The active heading is the LAST visible one in document order, so
|
|
17
|
+
// it stays highlighted while reading its section, then advances as the next
|
|
18
|
+
// heading crosses into the band. The 44px top inset matches the sticky TopBar
|
|
19
|
+
// so a heading scrolled flush under the bar still counts as "at the top".
|
|
20
|
+
export function useActiveHeading(shown: Heading[]) {
|
|
21
|
+
const [activeSlug, setActiveSlug] = useState<string | null>(null)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (shown.length === 0) return
|
|
25
|
+
|
|
26
|
+
const elements = shown
|
|
27
|
+
.map((h) => document.getElementById(h.slug))
|
|
28
|
+
.filter((el): el is HTMLElement => el !== null)
|
|
29
|
+
if (elements.length === 0) return
|
|
30
|
+
|
|
31
|
+
// Track visibility per slug so we can pick the last visible heading even
|
|
32
|
+
// across multiple observer callbacks.
|
|
33
|
+
const visible = new Set<string>()
|
|
34
|
+
|
|
35
|
+
const observer = new IntersectionObserver(
|
|
36
|
+
(entries) => {
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.isIntersecting) visible.add(entry.target.id)
|
|
39
|
+
else visible.delete(entry.target.id)
|
|
40
|
+
}
|
|
41
|
+
// Last visible heading in document order wins.
|
|
42
|
+
const slugs = shown.map((h) => h.slug)
|
|
43
|
+
const last = slugs.filter((s) => visible.has(s)).at(-1)
|
|
44
|
+
if (last) {
|
|
45
|
+
setActiveSlug(last)
|
|
46
|
+
} else {
|
|
47
|
+
// Nothing in the band (e.g. mid-section with a tall block):
|
|
48
|
+
// keep the last heading scrolled past the top of the band.
|
|
49
|
+
const passed = elements.filter((el) => el.getBoundingClientRect().top < 44)
|
|
50
|
+
const lastPassed = passed.at(-1)
|
|
51
|
+
if (lastPassed) setActiveSlug(lastPassed.id)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ rootMargin: '-44px 0px -80% 0px', threshold: 0 },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for (const el of elements) observer.observe(el)
|
|
58
|
+
return () => observer.disconnect()
|
|
59
|
+
}, [shown])
|
|
60
|
+
|
|
61
|
+
return activeSlug
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Shared link list rendered by the desktop sidebar TOC and the mobile TOC modal.
|
|
65
|
+
export function TocLinks({ shown }: { shown: Heading[] }) {
|
|
66
|
+
const activeSlug = useActiveHeading(shown)
|
|
67
|
+
|
|
68
|
+
if (shown.length === 0) {
|
|
69
|
+
return <p className="m-0 text-muted-foreground">Нет содержания</p>
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<ul className="m-0 list-none p-0">
|
|
73
|
+
{shown.map((h) => {
|
|
74
|
+
const isActive = h.slug === activeSlug
|
|
75
|
+
return (
|
|
76
|
+
<li key={h.slug} className={cn('m-0', h.depth === 3 && 'pl-3')}>
|
|
77
|
+
<a
|
|
78
|
+
href={`#${h.slug}`}
|
|
79
|
+
aria-current={isActive ? 'location' : undefined}
|
|
80
|
+
className={cn(
|
|
81
|
+
'block rounded px-2 py-1 no-underline hover:text-foreground',
|
|
82
|
+
isActive
|
|
83
|
+
? 'bg-accent font-medium text-foreground'
|
|
84
|
+
: 'text-muted-foreground',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{h.text}
|
|
88
|
+
</a>
|
|
89
|
+
</li>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
</ul>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function Toc({ headings }: { headings: Heading[] }) {
|
|
97
|
+
const shown = tocHeadings(headings)
|
|
98
|
+
|
|
99
|
+
// "Smart sticky" via react-sticky-box: a short TOC pins below the TopBar and
|
|
100
|
+
// stays; a TOC taller than the viewport follows the page as you scroll and
|
|
101
|
+
// pins at whichever edge you scrolled away from (bottom on the way down, top
|
|
102
|
+
// on the way back up) so every item is reachable without an inner scrollbar.
|
|
103
|
+
// Plain CSS `position: sticky` can only pin one edge, which clipped tall TOCs.
|
|
104
|
+
//
|
|
105
|
+
// offsetTop is the 44px TopBar height (the bar is `sticky top-0 h-11`). The tab
|
|
106
|
+
// strip lives in the content column (col 2), NOT over this TOC column (col 3),
|
|
107
|
+
// so it never overlaps the TOC and the offset stays 44px whether tabs are open.
|
|
108
|
+
const stickyRef = useStickyBox({ offsetTop: 44, offsetBottom: 0 })
|
|
109
|
+
|
|
110
|
+
// The <aside> is the full-height TRACK: it spans both grid rows so its height
|
|
111
|
+
// equals the scroll area the sticky inner node travels within. The inner <div>
|
|
112
|
+
// (stickyRef) is what react-sticky-box positions.
|
|
113
|
+
//
|
|
114
|
+
// `!row-start-1` is important-flagged on purpose: the parent Outlet wrapper in
|
|
115
|
+
// root.tsx applies `[&>*]:row-start-2` to every child, and that descendant
|
|
116
|
+
// selector outranks a plain `xl:row-start-1` here — so without `!` the TOC
|
|
117
|
+
// would start in row 2 (below the tab strip) instead of spanning from row 1.
|
|
118
|
+
return (
|
|
119
|
+
<aside
|
|
120
|
+
id="toc"
|
|
121
|
+
className="hidden border-l xl:col-start-3 xl:row-span-2 xl:!row-start-1 xl:block"
|
|
122
|
+
>
|
|
123
|
+
<div ref={stickyRef} className="px-5 pb-6 pt-2 text-[0.8125rem]">
|
|
124
|
+
<h2 className="mb-2 mt-0 text-lg font-semibold text-foreground">{t('onThisPage')}</h2>
|
|
125
|
+
<TocLinks shown={shown} />
|
|
126
|
+
</div>
|
|
127
|
+
</aside>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Keyboard } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { ThemeToggle } from '~/components/theme-toggle'
|
|
4
|
+
import { Search } from '~/components/Search'
|
|
5
|
+
import ProjectSwitcher from '~/components/ProjectSwitcher'
|
|
6
|
+
import { openShortcutsHelp } from '~/components/ShortcutsHelp'
|
|
7
|
+
import { Button } from '~/components/ui/button'
|
|
8
|
+
import { cn } from '~/lib/utils'
|
|
9
|
+
import { site, t } from '~/lib/site'
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Active project id, or null when no project is selected (e.g. `/`). */
|
|
13
|
+
projectId: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Slim top bar: logo + project switcher on the left, centered search, theme
|
|
18
|
+
* toggle on the right. Full-width and sticky at the top of the page: stays
|
|
19
|
+
* pinned while content scrolls beneath it, and the sidebar starts below it
|
|
20
|
+
* (so the bar overlaps neither the content nor the menu).
|
|
21
|
+
* Desktop only — mobile has its own bottom bar with the switcher and toggle.
|
|
22
|
+
*/
|
|
23
|
+
export default function TopBar({ projectId }: Props) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
'sticky top-0 z-50 flex h-11 items-center justify-between px-3 max-md:hidden',
|
|
28
|
+
'border-b bg-background',
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
32
|
+
<a href="/" className="flex shrink-0 items-center">
|
|
33
|
+
{/* Theme is class-based (<html class="dark">), driven by the in-app toggle —
|
|
34
|
+
NOT prefers-color-scheme — so swap logos on the `dark` class, not the OS
|
|
35
|
+
setting. The *-dark.svg has white text (for dark bg); *-light.svg has
|
|
36
|
+
black text (for light bg). Inline height: app.css has an unlayered
|
|
37
|
+
`img { height: auto }` rule that otherwise beats Tailwind's h-* utility. */}
|
|
38
|
+
<img
|
|
39
|
+
src={site.logo.light}
|
|
40
|
+
alt={site.title}
|
|
41
|
+
style={{ height: 20, width: 'auto' }}
|
|
42
|
+
className="block dark:hidden"
|
|
43
|
+
/>
|
|
44
|
+
<img
|
|
45
|
+
src={site.logo.dark}
|
|
46
|
+
alt={site.title}
|
|
47
|
+
style={{ height: 20, width: 'auto' }}
|
|
48
|
+
className="hidden dark:block"
|
|
49
|
+
/>
|
|
50
|
+
</a>
|
|
51
|
+
{/* Project selector sits just right of the logo. */}
|
|
52
|
+
<ProjectSwitcher activeId={projectId} />
|
|
53
|
+
</div>
|
|
54
|
+
{/* Centered search: absolutely positioned so it stays centered in the bar
|
|
55
|
+
regardless of the logo / toggle widths on either side. */}
|
|
56
|
+
<div className="absolute left-1/2 -translate-x-1/2">
|
|
57
|
+
<Search className="w-full max-w-md justify-start sm:w-80 md:w-96" />
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex shrink-0 items-center">
|
|
60
|
+
{/* Opens the `?` cheatsheet — same overlay the `?` key toggles. */}
|
|
61
|
+
<Button
|
|
62
|
+
variant="ghost"
|
|
63
|
+
size="icon"
|
|
64
|
+
onClick={openShortcutsHelp}
|
|
65
|
+
aria-label={t('shortcuts')}
|
|
66
|
+
title={`${t('shortcuts')} (?)`}
|
|
67
|
+
>
|
|
68
|
+
<Keyboard />
|
|
69
|
+
</Button>
|
|
70
|
+
<ThemeToggle />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Moon, Sun } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '~/components/ui/button'
|
|
5
|
+
import { site, t } from '~/lib/site'
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'theme'
|
|
8
|
+
|
|
9
|
+
/** Whether the site defaults to dark when the user hasn't picked a theme. */
|
|
10
|
+
const DEFAULTS_DARK = site.defaultTheme !== 'light'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Inline script injected into <head> (before paint) so the correct theme class
|
|
14
|
+
* is on <html> before first render — avoids a flash of the wrong theme. Uses the
|
|
15
|
+
* configured `site.defaultTheme` when nothing is stored.
|
|
16
|
+
*/
|
|
17
|
+
export const themeInitScript = `(function(){try{var t=localStorage.getItem('${STORAGE_KEY}');var d=t?t==='dark':${DEFAULTS_DARK};document.documentElement.classList.toggle('dark',d);}catch(e){document.documentElement.classList.${DEFAULTS_DARK ? 'add' : 'remove'}('dark');}})();`
|
|
18
|
+
|
|
19
|
+
function getInitialIsDark(): boolean {
|
|
20
|
+
if (typeof document === 'undefined') return DEFAULTS_DARK
|
|
21
|
+
return document.documentElement.classList.contains('dark')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ThemeToggle({ className }: { className?: string }) {
|
|
25
|
+
// Render a stable icon during SSR/first paint; sync to the real DOM state
|
|
26
|
+
// after mount so the button reflects whatever the init script applied.
|
|
27
|
+
const [isDark, setIsDark] = useState(DEFAULTS_DARK)
|
|
28
|
+
const [mounted, setMounted] = useState(false)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setMounted(true)
|
|
32
|
+
setIsDark(getInitialIsDark())
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
function toggle() {
|
|
36
|
+
const next = !isDark
|
|
37
|
+
setIsDark(next)
|
|
38
|
+
// Suppress CSS transitions for the duration of the theme swap. Several
|
|
39
|
+
// elements (search input, project switcher, home cards, buttons) carry a
|
|
40
|
+
// `transition-colors`/`transition-all` for their hover states, which would
|
|
41
|
+
// otherwise also animate the color change on theme toggle — making them lag
|
|
42
|
+
// visibly behind the sidebar/content that have no transition. We add a
|
|
43
|
+
// global override that zeroes transitions, flip the class, force a reflow,
|
|
44
|
+
// then remove the override on the next frame so hovers animate normally again.
|
|
45
|
+
const root = document.documentElement
|
|
46
|
+
root.classList.add('theme-switching')
|
|
47
|
+
root.classList.toggle('dark', next)
|
|
48
|
+
// Force a synchronous style flush so the no-transition state is committed
|
|
49
|
+
// with the new colors before transitions are restored.
|
|
50
|
+
void root.offsetHeight
|
|
51
|
+
requestAnimationFrame(() => root.classList.remove('theme-switching'))
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light')
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Button
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="icon"
|
|
63
|
+
className={className}
|
|
64
|
+
onClick={toggle}
|
|
65
|
+
aria-label={t('toggleTheme')}
|
|
66
|
+
title={t('toggleTheme')}
|
|
67
|
+
>
|
|
68
|
+
{mounted && !isDark ? <Sun /> : <Moon />}
|
|
69
|
+
</Button>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
|
|
5
|
+
import { cn } from '~/lib/utils'
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
|
13
|
+
destructive:
|
|
14
|
+
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20',
|
|
15
|
+
outline:
|
|
16
|
+
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
|
17
|
+
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
|
18
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
19
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
23
|
+
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
|
24
|
+
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
|
25
|
+
icon: 'size-9',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
variant: 'default',
|
|
30
|
+
size: 'default',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
function Button({
|
|
36
|
+
className,
|
|
37
|
+
variant,
|
|
38
|
+
size,
|
|
39
|
+
asChild = false,
|
|
40
|
+
...props
|
|
41
|
+
}: React.ComponentProps<'button'> &
|
|
42
|
+
VariantProps<typeof buttonVariants> & {
|
|
43
|
+
asChild?: boolean
|
|
44
|
+
}) {
|
|
45
|
+
const Comp = asChild ? Slot : 'button'
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Comp
|
|
49
|
+
data-slot="button"
|
|
50
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
|
|
5
|
+
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="card"
|
|
9
|
+
className={cn(
|
|
10
|
+
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
data-slot="card-header"
|
|
22
|
+
className={cn(
|
|
23
|
+
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
data-slot="card-title"
|
|
35
|
+
className={cn('leading-none font-semibold', className)}
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
data-slot="card-description"
|
|
45
|
+
className={cn('text-muted-foreground text-sm', className)}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
52
|
+
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent }
|