47.6 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' },
sleeping: { bg: 'rgba(107,114,128,0.15)', text: '#9ca3af', label: 'Sleeping' },
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 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>
);
}
// Tour is a single-step modal: shows "Launching..." then transitions to "You're live!" when ready.
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 [delayedReady, setDelayedReady] = useState(false);
const isActive = site.Status === 'active';
const isReady = isActive && delayedReady;
const Icon = isReady ? GlobeIcon : RocketIcon;
useEffect(() => {
if (isActive && !delayedReady) {
const timer = setTimeout(() => setDelayedReady(true), 2000);
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">{isReady ? 'You\'re live!' : 'Launching...'}</h2>
<p className="text-sm text-[#888] mb-8 leading-relaxed">{isReady ? 'Your site is ready. Visit it to start building.' : 'Your site will be live in a few seconds.'}</p>
<div className="flex flex-col gap-2">
<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' : '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 planBadge(plan) {
if (plan === 'pro') return { bg: 'rgba(139,92,246,0.2)', color: '#a78bfa', border: '1px solid rgba(139,92,246,0.3)', label: 'pro' };
if (plan === 'hobby') return { bg: 'rgba(16,185,129,0.2)', color: '#6ee7b7', border: '1px solid rgba(16,185,129,0.3)', label: 'hobby' };
return null;
}
function SiteNode({ data }) {
const site = data.site;
const isOpen = data.isOpen;
const status = statusColors[site.Status] || statusColors.pending;
const isFree = site.Plan === 'free';
const isPaid = site.Plan === 'hobby' || site.Plan === 'pro';
const isSleeping = site.Status === 'sleeping';
const isFreeActive = isFree && site.Status === 'active';
const hasFooter = isFreeActive || isSleeping || isPaid;
const isShutdown = site.Status === 'shutdown';
const badge = planBadge(site.Plan);
return (
<div
style={{
width: NODE_WIDTH,
opacity: isShutdown || isSleeping ? 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>
{badge ? (
<span className="shrink-0 text-xs px-2.5 py-0.5 rounded-full font-medium" style={{ background: badge.bg, color: badge.color, border: badge.border }}>{badge.label}</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]">sleeps when idle — upgrade to stay on</span>
</div>
)}
{isSleeping && (
<div
className="flex items-center gap-2 px-4 py-2"
style={{
background: 'rgba(107,114,128,0.06)',
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
border: '1px solid rgba(107,114,128,0.15)',
borderTop: 'none',
width: NODE_WIDTH,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<span className="text-xs text-[#9ca3af]">sleeping — visits will wake it</span>
</div>
)}
{isPaid && !isSleeping && (
<div
className="flex items-center gap-2 px-4 py-2"
style={{
background: site.Plan === 'pro' ? 'rgba(139,92,246,0.06)' : 'rgba(16,185,129,0.06)',
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
border: `1px solid ${site.Plan === 'pro' ? 'rgba(139,92,246,0.15)' : 'rgba(16,185,129,0.15)'}`,
borderTop: 'none',
width: NODE_WIDTH,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={site.Plan === 'pro' ? '#a78bfa' : '#6ee7b7'} 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" style={{ color: site.Plan === 'pro' ? '#a78bfa' : '#6ee7b7' }}>database</span>
<span className="text-xs ml-auto font-mono" style={{ color: site.Plan === 'pro' ? '#7c6cb0' : '#4ade80' }}>{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 Hobby</h3>
<p className="text-[#888] text-sm mb-3">
Your site will stay always on — no more sleeping after 30 minutes. $5/mo per site.
</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(16,185,129,0.3), rgba(34,211,238,0.3))', color: '#6ee7b7', border: '1px solid rgba(16,185,129,0.4)' }}>
Upgrade to Hobby
</button>
</div>
</div>
</div>
);
}
function DetailPanel({ site, onClose, onRenameSite, onUpgrade, onUpgradeToHobby, onDelete, onShutdown, onRestart }) {
const siteUrl = `https://${site.ID}.readysite.app`;
const isFree = site.Plan === 'free';
const isHobby = site.Plan === 'hobby';
const isPro = site.Plan === 'pro';
const isShutdown = site.Status === 'shutdown';
const isSleeping = site.Status === 'sleeping';
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">
{/* Sleeping info banner */}
{isSleeping && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: 'rgba(107,114,128,0.1)', border: '1px solid rgba(107,114,128,0.2)', color: '#9ca3af' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
Site is sleeping — visits will wake it
</div>
)}
{/* Upgrade to Hobby for free active/sleeping sites */}
{isFree && (isActive || isSleeping) && (
<button onClick={() => setShowUpgradeModal(true)}
className="w-full py-2.5 text-sm font-medium rounded-lg transition-colors"
style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.2), rgba(34,211,238,0.2))', color: '#6ee7b7', 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)'; }}>
Upgrade to Hobby — $5/mo
</button>
)}
{/* Upgrade to Pro for hobby active sites */}
{isHobby && isActive && (
<button onClick={onUpgrade}
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 — $20/mo
</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 paid active sites */}
{(isPro || isHobby) && 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 paid shutdown sites */}
{(isPro || isHobby) && 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', 'sleeping', '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 handleUpgradeToHobby = useCallback(async () => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}/upgrade?plan=hobby`, { method: 'POST' });
if (res.ok) {
const result = await res.json();
if (result.checkout_url) {
window.location.href = result.checkout_url;
return;
}
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 handleUpgrade = useCallback(async () => {
if (!selectedId) return;
const res = await fetch(`/api/sites/${selectedId}/upgrade?plan=pro`, { method: 'POST' });
if (res.ok) {
const result = await res.json();
if (result.checkout_url) {
window.location.href = result.checkout_url;
return;
}
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);
handleUpgradeToHobby();
}, [handleUpgradeToHobby]);
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}
onUpgradeToHobby={handleUpgradeToHobby}
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>
);
}