Add Craft.js site builder (v2) - complete rebuild from GrapesJS

Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets';
export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
useEffect(() => {
loadAssets();
}, [loadAssets]);
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files) return;
for (let i = 0; i < files.length; i++) {
await uploadAsset(files[i]);
}
}, [uploadAsset]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const copyUrl = useCallback((url: string) => {
navigator.clipboard.writeText(url).then(() => {
setCopiedUrl(url);
setTimeout(() => setCopiedUrl(null), 2000);
});
}, []);
const isImage = (type: string) =>
type.startsWith('image/') || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(type);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Upload button */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: '#fff',
background: loading ? 'var(--color-bg-active)' : 'var(--color-accent)',
border: 'none',
borderRadius: 'var(--radius-md)',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all var(--transition-fast)',
}}
>
{loading ? 'Uploading...' : 'Upload File'}
</button>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
style={{
padding: 20,
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
textAlign: 'center',
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
fontSize: 11,
transition: 'all var(--transition-fast)',
}}
>
Drop files here to upload
</div>
{/* Error message */}
{error && (
<div
style={{
padding: '6px 10px',
fontSize: 11,
color: 'var(--color-danger)',
background: 'rgba(239, 68, 68, 0.1)',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
{error}
</div>
)}
{/* Asset grid */}
{assets.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: 20,
color: 'var(--color-text-dim)',
fontSize: 12,
fontStyle: 'italic',
}}
>
No assets uploaded yet
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 6,
}}
>
{assets.map((asset) => (
<div
key={asset.name}
style={{
position: 'relative',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
cursor: 'pointer',
transition: 'border-color var(--transition-fast)',
}}
onClick={() => copyUrl(asset.url)}
title={`Click to copy URL: ${asset.url}`}
>
{/* Thumbnail */}
<div
style={{
width: '100%',
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-bg-base)',
overflow: 'hidden',
}}
>
{isImage(asset.type) ? (
<img
src={asset.url}
alt={asset.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
loading="lazy"
/>
) : (
<span
style={{
fontSize: 20,
color: 'var(--color-text-dim)',
}}
>
&#128196;
</span>
)}
</div>
{/* Name */}
<div
style={{
padding: '4px 6px',
fontSize: 10,
color: 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{copiedUrl === asset.url ? 'Copied!' : asset.name}
</div>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
deleteAsset(asset.name);
}}
title="Delete asset"
style={{
position: 'absolute',
top: 4,
right: 4,
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: '#fff',
background: 'rgba(0,0,0,0.6)',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
opacity: 0.7,
transition: 'opacity var(--transition-fast)',
}}
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
>
&#10005;
</button>
</div>
))}
</div>
{/* Loading indicator */}
{loading && assets.length > 0 && (
<div
style={{
textAlign: 'center',
padding: 10,
color: 'var(--color-text-dim)',
fontSize: 11,
}}
>
Loading...
</div>
)}
</div>
);
};