@cyguin/docs 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/.github/workflows/publish.yml +27 -0
- package/LICENSE +7 -0
- package/README.md +152 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +438 -0
- package/dist/index.mjs +438 -0
- package/dist/next.d.mts +10 -0
- package/dist/next.d.ts +10 -0
- package/dist/next.js +53 -0
- package/dist/next.mjs +53 -0
- package/dist/styles.css +11 -0
- package/dist/types-0oOQLVHA.d.mts +52 -0
- package/dist/types-0oOQLVHA.d.ts +52 -0
- package/package.json +46 -0
- package/src/adapters/index.ts +1 -0
- package/src/adapters/sqlite.ts +95 -0
- package/src/app/admin/docs/[...route]/route.ts +31 -0
- package/src/app/api/docs/[...cyguin]/route.ts +10 -0
- package/src/components/DocsWidget.tsx +521 -0
- package/src/components/index.ts +2 -0
- package/src/handlers/admin.ts +89 -0
- package/src/handlers/route.ts +70 -0
- package/src/index.ts +5 -0
- package/src/next.ts +3 -0
- package/src/styles.css +11 -0
- package/src/types.ts +62 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
import type { DocArticle, DocsWidgetProps } from '../types.js';
|
|
6
|
+
|
|
7
|
+
marked.setOptions({ breaks: true, gfm: true });
|
|
8
|
+
|
|
9
|
+
function HelpIcon() {
|
|
10
|
+
return (
|
|
11
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
12
|
+
<circle cx="12" cy="12" r="10" />
|
|
13
|
+
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
14
|
+
<path d="M12 17h.01" />
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function SearchIcon() {
|
|
20
|
+
return (
|
|
21
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
22
|
+
<circle cx="11" cy="11" r="8" />
|
|
23
|
+
<path d="m21 21-4.35-4.35" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function CloseIcon() {
|
|
29
|
+
return (
|
|
30
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
31
|
+
<path d="M18 6 6 18" />
|
|
32
|
+
<path d="m6 6 12 12" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ChevronRightIcon({ expanded }: { expanded: boolean }) {
|
|
38
|
+
return (
|
|
39
|
+
<svg
|
|
40
|
+
width="14"
|
|
41
|
+
height="14"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
stroke="currentColor"
|
|
45
|
+
strokeWidth="2"
|
|
46
|
+
strokeLinecap="round"
|
|
47
|
+
strokeLinejoin="round"
|
|
48
|
+
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}
|
|
49
|
+
>
|
|
50
|
+
<path d="m9 18 6-6-6-6" />
|
|
51
|
+
</svg>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function BackIcon() {
|
|
56
|
+
return (
|
|
57
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
58
|
+
<path d="m15 18-6-6 6-6" />
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderMarkdown(md: string): string {
|
|
64
|
+
return marked.parse(md) as string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ArticleGroup {
|
|
68
|
+
section: string;
|
|
69
|
+
articles: DocArticle[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function groupBySection(articles: DocArticle[]): ArticleGroup[] {
|
|
73
|
+
const map = new Map<string, DocArticle[]>();
|
|
74
|
+
for (const article of articles) {
|
|
75
|
+
const list = map.get(article.section) ?? [];
|
|
76
|
+
list.push(article);
|
|
77
|
+
map.set(article.section, list);
|
|
78
|
+
}
|
|
79
|
+
return Array.from(map.entries())
|
|
80
|
+
.map(([section, arts]) => ({ section, articles: arts.sort((a, b) => a.article_order - b.article_order) }))
|
|
81
|
+
.sort((a, b) => a.section.localeCompare(b.section));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface DocsTriggerProps {
|
|
85
|
+
label: string;
|
|
86
|
+
onClick: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function DocsTrigger({ label, onClick }: DocsTriggerProps) {
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
onClick={onClick}
|
|
93
|
+
aria-label={label}
|
|
94
|
+
style={{
|
|
95
|
+
position: 'fixed',
|
|
96
|
+
bottom: '24px',
|
|
97
|
+
right: '24px',
|
|
98
|
+
zIndex: 9998,
|
|
99
|
+
width: '48px',
|
|
100
|
+
height: '48px',
|
|
101
|
+
borderRadius: 'var(--cyguin-docs-radius)',
|
|
102
|
+
background: 'var(--cyguin-docs-accent)',
|
|
103
|
+
color: 'var(--cyguin-accent-fg, #0a0a0a)',
|
|
104
|
+
border: 'none',
|
|
105
|
+
cursor: 'pointer',
|
|
106
|
+
display: 'flex',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
justifyContent: 'center',
|
|
109
|
+
boxShadow: 'var(--cyguin-docs-shadow)',
|
|
110
|
+
fontWeight: 600,
|
|
111
|
+
fontSize: '14px',
|
|
112
|
+
fontFamily: 'var(--cyguin-docs-font)',
|
|
113
|
+
transition: 'opacity 0.15s',
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<HelpIcon />
|
|
117
|
+
</button>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface DocsSearchProps {
|
|
122
|
+
value: string;
|
|
123
|
+
onChange: (value: string) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function DocsSearch({ value, onChange }: DocsSearchProps) {
|
|
127
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const handler = (e: KeyboardEvent) => {
|
|
131
|
+
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
132
|
+
const active = document.activeElement;
|
|
133
|
+
const widget = document.getElementById('cyguin-docs-widget');
|
|
134
|
+
if (active && widget?.contains(active)) return;
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
ref.current?.focus();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
document.addEventListener('keydown', handler);
|
|
140
|
+
return () => document.removeEventListener('keydown', handler);
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div style={{ position: 'relative', padding: '12px 12px 8px' }}>
|
|
145
|
+
<SearchIcon />
|
|
146
|
+
<input
|
|
147
|
+
ref={ref}
|
|
148
|
+
type="text"
|
|
149
|
+
value={value}
|
|
150
|
+
onChange={(e) => onChange(e.target.value)}
|
|
151
|
+
placeholder="Search docs... (press / to focus)"
|
|
152
|
+
aria-label="Search documentation"
|
|
153
|
+
style={{
|
|
154
|
+
width: '100%',
|
|
155
|
+
padding: '8px 8px 8px 32px',
|
|
156
|
+
borderRadius: 'var(--cyguin-docs-radius)',
|
|
157
|
+
border: '1px solid var(--cyguin-docs-border)',
|
|
158
|
+
background: 'var(--cyguin-bg-subtle, #f5f5f5)',
|
|
159
|
+
color: 'var(--cyguin-docs-text)',
|
|
160
|
+
fontSize: '14px',
|
|
161
|
+
fontFamily: 'var(--cyguin-docs-font)',
|
|
162
|
+
boxSizing: 'border-box',
|
|
163
|
+
outline: 'none',
|
|
164
|
+
}}
|
|
165
|
+
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--cyguin-border-focus, #f5a800)'; }}
|
|
166
|
+
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--cyguin-docs-border)'; }}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface DocsNavProps {
|
|
173
|
+
groups: ArticleGroup[];
|
|
174
|
+
selectedId: string | null;
|
|
175
|
+
selectedIndex: number;
|
|
176
|
+
onSelectArticle: (id: string, index: number) => void;
|
|
177
|
+
searchQuery: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function DocsNav({ groups, selectedId, selectedIndex, onSelectArticle, searchQuery }: DocsNavProps) {
|
|
181
|
+
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(groups.map((g) => g.section)));
|
|
182
|
+
const navRef = useRef<HTMLDivElement>(null);
|
|
183
|
+
const flatArticles = groups.flatMap((g) => g.articles);
|
|
184
|
+
const activeIndexRef = useRef(-1);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
activeIndexRef.current = selectedIndex;
|
|
188
|
+
}, [selectedIndex]);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const handler = (e: KeyboardEvent) => {
|
|
192
|
+
if (e.key === 'ArrowDown') {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
const next = Math.min(activeIndexRef.current + 1, flatArticles.length - 1);
|
|
195
|
+
onSelectArticle(flatArticles[next].id, next);
|
|
196
|
+
scrollToSelected(next);
|
|
197
|
+
} else if (e.key === 'ArrowUp') {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
const prev = Math.max(activeIndexRef.current - 1, 0);
|
|
200
|
+
onSelectArticle(flatArticles[prev].id, prev);
|
|
201
|
+
scrollToSelected(prev);
|
|
202
|
+
} else if (e.key === 'Enter' && selectedId) {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
const idx = flatArticles.findIndex((a) => a.id === selectedId);
|
|
205
|
+
if (idx !== -1) onSelectArticle(selectedId, idx);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const widget = document.getElementById('cyguin-docs-widget');
|
|
209
|
+
widget?.addEventListener('keydown', handler);
|
|
210
|
+
return () => widget?.removeEventListener('keydown', handler);
|
|
211
|
+
}, [flatArticles, selectedId, onSelectArticle]);
|
|
212
|
+
|
|
213
|
+
function scrollToSelected(index: number) {
|
|
214
|
+
const buttons = navRef.current?.querySelectorAll<HTMLButtonElement>('[data-article-btn]');
|
|
215
|
+
buttons?.[index]?.scrollIntoView({ block: 'nearest' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function toggleSection(section: string) {
|
|
219
|
+
setExpandedSections((prev) => {
|
|
220
|
+
const next = new Set(prev);
|
|
221
|
+
if (next.has(section)) next.delete(section);
|
|
222
|
+
else next.add(section);
|
|
223
|
+
return next;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let articleIdx = 0;
|
|
228
|
+
return (
|
|
229
|
+
<div ref={navRef} style={{ overflowY: 'auto', flex: 1, borderRight: '1px solid var(--cyguin-docs-border)' }}>
|
|
230
|
+
{groups.length === 0 && (
|
|
231
|
+
<p style={{ padding: '16px', color: 'var(--cyguin-docs-muted)', fontSize: '13px', textAlign: 'center', fontFamily: 'var(--cyguin-docs-font)' }}>
|
|
232
|
+
{searchQuery ? 'No results found' : 'No articles yet'}
|
|
233
|
+
</p>
|
|
234
|
+
)}
|
|
235
|
+
{groups.map((group) => {
|
|
236
|
+
const isExpanded = expandedSections.has(group.section);
|
|
237
|
+
return (
|
|
238
|
+
<div key={group.section}>
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => toggleSection(group.section)}
|
|
241
|
+
style={{
|
|
242
|
+
width: '100%',
|
|
243
|
+
padding: '8px 12px',
|
|
244
|
+
background: 'none',
|
|
245
|
+
border: 'none',
|
|
246
|
+
cursor: 'pointer',
|
|
247
|
+
display: 'flex',
|
|
248
|
+
alignItems: 'center',
|
|
249
|
+
gap: '6px',
|
|
250
|
+
color: 'var(--cyguin-docs-text)',
|
|
251
|
+
fontSize: '12px',
|
|
252
|
+
fontWeight: 700,
|
|
253
|
+
fontFamily: 'var(--cyguin-docs-font)',
|
|
254
|
+
textTransform: 'uppercase',
|
|
255
|
+
letterSpacing: '0.05em',
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<ChevronRightIcon expanded={isExpanded} />
|
|
259
|
+
{group.section}
|
|
260
|
+
</button>
|
|
261
|
+
{isExpanded &&
|
|
262
|
+
group.articles.map((article) => {
|
|
263
|
+
const idx = articleIdx++;
|
|
264
|
+
const isSelected = article.id === selectedId;
|
|
265
|
+
return (
|
|
266
|
+
<button
|
|
267
|
+
key={article.id}
|
|
268
|
+
data-article-btn
|
|
269
|
+
onClick={() => onSelectArticle(article.id, idx)}
|
|
270
|
+
style={{
|
|
271
|
+
width: '100%',
|
|
272
|
+
padding: '6px 12px 6px 28px',
|
|
273
|
+
background: isSelected ? 'var(--cyguin-docs-accent)' : 'none',
|
|
274
|
+
color: isSelected ? 'var(--cyguin-accent-fg, #0a0a0a)' : 'var(--cyguin-docs-text)',
|
|
275
|
+
border: 'none',
|
|
276
|
+
cursor: 'pointer',
|
|
277
|
+
textAlign: 'left',
|
|
278
|
+
fontSize: '13px',
|
|
279
|
+
fontFamily: 'var(--cyguin-docs-font)',
|
|
280
|
+
borderRadius: 'var(--cyguin-docs-radius)',
|
|
281
|
+
margin: '1px 6px',
|
|
282
|
+
whiteSpace: 'nowrap',
|
|
283
|
+
overflow: 'hidden',
|
|
284
|
+
textOverflow: 'ellipsis',
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
{article.title}
|
|
288
|
+
</button>
|
|
289
|
+
);
|
|
290
|
+
})}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
})}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface DocsArticleViewProps {
|
|
299
|
+
article: DocArticle | null;
|
|
300
|
+
onBack: () => void;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function DocsArticleView({ article, onBack }: DocsArticleViewProps) {
|
|
304
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (contentRef.current) {
|
|
308
|
+
contentRef.current.scrollTop = 0;
|
|
309
|
+
}
|
|
310
|
+
}, [article?.id]);
|
|
311
|
+
|
|
312
|
+
if (!article) {
|
|
313
|
+
return (
|
|
314
|
+
<div style={{ flex: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--cyguin-docs-muted)', fontSize: '14px', fontFamily: 'var(--cyguin-docs-font)' }}>
|
|
315
|
+
Select an article to read
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div style={{ flex: 2, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
322
|
+
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--cyguin-docs-border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
323
|
+
<button
|
|
324
|
+
onClick={onBack}
|
|
325
|
+
aria-label="Back to list"
|
|
326
|
+
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cyguin-docs-muted)', display: 'flex', alignItems: 'center', padding: '4px', borderRadius: 'var(--cyguin-docs-radius)' }}
|
|
327
|
+
>
|
|
328
|
+
<BackIcon />
|
|
329
|
+
</button>
|
|
330
|
+
<span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--cyguin-docs-text)', fontFamily: 'var(--cyguin-docs-font)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
331
|
+
{article.title}
|
|
332
|
+
</span>
|
|
333
|
+
</div>
|
|
334
|
+
<div
|
|
335
|
+
ref={contentRef}
|
|
336
|
+
style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', fontSize: '14px', lineHeight: 1.7, color: 'var(--cyguin-docs-text)', fontFamily: 'var(--cyguin-docs-font)' }}
|
|
337
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(article.body_md) }}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface DocsPanelProps {
|
|
344
|
+
mode: 'modal' | 'sidebar';
|
|
345
|
+
open: boolean;
|
|
346
|
+
onClose: () => void;
|
|
347
|
+
groups: ArticleGroup[];
|
|
348
|
+
selectedId: string | null;
|
|
349
|
+
selectedIndex: number;
|
|
350
|
+
searchQuery: string;
|
|
351
|
+
onSearchChange: (q: string) => void;
|
|
352
|
+
onSelectArticle: (id: string, index: number) => void;
|
|
353
|
+
onBack: () => void;
|
|
354
|
+
article: DocArticle | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function DocsPanel({ mode, open, onClose, groups, selectedId, selectedIndex, searchQuery, onSearchChange, onSelectArticle, onBack, article }: DocsPanelProps) {
|
|
358
|
+
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
|
|
359
|
+
|
|
360
|
+
const panelStyle: React.CSSProperties = {
|
|
361
|
+
position: 'fixed',
|
|
362
|
+
top: 0,
|
|
363
|
+
right: 0,
|
|
364
|
+
bottom: 0,
|
|
365
|
+
width: isMobile ? '100vw' : mode === 'sidebar' ? '420px' : '680px',
|
|
366
|
+
maxWidth: '100vw',
|
|
367
|
+
background: 'var(--cyguin-docs-bg)',
|
|
368
|
+
borderRadius: mode === 'sidebar' || isMobile ? 0 : 'var(--cyguin-docs-radius)',
|
|
369
|
+
boxShadow: mode === 'modal' ? 'var(--cyguin-docs-shadow)' : 'none',
|
|
370
|
+
border: mode === 'modal' && !isMobile ? '1px solid var(--cyguin-docs-border)' : 'none',
|
|
371
|
+
display: 'flex',
|
|
372
|
+
flexDirection: 'column',
|
|
373
|
+
zIndex: 9999,
|
|
374
|
+
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
|
375
|
+
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
376
|
+
fontFamily: 'var(--cyguin-docs-font)',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
if (!open) return null;
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
{mode === 'modal' && (
|
|
384
|
+
<div
|
|
385
|
+
onClick={onClose}
|
|
386
|
+
style={{
|
|
387
|
+
position: 'fixed',
|
|
388
|
+
inset: 0,
|
|
389
|
+
background: `rgba(0, 0, 0, ${parseFloat(String(0.5))})`,
|
|
390
|
+
zIndex: 9998,
|
|
391
|
+
}}
|
|
392
|
+
aria-hidden="true"
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
<div id="cyguin-docs-widget" role="dialog" aria-modal={mode === 'modal'} aria-label="Documentation" style={panelStyle}>
|
|
396
|
+
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 12px 8px', borderBottom: '1px solid var(--cyguin-docs-border)' }}>
|
|
397
|
+
<span style={{ flex: 1, fontWeight: 700, fontSize: '15px', color: 'var(--cyguin-docs-text)', fontFamily: 'var(--cyguin-docs-font)' }}>Help Center</span>
|
|
398
|
+
<button
|
|
399
|
+
onClick={onClose}
|
|
400
|
+
aria-label="Close"
|
|
401
|
+
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cyguin-docs-muted)', display: 'flex', alignItems: 'center', padding: '4px', borderRadius: 'var(--cyguin-docs-radius)' }}
|
|
402
|
+
>
|
|
403
|
+
<CloseIcon />
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
<DocsSearch value={searchQuery} onChange={onSearchChange} />
|
|
407
|
+
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
408
|
+
<div style={{ width: isMobile ? '100%' : '180px', display: 'flex', flexDirection: 'column' }}>
|
|
409
|
+
<DocsNav
|
|
410
|
+
groups={groups}
|
|
411
|
+
selectedId={selectedId}
|
|
412
|
+
selectedIndex={selectedIndex}
|
|
413
|
+
onSelectArticle={onSelectArticle}
|
|
414
|
+
searchQuery={searchQuery}
|
|
415
|
+
/>
|
|
416
|
+
</div>
|
|
417
|
+
{!isMobile && <DocsArticleView article={article} onBack={onBack} />}
|
|
418
|
+
{isMobile && article && <DocsArticleView article={article} onBack={onBack} />}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
</>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function DocsWidget({
|
|
426
|
+
apiUrl = '/api/docs',
|
|
427
|
+
mode = 'modal',
|
|
428
|
+
triggerLabel = 'Help',
|
|
429
|
+
defaultOpen = false,
|
|
430
|
+
className = '',
|
|
431
|
+
}: DocsWidgetProps) {
|
|
432
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
433
|
+
const [articles, setArticles] = useState<DocArticle[]>([]);
|
|
434
|
+
const [loading, setLoading] = useState(false);
|
|
435
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
436
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
437
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
438
|
+
const [selectedArticle, setSelectedArticle] = useState<DocArticle | null>(null);
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (open && articles.length === 0) {
|
|
442
|
+
setLoading(true);
|
|
443
|
+
fetch(apiUrl)
|
|
444
|
+
.then((r) => r.json())
|
|
445
|
+
.then((data) => {
|
|
446
|
+
const arts: DocArticle[] = Array.isArray(data) ? data : data.articles ?? data.data ?? [];
|
|
447
|
+
setArticles(arts);
|
|
448
|
+
})
|
|
449
|
+
.catch(() => setArticles([]))
|
|
450
|
+
.finally(() => setLoading(false));
|
|
451
|
+
}
|
|
452
|
+
}, [open, apiUrl, articles.length]);
|
|
453
|
+
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
if (selectedId) {
|
|
456
|
+
const art = articles.find((a) => a.id === selectedId);
|
|
457
|
+
setSelectedArticle(art ?? null);
|
|
458
|
+
} else {
|
|
459
|
+
setSelectedArticle(null);
|
|
460
|
+
}
|
|
461
|
+
}, [selectedId, articles]);
|
|
462
|
+
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
if (!open) return;
|
|
465
|
+
const handler = (e: KeyboardEvent) => {
|
|
466
|
+
if (e.key === 'Escape') setOpen(false);
|
|
467
|
+
};
|
|
468
|
+
document.addEventListener('keydown', handler);
|
|
469
|
+
return () => document.removeEventListener('keydown', handler);
|
|
470
|
+
}, [open]);
|
|
471
|
+
|
|
472
|
+
const handleSearchChange = useCallback((q: string) => {
|
|
473
|
+
setSearchQuery(q);
|
|
474
|
+
setSelectedId(null);
|
|
475
|
+
setSelectedIndex(0);
|
|
476
|
+
setSelectedArticle(null);
|
|
477
|
+
}, []);
|
|
478
|
+
|
|
479
|
+
const handleSelectArticle = useCallback((id: string, index: number) => {
|
|
480
|
+
setSelectedId(id);
|
|
481
|
+
setSelectedIndex(index);
|
|
482
|
+
const art = articles.find((a) => a.id === id);
|
|
483
|
+
setSelectedArticle(art ?? null);
|
|
484
|
+
}, [articles]);
|
|
485
|
+
|
|
486
|
+
const handleBack = useCallback(() => {
|
|
487
|
+
setSelectedId(null);
|
|
488
|
+
setSelectedArticle(null);
|
|
489
|
+
}, []);
|
|
490
|
+
|
|
491
|
+
const filteredArticles = searchQuery.trim()
|
|
492
|
+
? articles.filter(
|
|
493
|
+
(a) =>
|
|
494
|
+
a.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
495
|
+
a.body_md.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
496
|
+
)
|
|
497
|
+
: articles;
|
|
498
|
+
|
|
499
|
+
const groups = groupBySection(filteredArticles);
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<>
|
|
503
|
+
<div className={className} style={{ fontFamily: 'var(--cyguin-docs-font)' }}>
|
|
504
|
+
<DocsTrigger label={triggerLabel} onClick={() => setOpen(true)} />
|
|
505
|
+
<DocsPanel
|
|
506
|
+
mode={mode}
|
|
507
|
+
open={open}
|
|
508
|
+
onClose={() => setOpen(false)}
|
|
509
|
+
groups={groups}
|
|
510
|
+
selectedId={selectedId}
|
|
511
|
+
selectedIndex={selectedIndex}
|
|
512
|
+
searchQuery={searchQuery}
|
|
513
|
+
onSearchChange={handleSearchChange}
|
|
514
|
+
onSelectArticle={handleSelectArticle}
|
|
515
|
+
onBack={handleBack}
|
|
516
|
+
article={selectedArticle}
|
|
517
|
+
/>
|
|
518
|
+
</div>
|
|
519
|
+
</>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { DocsAdapter, DocsHandlerOptions, NextHandler } from '../types'
|
|
2
|
+
|
|
3
|
+
let _adapter: DocsAdapter | null = null
|
|
4
|
+
let _secret: string | undefined = undefined
|
|
5
|
+
|
|
6
|
+
export function setAdminAdapter(adapter: DocsAdapter) {
|
|
7
|
+
_adapter = adapter
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setAdminSecret(secret: string) {
|
|
11
|
+
_secret = secret
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getAdminAdapter(): DocsAdapter {
|
|
15
|
+
if (!_adapter) {
|
|
16
|
+
throw new Error('Admin adapter not set. Call setAdminAdapter() first.')
|
|
17
|
+
}
|
|
18
|
+
return _adapter
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createAdminHandler(options?: DocsHandlerOptions) {
|
|
22
|
+
const adapter = options?.adapter ?? getAdminAdapter()
|
|
23
|
+
const secret = options?.secret ?? _secret
|
|
24
|
+
|
|
25
|
+
return async function handler(
|
|
26
|
+
req: Request,
|
|
27
|
+
context?: { params?: Record<string, string | string[]> }
|
|
28
|
+
): Promise<Response> {
|
|
29
|
+
if (secret) {
|
|
30
|
+
const authHeader = req.headers.get('Authorization')
|
|
31
|
+
const expected = `Bearer ${secret}`
|
|
32
|
+
if (authHeader !== expected) {
|
|
33
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pathParts = (context?.params?.route ?? []) as string[]
|
|
38
|
+
const method = req.method
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (pathParts.length === 0 && method === 'POST') {
|
|
42
|
+
const body = await req.json()
|
|
43
|
+
const article = await adapter.create({
|
|
44
|
+
title: body.title,
|
|
45
|
+
body_md: body.body_md,
|
|
46
|
+
section: body.section,
|
|
47
|
+
article_order: body.article_order,
|
|
48
|
+
published_at: body.published_at ?? null,
|
|
49
|
+
})
|
|
50
|
+
return Response.json(article, { status: 201 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (pathParts.length === 1 && method === 'PUT') {
|
|
54
|
+
const [id] = pathParts
|
|
55
|
+
const body = await req.json()
|
|
56
|
+
const article = await adapter.update(id, body)
|
|
57
|
+
return Response.json(article)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (pathParts.length === 2) {
|
|
61
|
+
const [id, action] = pathParts
|
|
62
|
+
|
|
63
|
+
if (action === 'order' && method === 'PATCH') {
|
|
64
|
+
const body = await req.json()
|
|
65
|
+
await adapter.reorder(id, body.article_order)
|
|
66
|
+
return Response.json({ success: true })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (action === 'section' && method === 'PATCH') {
|
|
70
|
+
const body = await req.json()
|
|
71
|
+
await adapter.moveSection(id, body.section)
|
|
72
|
+
return Response.json({ success: true })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pathParts.length === 1 && method === 'DELETE') {
|
|
77
|
+
const [id] = pathParts
|
|
78
|
+
await adapter.delete(id)
|
|
79
|
+
return Response.json({ success: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error('Admin handler error:', err)
|
|
85
|
+
const message = err instanceof Error ? err.message : 'Internal server error'
|
|
86
|
+
return Response.json({ error: message }, { status: 500 })
|
|
87
|
+
}
|
|
88
|
+
} as NextHandler
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DocsAdapter } from '../types'
|
|
2
|
+
|
|
3
|
+
let _adapter: DocsAdapter | null = null
|
|
4
|
+
|
|
5
|
+
export function setDocsAdapter(adapter: DocsAdapter) {
|
|
6
|
+
_adapter = adapter
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getDocsAdapter(): DocsAdapter {
|
|
10
|
+
if (!_adapter) {
|
|
11
|
+
throw new Error('Docs adapter not set. Call setDocsAdapter() first.')
|
|
12
|
+
}
|
|
13
|
+
return _adapter
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function slugify(title: string): string {
|
|
17
|
+
return title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function kebabToTitle(kebab: string): string {
|
|
21
|
+
return kebab.replace(/-/g, ' ')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createDocsHandler(options: { adapter?: DocsAdapter }) {
|
|
25
|
+
const adapter = options?.adapter ?? getDocsAdapter()
|
|
26
|
+
|
|
27
|
+
return async function handler(
|
|
28
|
+
req: Request,
|
|
29
|
+
context?: { params?: Record<string, string | string[]> }
|
|
30
|
+
): Promise<Response> {
|
|
31
|
+
const segments = (context?.params?.cyguin ?? []) as string[]
|
|
32
|
+
|
|
33
|
+
if (req.method !== 'GET') {
|
|
34
|
+
return Response.json({ error: 'Method not allowed' }, { status: 405 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (segments.length === 0) {
|
|
39
|
+
const articles = await adapter.list({ published: true })
|
|
40
|
+
return Response.json(articles)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (segments.length === 1) {
|
|
44
|
+
const [section] = segments
|
|
45
|
+
const articles = await adapter.list({ section, published: true })
|
|
46
|
+
return Response.json(articles)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (segments.length === 2) {
|
|
50
|
+
const [section, slug] = segments
|
|
51
|
+
const titleQuery = kebabToTitle(slug)
|
|
52
|
+
const articles = await adapter.list({ section, published: true })
|
|
53
|
+
const article = articles.find(
|
|
54
|
+
(a) => slugify(a.title) === slug || a.title.toLowerCase() === titleQuery.toLowerCase()
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (!article) {
|
|
58
|
+
return Response.json({ error: 'Article not found' }, { status: 404 })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Response.json(article)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('Docs handler error:', err)
|
|
67
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/index.ts
ADDED
package/src/next.ts
ADDED