first commit
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 "Add item" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user