@vibecms/cli 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.
@@ -0,0 +1,954 @@
1
+ // src/components/cms/Editor.tsx
2
+ 'use client';
3
+ import React, { useEffect, useState } from 'react';
4
+ import { motion } from 'framer-motion';
5
+ import { useStore } from '@nanostores/react';
6
+ import { isEditMode, contentDraft, currentDocument, updateDraftPath, focusedField, focusTrigger, setFocusedField, clipboardBuffer } from '@/lib/cms/store';
7
+ import { VibeEngine } from '@/lib/cms/engine';
8
+ import { AuthProvider } from '@vibecms/core';
9
+ import { toast } from 'sonner';
10
+ import { FormGenerator } from '@/components/cms/FormGenerator';
11
+ import { Button, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@vibecms/core';
12
+ import { schemaRegistry } from '@/lib/cms/registry';
13
+ // @ts-ignore - Expected error during VibeCMS CLI development because the config lives in the target user project
14
+ import vibeConfig from '../../../vibe.config';
15
+ import { MediaGallery } from '@/components/cms/fields/MediaGallery';
16
+ import { runVibeAudit, AuditReport } from '@/lib/cms/auditor';
17
+ import { AlignLeft, Settings, Image as ImageIcon, LogOut, PanelRightClose, GitCommit, Undo2, ChevronUp, Check, AlertCircle, Activity, HeartPulse, ExternalLink, History as HistoryIcon, Rewind, FastForward, ChevronRight, Trash2, Eraser } from 'lucide-react';
18
+
19
+ export function Editor({ auth }: { auth?: AuthProvider }) {
20
+ const draft = useStore(contentDraft);
21
+ const doc = useStore(currentDocument);
22
+ const focus = useStore(focusedField);
23
+ const trigger = useStore(focusTrigger);
24
+ const [isSaving, setIsSaving] = useState(false);
25
+ const [cmdOpen, setCmdOpen] = useState(false);
26
+ const [mediaOpen, setMediaOpen] = useState(false);
27
+ const [healthOpen, setHealthOpen] = useState(false);
28
+ const [historyOpen, setHistoryOpen] = useState(false);
29
+ const [auditReport, setAuditReport] = useState<AuditReport | null>(null);
30
+
31
+ // History State
32
+ const [historyCommits, setHistoryCommits] = useState<any[]>([]);
33
+ const [activeCommit, setActiveCommit] = useState<string | null>(null);
34
+ const [stashedDraft, setStashedDraft] = useState<any>(null); // To restore if we close history
35
+ const [isRestoring, setIsRestoring] = useState(false);
36
+
37
+ const [commitOpen, setCommitOpen] = useState(false);
38
+ const [commitMsg, setCommitMsg] = useState('');
39
+ const [isDirtyGit, setIsDirtyGit] = useState(false);
40
+ const [searchResults, setSearchResults] = useState<any[]>([]);
41
+ const [syncRequired, setSyncRequired] = useState(false);
42
+ const [publishBlockers, setPublishBlockers] = useState<AuditReport['issues'] | null>(null);
43
+ const [activePath, setActivePath] = useState<string>(''); // Drill-down state
44
+
45
+ // i18n State
46
+ const [activeLocale, setActiveLocale] = useState<string | undefined>(vibeConfig.defaultLocale || (vibeConfig.locales?.[0]));
47
+ const [syncCollision, setSyncCollision] = useState(false); // Phase 17 Merge Conflict handler
48
+ const [session, setSession] = useState<{ user: any } | null>(null);
49
+ const [authStatus, setAuthStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
50
+
51
+ useEffect(() => {
52
+ if (auth) {
53
+ auth.getSession().then(s => {
54
+ setSession(s);
55
+ setAuthStatus(s ? 'authenticated' : 'unauthenticated');
56
+ });
57
+ } else {
58
+ setAuthStatus('unauthenticated');
59
+ }
60
+ }, [auth]);
61
+
62
+ const [isMobile, setIsMobile] = useState(false);
63
+
64
+ useEffect(() => {
65
+ const checkMobile = () => setIsMobile(window.innerWidth < 768);
66
+ checkMobile(); // Check immediately
67
+ window.addEventListener('resize', checkMobile);
68
+ return () => window.removeEventListener('resize', checkMobile);
69
+ }, []);
70
+
71
+ // Monitor Draft Dirty status against Original Head
72
+ useEffect(() => {
73
+ if (doc) {
74
+ VibeEngine.isDirty(doc.collection, doc.slug).then(setIsDirtyGit);
75
+ }
76
+ }, [draft, doc]);
77
+
78
+ // Persist Clipboard state across Next.js hard navigations
79
+ useEffect(() => {
80
+ try {
81
+ const persistedStr = localStorage.getItem('vibe_clipboard_buffer');
82
+ if (persistedStr) {
83
+ clipboardBuffer.set(JSON.parse(persistedStr));
84
+ }
85
+ } catch (e) {
86
+ console.error("Failed to restore clipboard buffer from localStorage", e);
87
+ }
88
+
89
+ const unsub = clipboardBuffer.subscribe((val: any) => {
90
+ if (val) {
91
+ localStorage.setItem('vibe_clipboard_buffer', JSON.stringify(val));
92
+ } else {
93
+ localStorage.removeItem('vibe_clipboard_buffer');
94
+ }
95
+ });
96
+
97
+ return unsub;
98
+ }, []);
99
+
100
+ useEffect(() => {
101
+ if (cmdOpen) {
102
+ const fetchAll = async () => {
103
+ const all: any[] = [];
104
+ for (const key of Object.keys(vibeConfig.collections || {})) {
105
+ const list = await VibeEngine.list(key, { withMeta: true });
106
+ all.push(...list.map(f => ({ ...f, collection: key })));
107
+ }
108
+ // Singletons are just a single file (index.json by default or collection name)
109
+ for (const key of Object.keys(vibeConfig.singletons || {})) {
110
+ try {
111
+ const data = await VibeEngine.read(key, 'index');
112
+ if (data) all.push({ slug: 'index', title: data.title || data.name || key, collection: key });
113
+ } catch {}
114
+ }
115
+ setSearchResults(all);
116
+ };
117
+ fetchAll();
118
+ }
119
+ }, [cmdOpen]);
120
+
121
+ // Run audit when Health Dashboard is opened or Document changes
122
+ useEffect(() => {
123
+ if (healthOpen) {
124
+ const run = async () => {
125
+ // Tiny delay to ensure React DOM has completed layout
126
+ setTimeout(async () => {
127
+ const stage = document.getElementById('vibe-stage-container');
128
+ if (stage) {
129
+ const report = await runVibeAudit(stage.innerHTML, stage);
130
+ setAuditReport(report);
131
+ }
132
+ }, 500);
133
+ };
134
+ run();
135
+ }
136
+ }, [healthOpen, doc, draft]);
137
+
138
+ // Load History when History Tab opened
139
+ useEffect(() => {
140
+ if (historyOpen && doc) {
141
+ setStashedDraft(draft); // Stash current state
142
+ VibeEngine.getHistory(doc.collection, doc.slug).then(commits => {
143
+ setHistoryCommits(commits);
144
+ if (commits.length > 0) setActiveCommit(commits[0].oid);
145
+ });
146
+ } else if (!historyOpen && stashedDraft) {
147
+ // User closed history without restoring, put the stashed draft back
148
+ contentDraft.set(stashedDraft);
149
+ setStashedDraft(null);
150
+ setActiveCommit(null);
151
+ }
152
+ }, [historyOpen, doc]);
153
+
154
+ // Handle Scrubbing: Load the historic JSON blob
155
+ useEffect(() => {
156
+ if (historyOpen && activeCommit && doc) {
157
+ VibeEngine.getVersionContent(activeCommit, doc.collection, doc.slug).then(historicData => {
158
+ if (historicData) {
159
+ contentDraft.set(historicData);
160
+ }
161
+ });
162
+ }
163
+ }, [activeCommit, historyOpen]);
164
+
165
+ useEffect(() => {
166
+ const down = (e: KeyboardEvent) => {
167
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
168
+ e.preventDefault();
169
+ setCmdOpen((open) => !open);
170
+ }
171
+ };
172
+ document.addEventListener('keydown', down);
173
+ return () => document.removeEventListener('keydown', down);
174
+ }, []);
175
+
176
+ // Sync focus if something is clicked in VisualWrapper
177
+ useEffect(() => {
178
+ if (focus) {
179
+ const el = document.getElementById(`editor-field-${focus}`);
180
+ if (el) {
181
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
182
+
183
+ // Give a quick visual flash
184
+ el.classList.remove('ring-2', 'ring-indigo-500', 'rounded', 'transition-all', 'duration-300');
185
+ void el.offsetWidth; // trigger reflow
186
+ el.classList.add('ring-2', 'ring-indigo-500', 'rounded', 'transition-all', 'duration-300');
187
+
188
+ setTimeout(() => el.classList.remove('ring-2', 'ring-indigo-500'), 1500);
189
+
190
+ // Try to physically focus the input field underneath
191
+ const input = document.getElementById(`input-${focus}`);
192
+ if (input && document.activeElement !== input) {
193
+ input.focus();
194
+ }
195
+ }
196
+ }
197
+ }, [focus, trigger]);
198
+
199
+ const handleSaveDraft = async () => {
200
+ if (!doc) return;
201
+ setIsSaving(true);
202
+ try {
203
+ await VibeEngine.write(doc.collection, doc.slug, draft, { locale: activeLocale });
204
+ toast.success('Draft state secured locally.');
205
+ const dirty = await VibeEngine.isDirty(doc.collection, doc.slug);
206
+ setIsDirtyGit(dirty);
207
+ } catch (e: any) {
208
+ if (e.message?.startsWith('Conflict:')) {
209
+ toast.error(e.message, { duration: 10000 });
210
+ setSyncCollision(true);
211
+ } else {
212
+ toast.error(e.message || 'Failed to save draft.');
213
+ }
214
+ }
215
+ setIsSaving(false);
216
+ };
217
+
218
+ const handleCommitAndPush = async () => {
219
+ if (!doc) return;
220
+
221
+ // Phase 14: Strict A11y Publish Guard
222
+ const wrapperElement = document.getElementById('vibe-preview-wrapper');
223
+ if (wrapperElement) {
224
+ toast.loading('Running Pre-Flight Accessibility Scan...', { id: 'pre-flight' });
225
+ const audit = await runVibeAudit(wrapperElement.innerHTML, wrapperElement);
226
+ toast.dismiss('pre-flight');
227
+
228
+ const criticalIssues = audit.issues.filter(i => i.severity === 'critical');
229
+ if (criticalIssues.length > 0) {
230
+ setPublishBlockers(criticalIssues);
231
+ setCommitOpen(false); // Hide the commit UI
232
+ return; // Strictly abort pushing to production
233
+ }
234
+ }
235
+
236
+ setIsSaving(true);
237
+ setSyncRequired(false);
238
+
239
+ // Auto-save the absolute latest draft immediately into lightning-fs first before committing
240
+ try {
241
+ await VibeEngine.write(doc.collection, doc.slug, draft, { locale: activeLocale });
242
+ } catch (e: any) {
243
+ if (e.message?.startsWith('Conflict:')) {
244
+ toast.error('Cannot commit: ' + e.message, { duration: 10000 });
245
+ setSyncCollision(true);
246
+ } else {
247
+ toast.error(e.message || 'Failed to save before committing.');
248
+ }
249
+ setIsSaving(false);
250
+ setCommitOpen(false);
251
+ return;
252
+ }
253
+
254
+ // Explicit Commit layer
255
+ await VibeEngine.commit(commitMsg || `Update ${doc.collection}/${doc.slug}`);
256
+
257
+ if (authStatus === 'authenticated' && session && (session as any).accessToken) {
258
+ try {
259
+ await VibeEngine.push((session as any).accessToken);
260
+ toast.success(`Successfully committed to GitHub!`);
261
+ setCommitOpen(false);
262
+ setCommitMsg('');
263
+ } catch (e: any) {
264
+ console.error('Failed to push to GitHub', e);
265
+ if (e.code === 'PushRejectedError' || e?.message?.toLowerCase().includes('reject')) {
266
+ setSyncRequired(true);
267
+ toast.warning('Push rejected: Remote changes exist. Please Sync first.');
268
+ } else {
269
+ toast.error('Failed to push changes to GitHub.');
270
+ }
271
+ }
272
+ } else {
273
+ toast.success('Successfully committed locally.');
274
+ setCommitOpen(false);
275
+ setCommitMsg('');
276
+ }
277
+
278
+ const dirty = await VibeEngine.isDirty(doc.collection, doc.slug);
279
+ setIsDirtyGit(dirty);
280
+ setIsSaving(false);
281
+ };
282
+
283
+ const handleRevert = async () => {
284
+ if (!doc) return;
285
+ const ok = window.confirm("Are you sure you want to discard your draft? This un-does all changes since the last commit.");
286
+ if (!ok) return;
287
+
288
+ await VibeEngine.revert(doc.collection, doc.slug);
289
+ // Reload into UI instantly
290
+ const cleanData = await VibeEngine.read(doc.collection, doc.slug, { locale: activeLocale });
291
+ if (cleanData) contentDraft.set(cleanData);
292
+ toast.success('Reverted successfully to Git HEAD.');
293
+
294
+ const dirty = await VibeEngine.isDirty(doc.collection, doc.slug);
295
+ setIsDirtyGit(dirty);
296
+ };
297
+
298
+ const handleSync = async () => {
299
+ setIsSaving(true);
300
+ try {
301
+ await VibeEngine.pull();
302
+ if (session && (session as any).accessToken) {
303
+ await VibeEngine.push((session as any).accessToken);
304
+ }
305
+ setSyncRequired(false);
306
+ setSyncCollision(false);
307
+ toast.success('Successfully synced with remote!');
308
+ } catch (e: any) {
309
+ console.error('Pull/Sync failed', e);
310
+ if (e && (e.code === 'MergeNotSupportedError' || e.code === 'MergeConflictError' || String(e).includes('Merge'))) {
311
+ setSyncCollision(true);
312
+ } else {
313
+ toast.error('Failed to sync. Network or Permissions issue.');
314
+ }
315
+ }
316
+ setIsSaving(false);
317
+ };
318
+
319
+ const handleRestoreGhostState = async () => {
320
+ if (!doc || !activeCommit) return;
321
+ setIsRestoring(true);
322
+
323
+ // The current Ghost Draft is exactly what's loaded right now via scrubbing
324
+ const ghostData = draft;
325
+
326
+ // Write it aggressively to Lightning-FS, destroying the prior volatile draft
327
+ await VibeEngine.write(doc.collection, doc.slug, ghostData, { locale: activeLocale });
328
+
329
+ // Force a fresh forward-rolling commit on top of HEAD
330
+ const revertMessage = `revert: Restored document to historical hash ${activeCommit.slice(0, 7)}`;
331
+ await VibeEngine.commit(revertMessage);
332
+
333
+ toast.success('Successfully time-jumped and committed restoration to local Head!');
334
+ setHistoryOpen(false); // Closes tab, but the stashed draft will be overwritten
335
+ setStashedDraft(null); // Clear the stash so closing doesn't overwrite our new restore
336
+
337
+ const dirty = await VibeEngine.isDirty(doc.collection, doc.slug);
338
+ setIsDirtyGit(dirty);
339
+ setIsRestoring(false);
340
+ };
341
+
342
+ const handleDelete = async () => {
343
+ if (!doc) return;
344
+ const ok = window.confirm(`Are you sure you want to permanently delete /${doc.collection}/${doc.slug.replace('.json', '')}? This action cannot be undone.`);
345
+ if (!ok) return;
346
+
347
+ try {
348
+ await VibeEngine.delete(doc.collection, doc.slug.replace('.json', ''), { locale: activeLocale });
349
+ toast.success('Document deleted successfully.');
350
+ currentDocument.set(null);
351
+ contentDraft.set({});
352
+ } catch (e: any) {
353
+ toast.error(e.message || 'Failed to delete document.');
354
+ }
355
+ };
356
+
357
+ return (
358
+ <>
359
+ <motion.div
360
+ initial={{ width: 0, opacity: 0 }}
361
+ animate={{ width: isMobile ? '100vw' : 432, opacity: 1 }}
362
+ exit={{ width: 0, opacity: 0 }}
363
+ transition={{ type: 'spring', damping: 25, stiffness: 200 }}
364
+ className="flex-none h-screen bg-white border-l border-neutral-200 shadow-2xl flex z-[100] overflow-hidden text-neutral-900 fixed md:relative right-0 top-0 bottom-0 max-w-[100vw]"
365
+ >
366
+ {/* The Action Rail */}
367
+ <div className="w-12 shrink-0 border-r border-neutral-200 bg-[#f7f7f5] flex flex-col items-center py-4 gap-6 z-10">
368
+ <div className="flex flex-col gap-4 items-center">
369
+ <button
370
+ onClick={() => setCmdOpen(true)}
371
+ title="Content Pages (⌘K)"
372
+ className={`p-2 rounded-lg transition-colors ${doc?.collection === 'pages' ? 'bg-indigo-50 text-indigo-600' : 'text-neutral-400 hover:text-neutral-900 hover:bg-neutral-200'}`}
373
+ >
374
+ <AlignLeft className="w-5 h-5" />
375
+ </button>
376
+ <button
377
+ onClick={async () => {
378
+ const data = await VibeEngine.read('settings', 'site.json', { locale: activeLocale });
379
+ if (data) contentDraft.set(data);
380
+ currentDocument.set({ collection: 'settings', slug: 'site.json' });
381
+ setActivePath('');
382
+ }}
383
+ title="Global Settings"
384
+ className={`p-2 rounded-lg transition-colors ${doc?.collection === 'settings' ? 'bg-indigo-50 text-indigo-600' : 'text-neutral-400 hover:text-neutral-900 hover:bg-neutral-200'}`}
385
+ >
386
+ <Settings className="w-5 h-5" />
387
+ </button>
388
+ <button
389
+ onClick={() => setMediaOpen(true)}
390
+ title="Media Library"
391
+ className="p-2 rounded-lg text-neutral-400 hover:text-neutral-900 hover:bg-neutral-200 transition-colors"
392
+ >
393
+ <ImageIcon className="w-5 h-5" />
394
+ </button>
395
+ <button
396
+ onClick={async () => {
397
+ const ok = window.confirm('Scan and permanently delete orphaned media files from the server?');
398
+ if (!ok) return;
399
+ try {
400
+ const { deletedCount, bytesFreed } = await VibeEngine.pruneMedia();
401
+ const mb = (bytesFreed / 1024 / 1024).toFixed(2);
402
+ toast.success(`Pruned ${deletedCount} files, saving ${mb} MB.`);
403
+ } catch (e: any) {
404
+ toast.error('Failed to prune media: ' + e.message);
405
+ }
406
+ }}
407
+ title="Garbage Collect Media"
408
+ className="p-2 rounded-lg text-neutral-400 hover:text-rose-600 hover:bg-rose-50 transition-colors"
409
+ >
410
+ <Eraser className="w-5 h-5" />
411
+ </button>
412
+ <div className="w-6 h-px bg-neutral-200 my-2" />
413
+ <button
414
+ onClick={() => {
415
+ setHealthOpen(false);
416
+ setHistoryOpen(!historyOpen);
417
+ }}
418
+ title="Version Time Machine"
419
+ className={`p-2 rounded-lg transition-colors ${historyOpen ? 'bg-blue-50 text-blue-600' : 'text-neutral-400 hover:text-neutral-900 hover:bg-neutral-200'}`}
420
+ >
421
+ <HistoryIcon className="w-5 h-5" />
422
+ </button>
423
+ <button
424
+ onClick={() => {
425
+ setHistoryOpen(false);
426
+ setHealthOpen(!healthOpen);
427
+ }}
428
+ title="Vibe Health Auditor"
429
+ className={`p-2 rounded-lg transition-colors ${healthOpen ? 'bg-rose-50 text-rose-600' : 'text-neutral-400 hover:text-neutral-900 hover:bg-neutral-200'}`}
430
+ >
431
+ <Activity className="w-5 h-5" />
432
+ </button>
433
+ </div>
434
+
435
+ <div className="mt-auto flex flex-col gap-4 items-center">
436
+ {authStatus === 'authenticated' && auth && (
437
+ <button
438
+ onClick={() => auth.signOut()}
439
+ title="Log Out"
440
+ className="p-2 rounded-lg text-neutral-400 hover:text-neutral-900 hover:bg-neutral-200 transition-colors"
441
+ >
442
+ <LogOut className="w-4 h-4" />
443
+ </button>
444
+ )}
445
+ <button
446
+ onClick={() => {
447
+ isEditMode.set(false);
448
+ localStorage.setItem('vibe_edit', 'false');
449
+ }}
450
+ title="Close Editor"
451
+ className="p-2 rounded-lg text-red-500/60 hover:text-red-600 hover:bg-red-50 transition-colors"
452
+ >
453
+ <PanelRightClose className="w-5 h-5" />
454
+ </button>
455
+ </div>
456
+ </div>
457
+
458
+ {/* Main Sidebar Form Container */}
459
+ <div className="flex-1 flex flex-col h-full overflow-hidden relative">
460
+ <div className="flex items-center justify-between p-4 border-b border-neutral-200 bg-white/50 backdrop-blur-md shrink-0">
461
+ <div className="flex items-center gap-3">
462
+ <div className={`w-2.5 h-2.5 rounded-full ${isDirtyGit ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]' : doc ? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)]' : 'bg-neutral-300'} transition-all`} />
463
+ <div className="flex flex-col">
464
+ <span className="font-semibold text-neutral-900 tracking-wide text-sm truncate max-w-[200px]">
465
+ {doc ? `/${doc.slug.replace('.json', '')}` : 'VibeCMS'}
466
+ </span>
467
+ {isDirtyGit && <span className="text-[10px] uppercase font-bold text-amber-600 tracking-wider">Unsaved Draft</span>}
468
+ </div>
469
+ </div>
470
+ <div className="flex items-center gap-1">
471
+ {isDirtyGit && (
472
+ <button
473
+ onClick={handleRevert}
474
+ title="Discard Draft to HEAD"
475
+ className="p-1.5 rounded-md hover:bg-neutral-100 text-neutral-400 hover:text-neutral-900 transition-colors"
476
+ >
477
+ <Undo2 className="w-4 h-4" />
478
+ </button>
479
+ )}
480
+ {doc && (
481
+ <button
482
+ onClick={handleDelete}
483
+ title="Delete Document"
484
+ className="p-1.5 rounded-md hover:bg-rose-50 text-neutral-400 hover:text-rose-600 transition-colors"
485
+ >
486
+ <Trash2 className="w-4 h-4" />
487
+ </button>
488
+ )}
489
+ </div>
490
+ </div>
491
+
492
+ {healthOpen ? (
493
+ <div className="flex-1 overflow-y-auto p-5 custom-scrollbar bg-[#fcfcfc]">
494
+ <h3 className="text-xl font-semibold mb-6 flex items-center gap-2 text-neutral-900"><HeartPulse className="w-6 h-6 text-rose-500" /> Health Auditor</h3>
495
+ {!auditReport ? (
496
+ <div className="animate-pulse flex flex-col gap-4">
497
+ <div className="h-24 bg-neutral-100 rounded-xl border border-neutral-200" />
498
+ <div className="h-12 bg-neutral-100 rounded-xl border border-neutral-200" />
499
+ <div className="h-12 bg-neutral-100 rounded-xl border border-neutral-200" />
500
+ </div>
501
+ ) : (
502
+ <div className="flex flex-col gap-6 pb-12">
503
+ <div className={`p-6 rounded-2xl border flex flex-col items-center justify-center text-center shadow-sm ${
504
+ auditReport.score >= 90 ? 'bg-emerald-50 border-emerald-200 text-emerald-700' :
505
+ auditReport.score >= 70 ? 'bg-amber-50 border-amber-200 text-amber-700' :
506
+ 'bg-rose-50 border-rose-200 text-rose-700'
507
+ }`}>
508
+ <div className="text-5xl font-black mb-2">{auditReport.score}</div>
509
+ <div className="text-xs uppercase tracking-widest font-semibold opacity-70">Overall Score</div>
510
+ </div>
511
+
512
+ <div className="flex flex-col gap-3">
513
+ <h4 className="text-sm font-semibold tracking-wide text-neutral-500 uppercase mb-2">Issues ({auditReport.issues.length})</h4>
514
+ {auditReport.issues.length === 0 ? (
515
+ <div className="text-emerald-700 bg-emerald-50 p-4 rounded-xl text-sm border border-emerald-200 text-center font-medium">
516
+ Perfect! No SEO or Accessibility issues detected.
517
+ </div>
518
+ ) : (
519
+ auditReport.issues.map((iss, i) => (
520
+ <div
521
+ key={i}
522
+ className={`p-4 rounded-xl border transition-all ${iss.selector && doc?.collection === 'pages' ? 'cursor-pointer hover:bg-neutral-50 hover:shadow-sm ' : ''} ${iss.severity === 'critical' ? 'bg-rose-50 border-rose-200' : 'bg-amber-50 border-amber-200'}`}
523
+ onClick={() => {
524
+ // Deep Linking Heuristic
525
+ if (iss.selector) {
526
+ const el = document.querySelector(iss.selector);
527
+ if (el) {
528
+ // We appended field paths dynamically to VisualWrapper earlier, try to find the closest data-vibe-path
529
+ const wrapper = el.closest('[data-vibe-path]') as HTMLElement;
530
+ if (wrapper && wrapper.dataset.vibePath) {
531
+ setHealthOpen(false);
532
+ setFocusedField(wrapper.dataset.vibePath);
533
+ toast.info(`Jumped to: ${wrapper.dataset.vibePath}`);
534
+ } else {
535
+ // Flash the element on stage at least
536
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
537
+ el.classList.add('ring-4', 'ring-rose-500', 'transition-all');
538
+ setTimeout(() => el.classList.remove('ring-4', 'ring-rose-500'), 1500);
539
+ }
540
+ }
541
+ }
542
+ }}
543
+ >
544
+ <div className="flex gap-2 items-start mb-2">
545
+ {iss.severity === 'critical' ? <AlertCircle className="w-4 h-4 text-rose-600 mt-0.5 shrink-0" /> : <AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />}
546
+ <span className="text-sm font-semibold text-neutral-900">{iss.message}</span>
547
+ </div>
548
+
549
+ {/* Phase 16: Contrast Suggestion Swatches & Auto-Fix */}
550
+ {iss.failingColor && iss.suggestedColor && (
551
+ <div className="flex flex-col gap-2 mt-4 bg-black/40 p-4 rounded-lg border border-white/5 relative z-10" onClick={(e) => e.stopPropagation()}>
552
+ <div className="text-[10px] uppercase font-bold text-white/50 tracking-wider mb-1 flex items-center justify-between">
553
+ <span>Automated WCAG Fix</span>
554
+ <span className="text-emerald-400">AA (4.5:1)</span>
555
+ </div>
556
+ <div className="flex items-center gap-3">
557
+ <div className="flex flex-col items-center gap-1 opacity-60">
558
+ <div className="w-8 h-8 rounded-md ring-1 ring-white/10" style={{ backgroundColor: iss.failingColor }} title="Failing Color" />
559
+ <span className="text-[10px] font-mono text-white/40">{iss.failingColor}</span>
560
+ </div>
561
+ <ChevronRight className="w-4 h-4 text-white/20" />
562
+ <div className="flex flex-col items-center gap-1">
563
+ <div className="w-8 h-8 rounded-md ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.2)]" style={{ backgroundColor: iss.suggestedColor }} title="Calculated Fix" />
564
+ <span className="text-[10px] font-mono text-emerald-400 font-bold">{iss.suggestedColor}</span>
565
+ </div>
566
+
567
+ {iss.fieldPath ? (
568
+ <button
569
+ className="ml-auto bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500 hover:text-white transition-all text-xs font-semibold px-4 py-2 rounded shadow-sm flex items-center gap-1.5"
570
+ onClick={(e) => {
571
+ e.stopPropagation();
572
+ updateDraftPath(iss.fieldPath!, iss.suggestedColor!);
573
+ toast.success('AA Accessible contrast applied to draft!');
574
+ // Triggering a save/sync indirectly modifies draft which loops back to auditor
575
+ }}
576
+ >
577
+ Fix Color
578
+ </button>
579
+ ) : (
580
+ <span className="ml-auto text-[10px] text-white/30 tracking-wide uppercase max-w-[100px] text-right">Raw DOM Edit Required</span>
581
+ )}
582
+ </div>
583
+ </div>
584
+ )}
585
+
586
+ <div className="flex items-center justify-between mt-3">
587
+ <div className="text-[10px] uppercase font-bold text-neutral-500 tracking-wider">
588
+ {iss.type} • {iss.severity}
589
+ </div>
590
+ <div className="flex items-center gap-2">
591
+ {iss.selector && (
592
+ <code className="text-[10px] bg-white border border-neutral-200 px-1.5 py-0.5 rounded text-neutral-600 truncate max-w-[150px]" title={iss.selector}>
593
+ {iss.selector.split(' > ').pop()}
594
+ </code>
595
+ )}
596
+ {iss.selector && doc?.collection === 'pages' && (
597
+ <ExternalLink className="w-3 h-3 text-neutral-400" />
598
+ )}
599
+ </div>
600
+ </div>
601
+ </div>
602
+ ))
603
+ )}
604
+ </div>
605
+ </div>
606
+ )}
607
+ </div>
608
+ ) : historyOpen ? (
609
+ <div className="flex-1 overflow-y-auto p-5 custom-scrollbar bg-[#fcfcfc] flex flex-col">
610
+ <h3 className="text-xl font-semibold mb-2 flex items-center gap-2 text-neutral-900"><HistoryIcon className="w-6 h-6 text-blue-500" /> Version Control</h3>
611
+ <p className="text-xs text-neutral-500 mb-8 leading-relaxed">Scrub backwards in time to view historical commits. Clicking restore will safely roll forward a new commit.</p>
612
+
613
+ {historyCommits.length === 0 ? (
614
+ <div className="text-center text-neutral-400 mt-10">No history found for this document.</div>
615
+ ) : (
616
+ <div className="flex-1 flex flex-col gap-4 relative isolate">
617
+ {/* Vertical Timeline Line */}
618
+ <div className="absolute left-6 top-4 bottom-10 w-px bg-neutral-200 -z-10" />
619
+
620
+ {historyCommits.map((c, i) => {
621
+ const isActive = activeCommit === c.oid;
622
+ const date = new Date(c.timestamp).toLocaleString();
623
+ return (
624
+ <div
625
+ key={c.oid}
626
+ onClick={() => setActiveCommit(c.oid)}
627
+ className={`relative p-5 rounded-2xl cursor-pointer transition-all duration-300 border ${isActive ? 'bg-blue-50 border-blue-200 shadow-sm ml-2' : 'bg-white border-neutral-100 hover:bg-neutral-50 hover:border-neutral-200'}`}
628
+ >
629
+ <div className={`absolute top-1/2 -translate-y-1/2 -left-[1.35rem] w-3 h-3 rounded-full border-2 border-[#fcfcfc] transition-colors ${isActive ? 'bg-blue-500' : 'bg-neutral-300'}`} />
630
+
631
+ <div className="flex justify-between items-start mb-2">
632
+ <span className={`text-sm font-semibold tracking-wide ${isActive ? 'text-blue-700' : 'text-neutral-800'}`}>{c.message}</span>
633
+ <span className="text-[10px] font-mono text-neutral-400 bg-neutral-100 border border-neutral-200 px-2 py-1 rounded">{c.oid.substring(0, 7)}</span>
634
+ </div>
635
+ <div className="flex items-center gap-2 text-xs text-neutral-500">
636
+ <span className="truncate max-w-[120px]">{c.author}</span> • <span>{date}</span>
637
+ </div>
638
+ </div>
639
+ );
640
+ })}
641
+ </div>
642
+ )}
643
+
644
+ {/* Persist Restore Button pinned to bottom */}
645
+ {historyCommits.length > 0 && activeCommit !== historyCommits[0]?.oid && (
646
+ <div className="mt-8 pt-4 border-t border-neutral-200 sticky bottom-0 bg-white/80 backdrop-blur-md pb-4 z-10">
647
+ <button
648
+ onClick={handleRestoreGhostState}
649
+ disabled={isRestoring}
650
+ className="w-full py-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold tracking-wide transition-all shadow-sm flex items-center justify-center gap-2"
651
+ >
652
+ <Rewind className="w-5 h-5" />
653
+ {isRestoring ? 'Restoring Time...' : 'Restore This Version'}
654
+ </button>
655
+ </div>
656
+ )}
657
+ </div>
658
+ ) : (
659
+ <div className="flex-1 flex flex-col overflow-hidden">
660
+ {/* Breadcrumb Header */}
661
+ {doc && schemaRegistry[doc.collection] && (
662
+ <div className="px-5 py-3 border-b border-neutral-200 bg-neutral-50 flex items-center gap-2 text-xs text-neutral-500 shrink-0 overflow-x-auto no-scrollbar">
663
+ <button
664
+ onClick={() => setActivePath('')}
665
+ className={`transition-colors hover:text-neutral-900 ${!activePath ? 'text-neutral-900 font-medium' : ''}`}
666
+ >
667
+ Root
668
+ </button>
669
+ {activePath.split('.').filter(Boolean).map((segment, idx, arr) => {
670
+ const cumulativePath = arr.slice(0, idx + 1).join('.');
671
+ const isLast = idx === arr.length - 1;
672
+
673
+ // Traverse draft to get current node
674
+ let segmentData = draft;
675
+ for (const p of arr.slice(0, idx + 1)) {
676
+ if (segmentData) segmentData = segmentData[p];
677
+ }
678
+
679
+ let displaySegment = segment;
680
+
681
+ if (/^\d+$/.test(segment)) {
682
+ // If this is a numeric index, try to map to _type semantic name
683
+ if (segmentData && segmentData._type) {
684
+ displaySegment = `${segmentData._type} Block`;
685
+ } else {
686
+ return null; // hide raw numbers without types
687
+ }
688
+ } else {
689
+ // If the NEXT segment is an index with a _type, hide THIS container word (like "blocks" or "items")
690
+ const nextSegment = arr[idx + 1];
691
+ if (nextSegment && /^\d+$/.test(nextSegment)) {
692
+ const peekPath = arr.slice(0, idx + 2);
693
+ let peekData = draft;
694
+ for (const p of peekPath) {
695
+ if (peekData) peekData = peekData[p];
696
+ }
697
+ if (peekData && peekData._type) {
698
+ return null; // Skip "blocks", the next loop will render "Hero Block"
699
+ }
700
+ }
701
+ }
702
+
703
+ return (
704
+ <React.Fragment key={cumulativePath}>
705
+ <span className="text-neutral-300">/</span>
706
+ <button
707
+ onClick={() => setActivePath(cumulativePath)}
708
+ className={`capitalize transition-colors hover:text-neutral-900 ${isLast ? 'text-neutral-900 font-medium' : ''}`}
709
+ >
710
+ {displaySegment}
711
+ </button>
712
+ </React.Fragment>
713
+ );
714
+ })}
715
+ {vibeConfig.locales && vibeConfig.locales.length > 0 && (
716
+ <div className="ml-auto flex items-center gap-2">
717
+ <select
718
+ value={activeLocale}
719
+ onChange={async (e) => {
720
+ const newLocale = e.target.value;
721
+ setActiveLocale(newLocale);
722
+ if (doc) {
723
+ const data = await VibeEngine.read(doc.collection, doc.slug, { locale: newLocale });
724
+ contentDraft.set(data || {});
725
+ }
726
+ }}
727
+ className="bg-white border border-neutral-200 rounded px-2 py-1 text-xs text-neutral-700 outline-none focus:ring-2 focus:ring-indigo-500"
728
+ >
729
+ {vibeConfig.locales.map((loc: string) => (
730
+ <option key={loc} value={loc}>{loc.toUpperCase()}</option>
731
+ ))}
732
+ </select>
733
+ </div>
734
+ )}
735
+ </div>
736
+ )}
737
+
738
+ <div className="flex-1 overflow-y-auto p-5 custom-scrollbar pb-10">
739
+ {doc && schemaRegistry[doc.collection] ? (
740
+ <FormGenerator
741
+ schema={schemaRegistry[doc.collection]}
742
+ data={draft}
743
+ onChange={(p, v) => updateDraftPath(p, v)}
744
+ activePath={activePath}
745
+ setActivePath={setActivePath}
746
+ />
747
+ ) : (
748
+ <div className="text-center text-neutral-500 mt-16 flex flex-col items-center gap-3">
749
+ <svg className="w-10 h-10 text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
750
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
751
+ </svg>
752
+ <div>
753
+ <p className="text-sm font-medium text-neutral-700">No document selected</p>
754
+ <p className="text-xs mt-1">Press <kbd className="bg-neutral-200 border border-neutral-300 px-1 rounded mx-1 text-neutral-600">⌘K</kbd> to open command palette.</p>
755
+ </div>
756
+ </div>
757
+ )}
758
+ </div>
759
+ </div>
760
+ )}
761
+
762
+ {/* Commit Drawer Layer */}
763
+ <div className="absolute bottom-0 w-full z-20 pointer-events-none">
764
+ {commitOpen && (
765
+ <motion.div
766
+ initial={{ y: 20, opacity: 0 }}
767
+ animate={{ y: 0, opacity: 1 }}
768
+ className="bg-white border-t border-neutral-200 shadow-[0_-20px_50px_rgba(0,0,0,0.1)] pointer-events-auto p-4 flex flex-col gap-4"
769
+ >
770
+ <div className="flex items-center justify-between">
771
+ <div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
772
+ <GitCommit className="w-4 h-4 text-indigo-500" />
773
+ Commit Context Let
774
+ </div>
775
+ <button onClick={() => setCommitOpen(false)} className="text-neutral-400 hover:text-neutral-900 transition-colors">✕</button>
776
+ </div>
777
+
778
+ <textarea
779
+ value={commitMsg}
780
+ onChange={(e) => setCommitMsg(e.target.value)}
781
+ placeholder="What did you change? (e.g., Added pricing plans grid)"
782
+ className="w-full bg-neutral-50 border border-neutral-300 rounded-md p-3 text-sm text-neutral-900 resize-none focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 transition-all"
783
+ rows={3}
784
+ />
785
+
786
+ <div className="flex items-center gap-2 justify-end">
787
+ {syncRequired ? (
788
+ <Button onClick={handleSync} disabled={isSaving} className="bg-amber-600 hover:bg-amber-500">
789
+ {isSaving ? 'Synchronizing...' : 'Pull Required (Sync)'}
790
+ </Button>
791
+ ) : authStatus === 'authenticated' ? (
792
+ <Button onClick={handleCommitAndPush} disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-500 font-semibold gap-2">
793
+ <Check className="w-4 h-4" />
794
+ {isSaving ? 'Committing...' : 'Commit & Push'}
795
+ </Button>
796
+ ) : (
797
+ <>
798
+ <Button onClick={handleCommitAndPush} disabled={isSaving} className="bg-white border border-neutral-300 hover:bg-neutral-50 text-neutral-700 font-medium">
799
+ Commit Locally Only
800
+ </Button>
801
+ {auth && <Button onClick={() => auth.signIn()} className="bg-[#24292e] hover:bg-[#2f363d] text-white">Login to Push via OAuth</Button>}
802
+ </>
803
+ )}
804
+ </div>
805
+ </motion.div>
806
+ )}
807
+ {/* Phase 17 Merge Conflict Overlay */}
808
+ {syncCollision && (
809
+ <motion.div
810
+ initial={{ opacity: 0, y: 20 }}
811
+ animate={{ opacity: 1, y: 0 }}
812
+ className="absolute inset-x-4 bottom-20 bg-rose-950/90 backdrop-blur-3xl border border-rose-500/50 rounded-xl p-5 shadow-[0_0_50px_rgba(225,29,72,0.3)] z-50 flex flex-col gap-4"
813
+ >
814
+ <div className="flex items-start justify-between">
815
+ <div className="flex items-center gap-2 text-rose-400 font-bold mb-1">
816
+ <AlertCircle className="w-5 h-5" />
817
+ Sync Collision Detected
818
+ </div>
819
+ <button onClick={() => setSyncCollision(false)} className="text-rose-400/50 hover:text-rose-400 transition-colors">✕</button>
820
+ </div>
821
+ <p className="text-sm text-rose-200/80 leading-relaxed">
822
+ Another editor has pushed changes to this document that conflict with your local draft. VibeCMS halted the sync to protect your work.
823
+ </p>
824
+
825
+ <div className="bg-rose-900/20 p-3 rounded-lg border border-rose-500/20 text-xs text-rose-300 font-mono overflow-auto max-h-24">
826
+ Option A: Use the "Copy" tools on your blocks to stash them in the Vibe Clipboard, then discard your draft below.
827
+ <br/><br/>
828
+ Option B: Discard your local draft below permanently and pull the latest changes from the server.
829
+ </div>
830
+
831
+ <div className="flex items-center gap-3 justify-end mt-2">
832
+ <Button onClick={async () => {
833
+ if (!window.confirm("This will permanently delete your unpushed local changes and pull the remote server data. Proceed?")) return;
834
+ await handleRevert();
835
+ await handleSync();
836
+ setSyncCollision(false);
837
+ toast.success("Resolved collision. Remote data loaded.");
838
+ }} className="bg-rose-600 hover:bg-rose-500 text-white font-semibold">
839
+ Discard My Draft & Pull
840
+ </Button>
841
+ </div>
842
+ </motion.div>
843
+ )}
844
+
845
+ {/* The persistent footer bar */}
846
+ <div className="p-4 bg-white/80 backdrop-blur-xl border-t border-neutral-200 flex gap-2 w-full pointer-events-auto shrink-0 z-30 relative">
847
+ <button
848
+ onClick={handleSaveDraft}
849
+ title="Secure file without committing to chain"
850
+ className="flex-none px-4 rounded-md bg-white hover:bg-neutral-50 border border-neutral-300 text-sm font-medium transition-colors text-neutral-700 whitespace-nowrap"
851
+ >
852
+ Save Draft
853
+ </button>
854
+ <button
855
+ onClick={() => setCommitOpen(!commitOpen)}
856
+ className={`flex-1 rounded-md text-sm font-semibold tracking-wide h-10 flex items-center justify-center gap-2 transition-all ${isDirtyGit ? 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-[0_0_15px_rgba(99,102,241,0.4)]' : 'bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 border border-neutral-300 shadow-sm'}`}
857
+ >
858
+ {isDirtyGit ? <AlertCircle className="w-4 h-4" /> : <GitCommit className="w-4 h-4" />}
859
+ {isDirtyGit ? 'Review Commit Stage' : 'Commit Stage'}
860
+ <ChevronUp className={`w-4 h-4 transition-transform ${commitOpen ? 'rotate-180' : ''}`} />
861
+ </button>
862
+ </div>
863
+ </div>
864
+ </div>
865
+ </motion.div>
866
+
867
+ <MediaGallery open={mediaOpen} onOpenChange={setMediaOpen} onSelect={() => {}} />
868
+
869
+ <CommandDialog open={cmdOpen} onOpenChange={setCmdOpen}>
870
+ <CommandInput placeholder="Type a command or search..." />
871
+ <CommandList>
872
+ <CommandEmpty>No results found.</CommandEmpty>
873
+ <CommandGroup heading="Create New">
874
+ {Object.entries(vibeConfig.collections || {}).map(([key, col]: [string, any]) => (
875
+ <CommandItem
876
+ key={key}
877
+ onSelect={async () => {
878
+ const newSlug = window.prompt(`Enter new ${col.name} slug (e.g. docs):`);
879
+ if (newSlug) {
880
+ const cleanSlug = newSlug.replace(/[^a-z0-9-]/gi, '').toLowerCase();
881
+ const defaultData = { _type: `__${key}__` }; // minimal seed
882
+ await VibeEngine.write(key, `${cleanSlug}.json`, defaultData, { locale: activeLocale });
883
+ contentDraft.set(defaultData);
884
+ currentDocument.set({ collection: key, slug: `${cleanSlug}.json` });
885
+ setCmdOpen(false);
886
+ toast.success(`Created /${key}/${cleanSlug}`);
887
+ }
888
+ }}
889
+ className="cursor-pointer flex items-center gap-2"
890
+ >
891
+ <div className="p-1 rounded bg-indigo-50 text-indigo-600">
892
+ <AlignLeft className="w-3.5 h-3.5" />
893
+ </div>
894
+ <div>
895
+ <p className="font-medium text-neutral-900">{col.name}</p>
896
+ </div>
897
+ </CommandItem>
898
+ ))}
899
+ </CommandGroup>
900
+
901
+ {Object.entries(vibeConfig.collections || {}).map(([key, col]: [string, any]) => {
902
+ const docs = searchResults.filter((d: any) => d.collection === key);
903
+ if (docs.length === 0) return null;
904
+ return (
905
+ <CommandGroup key={key} heading={col.name}>
906
+ {docs.map((docItem: any, idx: number) => (
907
+ <CommandItem
908
+ key={`${key}-${docItem.slug}-${idx}`}
909
+ onSelect={async () => {
910
+ const data = await VibeEngine.read(key, docItem.slug, { locale: activeLocale });
911
+ if (data) contentDraft.set(data);
912
+ currentDocument.set({ collection: key, slug: docItem.slug });
913
+ setCmdOpen(false);
914
+ setActivePath('');
915
+ }}
916
+ className="cursor-pointer flex items-center gap-2 pl-6"
917
+ >
918
+ <span className="text-neutral-500 w-4 font-mono text-xs opacity-50">{idx + 1}</span>
919
+ <span className="font-medium text-neutral-800">{docItem.title || docItem.slug.replace('.json', '')}</span>
920
+ <span className="text-[10px] bg-neutral-100 text-neutral-400 px-1.5 py-0.5 rounded border border-neutral-200 ml-auto select-none">/{docItem.slug}</span>
921
+ </CommandItem>
922
+ ))}
923
+ </CommandGroup>
924
+ );
925
+ })}
926
+ {Object.entries(vibeConfig.singletons || {}).map(([key, col]: [string, any]) => {
927
+ const docs = searchResults.filter((d: any) => d.collection === key);
928
+ if (docs.length === 0) return null;
929
+ return (
930
+ <CommandGroup key={key} heading={col.name}>
931
+ {docs.map((docItem: any, idx: number) => (
932
+ <CommandItem
933
+ key={`${key}-${docItem.slug}-${idx}`}
934
+ onSelect={async () => {
935
+ const data = await VibeEngine.read(key, 'index', { locale: activeLocale });
936
+ if (data) contentDraft.set(data);
937
+ currentDocument.set({ collection: key, slug: 'index' });
938
+ setCmdOpen(false);
939
+ setActivePath('');
940
+ }}
941
+ className="cursor-pointer flex items-center gap-2 pl-6"
942
+ >
943
+ <span className="font-medium text-neutral-800">{col.name}</span>
944
+ <span className="text-[10px] bg-neutral-100 text-neutral-400 px-1.5 py-0.5 rounded border border-neutral-200 ml-auto select-none">Global</span>
945
+ </CommandItem>
946
+ ))}
947
+ </CommandGroup>
948
+ );
949
+ })}
950
+ </CommandList>
951
+ </CommandDialog>
952
+ </>
953
+ );
954
+ }