@marlinjai/clearify 1.11.2 → 1.13.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.
@@ -0,0 +1,782 @@
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import type { SectionConfig } from '../../types/index.js';
3
+ import { Modal as SharedModal } from './components/Modal.js';
4
+ import { ConfigPreview } from './components/ConfigPreview.js';
5
+
6
+ /* ------------------------------------------------------------------ */
7
+ /* Types */
8
+ /* ------------------------------------------------------------------ */
9
+
10
+ interface Toast {
11
+ message: string;
12
+ type: 'success' | 'error';
13
+ id: number;
14
+ }
15
+
16
+ /* ------------------------------------------------------------------ */
17
+ /* Helpers */
18
+ /* ------------------------------------------------------------------ */
19
+
20
+ function slugify(text: string): string {
21
+ return text
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, '-')
24
+ .replace(/(^-|-$)/g, '');
25
+ }
26
+
27
+ const API_DATA = '/__clearify/api/config/data';
28
+ const API_DIRS = '/__clearify/api/fs/dirs';
29
+
30
+ async function fetchSections(): Promise<SectionConfig[]> {
31
+ const res = await fetch(API_DATA);
32
+ const data = await res.json();
33
+ return data.sections ?? [];
34
+ }
35
+
36
+ async function saveSections(sections: SectionConfig[]): Promise<void> {
37
+ const res = await fetch(API_DATA, {
38
+ method: 'PATCH',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ sections }),
41
+ });
42
+ if (!res.ok) {
43
+ const err = await res.json().catch(() => ({ error: 'Unknown error' }));
44
+ throw new Error(err.error ?? 'Save failed');
45
+ }
46
+ }
47
+
48
+ async function fetchDirs(root: string): Promise<string[]> {
49
+ const res = await fetch(`${API_DIRS}?root=${encodeURIComponent(root)}`);
50
+ const data = await res.json();
51
+ return data.dirs ?? [];
52
+ }
53
+
54
+ /* ------------------------------------------------------------------ */
55
+ /* Shared styles */
56
+ /* ------------------------------------------------------------------ */
57
+
58
+ const btnBase: React.CSSProperties = {
59
+ display: 'inline-flex',
60
+ alignItems: 'center',
61
+ justifyContent: 'center',
62
+ border: 'none',
63
+ cursor: 'pointer',
64
+ fontFamily: 'inherit',
65
+ transition: 'background-color 0.15s, color 0.15s, opacity 0.15s',
66
+ };
67
+
68
+ const btnPrimary: React.CSSProperties = {
69
+ ...btnBase,
70
+ padding: '0.5rem 1rem',
71
+ borderRadius: 'var(--clearify-radius-sm)',
72
+ fontSize: '0.8125rem',
73
+ fontWeight: 600,
74
+ color: '#fff',
75
+ background: 'var(--clearify-primary)',
76
+ };
77
+
78
+ const btnSecondary: React.CSSProperties = {
79
+ ...btnBase,
80
+ padding: '0.5rem 1rem',
81
+ borderRadius: 'var(--clearify-radius-sm)',
82
+ fontSize: '0.8125rem',
83
+ fontWeight: 500,
84
+ color: 'var(--clearify-text)',
85
+ background: 'var(--clearify-bg-secondary)',
86
+ };
87
+
88
+ const btnDanger: React.CSSProperties = {
89
+ ...btnBase,
90
+ padding: '0.5rem 1rem',
91
+ borderRadius: 'var(--clearify-radius-sm)',
92
+ fontSize: '0.8125rem',
93
+ fontWeight: 500,
94
+ color: '#ef4444',
95
+ background: 'transparent',
96
+ };
97
+
98
+ const btnArrow: React.CSSProperties = {
99
+ ...btnBase,
100
+ width: 24,
101
+ height: 24,
102
+ borderRadius: 'var(--clearify-radius-sm)',
103
+ background: 'var(--clearify-bg-secondary)',
104
+ color: 'var(--clearify-text-secondary)',
105
+ fontSize: '0.75rem',
106
+ padding: 0,
107
+ };
108
+
109
+ const inputStyle: React.CSSProperties = {
110
+ width: '100%',
111
+ padding: '0.5rem 0.75rem',
112
+ borderRadius: 'var(--clearify-radius-sm)',
113
+ border: '1px solid var(--clearify-border)',
114
+ backgroundColor: 'var(--clearify-bg)',
115
+ color: 'var(--clearify-text)',
116
+ fontSize: '0.875rem',
117
+ fontFamily: 'inherit',
118
+ outline: 'none',
119
+ transition: 'border-color 0.15s',
120
+ };
121
+
122
+ const labelStyle: React.CSSProperties = {
123
+ display: 'block',
124
+ fontSize: '0.75rem',
125
+ fontWeight: 600,
126
+ color: 'var(--clearify-text-secondary)',
127
+ marginBottom: '0.375rem',
128
+ textTransform: 'uppercase',
129
+ letterSpacing: '0.04em',
130
+ };
131
+
132
+ const overlayStyle: React.CSSProperties = {
133
+ position: 'fixed',
134
+ inset: 0,
135
+ backgroundColor: 'rgba(0,0,0,0.45)',
136
+ display: 'flex',
137
+ alignItems: 'center',
138
+ justifyContent: 'center',
139
+ zIndex: 100,
140
+ };
141
+
142
+ const modalStyle: React.CSSProperties = {
143
+ backgroundColor: 'var(--clearify-surface)',
144
+ borderRadius: 'var(--clearify-radius)',
145
+ border: '1px solid var(--clearify-border)',
146
+ padding: '1.5rem',
147
+ width: '100%',
148
+ maxWidth: 480,
149
+ maxHeight: '80vh',
150
+ overflow: 'auto',
151
+ };
152
+
153
+ /* ------------------------------------------------------------------ */
154
+ /* Directory Picker */
155
+ /* ------------------------------------------------------------------ */
156
+
157
+ function DirPicker({
158
+ value,
159
+ onChange,
160
+ }: {
161
+ value: string;
162
+ onChange: (dir: string) => void;
163
+ }) {
164
+ const [open, setOpen] = useState(false);
165
+ const [dirs, setDirs] = useState<string[]>([]);
166
+ const [currentRoot, setCurrentRoot] = useState('.');
167
+ const [loading, setLoading] = useState(false);
168
+
169
+ const load = useCallback(async (root: string) => {
170
+ setLoading(true);
171
+ try {
172
+ const d = await fetchDirs(root);
173
+ setDirs(d.filter((name) => !name.startsWith('.')));
174
+ setCurrentRoot(root);
175
+ } catch {
176
+ setDirs([]);
177
+ } finally {
178
+ setLoading(false);
179
+ }
180
+ }, []);
181
+
182
+ useEffect(() => {
183
+ if (open) load('.');
184
+ }, [open, load]);
185
+
186
+ return (
187
+ <div style={{ position: 'relative' }}>
188
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
189
+ <input
190
+ style={inputStyle}
191
+ value={value}
192
+ onChange={(e) => onChange(e.target.value)}
193
+ placeholder="docs"
194
+ />
195
+ <button
196
+ type="button"
197
+ style={btnSecondary}
198
+ onClick={() => setOpen(!open)}
199
+ >
200
+ Browse
201
+ </button>
202
+ </div>
203
+
204
+ {open && (
205
+ <div
206
+ style={{
207
+ position: 'absolute',
208
+ top: '100%',
209
+ left: 0,
210
+ right: 0,
211
+ marginTop: 4,
212
+ backgroundColor: 'var(--clearify-surface)',
213
+ border: '1px solid var(--clearify-border)',
214
+ borderRadius: 'var(--clearify-radius-sm)',
215
+ maxHeight: 200,
216
+ overflow: 'auto',
217
+ zIndex: 20,
218
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
219
+ }}
220
+ >
221
+ {currentRoot !== '.' && (
222
+ <button
223
+ type="button"
224
+ style={{
225
+ ...btnBase,
226
+ width: '100%',
227
+ padding: '0.5rem 0.75rem',
228
+ fontSize: '0.8125rem',
229
+ color: 'var(--clearify-text-secondary)',
230
+ background: 'none',
231
+ textAlign: 'left',
232
+ justifyContent: 'flex-start',
233
+ gap: '0.375rem',
234
+ }}
235
+ onClick={() => {
236
+ const parent = currentRoot.split('/').slice(0, -1).join('/') || '.';
237
+ load(parent);
238
+ }}
239
+ >
240
+ {'← Back'}
241
+ </button>
242
+ )}
243
+ {loading && (
244
+ <div style={{ padding: '0.5rem 0.75rem', fontSize: '0.8125rem', color: 'var(--clearify-text-secondary)' }}>
245
+ Loading...
246
+ </div>
247
+ )}
248
+ {!loading && dirs.length === 0 && (
249
+ <div style={{ padding: '0.5rem 0.75rem', fontSize: '0.8125rem', color: 'var(--clearify-text-tertiary)' }}>
250
+ No subdirectories
251
+ </div>
252
+ )}
253
+ {dirs.map((dir) => {
254
+ const fullPath = currentRoot === '.' ? dir : `${currentRoot}/${dir}`;
255
+ return (
256
+ <div key={dir} style={{ display: 'flex', alignItems: 'center' }}>
257
+ <button
258
+ type="button"
259
+ style={{
260
+ ...btnBase,
261
+ flex: 1,
262
+ padding: '0.5rem 0.75rem',
263
+ fontSize: '0.8125rem',
264
+ color: 'var(--clearify-text)',
265
+ background: 'none',
266
+ textAlign: 'left',
267
+ justifyContent: 'flex-start',
268
+ fontFamily: 'var(--clearify-font-mono, monospace)',
269
+ }}
270
+ onClick={() => {
271
+ onChange(fullPath);
272
+ setOpen(false);
273
+ }}
274
+ onMouseEnter={(e) => {
275
+ e.currentTarget.style.backgroundColor = 'var(--clearify-bg-secondary)';
276
+ }}
277
+ onMouseLeave={(e) => {
278
+ e.currentTarget.style.backgroundColor = 'transparent';
279
+ }}
280
+ >
281
+ {dir}
282
+ </button>
283
+ <button
284
+ type="button"
285
+ style={{
286
+ ...btnBase,
287
+ padding: '0.5rem',
288
+ fontSize: '0.75rem',
289
+ color: 'var(--clearify-text-tertiary)',
290
+ background: 'none',
291
+ }}
292
+ title="Browse subdirectories"
293
+ onClick={() => load(fullPath)}
294
+ onMouseEnter={(e) => {
295
+ e.currentTarget.style.color = 'var(--clearify-text)';
296
+ }}
297
+ onMouseLeave={(e) => {
298
+ e.currentTarget.style.color = 'var(--clearify-text-tertiary)';
299
+ }}
300
+ >
301
+ {'→'}
302
+ </button>
303
+ </div>
304
+ );
305
+ })}
306
+ </div>
307
+ )}
308
+ </div>
309
+ );
310
+ }
311
+
312
+ /* ------------------------------------------------------------------ */
313
+ /* Section Form Modal */
314
+ /* ------------------------------------------------------------------ */
315
+
316
+ function SectionFormModal({
317
+ section,
318
+ onSave,
319
+ onClose,
320
+ }: {
321
+ section: SectionConfig | null;
322
+ onSave: (s: SectionConfig) => void;
323
+ onClose: () => void;
324
+ }) {
325
+ const isEdit = section !== null;
326
+ const [label, setLabel] = useState(section?.label ?? '');
327
+ const [docsDir, setDocsDir] = useState(section?.docsDir ?? '');
328
+ const [basePath, setBasePath] = useState(section?.basePath ?? '');
329
+ const [draft, setDraft] = useState(section?.draft ?? false);
330
+
331
+ const autoBasePath = basePath === '' ? `/${slugify(label)}` : basePath;
332
+
333
+ function handleSubmit(e: React.FormEvent) {
334
+ e.preventDefault();
335
+ if (!label.trim() || !docsDir.trim()) return;
336
+ const result: SectionConfig = {
337
+ label: label.trim(),
338
+ docsDir: docsDir.trim(),
339
+ };
340
+ const bp = basePath.trim();
341
+ if (bp) result.basePath = bp;
342
+ if (draft) result.draft = true;
343
+ onSave(result);
344
+ }
345
+
346
+ return (
347
+ <div style={overlayStyle} onClick={onClose}>
348
+ <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
349
+ <h3 style={{ fontSize: '1.125rem', fontWeight: 700, color: 'var(--clearify-text)', margin: '0 0 1.25rem' }}>
350
+ {isEdit ? 'Edit Section' : 'Add Section'}
351
+ </h3>
352
+ <form onSubmit={handleSubmit}>
353
+ <div style={{ marginBottom: '1rem' }}>
354
+ <label style={labelStyle}>Label *</label>
355
+ <input
356
+ style={inputStyle}
357
+ value={label}
358
+ onChange={(e) => setLabel(e.target.value)}
359
+ placeholder="Getting Started"
360
+ required
361
+ autoFocus
362
+ />
363
+ </div>
364
+
365
+ <div style={{ marginBottom: '1rem' }}>
366
+ <label style={labelStyle}>Docs Directory *</label>
367
+ <DirPicker value={docsDir} onChange={setDocsDir} />
368
+ </div>
369
+
370
+ <div style={{ marginBottom: '1rem' }}>
371
+ <label style={labelStyle}>Base Path</label>
372
+ <input
373
+ style={inputStyle}
374
+ value={basePath}
375
+ onChange={(e) => setBasePath(e.target.value)}
376
+ placeholder={autoBasePath || '/section-path'}
377
+ />
378
+ {label && !basePath && (
379
+ <div style={{ fontSize: '0.75rem', color: 'var(--clearify-text-tertiary)', marginTop: '0.25rem' }}>
380
+ Auto-generated: {autoBasePath}
381
+ </div>
382
+ )}
383
+ </div>
384
+
385
+ <div style={{ marginBottom: '1.5rem' }}>
386
+ <label
387
+ style={{
388
+ display: 'flex',
389
+ alignItems: 'center',
390
+ gap: '0.5rem',
391
+ cursor: 'pointer',
392
+ fontSize: '0.875rem',
393
+ color: 'var(--clearify-text)',
394
+ }}
395
+ >
396
+ <input
397
+ type="checkbox"
398
+ checked={draft}
399
+ onChange={(e) => setDraft(e.target.checked)}
400
+ style={{ accentColor: 'var(--clearify-primary)' }}
401
+ />
402
+ Draft (hidden from public navigation)
403
+ </label>
404
+ </div>
405
+
406
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
407
+ <button type="button" style={btnSecondary} onClick={onClose}>
408
+ Cancel
409
+ </button>
410
+ <button type="submit" style={{ ...btnPrimary, opacity: (!label.trim() || !docsDir.trim()) ? 0.5 : 1 }}>
411
+ {isEdit ? 'Save Changes' : 'Add Section'}
412
+ </button>
413
+ </div>
414
+ </form>
415
+ </div>
416
+ </div>
417
+ );
418
+ }
419
+
420
+ /* ------------------------------------------------------------------ */
421
+ /* Delete Confirmation Modal */
422
+ /* ------------------------------------------------------------------ */
423
+
424
+ function DeleteConfirmModal({
425
+ sectionLabel,
426
+ onConfirm,
427
+ onClose,
428
+ }: {
429
+ sectionLabel: string;
430
+ onConfirm: () => void;
431
+ onClose: () => void;
432
+ }) {
433
+ return (
434
+ <div style={overlayStyle} onClick={onClose}>
435
+ <div style={{ ...modalStyle, maxWidth: 380 }} onClick={(e) => e.stopPropagation()}>
436
+ <h3 style={{ fontSize: '1.125rem', fontWeight: 700, color: 'var(--clearify-text)', margin: '0 0 0.75rem' }}>
437
+ Delete Section
438
+ </h3>
439
+ <p style={{ fontSize: '0.875rem', color: 'var(--clearify-text-secondary)', margin: '0 0 1.25rem', lineHeight: 1.5 }}>
440
+ Are you sure you want to delete <strong style={{ color: 'var(--clearify-text)' }}>{sectionLabel}</strong>?
441
+ This will remove the section from the configuration but won't delete any files.
442
+ </p>
443
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
444
+ <button style={btnSecondary} onClick={onClose}>Cancel</button>
445
+ <button
446
+ style={{ ...btnPrimary, background: '#ef4444' }}
447
+ onClick={onConfirm}
448
+ >
449
+ Delete
450
+ </button>
451
+ </div>
452
+ </div>
453
+ </div>
454
+ );
455
+ }
456
+
457
+ /* ------------------------------------------------------------------ */
458
+ /* Toast */
459
+ /* ------------------------------------------------------------------ */
460
+
461
+ function ToastContainer({ toasts }: { toasts: Toast[] }) {
462
+ return (
463
+ <div style={{ position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 200, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
464
+ {toasts.map((t) => (
465
+ <div
466
+ key={t.id}
467
+ style={{
468
+ padding: '0.625rem 1rem',
469
+ borderRadius: 'var(--clearify-radius-sm)',
470
+ fontSize: '0.8125rem',
471
+ fontWeight: 500,
472
+ color: '#fff',
473
+ backgroundColor: t.type === 'success' ? '#22c55e' : '#ef4444',
474
+ boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
475
+ }}
476
+ >
477
+ {t.message}
478
+ </div>
479
+ ))}
480
+ </div>
481
+ );
482
+ }
483
+
484
+ /* ------------------------------------------------------------------ */
485
+ /* Main Component */
486
+ /* ------------------------------------------------------------------ */
487
+
488
+ export function SectionsManager() {
489
+ const [sections, setSections] = useState<SectionConfig[]>([]);
490
+ const [loading, setLoading] = useState(true);
491
+ const [saving, setSaving] = useState(false);
492
+ const [toasts, setToasts] = useState<Toast[]>([]);
493
+
494
+ // Modal state
495
+ const [showForm, setShowForm] = useState(false);
496
+ const [editIndex, setEditIndex] = useState<number | null>(null);
497
+ const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
498
+
499
+ // Config preview state
500
+ const [preview, setPreview] = useState<{ before: object; after: object; updatedSections: SectionConfig[] } | null>(null);
501
+
502
+ let toastId = 0;
503
+ const addToast = useCallback((message: string, type: 'success' | 'error') => {
504
+ const id = ++toastId;
505
+ setToasts((prev) => [...prev, { message, type, id }]);
506
+ setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
507
+ }, []);
508
+
509
+ const load = useCallback(async () => {
510
+ try {
511
+ const s = await fetchSections();
512
+ setSections(s);
513
+ } catch (err: any) {
514
+ addToast(err.message ?? 'Failed to load sections', 'error');
515
+ } finally {
516
+ setLoading(false);
517
+ }
518
+ }, [addToast]);
519
+
520
+ useEffect(() => {
521
+ load();
522
+ }, [load]);
523
+
524
+ const doPersist = useCallback(async (updated: SectionConfig[]) => {
525
+ setSaving(true);
526
+ try {
527
+ await saveSections(updated);
528
+ setSections(updated);
529
+ addToast('Sections saved', 'success');
530
+ setPreview(null);
531
+ } catch (err: any) {
532
+ addToast(err.message ?? 'Failed to save', 'error');
533
+ } finally {
534
+ setSaving(false);
535
+ }
536
+ }, [addToast]);
537
+
538
+ const persist = useCallback(async (updated: SectionConfig[]) => {
539
+ try {
540
+ const res = await fetch(API_DATA);
541
+ const currentData = await res.json();
542
+ const before = { sections: currentData?.sections ?? [] };
543
+ const after = { sections: updated };
544
+ setPreview({ before, after, updatedSections: updated });
545
+ } catch {
546
+ doPersist(updated);
547
+ }
548
+ }, [doPersist]);
549
+
550
+ /* Reorder */
551
+ const move = useCallback((index: number, direction: -1 | 1) => {
552
+ const target = index + direction;
553
+ if (target < 0 || target >= sections.length) return;
554
+ const updated = [...sections];
555
+ [updated[index], updated[target]] = [updated[target], updated[index]];
556
+ persist(updated);
557
+ }, [sections, persist]);
558
+
559
+ /* Add / Edit */
560
+ const handleSave = useCallback((s: SectionConfig) => {
561
+ let updated: SectionConfig[];
562
+ if (editIndex !== null) {
563
+ updated = sections.map((existing, i) => (i === editIndex ? s : existing));
564
+ } else {
565
+ updated = [...sections, s];
566
+ }
567
+ persist(updated);
568
+ setShowForm(false);
569
+ setEditIndex(null);
570
+ }, [sections, editIndex, persist]);
571
+
572
+ /* Delete */
573
+ const handleDelete = useCallback(() => {
574
+ if (deleteIndex === null) return;
575
+ const updated = sections.filter((_, i) => i !== deleteIndex);
576
+ persist(updated);
577
+ setDeleteIndex(null);
578
+ }, [sections, deleteIndex, persist]);
579
+
580
+ if (loading) {
581
+ return (
582
+ <div style={{ padding: '2rem' }}>
583
+ <h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--clearify-text)' }}>Sections</h2>
584
+ <p style={{ color: 'var(--clearify-text-secondary)', marginTop: '0.5rem' }}>Loading...</p>
585
+ </div>
586
+ );
587
+ }
588
+
589
+ return (
590
+ <div style={{ padding: '2rem', maxWidth: 860 }}>
591
+ {/* Header */}
592
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
593
+ <h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--clearify-text)', margin: 0 }}>
594
+ Sections
595
+ </h2>
596
+ <button
597
+ style={btnPrimary}
598
+ onClick={() => { setEditIndex(null); setShowForm(true); }}
599
+ disabled={saving}
600
+ >
601
+ + Add Section
602
+ </button>
603
+ </div>
604
+
605
+ <p style={{ fontSize: '0.8125rem', color: 'var(--clearify-text-tertiary)', margin: '0 0 1.5rem' }}>
606
+ First section serves as the home page. Drag order determines navigation priority.
607
+ </p>
608
+
609
+ {/* Section list */}
610
+ {sections.length === 0 ? (
611
+ <div
612
+ style={{
613
+ padding: '2.5rem',
614
+ textAlign: 'center',
615
+ borderRadius: 'var(--clearify-radius)',
616
+ border: '1px dashed var(--clearify-border)',
617
+ color: 'var(--clearify-text-tertiary)',
618
+ fontSize: '0.875rem',
619
+ }}
620
+ >
621
+ No sections configured yet. Click "Add Section" to get started.
622
+ </div>
623
+ ) : (
624
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
625
+ {sections.map((section, index) => {
626
+ const isDraft = !!section.draft;
627
+ const displayBasePath = section.basePath ?? `/${slugify(section.label)}`;
628
+ const isFirst = index === 0;
629
+
630
+ return (
631
+ <div
632
+ key={index}
633
+ style={{
634
+ display: 'flex',
635
+ alignItems: 'center',
636
+ gap: '0.75rem',
637
+ padding: '0.75rem 1rem',
638
+ borderRadius: 'var(--clearify-radius-sm)',
639
+ border: '1px solid var(--clearify-border)',
640
+ backgroundColor: 'var(--clearify-surface)',
641
+ opacity: isDraft ? 0.6 : 1,
642
+ transition: 'opacity 0.15s',
643
+ }}
644
+ >
645
+ {/* Reorder arrows */}
646
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
647
+ <button
648
+ style={{ ...btnArrow, opacity: index === 0 ? 0.3 : 1 }}
649
+ onClick={() => move(index, -1)}
650
+ disabled={index === 0 || saving}
651
+ title="Move up"
652
+ >
653
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
654
+ <polyline points="18 15 12 9 6 15" />
655
+ </svg>
656
+ </button>
657
+ <button
658
+ style={{ ...btnArrow, opacity: index === sections.length - 1 ? 0.3 : 1 }}
659
+ onClick={() => move(index, 1)}
660
+ disabled={index === sections.length - 1 || saving}
661
+ title="Move down"
662
+ >
663
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
664
+ <polyline points="6 9 12 15 18 9" />
665
+ </svg>
666
+ </button>
667
+ </div>
668
+
669
+ {/* Section info */}
670
+ <div style={{ flex: 1, minWidth: 0 }}>
671
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
672
+ <span style={{ fontWeight: 600, fontSize: '0.9375rem', color: 'var(--clearify-text)' }}>
673
+ {section.label}
674
+ </span>
675
+ {isFirst && (
676
+ <span
677
+ style={{
678
+ fontSize: '0.6875rem',
679
+ fontWeight: 600,
680
+ padding: '0.125rem 0.5rem',
681
+ borderRadius: '9999px',
682
+ backgroundColor: 'var(--clearify-primary-soft)',
683
+ color: 'var(--clearify-primary)',
684
+ }}
685
+ >
686
+ Home
687
+ </span>
688
+ )}
689
+ {isDraft && (
690
+ <span
691
+ style={{
692
+ fontSize: '0.6875rem',
693
+ fontWeight: 600,
694
+ padding: '0.125rem 0.5rem',
695
+ borderRadius: '9999px',
696
+ backgroundColor: 'var(--clearify-bg-secondary)',
697
+ color: 'var(--clearify-text-tertiary)',
698
+ }}
699
+ >
700
+ Draft
701
+ </span>
702
+ )}
703
+ </div>
704
+ <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
705
+ <span
706
+ style={{
707
+ fontSize: '0.8125rem',
708
+ color: 'var(--clearify-text-secondary)',
709
+ fontFamily: 'var(--clearify-font-mono, monospace)',
710
+ }}
711
+ >
712
+ {section.docsDir}
713
+ </span>
714
+ <span
715
+ style={{
716
+ fontSize: '0.8125rem',
717
+ color: 'var(--clearify-text-tertiary)',
718
+ }}
719
+ >
720
+ {displayBasePath}
721
+ </span>
722
+ </div>
723
+ </div>
724
+
725
+ {/* Actions */}
726
+ <div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
727
+ <button
728
+ style={btnSecondary}
729
+ onClick={() => { setEditIndex(index); setShowForm(true); }}
730
+ disabled={saving}
731
+ >
732
+ Edit
733
+ </button>
734
+ <button
735
+ style={btnDanger}
736
+ onClick={() => setDeleteIndex(index)}
737
+ disabled={saving}
738
+ >
739
+ Delete
740
+ </button>
741
+ </div>
742
+ </div>
743
+ );
744
+ })}
745
+ </div>
746
+ )}
747
+
748
+ {/* Modals */}
749
+ {showForm && (
750
+ <SectionFormModal
751
+ section={editIndex !== null ? sections[editIndex] : null}
752
+ onSave={handleSave}
753
+ onClose={() => { setShowForm(false); setEditIndex(null); }}
754
+ />
755
+ )}
756
+
757
+ {deleteIndex !== null && (
758
+ <DeleteConfirmModal
759
+ sectionLabel={sections[deleteIndex].label}
760
+ onConfirm={handleDelete}
761
+ onClose={() => setDeleteIndex(null)}
762
+ />
763
+ )}
764
+
765
+ {/* Config preview */}
766
+ <SharedModal open={preview !== null} onClose={() => setPreview(null)} title="Review Changes">
767
+ {preview && (
768
+ <ConfigPreview
769
+ before={preview.before}
770
+ after={preview.after}
771
+ onConfirm={() => doPersist(preview.updatedSections)}
772
+ onCancel={() => setPreview(null)}
773
+ />
774
+ )}
775
+ </SharedModal>
776
+
777
+ <ToastContainer toasts={toasts} />
778
+ </div>
779
+ );
780
+ }
781
+
782
+ export default SectionsManager;