@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,43 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { VibeEngine } from '@/lib/cms/engine';
|
|
3
|
+
|
|
4
|
+
export async function SiteHeader() {
|
|
5
|
+
const settings = await VibeEngine.read('settings', 'site.json');
|
|
6
|
+
const links = settings?.navigation || [];
|
|
7
|
+
const companyName = settings?.legal?.companyName || 'VibeCMS';
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<header className="sticky top-0 z-40 w-full backdrop-blur-md bg-white/80 border-b border-neutral-200 transition-colors shadow-sm">
|
|
11
|
+
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6">
|
|
12
|
+
<Link href="/" className="flex items-center gap-2 font-bold tracking-tighter text-lg text-neutral-900 group">
|
|
13
|
+
<div className="w-4 h-4 rounded bg-indigo-500 group-hover:bg-indigo-600 transition-colors" />
|
|
14
|
+
{companyName}
|
|
15
|
+
</Link>
|
|
16
|
+
<nav className="flex items-center gap-6 text-sm font-medium">
|
|
17
|
+
{links.map((link: any, idx: number) => (
|
|
18
|
+
<div key={idx} className="relative group">
|
|
19
|
+
<Link
|
|
20
|
+
href={link.url}
|
|
21
|
+
className="text-neutral-600 hover:text-neutral-900 transition-colors"
|
|
22
|
+
>
|
|
23
|
+
{link.label}
|
|
24
|
+
</Link>
|
|
25
|
+
{/* Visual marker helper for edit mode highlight bindings if needed */}
|
|
26
|
+
<EditTabIndicator field={`navigation.${idx}.label`} />
|
|
27
|
+
</div>
|
|
28
|
+
))}
|
|
29
|
+
</nav>
|
|
30
|
+
</div>
|
|
31
|
+
</header>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Very simple relative binding helper
|
|
36
|
+
function EditTabIndicator({ field }: { field: string }) {
|
|
37
|
+
return (
|
|
38
|
+
<span
|
|
39
|
+
id={`editor-field-${field}`}
|
|
40
|
+
className="absolute -inset-2 -z-10 rounded-md pointer-events-none"
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
|
|
2
|
+
// src/components/cms/VisualWrapper.tsx
|
|
3
|
+
'use client';
|
|
4
|
+
import React, { useEffect, useState, useRef } from 'react';
|
|
5
|
+
import { motion } from 'framer-motion';
|
|
6
|
+
import { useStore } from '@nanostores/react';
|
|
7
|
+
import { isEditMode, contentDraft, setFocusedField, focusedField, layoutWarnings } from '@/lib/cms/store';
|
|
8
|
+
import { sanitizeHtml } from '@/lib/cms/sanitize';
|
|
9
|
+
|
|
10
|
+
interface VisualWrapperProps {
|
|
11
|
+
fieldPath: string;
|
|
12
|
+
children?: React.ReactNode | ((value: any) => React.ReactNode);
|
|
13
|
+
className?: string;
|
|
14
|
+
fallback?: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function VisualWrapper({ fieldPath, children, className = '', fallback }: VisualWrapperProps) {
|
|
18
|
+
const isEditing = useStore(isEditMode);
|
|
19
|
+
const draft = useStore(contentDraft);
|
|
20
|
+
const focus = useStore(focusedField);
|
|
21
|
+
const [mounted, setMounted] = useState(false);
|
|
22
|
+
const wrapperRef = useRef<HTMLSpanElement>(null);
|
|
23
|
+
|
|
24
|
+
const isFocused = focus?.startsWith(fieldPath);
|
|
25
|
+
|
|
26
|
+
useEffect(() => setMounted(true), []);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!wrapperRef.current || !isEditing) return;
|
|
30
|
+
|
|
31
|
+
// Build the observer to capture Layout Overflows gracefully without exploding the main thread
|
|
32
|
+
let rafId: number;
|
|
33
|
+
const observer = new ResizeObserver(() => {
|
|
34
|
+
cancelAnimationFrame(rafId);
|
|
35
|
+
rafId = requestAnimationFrame(() => {
|
|
36
|
+
const el = wrapperRef.current;
|
|
37
|
+
if (!el) return;
|
|
38
|
+
|
|
39
|
+
const isOverflowing = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
|
40
|
+
const currentWarning = layoutWarnings.get()[fieldPath];
|
|
41
|
+
|
|
42
|
+
if (isOverflowing && !currentWarning) {
|
|
43
|
+
layoutWarnings.setKey(fieldPath, true);
|
|
44
|
+
} else if (!isOverflowing && currentWarning) {
|
|
45
|
+
layoutWarnings.setKey(fieldPath, false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
observer.observe(wrapperRef.current);
|
|
51
|
+
|
|
52
|
+
// Also track child additions or stylistic recalculations that might not resize the parent explicitly
|
|
53
|
+
const mutObserver = new MutationObserver(() => {
|
|
54
|
+
const el = wrapperRef.current;
|
|
55
|
+
if (!el) return;
|
|
56
|
+
const isOverflowing = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
|
57
|
+
const currentWarning = layoutWarnings.get()[fieldPath];
|
|
58
|
+
if (isOverflowing !== !!currentWarning) {
|
|
59
|
+
layoutWarnings.setKey(fieldPath, isOverflowing);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
mutObserver.observe(wrapperRef.current, { childList: true, subtree: true, characterData: true });
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
observer.disconnect();
|
|
67
|
+
mutObserver.disconnect();
|
|
68
|
+
cancelAnimationFrame(rafId);
|
|
69
|
+
layoutWarnings.setKey(fieldPath, false); // Cleanup
|
|
70
|
+
};
|
|
71
|
+
}, [isEditing, fieldPath]);
|
|
72
|
+
|
|
73
|
+
// Read value from drafted store
|
|
74
|
+
const keys = fieldPath.split('.');
|
|
75
|
+
let value: any = draft;
|
|
76
|
+
for (const k of keys) {
|
|
77
|
+
if (value === undefined || value === null) break;
|
|
78
|
+
value = value[k];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
82
|
+
if (!isEditing) return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
setFocusedField(fieldPath);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isEditingStyles = isEditing
|
|
89
|
+
? `cursor-pointer transition-all duration-200 ring-1 rounded relative z-10 block ${isFocused ? 'ring-2 ring-indigo-500 bg-indigo-500/10' : 'ring-transparent hover:ring-indigo-500 hover:bg-indigo-500/10'}`
|
|
90
|
+
: '';
|
|
91
|
+
|
|
92
|
+
// Determine what to render based on children type
|
|
93
|
+
let content = fallback;
|
|
94
|
+
if (typeof children === 'function') {
|
|
95
|
+
content = children(value);
|
|
96
|
+
} else if (value !== undefined && typeof value !== 'object') {
|
|
97
|
+
// Overwrite child with primitive value to ensure it updates visually
|
|
98
|
+
content = value;
|
|
99
|
+
} else if (children) {
|
|
100
|
+
content = children;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!mounted) {
|
|
104
|
+
// Avoid hydration mismatch by rendering default children on first pass
|
|
105
|
+
return (
|
|
106
|
+
<span className={className}>
|
|
107
|
+
{typeof children === 'function' ? fallback : children}
|
|
108
|
+
</span>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let finalRender = content;
|
|
113
|
+
if (typeof content === 'string' && (content.includes('<br') || content.includes('­'))) {
|
|
114
|
+
finalRender = <span dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<span
|
|
119
|
+
ref={wrapperRef}
|
|
120
|
+
className={`visual-wrapper ${isEditingStyles} ${className}`}
|
|
121
|
+
onClick={handleClick}
|
|
122
|
+
title={isEditing ? `Edit ${fieldPath}` : undefined}
|
|
123
|
+
data-vibe-path={isEditing ? fieldPath : undefined}
|
|
124
|
+
>
|
|
125
|
+
{finalRender}
|
|
126
|
+
</span>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger, Button, Label } from '@vibecms/core';
|
|
5
|
+
|
|
6
|
+
export const BRAND_COLORS = [
|
|
7
|
+
{ name: 'Indigo', value: 'indigo-500', hex: '#6366f1' },
|
|
8
|
+
{ name: 'Violet', value: 'violet-500', hex: '#8b5cf6' },
|
|
9
|
+
{ name: 'Purple', value: 'purple-500', hex: '#a855f7' },
|
|
10
|
+
{ name: 'Fuchsia', value: 'fuchsia-500', hex: '#d946ef' },
|
|
11
|
+
{ name: 'Rose', value: 'rose-500', hex: '#f43f5e' },
|
|
12
|
+
{ name: 'Emerald', value: 'emerald-500', hex: '#10b981' },
|
|
13
|
+
{ name: 'Amber', value: 'amber-500', hex: '#f59e0b' },
|
|
14
|
+
{ name: 'Sky', value: 'sky-500', hex: '#0ea5e9' },
|
|
15
|
+
{ name: 'Slate', value: 'slate-800', hex: '#1e293b' },
|
|
16
|
+
{ name: 'White', value: 'white', hex: '#ffffff' },
|
|
17
|
+
{ name: 'Black', value: 'black', hex: '#000000' },
|
|
18
|
+
{ name: 'Transparent', value: 'transparent', hex: 'transparent' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function ColorPicker({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) {
|
|
22
|
+
const [open, setOpen] = useState(false);
|
|
23
|
+
const activeColor = BRAND_COLORS.find(c => c.value === value) || BRAND_COLORS[0];
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col gap-2">
|
|
27
|
+
<Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider">{label}</Label>
|
|
28
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
29
|
+
<PopoverTrigger className="w-full">
|
|
30
|
+
<div
|
|
31
|
+
role="button"
|
|
32
|
+
className="w-full flex items-center justify-between bg-white border border-neutral-300 text-neutral-900 hover:bg-neutral-50 transition-colors p-2.5 rounded-md cursor-pointer text-sm font-medium shadow-sm"
|
|
33
|
+
>
|
|
34
|
+
<div className="flex items-center gap-2">
|
|
35
|
+
<div
|
|
36
|
+
className={`w-4 h-4 rounded-full border ${activeColor.value === 'white' ? 'border-neutral-300' : 'border-transparent'}`}
|
|
37
|
+
style={{ backgroundColor: activeColor.hex }}
|
|
38
|
+
title={activeColor.name}
|
|
39
|
+
/>
|
|
40
|
+
<span className="capitalize">{activeColor.name || value || 'Select Color'}</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</PopoverTrigger>
|
|
44
|
+
<PopoverContent className="w-56 bg-white border-neutral-200 p-3 shadow-2xl">
|
|
45
|
+
<div className="grid grid-cols-4 gap-2">
|
|
46
|
+
{BRAND_COLORS.map((color) => (
|
|
47
|
+
<button
|
|
48
|
+
key={color.value}
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => {
|
|
51
|
+
onChange(color.value);
|
|
52
|
+
setOpen(false);
|
|
53
|
+
}}
|
|
54
|
+
className={`w-10 h-10 rounded-full flex items-center justify-center transition-transform hover:scale-110 border-2 ${value === color.value ? 'border-white scale-110 shadow-[0_0_10px_rgba(255,255,255,0.3)]' : 'border-transparent'}`}
|
|
55
|
+
style={{
|
|
56
|
+
backgroundColor: color.hex,
|
|
57
|
+
background: color.value === 'transparent' ? 'repeating-conic-gradient(#333 0% 25%, transparent 0% 50%) 50% / 10px 10px' : color.hex
|
|
58
|
+
}}
|
|
59
|
+
title={color.name}
|
|
60
|
+
>
|
|
61
|
+
{value === color.value && color.value !== 'transparent' && color.value !== 'white' && (
|
|
62
|
+
<div className="w-2 h-2 bg-white rounded-full opacity-80" />
|
|
63
|
+
)}
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</PopoverContent>
|
|
68
|
+
</Popover>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger, Button, Label, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@vibecms/core';
|
|
5
|
+
import * as LucideIcons from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
const COMMON_ICONS = [
|
|
8
|
+
'Zap', 'Shield', 'Star', 'Heart', 'CheckCircle', 'ArrowRight',
|
|
9
|
+
'TrendingUp', 'Users', 'Target', 'Rocket', 'Settings', 'Code',
|
|
10
|
+
'Smartphone', 'Laptop', 'Globe', 'Mail', 'Phone', 'MapPin',
|
|
11
|
+
'Camera', 'Video', 'Music', 'Play', 'Search', 'Menu', 'X',
|
|
12
|
+
'Facebook', 'Twitter', 'Instagram', 'Linkedin', 'Github'
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export function IconPicker({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) {
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
|
|
18
|
+
// Safely get the current icon
|
|
19
|
+
const ActiveIcon = (LucideIcons as any)[value] || LucideIcons.Zap;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-col gap-2">
|
|
23
|
+
<Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider">{label}</Label>
|
|
24
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
25
|
+
<PopoverTrigger className="w-full">
|
|
26
|
+
<div
|
|
27
|
+
role="button"
|
|
28
|
+
className="w-full flex items-center justify-between bg-white border border-neutral-300 text-neutral-900 hover:bg-neutral-50 transition-colors p-2.5 rounded-md cursor-pointer text-sm font-medium shadow-sm"
|
|
29
|
+
>
|
|
30
|
+
<div className="flex items-center gap-2">
|
|
31
|
+
<ActiveIcon className="w-4 h-4 text-indigo-500" />
|
|
32
|
+
<span className="capitalize">{value || 'Select Icon'}</span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</PopoverTrigger>
|
|
36
|
+
<PopoverContent className="w-64 bg-white border-neutral-200 p-0 shadow-2xl">
|
|
37
|
+
<Command>
|
|
38
|
+
<CommandInput placeholder="Search icons..." className="border-none text-neutral-900 placeholder:text-neutral-400" />
|
|
39
|
+
<CommandList className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
|
40
|
+
<CommandEmpty>No icons found.</CommandEmpty>
|
|
41
|
+
<CommandGroup>
|
|
42
|
+
{COMMON_ICONS.map((iconName) => {
|
|
43
|
+
const Icon = (LucideIcons as any)[iconName];
|
|
44
|
+
if (!Icon) return null;
|
|
45
|
+
return (
|
|
46
|
+
<CommandItem
|
|
47
|
+
key={iconName}
|
|
48
|
+
value={iconName}
|
|
49
|
+
onSelect={(currentValue: string) => {
|
|
50
|
+
onChange(currentValue === value ? '' : iconName);
|
|
51
|
+
setOpen(false);
|
|
52
|
+
}}
|
|
53
|
+
className="cursor-pointer flex items-center gap-3 text-neutral-700 hover:text-neutral-900 hover:bg-neutral-100 data-[selected=true]:bg-indigo-50 data-[selected=true]:text-indigo-600"
|
|
54
|
+
>
|
|
55
|
+
<Icon className="w-4 h-4" />
|
|
56
|
+
{iconName}
|
|
57
|
+
</CommandItem>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</CommandGroup>
|
|
61
|
+
</CommandList>
|
|
62
|
+
</Command>
|
|
63
|
+
</PopoverContent>
|
|
64
|
+
</Popover>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import { VibeEngine } from '@/lib/cms/engine';
|
|
4
|
+
import { UploadCloud, Loader2, Image as ImageIcon } from 'lucide-react';
|
|
5
|
+
import { MediaGallery } from './MediaGallery';
|
|
6
|
+
import { toast } from 'sonner';
|
|
7
|
+
|
|
8
|
+
interface ImageUploadProps {
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
label?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ImageUpload({ value, onChange, label }: ImageUploadProps) {
|
|
15
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
16
|
+
const [previewUrl, setPreviewUrl] = useState<string>('');
|
|
17
|
+
const [dragActive, setDragActive] = useState(false);
|
|
18
|
+
const [galleryOpen, setGalleryOpen] = useState(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (value) {
|
|
22
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
23
|
+
setPreviewUrl(value);
|
|
24
|
+
} else {
|
|
25
|
+
VibeEngine.getMediaUrl(value).then(url => {
|
|
26
|
+
setPreviewUrl(url);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
setPreviewUrl('');
|
|
31
|
+
}
|
|
32
|
+
}, [value]);
|
|
33
|
+
|
|
34
|
+
const handleUpload = async (file: File) => {
|
|
35
|
+
if (!file.type.startsWith('image/')) return;
|
|
36
|
+
setIsUploading(true);
|
|
37
|
+
try {
|
|
38
|
+
const result = await VibeEngine.writeMedia(file);
|
|
39
|
+
// Result might be a string URL or an object with a url property
|
|
40
|
+
const url = typeof result === 'string' ? result : (result as any).url;
|
|
41
|
+
onChange(url);
|
|
42
|
+
toast.success('Media successfully uploaded to Repository');
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
console.error('Upload failed', e);
|
|
45
|
+
toast.error(e.message || 'Image upload failed.');
|
|
46
|
+
}
|
|
47
|
+
setIsUploading(false);
|
|
48
|
+
setDragActive(false);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
setDragActive(false);
|
|
55
|
+
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
|
56
|
+
handleUpload(e.dataTransfer.files[0]);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleDrag = (e: React.DragEvent) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
if (e.type === 'dragenter' || e.type === 'dragover') {
|
|
64
|
+
setDragActive(true);
|
|
65
|
+
} else if (e.type === 'dragleave') {
|
|
66
|
+
setDragActive(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex flex-col gap-1.5 my-2">
|
|
72
|
+
<div className="flex items-center justify-between">
|
|
73
|
+
<span className="text-neutral-500 text-xs font-mono uppercase tracking-wider">{label}</span>
|
|
74
|
+
<button
|
|
75
|
+
onClick={() => setGalleryOpen(true)}
|
|
76
|
+
className="text-[10px] flex items-center gap-1 bg-neutral-100 hover:bg-neutral-200 text-neutral-600 px-2 py-1 rounded border border-neutral-300 transition-colors shadow-sm"
|
|
77
|
+
>
|
|
78
|
+
<ImageIcon className="w-3 h-3" /> Browse Library
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div
|
|
82
|
+
className={`relative border-2 border-dashed rounded-lg p-4 flex flex-col items-center justify-center transition-colors min-h-[120px] ${dragActive ? 'border-indigo-500 bg-indigo-50' : 'border-neutral-300 bg-neutral-50 hover:bg-neutral-100'}`}
|
|
83
|
+
onDragEnter={handleDrag}
|
|
84
|
+
onDragLeave={handleDrag}
|
|
85
|
+
onDragOver={handleDrag}
|
|
86
|
+
onDrop={handleDrop}
|
|
87
|
+
>
|
|
88
|
+
<input
|
|
89
|
+
type="file"
|
|
90
|
+
accept="image/*"
|
|
91
|
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
92
|
+
onChange={(e) => {
|
|
93
|
+
if (e.target.files?.[0]) handleUpload(e.target.files[0]);
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{isUploading ? (
|
|
98
|
+
<Loader2 className="w-6 h-6 animate-spin text-indigo-400" />
|
|
99
|
+
) : previewUrl ? (
|
|
100
|
+
<div className="flex flex-col items-center gap-2">
|
|
101
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
102
|
+
<img src={previewUrl} alt="Preview" className="h-20 w-auto object-cover rounded shadow-md border border-neutral-200" />
|
|
103
|
+
<span className="text-xs text-neutral-500 truncate max-w-[200px]">{value}</span>
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
<div className="flex flex-col items-center text-neutral-400 gap-2 pointer-events-none">
|
|
107
|
+
<UploadCloud className="w-8 h-8" />
|
|
108
|
+
<span className="text-xs font-medium">Click or Drag Image</span>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<MediaGallery
|
|
114
|
+
open={galleryOpen}
|
|
115
|
+
onOpenChange={setGalleryOpen}
|
|
116
|
+
onSelect={(path) => onChange(path)}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@vibecms/core';
|
|
4
|
+
import { Image as ImageIcon, Loader2, Trash2, Edit2, Check, X } from 'lucide-react';
|
|
5
|
+
import { VibeEngine } from '@/lib/cms/engine';
|
|
6
|
+
import { toast } from 'sonner';
|
|
7
|
+
|
|
8
|
+
export function MediaGallery({
|
|
9
|
+
open,
|
|
10
|
+
onOpenChange,
|
|
11
|
+
onSelect
|
|
12
|
+
}: {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onOpenChange: (open: boolean) => void;
|
|
15
|
+
onSelect?: (path: string) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const [images, setImages] = useState<string[]>([]);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [renamingAsset, setRenamingAsset] = useState<string | null>(null);
|
|
20
|
+
const [newName, setNewName] = useState('');
|
|
21
|
+
|
|
22
|
+
const fetchMedia = async () => {
|
|
23
|
+
setLoading(true);
|
|
24
|
+
try {
|
|
25
|
+
const files = await VibeEngine.listMedia();
|
|
26
|
+
setImages(files || []);
|
|
27
|
+
} catch (e: any) {
|
|
28
|
+
toast.error('Failed to load media: ' + e.message);
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (open) {
|
|
36
|
+
fetchMedia();
|
|
37
|
+
}
|
|
38
|
+
}, [open]);
|
|
39
|
+
|
|
40
|
+
const handleDelete = async (e: React.MouseEvent, src: string) => {
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
if (!window.confirm('Are you certain you want to delete this asset? This cannot be undone.')) return;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await VibeEngine.deleteMedia(src);
|
|
46
|
+
toast.success('Asset deleted successfully');
|
|
47
|
+
setImages(images.filter(i => i !== src));
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
toast.error('Failed to delete asset: ' + err.message);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const startRename = (e: React.MouseEvent, src: string) => {
|
|
54
|
+
e.stopPropagation();
|
|
55
|
+
setRenamingAsset(src);
|
|
56
|
+
setNewName(src.split('/').pop() || '');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const confirmRename = async (e: React.MouseEvent, src: string) => {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
if (!newName.trim() || src.endsWith(newName)) {
|
|
62
|
+
setRenamingAsset(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await VibeEngine.renameMedia(src, newName);
|
|
68
|
+
toast.success('Asset renamed successfully');
|
|
69
|
+
setRenamingAsset(null);
|
|
70
|
+
await fetchMedia();
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
toast.error('Failed to rename asset: ' + err.message);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
78
|
+
<DialogContent className="max-w-4xl bg-white border-neutral-200 text-neutral-900 shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
|
|
79
|
+
<DialogHeader className="shrink-0 p-6 pb-2">
|
|
80
|
+
<DialogTitle className="flex items-center gap-2 text-xl font-bold">
|
|
81
|
+
<ImageIcon className="w-5 h-5 text-indigo-500" />
|
|
82
|
+
Vibe Media Dashboard
|
|
83
|
+
</DialogTitle>
|
|
84
|
+
<DialogDescription className="text-neutral-500">
|
|
85
|
+
{onSelect
|
|
86
|
+
? 'Select an existing asset from the CMS repository to insert into the page.'
|
|
87
|
+
: 'Manage, rename, and organize your Git-tracked media assets.'}
|
|
88
|
+
</DialogDescription>
|
|
89
|
+
</DialogHeader>
|
|
90
|
+
|
|
91
|
+
<div className="flex-1 overflow-y-auto p-6 pt-2 custom-scrollbar relative min-h-[400px]">
|
|
92
|
+
{loading ? (
|
|
93
|
+
<div className="absolute inset-0 flex items-center justify-center bg-white/50 backdrop-blur-sm z-10">
|
|
94
|
+
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
|
|
95
|
+
</div>
|
|
96
|
+
) : images.length === 0 ? (
|
|
97
|
+
<div className="flex flex-col h-full items-center justify-center text-neutral-400 gap-4 mt-20">
|
|
98
|
+
<ImageIcon className="w-12 h-12 opacity-50" />
|
|
99
|
+
<p>No media files uploaded yet.</p>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
103
|
+
{images.map(src => {
|
|
104
|
+
const isRenaming = renamingAsset === src;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
key={src}
|
|
109
|
+
onClick={() => {
|
|
110
|
+
if (onSelect && !isRenaming) {
|
|
111
|
+
onSelect(src);
|
|
112
|
+
onOpenChange(false);
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
className={`group relative aspect-square bg-neutral-100 rounded-xl overflow-hidden border transition-all shadow-sm ${onSelect ? 'cursor-pointer hover:border-indigo-400 hover:shadow-md' : 'border-neutral-200'} ${isRenaming ? 'ring-2 ring-indigo-500' : ''}`}
|
|
116
|
+
>
|
|
117
|
+
<img
|
|
118
|
+
src={src}
|
|
119
|
+
alt="Gallery item"
|
|
120
|
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
{/* Dark gradient overlay on hover for better visibility of actions */}
|
|
124
|
+
<div className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
|
125
|
+
|
|
126
|
+
{/* Action buttons (Top Right) */}
|
|
127
|
+
{!isRenaming && (
|
|
128
|
+
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 translate-y-[-10px] group-hover:translate-y-0">
|
|
129
|
+
<button
|
|
130
|
+
onClick={(e) => startRename(e, src)}
|
|
131
|
+
className="p-1.5 bg-white/90 backdrop-blur-sm hover:bg-white text-neutral-600 hover:text-indigo-600 rounded-md shadow-sm transition-colors"
|
|
132
|
+
title="Rename Asset"
|
|
133
|
+
>
|
|
134
|
+
<Edit2 className="w-3.5 h-3.5" />
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
onClick={(e) => handleDelete(e, src)}
|
|
138
|
+
className="p-1.5 bg-white/90 backdrop-blur-sm hover:bg-white text-neutral-600 hover:text-rose-600 rounded-md shadow-sm transition-colors"
|
|
139
|
+
title="Delete Asset"
|
|
140
|
+
>
|
|
141
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Bottom Label or Renaming Input */}
|
|
147
|
+
<div className={`absolute inset-x-0 bottom-0 bg-white/90 backdrop-blur-md border-t border-neutral-200 transition-all ${isRenaming ? 'p-2' : 'p-2 translate-y-full group-hover:translate-y-0'}`}>
|
|
148
|
+
{isRenaming ? (
|
|
149
|
+
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
|
150
|
+
<input
|
|
151
|
+
autoFocus
|
|
152
|
+
value={newName}
|
|
153
|
+
onChange={e => setNewName(e.target.value)}
|
|
154
|
+
onKeyDown={e => {
|
|
155
|
+
if (e.key === 'Enter') confirmRename(e as any, src);
|
|
156
|
+
if (e.key === 'Escape') setRenamingAsset(null);
|
|
157
|
+
}}
|
|
158
|
+
className="w-full bg-white border border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 rounded text-xs p-1 text-neutral-800"
|
|
159
|
+
/>
|
|
160
|
+
<button onClick={(e) => confirmRename(e, src)} className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"><Check className="w-3 h-3" /></button>
|
|
161
|
+
<button onClick={() => setRenamingAsset(null)} className="p-1 text-neutral-500 hover:bg-neutral-100 rounded"><X className="w-3 h-3" /></button>
|
|
162
|
+
</div>
|
|
163
|
+
) : (
|
|
164
|
+
<p className="text-[10px] text-neutral-700 font-medium truncate" title={src.split('/').pop()}>{src.split('/').pop()}</p>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</DialogContent>
|
|
174
|
+
</Dialog>
|
|
175
|
+
);
|
|
176
|
+
}
|