first commit

This commit is contained in:
sandhiya-hepl
2026-07-14 15:20:26 +05:30
commit 4b8af23f5f
125 changed files with 15124 additions and 0 deletions
+624
View File
@@ -0,0 +1,624 @@
import { useEffect, useRef, useState, Component } from 'react';
import {
LayoutDashboard,
Sparkles,
Building2,
Award,
Layers,
Target,
Images,
Package,
FileText,
Link2,
Settings,
LogOut,
ExternalLink,
Save,
Menu,
X,
CheckCircle2,
AlertCircle,
Clock,
} from 'lucide-react';
import { adminApi, getToken, setToken, clearToken } from './api';
import { Field, ImageUpload, ArrayEditor } from './components/FormFields';
import { SectionPreview } from './components/SectionPreview';
import { pickSectionData } from './contentHelpers';
import defaultContent from '../../server/seed/default-content.json';
const NAV_GROUPS = [
{
label: 'Overview',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, desc: 'Overview and quick access to all sections' },
],
},
{
label: 'Page Content',
items: [
{ id: 'hero', label: 'Hero & Metrics', icon: Sparkles, desc: 'Homepage headline, video, and statistics' },
{ id: 'about', label: 'About & Partners', icon: Building2, desc: 'About section and partner logos' },
{ id: 'certifications', label: 'Certifications', icon: Award, desc: 'Certification badges and headings' },
{ id: 'services', label: 'Services', icon: Layers, desc: 'Service cards and section copy' },
{ id: 'whyUs', label: 'Why Us', icon: Target, desc: 'Value propositions and images' },
{ id: 'gallery', label: 'Gallery', icon: Images, desc: 'Featured case studies and gallery items' },
{ id: 'products', label: 'Products', icon: Package, desc: 'Product suite cards and stats' },
{ id: 'insights', label: 'Insights', icon: FileText, desc: 'Blog and insight carousel items' },
{ id: 'footer', label: 'Footer & Links', icon: Link2, desc: 'CTA, social links, and footer navigation' },
],
},
{
label: 'Account',
items: [
{ id: 'settings', label: 'Settings', icon: Settings, desc: 'Password and account preferences' },
],
},
];
const ALL_SECTIONS = NAV_GROUPS.flatMap((g) => g.items);
const SECTION_DESCRIPTIONS = Object.fromEntries(ALL_SECTIONS.map((s) => [s.id, s.desc]));
function Toast({ message, type }) {
if (!message) return null;
return (
<div className={`toast ${type}`}>
{type === 'success' ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
{message}
</div>
);
}
function LoginPreview() {
const viewportRef = useRef(null);
const [scale, setScale] = useState(0.5);
const previewWidth = 1280;
const previewHeight = 800;
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const updateScale = () => {
const nextScale = el.clientWidth / previewWidth;
setScale(nextScale);
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className="login-preview">
<div className="login-preview-intro">
<img src="/assets/cavin_logo.svg" alt="Cavin Infotech" />
<div>
<h1>Content <span>Manager</span></h1>
<p>Preview the live website before you sign in. Edits publish instantly after login.</p>
</div>
<a href="/" target="_blank" rel="noopener noreferrer" className="login-preview-link">
<ExternalLink size={14} />
Open full site
</a>
</div>
<div className="login-preview-shell">
<div className="login-preview-chrome">
<span className="login-preview-dot" />
<span className="login-preview-dot" />
<span className="login-preview-dot" />
<span className="login-preview-url">cavininfotech.com</span>
</div>
<div
className="login-preview-viewport"
ref={viewportRef}
style={{ height: previewHeight * scale }}
>
<div
className="login-preview-stage"
style={{ width: previewWidth * scale, height: previewHeight * scale }}
>
<iframe
src="/"
title="Website preview"
className="login-preview-iframe"
style={{
width: previewWidth,
height: previewHeight,
transform: `scale(${scale})`,
}}
/>
</div>
</div>
</div>
</div>
);
}
function Login({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const { token } = await adminApi.login(username, password);
setToken(token);
onLogin();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<LoginPreview />
<div className="login-form-side">
<form className="login-card" onSubmit={handleSubmit}>
<h2>Welcome back</h2>
<p className="login-subtitle">Sign in to your admin account</p>
{error && <div className="login-error"><AlertCircle size={16} />{error}</div>}
<Field label="Username" value={username} onChange={setUsername} />
<Field label="Password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
function EditorPlaceholder({ loading, error }) {
return (
<div className="admin-editor-placeholder">
{loading ? (
<>
<div className="admin-spinner" />
<p>Loading section content...</p>
</>
) : (
<>
<AlertCircle size={22} />
<p>{error || 'Unable to load this section.'}</p>
</>
)}
</div>
);
}
class AdminErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div className="admin-card admin-editor-placeholder">
<AlertCircle size={22} />
<p>Something went wrong loading this section.</p>
<p style={{ fontSize: '0.8rem', color: '#94a3b8' }}>{this.state.error.message}</p>
<button type="button" className="btn-secondary" onClick={() => this.setState({ error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
function SectionEditor({ section, data, onChange }) {
if (!data) return <EditorPlaceholder loading={false} error="No content found for this section." />;
switch (section) {
case 'hero':
return (
<>
<ImageUpload label="Hero Video" value={data.hero?.video} onChange={(v) => onChange({ ...data, hero: { ...data.hero, video: v } })} />
<Field label="Badge Text" value={data.hero?.badge} onChange={(v) => onChange({ ...data, hero: { ...data.hero, badge: v } })} />
<Field label="Headline" value={data.hero?.headline} onChange={(v) => onChange({ ...data, hero: { ...data.hero, headline: v } })} />
<Field label="Subheadline" value={data.hero?.subheadline} onChange={(v) => onChange({ ...data, hero: { ...data.hero, subheadline: v } })} />
<ArrayEditor label="Metrics" items={data.metrics || []} onChange={(metrics) => onChange({ ...data, metrics })} fields={[{ key: 'value', label: 'Value' }, { key: 'label', label: 'Label' }]} />
</>
);
case 'about':
return (
<>
<Field label="About Heading" value={data.about?.heading} onChange={(v) => onChange({ about: { ...data.about, heading: v }, partners: data.partners })} />
<ImageUpload label="Badge Image" value={data.about?.badgeImage} onChange={(v) => onChange({ about: { ...data.about, badgeImage: v }, partners: data.partners })} />
<Field label="Badge Alt Text" value={data.about?.badgeAlt} onChange={(v) => onChange({ about: { ...data.about, badgeAlt: v }, partners: data.partners })} />
<ImageUpload label="Background Image" value={data.about?.backgroundImage} onChange={(v) => onChange({ about: { ...data.about, backgroundImage: v }, partners: data.partners })} />
<Field label="Partners Heading" value={data.partners?.heading} onChange={(v) => onChange({ about: data.about, partners: { ...data.partners, heading: v } })} />
<Field label="Partners Subheading" value={data.partners?.subheading} onChange={(v) => onChange({ about: data.about, partners: { ...data.partners, subheading: v } })} />
<Field label="Partners Body" value={data.partners?.body} onChange={(v) => onChange({ about: data.about, partners: { ...data.partners, body: v } })} multiline />
<ArrayEditor label="Partner Logos" items={data.partners?.logos || []} onChange={(logos) => onChange({ about: data.about, partners: { ...data.partners, logos } })} fields={[{ key: 'name', label: 'Name' }, { key: 'image', label: 'Logo', type: 'image' }]} />
</>
);
case 'certifications':
return (
<>
<Field label="Section Heading" value={data.heading} onChange={(v) => onChange({ ...data, heading: v })} />
<ArrayEditor label="Certifications" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[{ key: 'image', label: 'Image', type: 'image' }, { key: 'alt', label: 'Alt Text' }]} />
</>
);
case 'services':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Intro" value={data.intro} onChange={(v) => onChange({ ...data, intro: v })} multiline />
<ArrayEditor label="Service Cards" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'number', label: 'Number' }, { key: 'title', label: 'Title' }, { key: 'boldStatement', label: 'Bold Statement' },
{ key: 'description', label: 'Description', multiline: true }, { key: 'cta', label: 'CTA Text' },
]} />
</>
);
case 'whyUs':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<ArrayEditor label="Value Cards" items={data.cards || []} onChange={(cards) => onChange({ ...data, cards })} fields={[{ key: 'title', label: 'Title' }, { key: 'description', label: 'Description', multiline: true }]} />
<ArrayEditor label="Images" items={data.images || []} onChange={(images) => onChange({ ...data, images })} fields={[{ key: 'src', label: 'Image', type: 'image' }, { key: 'alt', label: 'Alt Text' }]} />
</>
);
case 'gallery':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Subtitle" value={data.subtitle} onChange={(v) => onChange({ ...data, subtitle: v })} multiline />
<ArrayEditor label="Gallery Items" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'title', label: 'Title' }, { key: 'category', label: 'Category' },
{ key: 'excerpt', label: 'Excerpt', multiline: true }, { key: 'image', label: 'Image', type: 'image' }, { key: 'readTime', label: 'Read Time' },
]} />
</>
);
case 'products':
return (
<>
<Field label="Eyebrow" value={data.eyebrow} onChange={(v) => onChange({ ...data, eyebrow: v })} />
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Subtitle" value={data.subtitle} onChange={(v) => onChange({ ...data, subtitle: v })} multiline />
<ArrayEditor label="Products" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'name', label: 'Name' }, { key: 'description', label: 'Description', multiline: true }, { key: 'image', label: 'Dashboard Image', type: 'image' },
]} />
</>
);
case 'insights':
return (
<>
<Field label="Title" value={data.title} onChange={(v) => onChange({ ...data, title: v })} />
<Field label="Subtitle" value={data.subtitle} onChange={(v) => onChange({ ...data, subtitle: v })} multiline />
<ArrayEditor label="Insight Cards" items={data.items || []} onChange={(items) => onChange({ ...data, items })} fields={[
{ key: 'category', label: 'Category' }, { key: 'title', label: 'Title' }, { key: 'date', label: 'Date' },
{ key: 'excerpt', label: 'Excerpt', multiline: true }, { key: 'image', label: 'Image', type: 'image' },
]} />
</>
);
case 'footer':
return (
<>
<Field label="CTA Headline" value={data.cta?.headline} onChange={(v) => onChange({ ...data, cta: { ...data.cta, headline: v } })} />
<Field label="CTA Body" value={data.cta?.body} onChange={(v) => onChange({ ...data, cta: { ...data.cta, body: v } })} multiline />
<ImageUpload label="CTA Image" value={data.cta?.image} onChange={(v) => onChange({ ...data, cta: { ...data.cta, image: v } })} />
<Field label="Primary Button" value={data.cta?.primaryButton} onChange={(v) => onChange({ ...data, cta: { ...data.cta, primaryButton: v } })} />
<Field label="Secondary Button" value={data.cta?.secondaryButton} onChange={(v) => onChange({ ...data, cta: { ...data.cta, secondaryButton: v } })} />
<ArrayEditor label="Quick Links" items={data.quickLinks || []} onChange={(quickLinks) => onChange({ ...data, quickLinks })} fields={[{ key: 'label', label: 'Label' }, { key: 'href', label: 'Link' }]} />
<ArrayEditor label="Product Links" items={data.productLinks || []} onChange={(productLinks) => onChange({ ...data, productLinks })} fields={[{ key: 'label', label: 'Label' }, { key: 'href', label: 'Link' }]} />
<ArrayEditor label="Social Links" items={data.social || []} onChange={(social) => onChange({ ...data, social })} fields={[{ key: 'platform', label: 'Platform' }, { key: 'url', label: 'URL' }]} />
<ArrayEditor label="Legal Links" items={data.legal || []} onChange={(legal) => onChange({ ...data, legal })} fields={[{ key: 'label', label: 'Label' }, { key: 'href', label: 'Link' }]} />
</>
);
default:
return null;
}
}
function SettingsPage() {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [msg, setMsg] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await adminApi.changePassword(current, next);
setMsg('Password updated successfully');
setCurrent('');
setNext('');
} catch (err) {
setMsg(err.message);
}
};
return (
<div className="admin-card settings-card">
<h1>Settings</h1>
<p className="subtitle">Update your admin password. Use at least 6 characters.</p>
<form onSubmit={handleSubmit}>
<Field label="Current Password" value={current} onChange={setCurrent} type="password" />
<Field label="New Password" value={next} onChange={setNext} type="password" />
{msg && <p className={msg.includes('success') ? 'msg-success' : 'msg-error'}>{msg}</p>}
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>
Update Password
</button>
</form>
</div>
);
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-IN', {
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
export default function App() {
const [authed, setAuthed] = useState(false);
const [checking, setChecking] = useState(true);
const [activeSection, setActiveSection] = useState('dashboard');
const [content, setContent] = useState(null);
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState('');
const [sectionData, setSectionData] = useState(null);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState({ message: '', type: '' });
const [sidebarOpen, setSidebarOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState(null);
const showToast = (message, type) => {
setToast({ message, type });
setTimeout(() => setToast({ message: '', type: '' }), 3500);
};
const goToSection = (id) => {
setActiveSection(id);
setSidebarOpen(false);
if (id === 'dashboard' || id === 'settings') {
setSectionData(null);
return;
}
if (!content) return;
setSectionData(pickSectionData(content, id));
};
useEffect(() => {
const onResize = () => {
if (window.innerWidth > 900) setSidebarOpen(false);
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
const token = getToken();
if (!token) { setChecking(false); return; }
adminApi.me().then(() => { setAuthed(true); setChecking(false); }).catch(() => { clearToken(); setChecking(false); });
}, []);
useEffect(() => {
if (!authed) return;
const prefetch = document.createElement('link');
prefetch.rel = 'prefetch';
prefetch.href = '/preview.html';
document.head.appendChild(prefetch);
setContentLoading(true);
setContentError('');
adminApi.getContent()
.then((data) => {
const { _updatedAt, ...rest } = data;
setContent(rest);
setLastUpdated(_updatedAt);
})
.catch((err) => {
console.warn('Admin content API failed, using seed fallback:', err.message);
setContent(defaultContent);
setContentError('Could not reach the API. Showing default content — start the server with npm run dev.');
})
.finally(() => setContentLoading(false));
}, [authed]);
useEffect(() => {
if (!content || activeSection === 'dashboard' || activeSection === 'settings') return;
setSectionData(pickSectionData(content, activeSection));
}, [content, activeSection]);
const handleSave = async () => {
setSaving(true);
try {
if (activeSection === 'hero') {
await adminApi.updateSection('hero', sectionData.hero);
await adminApi.updateSection('metrics', sectionData.metrics);
} else if (activeSection === 'about') {
await adminApi.updateSection('about', sectionData.about);
await adminApi.updateSection('partners', sectionData.partners);
} else {
await adminApi.updateSection(activeSection, sectionData);
}
const updated = await adminApi.getContent();
const { _updatedAt, ...rest } = updated;
setContent(rest);
setLastUpdated(_updatedAt);
showToast('Changes saved successfully', 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
setSaving(false);
}
};
if (checking) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<span>Loading admin panel...</span>
</div>
);
}
if (!authed) return <Login onLogin={() => setAuthed(true)} />;
const sectionInfo = ALL_SECTIONS.find((s) => s.id === activeSection);
const isEditor = activeSection !== 'dashboard' && activeSection !== 'settings';
return (
<div className="admin-layout">
<button
type="button"
className="admin-mobile-toggle"
aria-label="Toggle menu"
onClick={() => setSidebarOpen((o) => !o)}
>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
<div
className={`admin-sidebar-overlay ${sidebarOpen ? 'visible' : ''}`}
onClick={() => setSidebarOpen(false)}
/>
<aside className={`admin-sidebar ${sidebarOpen ? 'open' : ''}`}>
<div className="admin-sidebar-brand">
<img src="/assets/cavin_logo.svg" alt="Cavin Infotech" />
<h2>Content Manager</h2>
<p>Cavin Infotech Website</p>
</div>
<nav className="admin-nav">
{NAV_GROUPS.map((group) => (
<div key={group.label} className="admin-nav-group">
<div className="admin-nav-group-label">{group.label}</div>
{group.items.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className={activeSection === id ? 'active' : ''}
onClick={() => goToSection(id)}
>
<Icon size={17} />
{label}
</button>
))}
</div>
))}
</nav>
<div className="admin-sidebar-footer">
<button type="button" className="logout-btn" onClick={() => { clearToken(); setAuthed(false); }}>
<LogOut size={15} />
Sign out
</button>
</div>
</aside>
<div className="admin-main-wrap">
<header className="admin-topbar">
<div>
<h1 className="admin-topbar-title">{sectionInfo?.label || 'Admin'}</h1>
<p className="admin-topbar-sub">{SECTION_DESCRIPTIONS[activeSection]}</p>
</div>
<div className="admin-topbar-actions">
<a href="/" target="_blank" rel="noopener noreferrer" className="btn-ghost">
<ExternalLink size={14} />
View site
</a>
{isEditor && (
<button type="button" className="btn-primary" onClick={handleSave} disabled={saving}>
<Save size={15} />
{saving ? 'Saving...' : 'Save changes'}
</button>
)}
</div>
</header>
<main className="admin-main">
{activeSection === 'dashboard' ? (
<>
<div className="dashboard-welcome">
<h2>Welcome to your CMS</h2>
<p>Edit any section below. All changes are saved to the database and appear on the live website immediately.</p>
<div className="dashboard-meta">
<span className="dashboard-meta-item">
<Clock size={13} />
Last updated: {formatDate(lastUpdated)}
</span>
</div>
</div>
<div className="dashboard-grid">
{ALL_SECTIONS.filter((s) => s.id !== 'dashboard').map(({ id, label, icon: Icon, desc }) => (
<button key={id} type="button" className="dashboard-tile" onClick={() => goToSection(id)}>
<div className="dashboard-tile-icon">
<Icon size={20} />
</div>
<h3>{label}</h3>
<p>{desc}</p>
</button>
))}
</div>
</>
) : activeSection === 'settings' ? (
<SettingsPage />
) : (
<AdminErrorBoundary>
<div className="admin-editor-layout">
<div className="admin-editor-form admin-card">
{contentLoading || !sectionData ? (
<EditorPlaceholder loading={contentLoading} error={contentError} />
) : (
<SectionEditor
key={activeSection}
section={activeSection}
data={sectionData}
onChange={setSectionData}
/>
)}
<div className="save-bar">
<button type="button" className="btn-secondary" onClick={() => goToSection('dashboard')}>
Cancel
</button>
<button type="button" className="btn-primary" onClick={handleSave} disabled={saving || !sectionData}>
<Save size={15} />
{saving ? 'Saving...' : 'Save changes'}
</button>
</div>
</div>
{!contentLoading && content && sectionData && (
<SectionPreview
section={activeSection}
label={sectionInfo?.label || 'Section'}
content={content}
sectionData={sectionData}
/>
)}
</div>
</AdminErrorBoundary>
)}
</main>
</div>
<Toast {...toast} />
</div>
);
}
+1131
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
const API_BASE = import.meta.env.VITE_API_URL || '';
export function getToken() {
return localStorage.getItem('admin_token');
}
export function setToken(token) {
localStorage.setItem('admin_token', token);
}
export function clearToken() {
localStorage.removeItem('admin_token');
}
async function apiFetch(path, options = {}) {
const headers = { ...options.headers };
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
export const adminApi = {
login: (username, password) =>
apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => apiFetch('/api/auth/me'),
getContent: () => apiFetch('/api/content'),
updateSection: (section, data) =>
apiFetch(`/api/content/${section}`, { method: 'PUT', body: JSON.stringify(data) }),
changePassword: (currentPassword, newPassword) =>
apiFetch('/api/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }),
upload: async (file) => {
const form = new FormData();
form.append('file', file);
return apiFetch('/api/upload', { method: 'POST', body: form });
},
};
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Upload, Plus, Trash2 } from 'lucide-react';
import { adminApi } from '../api';
export function ImageUpload({ value, onChange, label = 'Image' }) {
const [uploading, setUploading] = useState(false);
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { url } = await adminApi.upload(file);
onChange(url);
} catch (err) {
alert(err.message);
} finally {
setUploading(false);
}
};
return (
<div className="form-field">
<label className="form-label">{label}</label>
{value && (
<div className="form-preview">
{value.match(/\.(mp4|webm)$/i) ? (
<video src={value} controls />
) : (
<img src={value} alt="" />
)}
</div>
)}
<input
type="text"
className="form-input"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Paste URL or upload a file below"
/>
<label className="form-upload-btn">
<Upload size={14} />
{uploading ? 'Uploading...' : 'Choose file'}
<input type="file" accept="image/*,video/mp4" onChange={handleUpload} hidden disabled={uploading} />
</label>
</div>
);
}
export function Field({ label, value, onChange, multiline = false, type = 'text' }) {
return (
<div className="form-field">
<label className="form-label">{label}</label>
{multiline ? (
<textarea
className="form-textarea"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
) : (
<input
type={type}
className="form-input"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
/>
)}
</div>
);
}
export function ArrayEditor({ label, items, onChange, fields }) {
const updateItem = (index, key, val) => {
const next = items.map((item, i) => (i === index ? { ...item, [key]: val } : item));
onChange(next);
};
const addItem = () => {
const blank = fields.reduce((acc, f) => ({ ...acc, [f.key]: f.default ?? '' }), { id: Date.now() });
onChange([...items, blank]);
};
const removeItem = (index) => onChange(items.filter((_, i) => i !== index));
return (
<div className="array-section">
<div className="array-header">
<h4 className="array-title">{label}</h4>
<button type="button" onClick={addItem} className="array-add-btn">
<Plus size={14} /> Add item
</button>
</div>
{items.length === 0 && (
<p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: '0 0 1rem' }}>No items yet. Click &quot;Add item&quot; to create one.</p>
)}
{items.map((item, index) => (
<div key={item.id ?? index} className="array-item">
<div className="array-item-header">
<span className="array-item-badge">Item {index + 1}</span>
<button type="button" onClick={() => removeItem(index)} className="array-remove-btn">
<Trash2 size={12} /> Remove
</button>
</div>
{fields.map((f) =>
f.type === 'image' ? (
<ImageUpload key={f.key} label={f.label} value={item[f.key]} onChange={(v) => updateItem(index, f.key, v)} />
) : (
<Field
key={f.key}
label={f.label}
value={item[f.key]}
onChange={(v) => updateItem(index, f.key, v)}
multiline={f.multiline}
/>
)
)}
</div>
))}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ExternalLink, Eye } from 'lucide-react';
export const SECTION_PREVIEW_ANCHORS = {
hero: 'home',
about: 'aboutus',
certifications: 'certifications',
services: 'services',
whyUs: 'whyus',
gallery: 'featured-gallery',
products: 'products',
insights: 'insights',
footer: 'contactus',
};
export function buildPreviewContent(baseContent, section, sectionData) {
if (!baseContent || !sectionData) return baseContent;
const merged = { ...baseContent };
if (section === 'hero') {
merged.hero = sectionData.hero ?? merged.hero;
merged.metrics = sectionData.metrics ?? merged.metrics;
} else if (section === 'about') {
merged.about = sectionData.about ?? merged.about;
merged.partners = sectionData.partners ?? merged.partners;
} else {
merged[section] = sectionData;
}
return merged;
}
const PREVIEW_SRC = '/preview.html';
const PREVIEW_WIDTH = 1440;
const PREVIEW_HEIGHT = 900;
export function SectionPreview({ section, label, content, sectionData }) {
const viewportRef = useRef(null);
const iframeRef = useRef(null);
const payloadRef = useRef({});
const debounceRef = useRef(null);
const anchor = SECTION_PREVIEW_ANCHORS[section] || 'home';
const [scale, setScale] = useState(0.45);
const [iframeReady, setIframeReady] = useState(false);
const [previewReady, setPreviewReady] = useState(false);
const [showIframe, setShowIframe] = useState(false);
payloadRef.current = { content, sectionData, section, anchor };
// Defer iframe mount so the editor form paints first
useEffect(() => {
const timer = window.setTimeout(() => setShowIframe(true), 50);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'PREVIEW_READY') setPreviewReady(true);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const updateScale = () => {
const width = el.clientWidth;
setScale(width > 0 ? width / PREVIEW_WIDTH : 0.45);
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(el);
return () => observer.disconnect();
}, []);
const postPreview = useCallback((immediate = false) => {
const send = () => {
const iframe = iframeRef.current;
const { content: base, sectionData: data, section: sec, anchor: id } = payloadRef.current;
if (!iframe?.contentWindow || !base || !data) return;
iframe.contentWindow.postMessage(
{
type: 'ADMIN_PREVIEW',
section: sec,
anchor: id,
fullContent: buildPreviewContent(base, sec, data),
},
window.location.origin,
);
};
if (immediate) {
window.clearTimeout(debounceRef.current);
send();
return;
}
window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(send, 200);
}, []);
useEffect(() => {
if (!iframeReady) return;
postPreview(true);
}, [iframeReady, section, anchor, iframeReady, postPreview]);
useEffect(() => {
if (!iframeReady) return;
postPreview(false);
return () => window.clearTimeout(debounceRef.current);
}, [content, sectionData, iframeReady, postPreview]);
const handleIframeLoad = () => {
setIframeReady(true);
postPreview(true);
};
const isLoading = !showIframe || !iframeReady || !previewReady;
return (
<aside className="admin-preview-panel">
<div className="admin-preview-panel-header">
<div>
<span className="admin-preview-eyebrow">
<Eye size={14} />
Live preview
</span>
<h3>{label}</h3>
</div>
<a
href={`/#${anchor}`}
target="_blank"
rel="noopener noreferrer"
className="admin-preview-open"
>
<ExternalLink size={13} />
Open
</a>
</div>
<div className="admin-preview-shell">
<div className="admin-preview-chrome">
<span className="admin-preview-dot" />
<span className="admin-preview-dot" />
<span className="admin-preview-dot" />
<span className="admin-preview-url">/{anchor}</span>
</div>
<div
className="admin-preview-viewport"
ref={viewportRef}
style={{ height: PREVIEW_HEIGHT * scale }}
>
{isLoading && (
<div className="admin-preview-loading">
<div className="admin-spinner" />
<span>Loading preview</span>
</div>
)}
{showIframe && (
<div
className="admin-preview-stage"
style={{ width: PREVIEW_WIDTH * scale, height: PREVIEW_HEIGHT * scale, opacity: isLoading ? 0 : 1 }}
>
<iframe
ref={iframeRef}
src={PREVIEW_SRC}
title={`${label} preview`}
className="admin-preview-iframe"
onLoad={handleIframeLoad}
style={{
width: PREVIEW_WIDTH,
height: PREVIEW_HEIGHT,
transform: `scale(${scale})`,
}}
/>
</div>
)}
</div>
</div>
<p className="admin-preview-note">Updates as you edit. Save to publish to the live site.</p>
</aside>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function pickSectionData(content, section) {
if (!content || section === 'dashboard' || section === 'settings') return null;
if (section === 'hero') {
return {
hero: structuredClone(content.hero ?? {}),
metrics: structuredClone(content.metrics ?? []),
};
}
if (section === 'about') {
return {
about: structuredClone(content.about ?? {}),
partners: structuredClone(content.partners ?? { logos: [] }),
};
}
const data = content[section];
return data ? structuredClone(data) : null;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './admin.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);