@marlinjai/clearify 1.11.1 → 1.12.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,546 @@
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import type { HubProject } from '../../types/index.js';
3
+ import { Modal } from './components/Modal.js';
4
+ import { ConfirmDialog } from './components/ConfirmDialog.js';
5
+ import { useToast, ToastContainer } from './components/Toast.js';
6
+ import { ConfigPreview } from './components/ConfigPreview.js';
7
+
8
+ /* ------------------------------------------------------------------ */
9
+ /* Status badge colors */
10
+ /* ------------------------------------------------------------------ */
11
+ const statusColors: Record<string, { bg: string; text: string }> = {
12
+ active: { bg: '#16a34a22', text: '#16a34a' },
13
+ beta: { bg: '#2563eb22', text: '#2563eb' },
14
+ planned: { bg: '#ca8a0422', text: '#ca8a04' },
15
+ deprecated: { bg: '#dc262622', text: '#dc2626' },
16
+ };
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Empty default */
20
+ /* ------------------------------------------------------------------ */
21
+ function emptyProject(): HubProject {
22
+ return {
23
+ name: '',
24
+ description: '',
25
+ mode: 'link',
26
+ status: 'active',
27
+ icon: '',
28
+ group: '',
29
+ href: '',
30
+ tags: [],
31
+ git: { repo: '', ref: '' },
32
+ embedSections: 'all',
33
+ injectInto: '',
34
+ docsPath: 'docs',
35
+ };
36
+ }
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* Shared inline-style helpers */
40
+ /* ------------------------------------------------------------------ */
41
+ const inputStyle: React.CSSProperties = {
42
+ width: '100%',
43
+ padding: '0.5rem 0.75rem',
44
+ border: '1px solid var(--clearify-border)',
45
+ borderRadius: 'var(--clearify-radius-sm)',
46
+ fontSize: '0.875rem',
47
+ backgroundColor: 'var(--clearify-bg)',
48
+ color: 'var(--clearify-text)',
49
+ outline: 'none',
50
+ boxSizing: 'border-box',
51
+ };
52
+
53
+ const labelStyle: React.CSSProperties = {
54
+ display: 'block',
55
+ fontSize: '0.8125rem',
56
+ fontWeight: 600,
57
+ color: 'var(--clearify-text-secondary)',
58
+ marginBottom: '0.25rem',
59
+ };
60
+
61
+ const primaryBtnStyle: React.CSSProperties = {
62
+ padding: '0.5rem 1rem',
63
+ background: 'var(--clearify-gradient)',
64
+ color: '#fff',
65
+ border: 'none',
66
+ borderRadius: 'var(--clearify-radius-sm)',
67
+ fontSize: '0.875rem',
68
+ fontWeight: 600,
69
+ cursor: 'pointer',
70
+ };
71
+
72
+ /* ------------------------------------------------------------------ */
73
+ /* Clean project for saving */
74
+ /* ------------------------------------------------------------------ */
75
+ function cleanProject(draft: HubProject, original?: HubProject): HubProject {
76
+ const cleaned: HubProject = { ...draft };
77
+ const mode = cleaned.mode ?? 'link';
78
+ if (mode === 'link') {
79
+ delete cleaned.git;
80
+ delete cleaned.embedSections;
81
+ delete cleaned.injectInto;
82
+ delete cleaned.docsPath;
83
+ // Don't add mode: 'link' if original didn't have it (it's the default)
84
+ if (!original?.mode) delete cleaned.mode;
85
+ } else if (mode === 'embed') {
86
+ delete cleaned.href;
87
+ delete cleaned.injectInto;
88
+ delete cleaned.docsPath;
89
+ } else if (mode === 'inject') {
90
+ delete cleaned.href;
91
+ delete cleaned.embedSections;
92
+ }
93
+ if (!cleaned.icon) delete cleaned.icon;
94
+ if (!cleaned.group) delete cleaned.group;
95
+ if (!cleaned.href) delete cleaned.href;
96
+ if (cleaned.tags && cleaned.tags.length === 0) delete cleaned.tags;
97
+ if (cleaned.git && !cleaned.git.repo) delete cleaned.git;
98
+ if (cleaned.git && !cleaned.git.ref) delete cleaned.git.ref;
99
+ if (cleaned.status === 'active' && !original?.status) delete cleaned.status;
100
+
101
+ // Preserve original field ordering: rebuild using original's key order first
102
+ if (original) {
103
+ const ordered: any = {};
104
+ for (const key of Object.keys(original)) {
105
+ if (key in cleaned) ordered[key] = (cleaned as any)[key];
106
+ }
107
+ for (const key of Object.keys(cleaned)) {
108
+ if (!(key in ordered)) ordered[key] = (cleaned as any)[key];
109
+ }
110
+ return ordered as HubProject;
111
+ }
112
+ return cleaned;
113
+ }
114
+
115
+ /* ------------------------------------------------------------------ */
116
+ /* Project form */
117
+ /* ------------------------------------------------------------------ */
118
+ function ProjectForm({
119
+ project,
120
+ onChange,
121
+ onSave,
122
+ saving,
123
+ }: {
124
+ project: HubProject;
125
+ onChange: (p: HubProject) => void;
126
+ onSave: () => void;
127
+ saving: boolean;
128
+ }) {
129
+ const mode = project.mode ?? 'link';
130
+
131
+ const set = <K extends keyof HubProject>(key: K, value: HubProject[K]) =>
132
+ onChange({ ...project, [key]: value });
133
+
134
+ return (
135
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
136
+ <div>
137
+ <label style={labelStyle}>Name *</label>
138
+ <input style={inputStyle} value={project.name} onChange={(e) => set('name', e.target.value)} placeholder="my-project" />
139
+ </div>
140
+ <div>
141
+ <label style={labelStyle}>Description *</label>
142
+ <textarea style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }} value={project.description} onChange={(e) => set('description', e.target.value)} placeholder="A short project description" />
143
+ </div>
144
+ <div>
145
+ <label style={labelStyle}>Icon</label>
146
+ <input style={inputStyle} value={project.icon ?? ''} onChange={(e) => set('icon', e.target.value)} placeholder="e.g. book, code, rocket" />
147
+ </div>
148
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
149
+ <div style={{ flex: 1 }}>
150
+ <label style={labelStyle}>Mode</label>
151
+ <select style={inputStyle} value={mode} onChange={(e) => set('mode', e.target.value as HubProject['mode'])}>
152
+ <option value="link">Link</option>
153
+ <option value="embed">Embed</option>
154
+ <option value="inject">Inject</option>
155
+ </select>
156
+ </div>
157
+ <div style={{ flex: 1 }}>
158
+ <label style={labelStyle}>Status</label>
159
+ <select style={inputStyle} value={project.status ?? 'active'} onChange={(e) => set('status', e.target.value as HubProject['status'])}>
160
+ <option value="active">Active</option>
161
+ <option value="beta">Beta</option>
162
+ <option value="planned">Planned</option>
163
+ <option value="deprecated">Deprecated</option>
164
+ </select>
165
+ </div>
166
+ </div>
167
+ <div>
168
+ <label style={labelStyle}>Group</label>
169
+ <input style={inputStyle} value={project.group ?? ''} onChange={(e) => set('group', e.target.value)} placeholder="e.g. backend, frontend" />
170
+ </div>
171
+
172
+ {mode === 'link' && (
173
+ <div>
174
+ <label style={labelStyle}>URL (href)</label>
175
+ <input style={inputStyle} value={project.href ?? ''} onChange={(e) => set('href', e.target.value)} placeholder="https://example.com/docs" />
176
+ </div>
177
+ )}
178
+
179
+ {(mode === 'embed' || mode === 'inject') && (
180
+ <>
181
+ <div>
182
+ <label style={labelStyle}>Git Repo</label>
183
+ <input style={inputStyle} value={project.git?.repo ?? ''} onChange={(e) => set('git', { ...project.git, repo: e.target.value })} placeholder="org/repo" />
184
+ </div>
185
+ <div>
186
+ <label style={labelStyle}>Git Ref</label>
187
+ <input style={inputStyle} value={project.git?.ref ?? ''} onChange={(e) => set('git', { ...project.git, repo: project.git?.repo ?? '', ref: e.target.value })} placeholder="main" />
188
+ </div>
189
+ </>
190
+ )}
191
+
192
+ {mode === 'embed' && (
193
+ <div>
194
+ <label style={labelStyle}>Embed Sections</label>
195
+ <select style={inputStyle} value={typeof project.embedSections === 'string' ? project.embedSections : 'all'} onChange={(e) => set('embedSections', e.target.value as 'all' | 'public')}>
196
+ <option value="all">All</option>
197
+ <option value="public">Public</option>
198
+ </select>
199
+ </div>
200
+ )}
201
+
202
+ {mode === 'inject' && (
203
+ <>
204
+ <div>
205
+ <label style={labelStyle}>Inject Into</label>
206
+ <input style={inputStyle} value={project.injectInto ?? ''} onChange={(e) => set('injectInto', e.target.value)} placeholder="section-id" />
207
+ </div>
208
+ <div>
209
+ <label style={labelStyle}>Docs Path</label>
210
+ <input style={inputStyle} value={project.docsPath ?? 'docs'} onChange={(e) => set('docsPath', e.target.value)} placeholder="docs" />
211
+ </div>
212
+ </>
213
+ )}
214
+
215
+ <div>
216
+ <label style={labelStyle}>Tags (comma-separated)</label>
217
+ <input
218
+ style={inputStyle}
219
+ value={(project.tags ?? []).join(', ')}
220
+ onChange={(e) => set('tags', e.target.value.split(',').map((t) => t.trim()).filter(Boolean))}
221
+ placeholder="api, docs, internal"
222
+ />
223
+ </div>
224
+
225
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '0.25rem' }}>
226
+ <button style={primaryBtnStyle} onClick={onSave} disabled={saving}>
227
+ {saving ? 'Saving\u2026' : 'Save Project'}
228
+ </button>
229
+ </div>
230
+ </div>
231
+ );
232
+ }
233
+
234
+ /* ------------------------------------------------------------------ */
235
+ /* ProjectsManager (main) */
236
+ /* ------------------------------------------------------------------ */
237
+ export function ProjectsManager() {
238
+ const [projects, setProjects] = useState<HubProject[]>([]);
239
+ const [scannedProjects, setScannedProjects] = useState<HubProject[]>([]);
240
+ const [loading, setLoading] = useState(true);
241
+ const [error, setError] = useState<string | null>(null);
242
+ const { toasts, show } = useToast();
243
+
244
+ // modal state
245
+ const [editIndex, setEditIndex] = useState<number | null>(null); // null = closed, -1 = new
246
+ const [draft, setDraft] = useState<HubProject>(emptyProject());
247
+ const [saving, setSaving] = useState(false);
248
+
249
+ // delete confirm
250
+ const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
251
+
252
+ // config preview state
253
+ const [preview, setPreview] = useState<{ before: object; after: object; updatedProjects: HubProject[] } | null>(null);
254
+
255
+ /* ---------- fetch ---------- */
256
+ const fetchProjects = useCallback(async () => {
257
+ try {
258
+ const [dataRes, resolvedRes] = await Promise.all([
259
+ fetch('/__clearify/api/config/data'),
260
+ fetch('/__clearify/api/config'),
261
+ ]);
262
+ if (!dataRes.ok) throw new Error(`Failed to load config (${dataRes.status})`);
263
+ const data = await dataRes.json();
264
+ const manualProjects: HubProject[] = data?.hub?.projects ?? [];
265
+ setProjects(manualProjects);
266
+
267
+ // Resolved config includes scanned projects merged with manual ones.
268
+ // Show scanned-only projects (not in manual list) as read-only.
269
+ if (resolvedRes.ok) {
270
+ const resolved = await resolvedRes.json();
271
+ const allResolved: HubProject[] = resolved?.hub?.projects ?? [];
272
+ const manualNames = new Set(manualProjects.map((p: HubProject) => p.name));
273
+ setScannedProjects(allResolved.filter((p: HubProject) => !manualNames.has(p.name)));
274
+ }
275
+
276
+ setError(null);
277
+ } catch (err: unknown) {
278
+ setError(err instanceof Error ? err.message : 'Unknown error');
279
+ } finally {
280
+ setLoading(false);
281
+ }
282
+ }, []);
283
+
284
+ useEffect(() => {
285
+ fetchProjects();
286
+ }, [fetchProjects]);
287
+
288
+ /* ---------- persist (actual PATCH) ---------- */
289
+ const persist = useCallback(
290
+ async (updatedProjects: HubProject[]) => {
291
+ setSaving(true);
292
+ try {
293
+ const res = await fetch('/__clearify/api/config/data', {
294
+ method: 'PATCH',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify({ hub: { projects: updatedProjects } }),
297
+ });
298
+ if (!res.ok) {
299
+ const body = await res.json().catch(() => ({}));
300
+ throw new Error(body.error ?? `Save failed (${res.status})`);
301
+ }
302
+ setProjects(updatedProjects);
303
+ show('Project saved', 'success');
304
+ setEditIndex(null);
305
+ setPreview(null);
306
+ } catch (err: unknown) {
307
+ show(err instanceof Error ? err.message : 'Save failed', 'error');
308
+ } finally {
309
+ setSaving(false);
310
+ }
311
+ },
312
+ [show],
313
+ );
314
+
315
+ /* ---------- save: show preview first ---------- */
316
+ const handleSave = useCallback(async () => {
317
+ if (!draft.name.trim() || !draft.description.trim()) {
318
+ show('Name and description are required', 'error');
319
+ return;
320
+ }
321
+
322
+ const original = editIndex !== null && editIndex >= 0 ? projects[editIndex] : undefined;
323
+ const cleaned = cleanProject(draft, original);
324
+ const next = [...projects];
325
+ if (editIndex === -1) {
326
+ next.push(cleaned);
327
+ } else if (editIndex !== null) {
328
+ next[editIndex] = cleaned;
329
+ }
330
+
331
+ // Fetch current server state as "before"
332
+ try {
333
+ const res = await fetch('/__clearify/api/config/data');
334
+ const currentData = await res.json();
335
+ const before = { hub: { projects: currentData?.hub?.projects ?? [] } };
336
+ const after = { hub: { projects: next } };
337
+ setPreview({ before, after, updatedProjects: next });
338
+ } catch {
339
+ // If fetch fails, persist directly
340
+ persist(next);
341
+ }
342
+ }, [draft, editIndex, projects, persist, show]);
343
+
344
+ /* ---------- delete: show preview ---------- */
345
+ const handleDelete = useCallback(async () => {
346
+ if (deleteIndex === null) return;
347
+ const next = projects.filter((_, i) => i !== deleteIndex);
348
+ setDeleteIndex(null);
349
+
350
+ try {
351
+ const res = await fetch('/__clearify/api/config/data');
352
+ const currentData = await res.json();
353
+ const before = { hub: { projects: currentData?.hub?.projects ?? [] } };
354
+ const after = { hub: { projects: next } };
355
+ setPreview({ before, after, updatedProjects: next });
356
+ } catch {
357
+ persist(next);
358
+ }
359
+ }, [deleteIndex, projects, persist]);
360
+
361
+ /* ---------- open modal ---------- */
362
+ const openAdd = () => {
363
+ setDraft(emptyProject());
364
+ setEditIndex(-1);
365
+ };
366
+
367
+ const openEdit = (index: number) => {
368
+ const p = projects[index];
369
+ setDraft({
370
+ ...emptyProject(),
371
+ ...p,
372
+ git: p.git ? { ...p.git } : { repo: '', ref: '' },
373
+ tags: p.tags ? [...p.tags] : [],
374
+ });
375
+ setEditIndex(index);
376
+ };
377
+
378
+ /* ---------- render ---------- */
379
+ if (loading) {
380
+ return (
381
+ <div style={{ padding: '2rem' }}>
382
+ <p style={{ color: 'var(--clearify-text-secondary)' }}>Loading projects\u2026</p>
383
+ </div>
384
+ );
385
+ }
386
+
387
+ if (error) {
388
+ return (
389
+ <div style={{ padding: '2rem' }}>
390
+ <p style={{ color: '#dc2626' }}>Error: {error}</p>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ return (
396
+ <div style={{ padding: '2rem' }}>
397
+ {/* Header */}
398
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
399
+ <div>
400
+ <h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--clearify-text)', margin: 0 }}>Projects</h2>
401
+ <p style={{ color: 'var(--clearify-text-secondary)', fontSize: '0.875rem', marginTop: '0.25rem' }}>
402
+ Manage hub projects and their configuration.
403
+ </p>
404
+ </div>
405
+ <button style={primaryBtnStyle} onClick={openAdd}>+ Add Project</button>
406
+ </div>
407
+
408
+ {/* Table */}
409
+ {projects.length === 0 ? (
410
+ <div style={{ textAlign: 'center', padding: '3rem 1rem', color: 'var(--clearify-text-tertiary)', fontSize: '0.9375rem' }}>
411
+ No projects configured yet. Click &quot;Add Project&quot; to get started.
412
+ </div>
413
+ ) : (
414
+ <div style={{ border: '1px solid var(--clearify-border)', borderRadius: 'var(--clearify-radius-sm)', overflow: 'hidden' }}>
415
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
416
+ <thead>
417
+ <tr style={{ backgroundColor: 'var(--clearify-bg-secondary)', borderBottom: '1px solid var(--clearify-border)' }}>
418
+ {['Name', 'Description', 'Mode', 'Group', 'Status', ''].map((h) => (
419
+ <th key={h} style={{ textAlign: 'left', padding: '0.625rem 0.75rem', fontWeight: 600, color: 'var(--clearify-text-secondary)', fontSize: '0.8125rem' }}>{h}</th>
420
+ ))}
421
+ </tr>
422
+ </thead>
423
+ <tbody>
424
+ {projects.map((p, i) => {
425
+ const st = p.status ?? 'active';
426
+ const colors = statusColors[st] ?? statusColors.active;
427
+ return (
428
+ <tr
429
+ key={i}
430
+ style={{ borderBottom: i < projects.length - 1 ? '1px solid var(--clearify-border)' : undefined, cursor: 'pointer', transition: 'background-color 0.1s' }}
431
+ onClick={() => openEdit(i)}
432
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--clearify-bg-secondary)'; }}
433
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
434
+ >
435
+ <td style={{ padding: '0.625rem 0.75rem', fontWeight: 600, color: 'var(--clearify-text)' }}>
436
+ {p.icon ? `${p.icon} ` : ''}{p.name}
437
+ </td>
438
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)', maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
439
+ {p.description}
440
+ </td>
441
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.mode ?? 'link'}</td>
442
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '\u2014'}</td>
443
+ <td style={{ padding: '0.625rem 0.75rem' }}>
444
+ <span style={{ display: 'inline-block', padding: '0.125rem 0.5rem', borderRadius: '9999px', fontSize: '0.75rem', fontWeight: 600, backgroundColor: colors.bg, color: colors.text }}>
445
+ {st}
446
+ </span>
447
+ </td>
448
+ <td style={{ padding: '0.625rem 0.75rem', textAlign: 'right' }}>
449
+ <button
450
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--clearify-text-tertiary)', padding: '0.25rem', display: 'inline-flex' }}
451
+ title="Delete project"
452
+ onClick={(e) => { e.stopPropagation(); setDeleteIndex(i); }}
453
+ >
454
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
455
+ <polyline points="3 6 5 6 21 6" />
456
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
457
+ </svg>
458
+ </button>
459
+ </td>
460
+ </tr>
461
+ );
462
+ })}
463
+ </tbody>
464
+ </table>
465
+ </div>
466
+ )}
467
+
468
+ {/* Scanned projects (read-only) */}
469
+ {scannedProjects.length > 0 && (
470
+ <div style={{ marginTop: '2rem' }}>
471
+ <h3 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--clearify-text-secondary)', marginBottom: '0.75rem' }}>
472
+ Auto-discovered projects
473
+ <span style={{ fontWeight: 400, fontSize: '0.8125rem', marginLeft: '0.5rem' }}>
474
+ (from hub.scan — read-only)
475
+ </span>
476
+ </h3>
477
+ <div style={{ border: '1px solid var(--clearify-border)', borderRadius: 'var(--clearify-radius-sm)', overflow: 'hidden', opacity: 0.75 }}>
478
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
479
+ <thead>
480
+ <tr style={{ backgroundColor: 'var(--clearify-bg-secondary)', borderBottom: '1px solid var(--clearify-border)' }}>
481
+ {['Name', 'Description', 'Group', 'Status'].map((h) => (
482
+ <th key={h} style={{ textAlign: 'left', padding: '0.625rem 0.75rem', fontWeight: 600, color: 'var(--clearify-text-secondary)', fontSize: '0.8125rem' }}>{h}</th>
483
+ ))}
484
+ </tr>
485
+ </thead>
486
+ <tbody>
487
+ {scannedProjects.map((p, i) => {
488
+ const st = p.status ?? 'active';
489
+ const colors = statusColors[st] ?? statusColors.active;
490
+ return (
491
+ <tr key={i} style={{ borderBottom: i < scannedProjects.length - 1 ? '1px solid var(--clearify-border)' : undefined }}>
492
+ <td style={{ padding: '0.625rem 0.75rem', fontWeight: 600, color: 'var(--clearify-text)' }}>
493
+ {p.icon ? `${p.icon} ` : ''}{p.name}
494
+ </td>
495
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)', maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
496
+ {p.description}
497
+ </td>
498
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '\u2014'}</td>
499
+ <td style={{ padding: '0.625rem 0.75rem' }}>
500
+ <span style={{ display: 'inline-block', padding: '0.125rem 0.5rem', borderRadius: '9999px', fontSize: '0.75rem', fontWeight: 600, backgroundColor: colors.bg, color: colors.text }}>
501
+ {st}
502
+ </span>
503
+ </td>
504
+ </tr>
505
+ );
506
+ })}
507
+ </tbody>
508
+ </table>
509
+ </div>
510
+ </div>
511
+ )}
512
+
513
+ {/* Edit / Add modal */}
514
+ <Modal open={editIndex !== null} onClose={() => setEditIndex(null)} title={editIndex === -1 ? 'Add Project' : `Edit: ${editIndex !== null && editIndex >= 0 ? projects[editIndex]?.name ?? '' : ''}`}>
515
+ <ProjectForm project={draft} onChange={setDraft} onSave={handleSave} saving={saving} />
516
+ </Modal>
517
+
518
+ {/* Delete confirmation */}
519
+ <ConfirmDialog
520
+ open={deleteIndex !== null}
521
+ onClose={() => setDeleteIndex(null)}
522
+ onConfirm={handleDelete}
523
+ title="Delete Project"
524
+ message={deleteIndex !== null ? `Delete project "${projects[deleteIndex]?.name}"? This action cannot be undone.` : ''}
525
+ confirmLabel="Delete"
526
+ danger
527
+ />
528
+
529
+ {/* Config preview */}
530
+ <Modal open={preview !== null} onClose={() => setPreview(null)} title="Review Changes">
531
+ {preview && (
532
+ <ConfigPreview
533
+ before={preview.before}
534
+ after={preview.after}
535
+ onConfirm={() => persist(preview.updatedProjects)}
536
+ onCancel={() => setPreview(null)}
537
+ />
538
+ )}
539
+ </Modal>
540
+
541
+ <ToastContainer toasts={toasts} />
542
+ </div>
543
+ );
544
+ }
545
+
546
+ export default ProjectsManager;