readysite / readysite.org / frontend / components / SitesPage.jsx
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 &mdash; 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 &mdash; 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 &mdash; 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">&times;</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 &mdash; 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 &mdash; $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 &mdash; $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>
    );
}
← Back