serve-my-md 1.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/README.md +52 -0
- package/bin/index.js +487 -0
- package/index.html +70 -0
- package/package.json +111 -0
- package/shared/constants.json +3 -0
- package/shared/index.d.ts +34 -0
- package/web/.cta.json +12 -0
- package/web/.cursorrules +7 -0
- package/web/.prettierignore +3 -0
- package/web/.vscode/settings.json +11 -0
- package/web/README.md +489 -0
- package/web/components.json +21 -0
- package/web/eslint.config.js +5 -0
- package/web/index.html +66 -0
- package/web/prettier.config.js +10 -0
- package/web/public/og-image.png +0 -0
- package/web/src/.generated/output.json +1 -0
- package/web/src/.generated/paths.json +1 -0
- package/web/src/App.tsx +15 -0
- package/web/src/article.css +199 -0
- package/web/src/components/Bettercrumb.tsx +86 -0
- package/web/src/components/Fonts.tsx +13 -0
- package/web/src/components/Header.tsx +10 -0
- package/web/src/components/IntentLink.tsx +20 -0
- package/web/src/components/Rendrer.tsx +140 -0
- package/web/src/components/Search.tsx +275 -0
- package/web/src/components/Sidebar.tsx +89 -0
- package/web/src/components/ThemeSwitcher.tsx +46 -0
- package/web/src/components/ui/breadcrumb.tsx +122 -0
- package/web/src/components/ui/button.tsx +60 -0
- package/web/src/components/ui/collapsible.tsx +33 -0
- package/web/src/components/ui/dropdown-menu.tsx +255 -0
- package/web/src/components/ui/input.tsx +21 -0
- package/web/src/components/ui/kbd.tsx +28 -0
- package/web/src/components/ui/separator.tsx +26 -0
- package/web/src/components/ui/sheet.tsx +139 -0
- package/web/src/components/ui/sidebar.tsx +727 -0
- package/web/src/components/ui/skeleton.tsx +13 -0
- package/web/src/components/ui/tooltip.tsx +59 -0
- package/web/src/contexts.ts +10 -0
- package/web/src/hooks/useMobile.ts +19 -0
- package/web/src/lib/utils.tsx +89 -0
- package/web/src/main.tsx +100 -0
- package/web/src/reportWebVitals.ts +13 -0
- package/web/src/styles.css +196 -0
- package/web/src/types/index.ts +3 -0
- package/web/tsconfig.json +35 -0
- package/web/vite.config.ts +31 -0
- package/web/vitest.config.ts +16 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useNavigate } from '@tanstack/react-router';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { useHotkeys } from 'react-hotkeys-hook';
|
|
4
|
+
|
|
5
|
+
import Bettercrumb from './Bettercrumb';
|
|
6
|
+
import { Button } from './ui/button';
|
|
7
|
+
import IntentLink from './IntentLink';
|
|
8
|
+
import { Kbd } from './ui/kbd';
|
|
9
|
+
import { SidebarTrigger } from './ui/sidebar';
|
|
10
|
+
import { useIsMobile } from '@/hooks/useMobile';
|
|
11
|
+
import { markElements, slugify } from '@/lib/utils';
|
|
12
|
+
import Search from './Search';
|
|
13
|
+
import ThemeSwitch from './ThemeSwitcher';
|
|
14
|
+
import pathBrowser from 'path-browserify';
|
|
15
|
+
import out from '@/.generated/output.json' with { type: 'json' };
|
|
16
|
+
|
|
17
|
+
export default function Rendrer({
|
|
18
|
+
path,
|
|
19
|
+
content,
|
|
20
|
+
next,
|
|
21
|
+
prev,
|
|
22
|
+
title
|
|
23
|
+
}: {
|
|
24
|
+
path: string;
|
|
25
|
+
content: string;
|
|
26
|
+
next?: string;
|
|
27
|
+
prev?: string;
|
|
28
|
+
title: string;
|
|
29
|
+
}) {
|
|
30
|
+
const articleRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const navigate = useNavigate();
|
|
32
|
+
const isMobile = useIsMobile();
|
|
33
|
+
|
|
34
|
+
const prevRef = useRef<HTMLButtonElement>(null);
|
|
35
|
+
const nextRef = useRef<HTMLButtonElement>(null);
|
|
36
|
+
|
|
37
|
+
useHotkeys(
|
|
38
|
+
'alt+shift+enter',
|
|
39
|
+
(e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (prev && prevRef.current) prevRef.current.click();
|
|
42
|
+
},
|
|
43
|
+
[prev]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
useHotkeys(
|
|
47
|
+
'alt+enter',
|
|
48
|
+
(e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
if (next && nextRef.current) nextRef.current.click();
|
|
51
|
+
},
|
|
52
|
+
[next]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!articleRef.current) return;
|
|
57
|
+
|
|
58
|
+
const article = articleRef.current;
|
|
59
|
+
article.querySelectorAll('a').forEach((elem: HTMLAnchorElement) => {
|
|
60
|
+
elem.setAttribute(
|
|
61
|
+
'href',
|
|
62
|
+
pathBrowser.join(out.baseRoute || '/', elem.getAttribute('href') || '')
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
elem.addEventListener('click', (e) => {
|
|
66
|
+
if (
|
|
67
|
+
elem.getAttribute('href')?.startsWith('http://') ||
|
|
68
|
+
elem.getAttribute('href')?.startsWith('https://')
|
|
69
|
+
)
|
|
70
|
+
return;
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
navigate({ to: elem.getAttribute('href') || '/' });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
markElements(article);
|
|
77
|
+
article.querySelectorAll('h1,h2,h3,h4').forEach((element) => {
|
|
78
|
+
element.id = slugify(element.textContent);
|
|
79
|
+
|
|
80
|
+
const a = document.createElement('a');
|
|
81
|
+
a.href = `#${element.id}`;
|
|
82
|
+
a.classList.add('heading-anchor');
|
|
83
|
+
a.innerHTML = element.innerHTML;
|
|
84
|
+
element.innerHTML = '';
|
|
85
|
+
element.appendChild(a);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Prism.highlightAllUnder(article, true);
|
|
89
|
+
}, [articleRef.current]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<main className="py-10 w-full">
|
|
94
|
+
<div className="flex items-center gap-2 justify-between">
|
|
95
|
+
<div className="flex items-center gap-2 justify-start">
|
|
96
|
+
{isMobile && <SidebarTrigger variant="outline" />}
|
|
97
|
+
<Bettercrumb path={path} />
|
|
98
|
+
</div>
|
|
99
|
+
<div className="flex items-center gap-2 justify-end">
|
|
100
|
+
<ThemeSwitch />
|
|
101
|
+
<Search />
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<h1 className="text-3xl font-bold mt-4">{title}</h1>
|
|
106
|
+
|
|
107
|
+
<article
|
|
108
|
+
ref={articleRef}
|
|
109
|
+
className="main-article w-full mt-4"
|
|
110
|
+
dangerouslySetInnerHTML={{ __html: content }}
|
|
111
|
+
/>
|
|
112
|
+
|
|
113
|
+
<div className="flex justify-between mt-10 w-full">
|
|
114
|
+
{prev ? (
|
|
115
|
+
<>
|
|
116
|
+
<Button variant="outline" asChild ref={prevRef}>
|
|
117
|
+
<IntentLink to={prev}>
|
|
118
|
+
Previous <Kbd>Alt + Shift + ⏎</Kbd>
|
|
119
|
+
</IntentLink>
|
|
120
|
+
</Button>
|
|
121
|
+
</>
|
|
122
|
+
) : (
|
|
123
|
+
<span></span>
|
|
124
|
+
)}
|
|
125
|
+
{next ? (
|
|
126
|
+
<>
|
|
127
|
+
<Button variant="outline" asChild ref={nextRef}>
|
|
128
|
+
<IntentLink to={next}>
|
|
129
|
+
Next <Kbd>Alt + ⏎</Kbd>
|
|
130
|
+
</IntentLink>
|
|
131
|
+
</Button>
|
|
132
|
+
</>
|
|
133
|
+
) : (
|
|
134
|
+
<span></span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</main>
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import ReactDOM from 'react-dom';
|
|
2
|
+
import { SearchIcon } from 'lucide-react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useHotkeys } from 'react-hotkeys-hook';
|
|
5
|
+
|
|
6
|
+
import { Button } from './ui/button';
|
|
7
|
+
import { Input } from './ui/input';
|
|
8
|
+
import output from '@/.generated/output.json' with { type: 'json' };
|
|
9
|
+
|
|
10
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
11
|
+
import {
|
|
12
|
+
cn,
|
|
13
|
+
extractText,
|
|
14
|
+
getTitleFromExtraction,
|
|
15
|
+
highlightSubstring
|
|
16
|
+
} from '@/lib/utils';
|
|
17
|
+
import { useRouterState } from '@tanstack/react-router';
|
|
18
|
+
import IntentLink from './IntentLink';
|
|
19
|
+
|
|
20
|
+
type SearchResult = {
|
|
21
|
+
path: string;
|
|
22
|
+
title: string;
|
|
23
|
+
matches: number;
|
|
24
|
+
text: string;
|
|
25
|
+
targetElement: HTMLElement;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type SearchResultSimple = {
|
|
29
|
+
text: string;
|
|
30
|
+
targetElement: HTMLElement;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type SearchResults = {
|
|
34
|
+
internal: SearchResultSimple[];
|
|
35
|
+
external: SearchResult[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default function Search() {
|
|
39
|
+
const [trig, trigger] = useState(false);
|
|
40
|
+
const [results, setResults] = useState<{
|
|
41
|
+
internal: SearchResultSimple[];
|
|
42
|
+
external: SearchResult[];
|
|
43
|
+
}>({
|
|
44
|
+
internal: [],
|
|
45
|
+
external: []
|
|
46
|
+
});
|
|
47
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
48
|
+
|
|
49
|
+
const onChange = (val: string) => {
|
|
50
|
+
if (!val) {
|
|
51
|
+
setResults({ internal: [], external: [] });
|
|
52
|
+
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setResults(
|
|
57
|
+
output.routes.reduce(
|
|
58
|
+
(acc, route) => {
|
|
59
|
+
const extraction = extractText(route.content);
|
|
60
|
+
|
|
61
|
+
if (route.path === pathname) {
|
|
62
|
+
extraction.forEach(({ targetElement, text }) => {
|
|
63
|
+
if (text.toLowerCase().includes(val.toLowerCase())) {
|
|
64
|
+
acc.internal.push({
|
|
65
|
+
text,
|
|
66
|
+
targetElement
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return acc;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
extraction.every(({ targetElement, text }) => {
|
|
75
|
+
if (text.toLowerCase().includes(val.toLowerCase())) {
|
|
76
|
+
acc.external.push({
|
|
77
|
+
path: route.path,
|
|
78
|
+
matches: (text.match(new RegExp(val, 'gi')) || []).length,
|
|
79
|
+
text,
|
|
80
|
+
targetElement,
|
|
81
|
+
title: getTitleFromExtraction(extraction)
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return acc;
|
|
91
|
+
},
|
|
92
|
+
{ internal: [], external: [] } as typeof results
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
setResults({ internal: [], external: [] });
|
|
99
|
+
}, [trig]);
|
|
100
|
+
|
|
101
|
+
useHotkeys('ctrl+shift+f', () => trigger((t) => !t));
|
|
102
|
+
useHotkeys('esc', () => trigger(false));
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<Button
|
|
107
|
+
variant="outline"
|
|
108
|
+
size="icon"
|
|
109
|
+
title="Search"
|
|
110
|
+
onClick={() => trigger((t) => !t)}
|
|
111
|
+
aria-keyshortcuts="Ctrl+Shift+F"
|
|
112
|
+
>
|
|
113
|
+
<SearchIcon />
|
|
114
|
+
</Button>
|
|
115
|
+
<SearchInput
|
|
116
|
+
displayState={trig}
|
|
117
|
+
trigger={trigger}
|
|
118
|
+
onChange={onChange}
|
|
119
|
+
searchResults={results}
|
|
120
|
+
/>
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface SearchInputProps {
|
|
126
|
+
trigger: Dispatch<SetStateAction<boolean>>;
|
|
127
|
+
displayState: boolean;
|
|
128
|
+
query?: string;
|
|
129
|
+
onChange: (val: string) => void;
|
|
130
|
+
searchResults: SearchResults;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function SearchInput({
|
|
134
|
+
displayState,
|
|
135
|
+
trigger,
|
|
136
|
+
query,
|
|
137
|
+
onChange,
|
|
138
|
+
searchResults
|
|
139
|
+
}: SearchInputProps) {
|
|
140
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
141
|
+
const [changed, setChanged] = useState(0);
|
|
142
|
+
const [value, setValue] = useState(query || '');
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
setChanged((c) => c + 1);
|
|
146
|
+
}, [searchResults]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
function remInput(e: MouseEvent) {
|
|
150
|
+
if (!inputRef.current) return;
|
|
151
|
+
|
|
152
|
+
const input = inputRef.current;
|
|
153
|
+
const rect = input.getBoundingClientRect();
|
|
154
|
+
if (
|
|
155
|
+
e.clientX < rect.x ||
|
|
156
|
+
e.clientY < rect.y ||
|
|
157
|
+
e.clientX > rect.x + rect.width ||
|
|
158
|
+
e.clientY > rect.y + rect.height
|
|
159
|
+
) {
|
|
160
|
+
trigger(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (displayState)
|
|
165
|
+
setTimeout(() => document.addEventListener('click', remInput), 100);
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
if (displayState) document.removeEventListener('click', remInput);
|
|
169
|
+
};
|
|
170
|
+
}, [displayState]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
setChanged(0);
|
|
174
|
+
setValue(query || '');
|
|
175
|
+
if (displayState && inputRef.current) {
|
|
176
|
+
inputRef.current.focus();
|
|
177
|
+
}
|
|
178
|
+
}, [displayState]);
|
|
179
|
+
|
|
180
|
+
if (!displayState) return null;
|
|
181
|
+
|
|
182
|
+
return ReactDOM.createPortal(
|
|
183
|
+
<div
|
|
184
|
+
className={cn(
|
|
185
|
+
'fixed top-0 left-0 w-full h-screen z-50 transition-all',
|
|
186
|
+
searchResults.internal.length + searchResults.external.length ||
|
|
187
|
+
changed > 2
|
|
188
|
+
? 'bg-background/50 backdrop-blur-sm'
|
|
189
|
+
: ''
|
|
190
|
+
)}
|
|
191
|
+
onClick={(e) => {
|
|
192
|
+
if (e.target === e.currentTarget) {
|
|
193
|
+
trigger(false);
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 w-1/2">
|
|
198
|
+
<Input
|
|
199
|
+
className="shadow-[0px_-25px_50px_-10px_black] backdrop-blur-md bg-background/80!"
|
|
200
|
+
placeholder="Search for a candy.."
|
|
201
|
+
ref={inputRef}
|
|
202
|
+
value={value}
|
|
203
|
+
onChange={(e) => {
|
|
204
|
+
setValue(e.currentTarget.value);
|
|
205
|
+
onChange(e.currentTarget.value);
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
<SearchResults searchResults={searchResults} query={value} />
|
|
210
|
+
</div>
|
|
211
|
+
</div>,
|
|
212
|
+
document.body
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface SearchResultsProps {
|
|
217
|
+
searchResults: SearchResults;
|
|
218
|
+
query: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function SearchResults({ searchResults, query }: SearchResultsProps) {
|
|
222
|
+
if (!searchResults.internal.length && !searchResults.external.length) {
|
|
223
|
+
return <></>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<>
|
|
228
|
+
{searchResults.internal.length > 0 && (
|
|
229
|
+
<p className="px-2 text-sm text-muted-foreground">On this page</p>
|
|
230
|
+
)}
|
|
231
|
+
{searchResults.internal.map(
|
|
232
|
+
(result, i) =>
|
|
233
|
+
!result.targetElement.classList.contains('template') && (
|
|
234
|
+
<div
|
|
235
|
+
key={i}
|
|
236
|
+
onClick={() => {
|
|
237
|
+
if (result.targetElement.getAttribute('data-label')) {
|
|
238
|
+
const el = document.querySelector(
|
|
239
|
+
`[data-label="${result.targetElement.getAttribute('data-label')}"]`
|
|
240
|
+
) as HTMLElement;
|
|
241
|
+
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
|
242
|
+
} else if (result.targetElement.id) {
|
|
243
|
+
const el = document.getElementById(result.targetElement.id);
|
|
244
|
+
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
|
245
|
+
}
|
|
246
|
+
}}
|
|
247
|
+
className="cursor-pointer p-2 rounded hover:bg-secondary"
|
|
248
|
+
>
|
|
249
|
+
<p>{highlightSubstring(result.text, query)}</p>
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
<hr className="my-4" />
|
|
255
|
+
|
|
256
|
+
{searchResults.external.map((result, i) => (
|
|
257
|
+
<IntentLink
|
|
258
|
+
key={i}
|
|
259
|
+
to={result.path}
|
|
260
|
+
className="block p-3 rounded hover:bg-secondary border border-outline my-3"
|
|
261
|
+
>
|
|
262
|
+
<p className="text-sm text-muted-foreground">In {result.title}</p>
|
|
263
|
+
<div className="flex items-center gap-2">
|
|
264
|
+
<p className="block text-ellipsis w-full overflow-hidden text-nowrap">
|
|
265
|
+
{highlightSubstring(result.text, query)}
|
|
266
|
+
</p>
|
|
267
|
+
<span className="py-1 px-1.5 rounded bg-secondary text-xs float-right text-nowrap">
|
|
268
|
+
matches: {result.matches}
|
|
269
|
+
</span>
|
|
270
|
+
</div>
|
|
271
|
+
</IntentLink>
|
|
272
|
+
))}
|
|
273
|
+
</>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useContext, useMemo } from 'react';
|
|
2
|
+
import { ChevronsUpDown } from 'lucide-react';
|
|
3
|
+
import type { JSX } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { Out } from '@shared/index';
|
|
6
|
+
import { pathsContext } from '@/contexts';
|
|
7
|
+
import {
|
|
8
|
+
Sidebar as Sb,
|
|
9
|
+
SidebarContent,
|
|
10
|
+
SidebarGroup,
|
|
11
|
+
SidebarGroupContent,
|
|
12
|
+
SidebarHeader
|
|
13
|
+
} from '@/components/ui/sidebar';
|
|
14
|
+
import outJ from '@/.generated/output.json' with { type: 'json' };
|
|
15
|
+
import IntentLink from '@/components/IntentLink';
|
|
16
|
+
import {
|
|
17
|
+
Collapsible,
|
|
18
|
+
CollapsibleContent,
|
|
19
|
+
CollapsibleTrigger
|
|
20
|
+
} from '@/components/ui/collapsible';
|
|
21
|
+
|
|
22
|
+
const out = outJ as Out;
|
|
23
|
+
|
|
24
|
+
export default function Sidebar() {
|
|
25
|
+
const paths = useContext(pathsContext);
|
|
26
|
+
|
|
27
|
+
const nestedLinks = useMemo(() => {
|
|
28
|
+
const buildLinks = (
|
|
29
|
+
pairs: typeof paths,
|
|
30
|
+
prefix: string
|
|
31
|
+
): Array<JSX.Element | null> => {
|
|
32
|
+
return pairs.map(({label, children, isGrouper, pathSegment}, i) => {
|
|
33
|
+
if (prefix !== '/' && !label) return null;
|
|
34
|
+
|
|
35
|
+
return children ? isGrouper ? (
|
|
36
|
+
<div key={i} title={label} className="pl-2 mt-2 mb-0.5">
|
|
37
|
+
<span className="text-sm text-muted-foreground pr-2">{label}</span>
|
|
38
|
+
<div className="flex flex-col gap-0 pl-1">
|
|
39
|
+
{buildLinks(children, prefix)}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
) : (
|
|
43
|
+
<Collapsible key={i} className="my-0.5">
|
|
44
|
+
<CollapsibleTrigger className="flex justify-between w-full font-body">
|
|
45
|
+
<IntentLink to={prefix + '/' + pathSegment} className="px-2 py-0.5">
|
|
46
|
+
{label || 'Home'}
|
|
47
|
+
</IntentLink>
|
|
48
|
+
<ChevronsUpDown className="w-4 text-muted-foreground" />
|
|
49
|
+
</CollapsibleTrigger>
|
|
50
|
+
|
|
51
|
+
<CollapsibleContent style={{ paddingLeft: '0.75em' }}>
|
|
52
|
+
{buildLinks(children, prefix + '/' + pathSegment)}
|
|
53
|
+
</CollapsibleContent>
|
|
54
|
+
</Collapsible>
|
|
55
|
+
) : (
|
|
56
|
+
<IntentLink
|
|
57
|
+
key={i}
|
|
58
|
+
to={prefix + '/' + pathSegment}
|
|
59
|
+
className="block px-2 py-0.5 my-0.5 font-body"
|
|
60
|
+
>
|
|
61
|
+
{label || 'Home'}
|
|
62
|
+
</IntentLink>
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
return buildLinks(paths, out.baseRoute || "/");
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<Sb variant="inset">
|
|
72
|
+
<SidebarHeader>
|
|
73
|
+
{out.logo && (
|
|
74
|
+
<img src={out.logo} height="24px" alt={`${out.name} logo`} />
|
|
75
|
+
)}
|
|
76
|
+
{(out.showNameWithLogo || !out.logo) && out.name}
|
|
77
|
+
</SidebarHeader>
|
|
78
|
+
|
|
79
|
+
<SidebarContent>
|
|
80
|
+
<SidebarGroup>
|
|
81
|
+
<SidebarGroupContent>
|
|
82
|
+
{nestedLinks}
|
|
83
|
+
</SidebarGroupContent>
|
|
84
|
+
</SidebarGroup>
|
|
85
|
+
</SidebarContent>
|
|
86
|
+
</Sb>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Moon, Sun } from "lucide-react";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import output from "@/.generated/output.json" with { type: "json" };
|
|
7
|
+
|
|
8
|
+
export default function ThemeSwitch({ className }: { className?: string }) {
|
|
9
|
+
const switchTheme = () => {
|
|
10
|
+
if (document.body.classList.contains("dark")) {
|
|
11
|
+
document.body.classList.remove("dark");
|
|
12
|
+
localStorage.setItem("theme", "light");
|
|
13
|
+
} else {
|
|
14
|
+
document.body.classList.add("dark");
|
|
15
|
+
localStorage.setItem("theme", "dark");
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const initTheme =
|
|
21
|
+
localStorage.getItem("theme") ||
|
|
22
|
+
output.defaultTheme ||
|
|
23
|
+
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
24
|
+
|
|
25
|
+
if (initTheme === "dark") {
|
|
26
|
+
document.body.classList.add("dark");
|
|
27
|
+
} else {
|
|
28
|
+
document.body.classList.remove("dark");
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<Button
|
|
35
|
+
variant="outline"
|
|
36
|
+
size="icon"
|
|
37
|
+
className={cn("relative", className)}
|
|
38
|
+
onClick={switchTheme}
|
|
39
|
+
title={"Switch Theme"}
|
|
40
|
+
>
|
|
41
|
+
<Sun className="scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0 dark:hover:scale-75 dark:hover:rotate-90" />
|
|
42
|
+
<Moon className="absolute scale-100 rotate-0 transition-all hover:scale-100 hover:rotate-0 dark:scale-0 dark:rotate-90" />
|
|
43
|
+
</Button>
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
3
|
+
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import IntentLink from '../IntentLink';
|
|
6
|
+
import type { IntentLinkProps } from '../IntentLink';
|
|
7
|
+
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
|
11
|
+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
|
15
|
+
return (
|
|
16
|
+
<ol
|
|
17
|
+
data-slot="breadcrumb-list"
|
|
18
|
+
className={cn(
|
|
19
|
+
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5',
|
|
20
|
+
className
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
|
28
|
+
return (
|
|
29
|
+
<li
|
|
30
|
+
data-slot="breadcrumb-item"
|
|
31
|
+
className={cn('inline-flex items-center gap-1.5', className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function BreadcrumbLink({
|
|
38
|
+
asChild,
|
|
39
|
+
className,
|
|
40
|
+
...props
|
|
41
|
+
}: IntentLinkProps & {
|
|
42
|
+
asChild?: boolean;
|
|
43
|
+
className?: string;
|
|
44
|
+
}) {
|
|
45
|
+
if (asChild) {
|
|
46
|
+
const slotProps = props as React.ComponentProps<typeof Slot>;
|
|
47
|
+
return (
|
|
48
|
+
<Slot
|
|
49
|
+
data-slot="breadcrumb-link"
|
|
50
|
+
className={cn('hover:text-foreground transition-colors', className)}
|
|
51
|
+
{...slotProps}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<IntentLink
|
|
58
|
+
data-slot="breadcrumb-link"
|
|
59
|
+
className={cn('hover:text-foreground transition-colors', className)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
data-slot="breadcrumb-page"
|
|
69
|
+
role="link"
|
|
70
|
+
aria-disabled="true"
|
|
71
|
+
aria-current="page"
|
|
72
|
+
className={cn('text-foreground font-normal', className)}
|
|
73
|
+
{...props}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function BreadcrumbSeparator({
|
|
79
|
+
children,
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: React.ComponentProps<'li'>) {
|
|
83
|
+
return (
|
|
84
|
+
<li
|
|
85
|
+
data-slot="breadcrumb-separator"
|
|
86
|
+
role="presentation"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
className={cn('[&>svg]:size-3.5', className)}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
{children ?? <ChevronRight />}
|
|
92
|
+
</li>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function BreadcrumbEllipsis({
|
|
97
|
+
className,
|
|
98
|
+
...props
|
|
99
|
+
}: React.ComponentProps<'span'>) {
|
|
100
|
+
return (
|
|
101
|
+
<span
|
|
102
|
+
data-slot="breadcrumb-ellipsis"
|
|
103
|
+
role="presentation"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
className={cn('flex size-9 items-center justify-center', className)}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
<MoreHorizontal className="size-4" />
|
|
109
|
+
<span className="sr-only">More</span>
|
|
110
|
+
</span>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export {
|
|
115
|
+
Breadcrumb,
|
|
116
|
+
BreadcrumbList,
|
|
117
|
+
BreadcrumbItem,
|
|
118
|
+
BreadcrumbLink,
|
|
119
|
+
BreadcrumbPage,
|
|
120
|
+
BreadcrumbSeparator,
|
|
121
|
+
BreadcrumbEllipsis
|
|
122
|
+
};
|