47.0 KB
SitesPage.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
useNodesState,
} from '@xyflow/react';
// --- Status colors ---
const statusColors = {
active: { bg: 'rgba(16,185,129,0.15)', text: '#10b981', label: 'Live' },
launching: { bg: 'rgba(139,92,246,0.15)', text: '#a78bfa', label: 'Launching' },
pending: { bg: 'rgba(245,158,11,0.15)', text: '#f59e0b', label: 'Pending' },
stopped: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', label: 'Stopped' },
failed: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', label: 'Failed' },
shutdown: { bg: 'rgba(107,114,128,0.15)', text: '#6b7280', label: 'Shutdown' },
};
// --- Tour ---
function RocketIcon() {
return (
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
{/* Exhaust flames */}
<ellipse cx="60" cy="108" rx="14" ry="6" fill="rgba(245,158,11,0.15)" />
<path d="M54 95 L50 112 L56 104 L60 115 L64 104 L70 112 L66 95" fill="url(#flame)" />
{/* Rocket body */}
<rect x="48" y="30" width="24" height="55" rx="4" fill="#a78bfa" />
<rect x="48" y="30" width="24" height="55" rx="4" fill="url(#rocketShine)" />
{/* Nose cone */}
<path d="M48 30 L60 8 L72 30" fill="#c4b5fd" />
{/* Window */}
<circle cx="60" cy="50" r="7" fill="#1a1a1a" stroke="#c4b5fd" strokeWidth="2" />
<circle cx="58" cy="48" r="2" fill="rgba(255,255,255,0.3)" />
{/* Fins */}
<path d="M48 75 L36 90 L48 85Z" fill="#8b5cf6" />
<path d="M72 75 L84 90 L72 85Z" fill="#8b5cf6" />
{/* Stars */}
<circle cx="20" cy="25" r="1.5" fill="rgba(255,255,255,0.4)" />
<circle cx="95" cy="40" r="1" fill="rgba(255,255,255,0.3)" />
<circle cx="30" cy="65" r="1" fill="rgba(255,255,255,0.25)" />
<circle cx="100" cy="20" r="1.5" fill="rgba(255,255,255,0.35)" />
<circle cx="15" cy="90" r="1" fill="rgba(255,255,255,0.2)" />
<defs>
<linearGradient id="flame" x1="60" y1="95" x2="60" y2="115" gradientUnits="userSpaceOnUse">
<stop stopColor="#f59e0b" /><stop offset="1" stopColor="#ef4444" stopOpacity="0" />
</linearGradient>
<linearGradient id="rocketShine" x1="48" y1="30" x2="72" y2="30" gradientUnits="userSpaceOnUse">
<stop stopColor="rgba(255,255,255,0.1)" /><stop offset="1" stopColor="rgba(255,255,255,0)" />
</linearGradient>
</defs>
</svg>
);
}
function SandboxIcon() {
return (
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
{/* Flask body */}
<path d="M46 35 L46 65 L30 100 Q28 105 33 108 L87 108 Q92 105 90 100 L74 65 L74 35" fill="rgba(139,92,246,0.12)" stroke="#a78bfa" strokeWidth="2" />
{/* Flask neck */}
<rect x="46" y="22" width="28" height="13" rx="2" fill="rgba(139,92,246,0.08)" stroke="#a78bfa" strokeWidth="2" />
{/* Liquid */}
<path d="M36 88 Q60 78 84 88 L87 108 Q92 105 90 100 L84 88 Q60 78 36 88 L30 100 Q28 105 33 108 L87 108 Q92 105 90 100 L84 88" fill="rgba(139,92,246,0.2)" />
<path d="M36 88 Q60 78 84 88" stroke="#a78bfa" strokeWidth="1" strokeOpacity="0.4" />
{/* Bubbles */}
<circle cx="50" cy="95" r="3" fill="rgba(139,92,246,0.3)" />
<circle cx="65" cy="90" r="2" fill="rgba(139,92,246,0.25)" />
<circle cx="72" cy="98" r="2.5" fill="rgba(139,92,246,0.2)" />
<circle cx="55" cy="82" r="1.5" fill="rgba(139,92,246,0.2)" />
{/* Cork */}
<rect x="50" y="16" width="20" height="8" rx="3" fill="#7c6cb0" />
</svg>
);
}
function GlobeIcon() {
return (
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
{/* Glow */}
<circle cx="60" cy="60" r="44" fill="rgba(16,185,129,0.06)" />
{/* Globe */}
<circle cx="60" cy="60" r="36" stroke="#10b981" strokeWidth="2" fill="rgba(16,185,129,0.08)" />
{/* Longitude lines */}
<ellipse cx="60" cy="60" rx="16" ry="36" stroke="#10b981" strokeWidth="1" strokeOpacity="0.4" />
<ellipse cx="60" cy="60" rx="30" ry="36" stroke="#10b981" strokeWidth="1" strokeOpacity="0.3" />
{/* Latitude lines */}
<ellipse cx="60" cy="42" rx="32" ry="6" stroke="#10b981" strokeWidth="1" strokeOpacity="0.3" />
<ellipse cx="60" cy="60" rx="36" ry="6" stroke="#10b981" strokeWidth="1" strokeOpacity="0.3" />
<ellipse cx="60" cy="78" rx="32" ry="6" stroke="#10b981" strokeWidth="1" strokeOpacity="0.3" />
{/* Shine */}
<path d="M42 36 Q50 28 60 26" stroke="rgba(255,255,255,0.15)" strokeWidth="2" strokeLinecap="round" />
{/* Signal waves */}
<path d="M96 30 Q102 36 96 42" stroke="#10b981" strokeWidth="1.5" strokeLinecap="round" fill="none" opacity="0.5" />
<path d="M100 26 Q110 36 100 46" stroke="#10b981" strokeWidth="1.5" strokeLinecap="round" fill="none" opacity="0.3" />
</svg>
);
}
function AIIcon() {
return (
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
{/* Main thought bubble */}
<rect x="16" y="12" width="88" height="72" rx="20" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
{/* Bubble tail dots */}
<circle cx="36" cy="92" r="5" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
<circle cx="24" cy="104" r="3" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
{/* Anthropic logo — stylized "A" / sparkle shape */}
<g transform="translate(30, 30)">
<path d="M16 4 L20 14 L28 10 L22 18 L28 26 L20 22 L16 32 L12 22 L4 26 L10 18 L4 10 L12 14Z" fill="#d4a574" opacity="0.9" />
</g>
{/* OpenAI logo — hexagonal flower shape */}
<g transform="translate(66, 30)">
<path d="M12 2 L20 7 L20 17 L12 22 L4 17 L4 7Z" fill="none" stroke="#10b981" strokeWidth="1.8" opacity="0.9" />
<path d="M12 7 L12 17" stroke="#10b981" strokeWidth="1.5" opacity="0.7" />
<path d="M7 9.5 L17 14.5" stroke="#10b981" strokeWidth="1.5" opacity="0.7" />
<path d="M7 14.5 L17 9.5" stroke="#10b981" strokeWidth="1.5" opacity="0.7" />
</g>
{/* Sparkles around */}
<circle cx="52" cy="24" r="1" fill="rgba(255,255,255,0.3)" />
<circle cx="68" cy="62" r="1" fill="rgba(255,255,255,0.25)" />
<circle cx="34" cy="58" r="1" fill="rgba(255,255,255,0.2)" />
</svg>
);
}
const tourSteps = [
{ icon: RocketIcon, title: 'Launching...', body: 'Your site will be live in a few seconds.' },
{ icon: AIIcon, title: 'Use your favorite AI', body: 'Connect Claude or ChatGPT to build anything you can dream.' },
{ icon: GlobeIcon, title: 'You\'re live!', body: 'Your site is ready. Visit it to start building.' },
];
function ModalShell({ children }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}>
<div
className="max-w-xs w-full mx-4 py-10 px-8 text-center"
style={{
background: '#1a1a1a',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '24px',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5), 0 0 80px rgba(139,92,246,0.08)',
}}
>
{children}
</div>
</div>
);
}
function LaunchTour({ site, onDismiss }) {
const [step, setStep] = useState(0);
const [delayedReady, setDelayedReady] = useState(false);
const isActive = site.Status === 'active';
const isReady = isActive && delayedReady;
const isLast = step === tourSteps.length - 1;
const current = tourSteps[step];
const Icon = current.icon;
useEffect(() => {
if (isActive && !delayedReady) {
const timer = setTimeout(() => setDelayedReady(true), 3000);
return () => clearTimeout(timer);
}
}, [isActive, delayedReady]);
const handleVisit = () => {
window.open(`/api/sites/${site.ID}/admin`, '_blank');
onDismiss();
};
return (
<ModalShell>
<div className="mb-6 flex justify-center">
<Icon />
</div>
<h2 className="text-lg font-bold text-white mb-2">{current.title}</h2>
<p className="text-sm text-[#888] mb-8 leading-relaxed">{current.body}</p>
{isLast ? (
<button
onClick={handleVisit}
disabled={!isReady}
className="w-full py-2.5 text-sm font-medium rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: isReady ? 'white' : 'rgba(255,255,255,0.5)', color: '#111' }}
onMouseEnter={e => { if (isReady) e.currentTarget.style.background = '#e5e5e5'; }}
onMouseLeave={e => { if (isReady) e.currentTarget.style.background = 'white'; }}
>{isReady ? 'Visit your site' : 'Going live...'}</button>
) : (
<div className="flex flex-col gap-2">
<button
onClick={() => setStep(tourSteps.length - 1)}
disabled={!isReady}
className="w-full py-2.5 text-sm font-medium rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: isReady ? 'white' : 'rgba(255,255,255,0.5)', color: '#111' }}
onMouseEnter={e => { if (isReady) e.currentTarget.style.background = '#e5e5e5'; }}
onMouseLeave={e => { if (isReady) e.currentTarget.style.background = 'white'; }}
>{isReady ? 'Continue' : 'Launching...'}</button>
<button onClick={onDismiss} className="text-xs text-[#555] hover:text-[#888] transition-colors py-1">
Skip
</button>
</div>
)}
</ModalShell>
);
}
// --- Helper ---
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 30) return `${diffDays} days ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// --- Site Node (ReactFlow custom node) ---
const NODE_HEIGHT = 148;
const WARNING_HEIGHT = 40;
const NODE_WIDTH = window.innerWidth < 640 ? 260 : 280;
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return `${val < 10 && i > 0 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
}
function SiteNode({ data }) {
const site = data.site;
const isOpen = data.isOpen;
const status = statusColors[site.Status] || statusColors.pending;
const isFreeActive = site.Plan === 'free' && site.Status === 'active';
const isPro = site.Plan === 'pro';
const hasFooter = isFreeActive || isPro;
const isShutdown = site.Status === 'shutdown';
return (
<div
style={{
width: NODE_WIDTH,
opacity: isShutdown ? 0.5 : 1,
transition: 'opacity 0.3s',
}}
>
<div
className="rounded-2xl border transition-all duration-300 backdrop-blur-sm flex flex-col"
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
padding: '16px 20px',
background: isOpen ? 'rgba(139,92,246,0.08)' : 'rgba(17,17,17,0.85)',
borderColor: isOpen ? 'rgba(139,92,246,0.4)' : 'rgba(255,255,255,0.1)',
boxShadow: isOpen ? '0 0 24px rgba(139,92,246,0.15)' : '0 4px 12px rgba(0,0,0,0.3)',
borderBottomLeftRadius: hasFooter ? 0 : undefined,
borderBottomRightRadius: hasFooter ? 0 : undefined,
}}
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold truncate mr-3" style={{ fontSize: 14 }}>{site.Name}</h3>
<span className="shrink-0 text-xs px-2.5 py-0.5 rounded-full font-medium" style={{ background: status.bg, color: status.text }}>
{status.label}
</span>
</div>
<p className={`text-sm truncate ${site.Description ? 'text-[#999]' : 'text-[#555] italic opacity-80'}`}>
{site.Description || 'No description'}
</p>
<div className="flex items-center justify-between text-xs text-[#666] mt-auto gap-3">
<span className="font-mono truncate">{site.ID}.readysite.app</span>
{isPro ? (
<span className="shrink-0 text-xs px-2.5 py-0.5 rounded-full font-medium" style={{ background: 'rgba(139,92,246,0.2)', color: '#a78bfa', border: '1px solid rgba(139,92,246,0.3)' }}>pro</span>
) : (
<span className="shrink-0 px-2.5 py-0.5 rounded-full bg-white/5 text-[#888]">{site.Plan}</span>
)}
</div>
</div>
{isFreeActive && (
<div
onClick={(e) => { e.stopPropagation(); data.onUpgradeClick?.(); }}
className="flex items-center gap-2 px-4 py-2 cursor-pointer transition-colors hover:bg-white/[0.06]"
style={{
background: 'rgba(255,255,255,0.02)',
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
border: '1px solid rgba(255,255,255,0.1)',
borderTop: 'none',
width: NODE_WIDTH,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#666" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span className="text-xs text-[#666]">no database attached</span>
</div>
)}
{isPro && (
<div
className="flex items-center gap-2 px-4 py-2"
style={{
background: 'rgba(139,92,246,0.06)',
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
border: '1px solid rgba(139,92,246,0.15)',
borderTop: 'none',
width: NODE_WIDTH,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
<span className="text-xs text-[#a78bfa]">database</span>
<span className="text-xs text-[#7c6cb0] ml-auto font-mono">{formatBytes(site.DataSize)}</span>
</div>
)}
</div>
);
}
const nodeTypes = { site: SiteNode };
// --- Build nodes in a grid layout ---
function getGridLayout() {
const w = window.innerWidth;
if (w < 640) return { cols: 1, nodeW: 300, nodeH: NODE_HEIGHT + WARNING_HEIGHT + 30 };
if (w < 1024) return { cols: 2, nodeW: 320, nodeH: NODE_HEIGHT + WARNING_HEIGHT + 36 };
return { cols: 3, nodeW: 340, nodeH: NODE_HEIGHT + WARNING_HEIGHT + 40 };
}
const POS_KEY = 'sites-node-positions';
function loadPositions() {
try {
return JSON.parse(localStorage.getItem(POS_KEY)) || {};
} catch { return {}; }
}
function savePositions(nodes) {
const pos = {};
nodes.forEach(n => { pos[n.id] = n.position; });
localStorage.setItem(POS_KEY, JSON.stringify(pos));
}
// --- Detail Panel ---
function statusDotColor(status) {
if (status === 'active') return '#10b981';
if (status === 'failed' || status === 'stopped') return '#ef4444';
if (status === 'shutdown') return '#6b7280';
return '#f59e0b';
}
function ShutdownFreeConfirmModal({ siteName, onConfirm, onCancel }) {
const [input, setInput] = useState('');
const matches = input === siteName;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}>
<div className="max-w-sm w-full mx-4 p-6" style={{ background: '#1a1a1a', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '16px', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)' }}>
<h3 className="text-white font-semibold text-base mb-1">Shutdown site</h3>
<p className="text-[#888] text-sm mb-4">
This will stop and remove all infrastructure for this site. Free sites cannot be restarted.
</p>
<p className="text-[#888] text-sm mb-3">
Type <span className="text-white font-medium">{siteName}</span> to confirm:
</p>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder={siteName}
autoFocus
className="w-full px-3 py-2 rounded-lg text-sm text-white placeholder:text-[#555] mb-4 focus:outline-none"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}
/>
<div className="flex items-center justify-end gap-2">
<button onClick={onCancel}
className="px-4 py-2 text-sm text-[#888] hover:text-white transition-colors rounded-lg">
Cancel
</button>
<button onClick={onConfirm} disabled={!matches}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
style={{ background: matches ? 'rgba(239,68,68,0.2)' : 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)' }}>
Shutdown Site
</button>
</div>
</div>
</div>
);
}
function ShutdownConfirmModal({ siteName, onConfirm, onCancel }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}>
<div className="max-w-sm w-full mx-4 p-6" style={{ background: '#1a1a1a', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '16px', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)' }}>
<h3 className="text-white font-semibold text-base mb-1">Shutdown site</h3>
<p className="text-[#888] text-sm mb-4">
This will stop <span className="text-white font-medium">{siteName}</span>. Your data will be preserved and you can restart it anytime.
</p>
<div className="flex items-center justify-end gap-2">
<button onClick={onCancel}
className="px-4 py-2 text-sm text-[#888] hover:text-white transition-colors rounded-lg">
Cancel
</button>
<button onClick={onConfirm}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ background: 'rgba(107,114,128,0.2)', color: '#9ca3af', border: '1px solid rgba(107,114,128,0.3)' }}>
Shutdown Site
</button>
</div>
</div>
</div>
);
}
function DeleteConfirmModal({ siteName, onConfirm, onCancel }) {
const [input, setInput] = useState('');
const matches = input === siteName;
return (
<ModalShell>
<h3 className="text-white font-semibold text-base mb-1 text-left">Delete site</h3>
<p className="text-[#888] text-sm mb-4 text-left">
This will permanently delete <span className="text-white font-medium">{siteName}</span> and all its data. This cannot be undone.
</p>
<p className="text-[#888] text-sm mb-3 text-left">
Type <span className="text-white font-medium">{siteName}</span> to confirm:
</p>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder={siteName}
autoFocus
className="w-full px-3 py-2 rounded-lg text-sm text-white placeholder:text-[#555] mb-4 focus:outline-none"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}
/>
<div className="flex items-center justify-end gap-2">
<button onClick={onCancel}
className="px-4 py-2 text-sm text-[#888] hover:text-white transition-colors rounded-lg">
Cancel
</button>
<button onClick={onConfirm} disabled={!matches}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
style={{ background: matches ? 'rgba(239,68,68,0.2)' : 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)' }}>
Delete Permanently
</button>
</div>
</ModalShell>
);
}
function UpgradeConfirmModal({ onConfirm, onCancel }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}>
<div className="max-w-sm w-full mx-4 p-6" style={{ background: '#1a1a1a', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '16px', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)' }}>
<h3 className="text-white font-semibold text-base mb-1">Upgrade to Pro</h3>
<p className="text-[#888] text-sm mb-3">
Your site will get a persistent database. Data will be preserved across restarts.
</p>
<div className="flex items-center justify-end gap-2">
<button onClick={onCancel}
className="px-4 py-2 text-sm text-[#888] hover:text-white transition-colors rounded-lg">
Cancel
</button>
<button onClick={onConfirm}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ background: 'linear-gradient(135deg, rgba(139,92,246,0.3), rgba(34,211,238,0.3))', color: '#c4b5fd', border: '1px solid rgba(139,92,246,0.4)' }}>
Upgrade to Pro
</button>
</div>
</div>
</div>
);
}
function DetailPanel({ site, onClose, onRenameSite, onUpgrade, onDelete, onShutdown, onRestart }) {
const siteUrl = `https://${site.ID}.readysite.app`;
const isFree = site.Plan === 'free';
const isPro = site.Plan === 'pro';
const isShutdown = site.Status === 'shutdown';
const isActive = site.Status === 'active';
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showShutdownModal, setShowShutdownModal] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
return (
<div
className="absolute left-2 right-2 bottom-2 sm:inset-auto sm:right-4 sm:top-4 sm:bottom-4 z-10 flex flex-col overflow-hidden"
style={{
maxWidth: 420,
maxHeight: window.innerWidth < 640 ? 'calc(100% - 64px)' : undefined,
width: window.innerWidth < 640 ? undefined : 420,
background: 'rgba(26,26,26,0.95)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '16px',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: statusDotColor(site.Status) }} />
<h3 className="text-white font-semibold text-sm">{site.Name}</h3>
</div>
<div className="flex items-center gap-1">
<a href={siteUrl} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2.5 h-7 rounded-md text-xs text-[#666] hover:text-white hover:bg-white/10 transition-colors mr-1"
title="Open site">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
</svg>
Open
</a>
<button onClick={onClose} className="w-7 h-7 flex items-center justify-center rounded-md text-[#666] hover:text-white hover:bg-white/10 transition-colors text-lg leading-none">×</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 pb-5 flex flex-col gap-4">
{site.CreatedAt && (
<div>
<label className="text-[11px] uppercase tracking-wider text-[#555] block mb-1">Created</label>
<div className="text-[#999] text-sm">{formatDate(site.CreatedAt)}</div>
</div>
)}
<div>
<label className="text-[11px] uppercase tracking-wider text-[#555] block mb-1">Description</label>
<div className={`text-sm ${site.Description ? 'text-[#999]' : 'text-[#555] italic opacity-80'}`}>
{site.Description || 'No description'}
</div>
</div>
{/* Links */}
<div>
<label className="text-[11px] uppercase tracking-wider text-[#555] block mb-2">Links</label>
<div className="flex flex-col gap-3">
<a href={siteUrl} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-colors hover:bg-white/[0.04]"
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" style={{ background: 'rgba(16,185,129,0.12)' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#10b981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-white">Website</div>
<div className="text-[11px] text-[#555] font-mono truncate">{site.ID}.readysite.app</div>
</div>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<a href={`/api/sites/${site.ID}/admin`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-colors hover:bg-white/[0.04]"
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" style={{ background: 'rgba(139,92,246,0.12)' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-white">Admin Panel</div>
<div className="text-[11px] text-[#555] font-mono truncate">{site.ID}.readysite.app/admin</div>
</div>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
</div>
</div>
<div className="mt-auto flex flex-col gap-2">
{/* Upgrade button for free active sites */}
{isFree && isActive && (
<button onClick={() => setShowUpgradeModal(true)}
className="w-full py-2.5 text-sm font-medium rounded-lg transition-colors"
style={{ background: 'linear-gradient(135deg, rgba(139,92,246,0.2), rgba(34,211,238,0.2))', color: '#c4b5fd', border: '1px solid rgba(139,92,246,0.3)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(139,92,246,0.5)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(139,92,246,0.3)'; }}>
Upgrade to Pro
</button>
)}
{/* Shutdown button for free active sites */}
{isFree && isActive && (
<button onClick={() => setShowDeleteModal(true)}
className="w-full py-2 text-xs font-medium rounded-lg transition-colors text-[#666] hover:text-red-400 hover:bg-red-500/5"
style={{ border: '1px solid rgba(255,255,255,0.05)' }}>
Shutdown Site
</button>
)}
{/* Shutdown button for pro active sites */}
{isPro && isActive && (
<button onClick={() => setShowShutdownModal(true)}
className="w-full py-2 text-xs font-medium rounded-lg transition-colors text-[#666] hover:text-[#999] hover:bg-white/5"
style={{ border: '1px solid rgba(255,255,255,0.05)' }}>
Shutdown Site
</button>
)}
{/* Restart button for pro shutdown sites */}
{isPro && isShutdown && (
<button onClick={onRestart}
className="w-full py-2.5 text-sm font-medium rounded-lg transition-colors"
style={{ background: 'rgba(16,185,129,0.15)', color: '#10b981', border: '1px solid rgba(16,185,129,0.3)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(16,185,129,0.5)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(16,185,129,0.3)'; }}>
Restart
</button>
)}
{/* Delete button for shutdown free sites */}
{isFree && isShutdown && (
<button onClick={() => setShowDeleteConfirmModal(true)}
className="w-full py-2 text-xs font-medium rounded-lg transition-colors text-[#666] hover:text-red-400 hover:bg-red-500/5"
style={{ border: '1px solid rgba(255,255,255,0.05)' }}>
Delete Site
</button>
)}
</div>
</div>
{showDeleteModal && createPortal(
<ShutdownFreeConfirmModal
siteName={site.Name}
onConfirm={() => { setShowDeleteModal(false); onDelete(); }}
onCancel={() => setShowDeleteModal(false)}
/>, document.body
)}
{showShutdownModal && createPortal(
<ShutdownConfirmModal
siteName={site.Name}
onConfirm={() => { setShowShutdownModal(false); onShutdown(); }}
onCancel={() => setShowShutdownModal(false)}
/>, document.body
)}
{showUpgradeModal && createPortal(
<UpgradeConfirmModal
onConfirm={() => { setShowUpgradeModal(false); onUpgrade(); }}
onCancel={() => setShowUpgradeModal(false)}
/>, document.body
)}
{showDeleteConfirmModal && createPortal(
<DeleteConfirmModal
siteName={site.Name}
onConfirm={() => { setShowDeleteConfirmModal(false); onDelete(); }}
onCancel={() => setShowDeleteConfirmModal(false)}
/>, document.body
)}
</div>
);
}
// --- Main SitesPage ---
export function SitesPage() {
const [sites, setSites] = useState([]);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [selectedId, setSelectedId] = useState(null);
const [selectedSite, setSelectedSite] = useState(null);
const [loading, setLoading] = useState(true);
const [showTour, setShowTour] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const eventSourceRef = useRef(null);
const selectCounterRef = useRef(0);
const reactFlowRef = useRef(null);
const getInitialId = () => new URLSearchParams(window.location.search).get('id');
const fetchSites = useCallback(async () => {
try {
const res = await fetch('/api/sites');
if (!res.ok) return;
const data = await res.json();
setSites(data || []);
return data || [];
} finally {
setLoading(false);
}
}, []);
const handleUpgradeFromCard = useCallback((siteId) => {
selectSite(siteId);
setShowUpgradeModal(true);
}, []);
// When sites change, rebuild nodes (preserving dragged positions + localStorage)
useEffect(() => {
const saved = loadPositions();
const { cols, nodeW, nodeH } = getGridLayout();
setNodes(prev => {
const posMap = {};
prev.forEach(n => { posMap[n.id] = n.position; });
return sites.map((site, i) => ({
id: site.ID,
type: 'site',
position: posMap[site.ID] || saved[site.ID] || { x: (i % cols) * nodeW, y: Math.floor(i / cols) * nodeH },
data: { site, isOpen: site.ID === selectedId, onUpgradeClick: () => handleUpgradeFromCard(site.ID) },
}));
});
}, [sites, selectedId]);
const onNodeDragStop = useCallback(() => {
setNodes(cur => { savePositions(cur); return cur; });
}, []);
const fetchSiteDetail = useCallback(async (id) => {
const res = await fetch(`/api/sites/${id}`);
if (!res.ok) return null;
return await res.json();
}, []);
const connectSSE = useCallback((site) => {
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
const terminal = ['active', 'stopped', 'deleted', 'failed'];
if (terminal.includes(site.Status)) return;
const source = new EventSource(`/api/sites/${site.ID}/events`);
eventSourceRef.current = source;
source.addEventListener('status', (e) => {
const event = JSON.parse(e.data);
setSelectedSite(prev => prev ? { ...prev, Status: event.status } : prev);
setSites(prev => prev.map(s => s.ID === site.ID ? { ...s, Status: event.status } : s));
if (terminal.includes(event.status)) {
source.close();
eventSourceRef.current = null;
fetchSiteDetail(site.ID).then(data => {
if (data) {
setSelectedSite(data.site);
setSites(prev => prev.map(s => s.ID === data.site.ID ? data.site : s));
}
});
}
});
source.onerror = () => { source.close(); eventSourceRef.current = null; };
}, [fetchSiteDetail]);
const selectSite = useCallback(async (id) => {
const thisRequest = ++selectCounterRef.current;
if (!id) {
setSelectedId(null);
setSelectedSite(null);
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
window.history.replaceState(null, '', '/sites');
return;
}
setSelectedId(id);
window.history.replaceState(null, '', `/sites?id=${id}`);
const data = await fetchSiteDetail(id);
// Guard against stale response from a superseded selection
if (selectCounterRef.current !== thisRequest) return;
if (!data) return;
setSelectedSite(data.site);
if (data.site.Status === 'launching') setShowTour(true);
connectSSE(data.site);
}, [fetchSiteDetail, connectSSE]);
const onNodeDragStart = useCallback((_, node) => {
if (selectedId) selectSite(node.id);
}, [selectSite, selectedId]);
const centerOnNode = useCallback((id) => {
const rf = reactFlowRef.current;
if (!rf) return;
const node = rf.getNode(id);
if (!node) return;
const x = node.position.x + NODE_WIDTH / 2;
const y = node.position.y + NODE_HEIGHT / 2;
rf.setCenter(x, y, { zoom: 1, duration: 300 });
}, []);
useEffect(() => {
(async () => {
const data = await fetchSites();
const initialId = getInitialId();
if (initialId && data?.some(s => s.ID === initialId)) {
selectSite(initialId);
// Center after ReactFlow has had time to mount and lay out nodes
setTimeout(() => centerOnNode(initialId), 100);
}
})();
return () => { if (eventSourceRef.current) eventSourceRef.current.close(); };
}, []);
const dismissTour = useCallback(() => {
if (selectedId) localStorage.setItem(`welcome-dismissed-${selectedId}`, '1');
setShowTour(false);
}, [selectedId]);
const handleRenameSite = useCallback(async (newName) => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) });
if (res.ok) {
const updated = await res.json();
setSelectedSite(updated);
setSites(prev => prev.map(s => s.ID === updated.ID ? { ...s, Name: updated.Name } : s));
}
}, [selectedId]);
const handleUpgrade = useCallback(async () => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}/upgrade`, { method: 'POST' });
if (res.ok) {
const data = await fetchSiteDetail(selectedId);
if (data) {
setSelectedSite(data.site);
setSites(prev => prev.map(s => s.ID === data.site.ID ? data.site : s));
}
}
}, [selectedId, fetchSiteDetail]);
const handleDelete = useCallback(async () => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}`, { method: 'DELETE' });
if (res.ok) {
fetchSites();
selectSite(null);
}
}, [selectedId, selectSite, fetchSites]);
const handleShutdown = useCallback(async () => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}`, { method: 'DELETE' });
if (res.ok) {
const data = await fetchSiteDetail(selectedId);
if (data) {
setSelectedSite(data.site);
setSites(prev => prev.map(s => s.ID === data.site.ID ? data.site : s));
}
}
}, [selectedId, fetchSiteDetail]);
const handleRestart = useCallback(async () => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}/restart`, { method: 'POST' });
if (res.ok) {
const data = await fetchSiteDetail(selectedId);
if (data) {
setSelectedSite(data.site);
setSites(prev => prev.map(s => s.ID === data.site.ID ? data.site : s));
}
}
}, [selectedId, fetchSiteDetail]);
const confirmUpgradeFromModal = useCallback(() => {
setShowUpgradeModal(false);
handleUpgrade();
}, [handleUpgrade]);
const onNodeClick = useCallback((_, node) => {
selectSite(node.id);
}, [selectSite]);
const onPaneClick = useCallback(() => {
selectSite(null);
}, [selectSite]);
if (loading) {
return <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-[#555] text-sm">Loading...</div>
</div>;
}
if (sites.length === 0) {
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<ReactFlow
nodes={[]}
edges={[]}
proOptions={{ hideAttribution: true }}
zoomOnScroll={true}
>
<Background variant={BackgroundVariant.Dots} color="rgba(255,255,255,0.15)" gap={24} size={1.5} />
</ReactFlow>
<div style={{ position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center" style={{ pointerEvents: 'auto' }}>
<img src="/static/gophers/jet-pack.svg" alt="Gopher with jet pack" className="w-36 h-36 sm:w-48 sm:h-48 mx-auto mb-6 sm:mb-8 opacity-90" />
<h2 className="text-xl sm:text-2xl font-bold text-white mb-3">Launch your first site</h2>
<p className="text-[#888] mb-8 max-w-md mx-auto text-sm sm:text-base">Describe your idea and get a live preview in seconds.</p>
<button className="btn bg-white text-black hover:bg-[#e5e5e5] border-0 px-6 sm:px-8"
onClick={() => document.getElementById('new-site-modal')?.showModal()}>
Create Your First Site
</button>
</div>
</div>
</div>
);
}
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<ReactFlow
nodes={nodes}
edges={[]}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={(instance) => { reactFlowRef.current = instance; }}
nodeTypes={nodeTypes}
fitView={!getInitialId()}
fitViewOptions={{ maxZoom: 1, padding: 0.3 }}
proOptions={{ hideAttribution: true }}
zoomOnScroll={true}
nodeDragThreshold={5}
>
<Background
variant={BackgroundVariant.Dots}
color="rgba(255,255,255,0.15)"
gap={24}
size={1.5}
/>
<Controls
showInteractive={false}
className="!bg-[#1a1a1a] !border-white/10 !rounded-xl !shadow-none [&>button]:!bg-[#1a1a1a] [&>button]:!border-white/10 [&>button]:!text-white [&>button:hover]:!bg-white/10"
/>
</ReactFlow>
{/* Detail panel */}
{selectedSite && (
<DetailPanel
site={selectedSite}
onClose={() => selectSite(null)}
onRenameSite={handleRenameSite}
onUpgrade={handleUpgrade}
onDelete={handleDelete}
onShutdown={handleShutdown}
onRestart={handleRestart}
/>
)}
{/* Upgrade confirmation modal (from card warning click) */}
{showUpgradeModal && (
<UpgradeConfirmModal
onConfirm={confirmUpgradeFromModal}
onCancel={() => setShowUpgradeModal(false)}
/>
)}
{/* Launch tour */}
{showTour && selectedSite && <LaunchTour site={selectedSite} onDismiss={dismissTour} />}
</div>
);
}