@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.
- package/bin/vibe.js +497 -0
- package/package.json +39 -0
- package/templates/components/cms/Analytics.tsx +42 -0
- package/templates/components/cms/BlockRenderer.tsx +37 -0
- package/templates/components/cms/CmsStage.tsx +116 -0
- package/templates/components/cms/EditProvider.tsx +121 -0
- package/templates/components/cms/Editor.tsx +954 -0
- package/templates/components/cms/FormGenerator.tsx +611 -0
- package/templates/components/cms/SEO.tsx +35 -0
- package/templates/components/cms/SiteFooter.tsx +39 -0
- package/templates/components/cms/SiteHeader.tsx +43 -0
- package/templates/components/cms/VisualWrapper.tsx +128 -0
- package/templates/components/cms/fields/ColorPicker.tsx +71 -0
- package/templates/components/cms/fields/IconPicker.tsx +67 -0
- package/templates/components/cms/fields/ImageUpload.tsx +120 -0
- package/templates/components/cms/fields/MediaGallery.tsx +176 -0
- package/templates/components/cms/fields/MultiReferencePicker.tsx +83 -0
- package/templates/components/cms/fields/ReferencePicker.tsx +75 -0
- package/templates/components/cms/fields/RichText.tsx +121 -0
- package/templates/lib/cms/auditor.ts +307 -0
- package/templates/lib/cms/auth-nextauth.ts +26 -0
- package/templates/lib/cms/engine.ts +3 -0
- package/templates/lib/cms/registry.ts +12 -0
- package/templates/lib/cms/sanitize.ts +18 -0
- package/templates/lib/cms/schema.ts +51 -0
- package/templates/lib/cms/store.ts +59 -0
|
@@ -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
|
+
}
|