AdminLayout.jsx
import * as React from 'react';
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { ChatPanel } from '../components/ChatPanel.jsx';
const CHAT_OPEN_KEY = 'admin_chat_open';
// Chat context for sharing chat state across components
const ChatContext = createContext(null);
export const useChat = () => useContext(ChatContext);
export function AdminLayout({ user, tourCompleted, children }) {
const location = useLocation();
const navigate = useNavigate();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [chatOpen, setChatOpen] = useState(() => {
const stored = localStorage.getItem(CHAT_OPEN_KEY);
return stored === 'true';
});
const isActive = (path) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
const toggleChat = useCallback(() => {
setChatOpen(prev => {
const newValue = !prev;
localStorage.setItem(CHAT_OPEN_KEY, String(newValue));
return newValue;
});
}, []);
// Keyboard shortcut: Cmd/Ctrl+K to toggle chat
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
toggleChat();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleChat]);
// Handle navigation from chat (AI tool calls)
const handleChatNavigate = useCallback((url) => {
// Convert absolute URLs to relative for React Router
if (url.startsWith('/admin/')) {
navigate(url.replace('/admin', ''));
} else if (url.startsWith('/')) {
// Other absolute URLs open in new tab (public pages, etc.)
window.open(url, '_blank');
}
}, [navigate]);
return (
<div className="flex h-screen bg-base-200">
{/* Mobile hamburger menu button */}
<button
className="fixed top-2 left-2 z-50 btn btn-ghost btn-sm btn-square lg:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Sidebar overlay for mobile */}
{sidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Sidebar */}
<aside className={`w-16 flex-shrink-0 bg-gradient-to-b from-base-300/80 to-base-300/40 border-r border-base-300/80 flex flex-col fixed lg:relative inset-y-0 left-0 z-40 transition-transform duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
{/* Logo */}
<div className="p-3 flex justify-center">
<Link to="/" className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white font-bold text-lg shadow-lg hover:shadow-xl transition-shadow">
R
</Link>
</div>
{/* Main Navigation */}
<nav className="flex-1 flex flex-col items-center gap-1 py-4">
{/* Dashboard */}
<NavItem path="/" icon="home" label="Dashboard" active={isActive('/')} onClick={() => setSidebarOpen(false)} />
<div className="w-6 h-px bg-base-300 my-2" />
{/* Users */}
<NavItem path="/users" icon="users" label="Users" active={isActive('/users')} onClick={() => setSidebarOpen(false)} />
{/* Pages */}
<NavItem path="/pages" icon="pages" label="Pages" active={isActive('/pages')} onClick={() => setSidebarOpen(false)} />
{/* Collections */}
<NavItem path="/collections" icon="collections" label="Collections" active={isActive('/collections')} onClick={() => setSidebarOpen(false)} />
{/* Files */}
<NavItem path="/files" icon="files" label="Files" active={isActive('/files')} onClick={() => setSidebarOpen(false)} />
</nav>
{/* Bottom Actions */}
<div className="flex flex-col items-center gap-1 py-4 border-t border-base-300/80">
{/* Audit Logs */}
<NavItem path="/audit" icon="audit" label="Audit Logs" active={isActive('/audit')} onClick={() => setSidebarOpen(false)} />
{/* Settings */}
<NavItem path="/settings" icon="settings" label="Settings" active={isActive('/settings')} onClick={() => setSidebarOpen(false)} />
{/* View Site */}
<div className="tooltip tooltip-right" data-tip="View Site">
<a href="/" target="_blank" className="btn btn-ghost btn-square btn-sm hover:bg-base-200/80">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
{/* Sign Out */}
<div className="tooltip tooltip-right" data-tip="Sign Out">
<button
onClick={() => {
if (confirm('Sign out?')) {
fetch('/auth/signout', { method: 'POST' }).then(() => {
window.location.href = '/signin';
});
}
}}
className="btn btn-ghost btn-square btn-sm text-base-content/50 hover:text-error hover:bg-error/10"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
{/* Main Content Area + Chat Panel */}
<div className="flex flex-1 min-w-0">
{/* Page Content */}
<div className={`flex-1 flex flex-col min-w-0 bg-base-100 transition-[margin] duration-200 ${chatOpen ? 'mr-[320px] lg:mr-[480px]' : ''}`}>
<ChatContext.Provider value={{ chatOpen, toggleChat }}>
{children}
</ChatContext.Provider>
</div>
{/* Chat Panel Overlay for Mobile */}
{chatOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={toggleChat}
/>
)}
{/* Chat Panel */}
<div className={`fixed inset-y-0 right-0 z-50 transition-transform duration-200 ${chatOpen ? 'translate-x-0' : 'translate-x-full'}`}>
<ChatPanel
open={chatOpen}
onToggle={toggleChat}
onNavigate={handleChatNavigate}
/>
</div>
</div>
{/* Welcome Tour */}
<WelcomeTour tourCompleted={tourCompleted} onOpenChat={toggleChat} />
</div>
);
}
function WelcomeTour({ tourCompleted, onOpenChat }) {
const [visible, setVisible] = useState(!tourCompleted);
const [step, setStep] = useState(0);
if (!visible) return null;
const steps = [
{
title: 'Welcome to ReadySite!',
description: "Let's take a quick look around. ReadySite is your AI-powered website builder — create pages, manage content, and launch your site from right here.",
},
{
title: 'Sidebar Navigation',
description: 'The sidebar on the left has your main tools: Pages, Collections, Files, Users, Audit Logs, and Settings. Click any icon to navigate.',
},
{
title: 'AI Assistant',
description: 'Press Cmd+K (or Ctrl+K) to open the AI assistant. It can create pages, set up collections, manage content, and build your entire site through conversation.',
},
{
title: "You're all set!",
description: 'Try asking the AI to create your first page, or explore the sidebar to see what\'s here. Need help? Check out the docs at readysite.org.',
},
];
const current = steps[step];
const isLast = step === steps.length - 1;
const complete = () => {
setVisible(false);
fetch('/api/admin/tour/complete', { method: 'POST' });
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60">
<div className="bg-base-200 border border-base-300 rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6">
{/* Step indicator */}
<div className="flex items-center justify-between mb-4">
<div className="flex gap-1.5">
{steps.map((_, i) => (
<div key={i} className={`w-2 h-2 rounded-full transition-colors ${i === step ? 'bg-primary' : 'bg-base-content/20'}`} />
))}
</div>
<button onClick={complete} className="text-xs text-base-content/40 hover:text-base-content/70 transition-colors">
Skip tour
</button>
</div>
{/* Content */}
<div className="mb-6">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary/20 to-secondary/20 flex items-center justify-center mb-4">
{step === 0 && <span className="text-2xl">👋</span>}
{step === 1 && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
{step === 2 && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
{step === 3 && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)}
</div>
<h3 className="text-lg font-semibold mb-2">{current.title}</h3>
<p className="text-base-content/60 text-sm leading-relaxed">{current.description}</p>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<button
onClick={() => setStep(s => s - 1)}
className={`btn btn-ghost btn-sm ${step === 0 ? 'invisible' : ''}`}
>
Back
</button>
<button
onClick={() => {
if (isLast) {
complete();
} else {
setStep(s => s + 1);
}
}}
className="btn btn-primary btn-sm"
>
{isLast ? 'Get Started' : 'Next'}
</button>
</div>
</div>
</div>
);
}
function NavItem({ path, icon, label, active, onClick }) {
return (
<div className="tooltip tooltip-right" data-tip={label}>
<Link
to={path}
onClick={onClick}
className={`btn btn-ghost btn-square btn-sm ${active ? 'bg-primary/20 text-primary' : 'hover:bg-base-200/80'}`}
>
<NavIcon name={icon} />
</Link>
</div>
);
}
function NavIcon({ name }) {
const icons = {
home: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>,
pages: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>,
files: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>,
collections: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>,
partials: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /></svg>,
users: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>,
audit: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg>,
settings: <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
};
return icons[name] || null;
}