import React, { useState, useEffect, useRef, useId, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
CheckCircle, Search,
Info, RefreshCw, X, Globe, Shield, BarChart3, Settings,
Plus, Trash2, Link, ExternalLink, Unlink
} from "lucide-react";
import { colors as themeColors } from '../theme/colors';
import {
StatTile,
ActionButton,
DashboardEmptyState,
DashboardLoadingSpinner,
SettingsPanel,
SectionCard,
NumberSpinner,
Dropdown,
} from './dashboardPrimitives';
export const colors = themeColors;
// --- Helper Functions ---
export const parseServerDate = (dateString) => {
if (!dateString) return 'N/A';
try {
const datePart = dateString.split(' ')[0].split('T')[0];
if (datePart && /^\d{4}-\d{2}-\d{2}$/.test(datePart)) {
return datePart;
}
return 'Invalid Date';
} catch (error) {
console.error('Error parsing date:', dateString, error);
return 'Invalid Date';
}
};
export const formatCPU = (cpuStr) => {
if (!cpuStr) return { value: 0, unit: 'm', percentage: 0 };
if (cpuStr.endsWith('m')) {
const millicores = parseInt(cpuStr) || 0;
return {
value: millicores,
unit: 'm',
percentage: Math.min((millicores / 1000) * 100, 100),
display: `${millicores}m`
};
}
const cores = parseFloat(cpuStr) || 0;
return {
value: cores,
unit: 'cores',
percentage: Math.min(cores * 100, 100),
display: `${cores} core${cores !== 1 ? 's' : ''}`
};
};
export const formatMemory = (memoryStr, totalMemoryGB = 1) => {
if (!memoryStr) return { value: 0, unit: 'Mi', percentage: 0 };
if (memoryStr.endsWith('Mi')) {
const mebibytes = parseInt(memoryStr) || 0;
const totalMebibytes = totalMemoryGB * 1024;
return {
value: mebibytes,
unit: 'Mi',
percentage: Math.min((mebibytes / totalMebibytes) * 100, 100),
display: `${(mebibytes / 1024).toFixed(1)} GB`
};
}
if (memoryStr.endsWith('Gi') || memoryStr.toLowerCase().includes('gb')) {
const gibibytes = parseFloat(memoryStr) || 0;
return {
value: gibibytes * 1024,
unit: 'Gi',
percentage: Math.min((gibibytes / totalMemoryGB) * 100, 100),
display: `${gibibytes.toFixed(1)} GB`
};
}
if (memoryStr.toLowerCase().includes('mb')) {
const mebibytes = parseFloat(memoryStr) || 0;
const totalMebibytes = totalMemoryGB * 1024;
return {
value: mebibytes,
unit: 'Mi',
percentage: Math.min((mebibytes / totalMebibytes) * 100, 100),
display: `${(mebibytes / 1024).toFixed(1)} GB`
};
}
return { value: 0, unit: 'Mi', percentage: 0, display: '0 GB' };
};
export const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
export const getServerStatusInfo = (status) => {
switch (status) {
case 'Running':
return { label: 'Running', color: colors.green, bgColor: `${colors.green}20` };
case 'Stopped':
return { label: 'Stopped', color: colors.red, bgColor: `${colors.red}20` };
case 'Starting':
return { label: 'Starting', color: colors.yellow, bgColor: `${colors.yellow}20` };
case 'Stopping':
return { label: 'Stopping', color: colors.yellow, bgColor: `${colors.yellow}20` };
case 'Restarting':
return { label: 'Restarting', color: colors.yellow, bgColor: `${colors.yellow}20` };
case 'Error':
return { label: 'Error', color: colors.red, bgColor: `${colors.red}20` };
default:
return {
label: status && String(status).length ? String(status) : 'Unknown',
color: colors.foreground,
bgColor: colors.brightBlack,
};
}
};
export const minecraftVersions = [
{ series: '26 Series', versions: ['26.2', '26.1.2', '26.1.1', '26.1'] },
{ series: '1.21 Series (Tricky Trials / Mounts of Mayhem)', versions: ['1.21.11', '1.21.10', '1.21.9', '1.21.8', '1.21.7', '1.21.6', '1.21.5', '1.21.4', '1.21.3', '1.21.2', '1.21.1', '1.21'] },
{ series: '1.20 Series (Trails & Tales)', versions: ['1.20.6', '1.20.5', '1.20.4', '1.20.3', '1.20.2', '1.20.1', '1.20'] },
{ series: '1.19 Series (The Wild Update)', versions: ['1.19.4', '1.19.3', '1.19.2', '1.19.1', '1.19'] },
{ series: '1.18 Series (Caves & Cliffs Part II)', versions: ['1.18.2', '1.18.1', '1.18'] },
{ series: '1.17 Series (Caves & Cliffs Part I)', versions: ['1.17.1', '1.17'] },
{ series: '1.16 Series (Nether Update)', versions: ['1.16.5', '1.16.4', '1.16.3', '1.16.2', '1.16.1', '1.16'] },
{ series: '1.15 Series (Buzzy Bees)', versions: ['1.15.2', '1.15.1', '1.15'] },
{ series: '1.14 Series (Village & Pillage)', versions: ['1.14.4', '1.14.3', '1.14.2', '1.14.1', '1.14'] },
{ series: '1.13 Series (Update Aquatic)', versions: ['1.13.2', '1.13.1', '1.13'] },
{ series: '1.12 Series (World of Color Update)', versions: ['1.12.2', '1.12.1', '1.12'] },
{ series: '1.11 Series (Exploration Update)', versions: ['1.11.2', '1.11.1', '1.11'] },
{ series: '1.10 Series (Frostburn Update)', versions: ['1.10.2', '1.10.1', '1.10'] },
{ series: '1.9 Series (Combat Update)', versions: ['1.9.4', '1.9.3', '1.9.2', '1.9.1', '1.9'] },
{ series: '1.8 Series (Bountiful Update)', versions: ['1.8.9', '1.8.8', '1.8.7', '1.8.6', '1.8.5', '1.8.4', '1.8.3', '1.8.2', '1.8.1', '1.8'] },
{ series: '1.7 Series (The Update that Changed the World)', versions: ['1.7.10', '1.7.9', '1.7.8', '1.7.7', '1.7.6', '1.7.5', '1.7.4', '1.7.2'] },
{ series: '1.6 Series (Horse Update)', versions: ['1.6.4', '1.6.2', '1.6.1'] },
{ series: '1.5 Series (Redstone Update)', versions: ['1.5.2', '1.5.1', '1.5'] },
{ series: '1.4 Series (Pretty Scary Update)', versions: ['1.4.7', '1.4.6', '1.4.5', '1.4.4', '1.4.2'] },
{ series: '1.3 Series (Villager Trading Update)', versions: ['1.3.2', '1.3.1'] },
{ series: '1.2 Series (Faithful Update)', versions: ['1.2.5', '1.2.4', '1.2.3', '1.2.2', '1.2.1'] },
{ series: '1.1 Series (Spawn Egg Update)', versions: ['1.1'] },
{ series: '1.0 Series (Adventure Update)', versions: ['1.0.1', '1.0.0'] }
];
export const processMetrics = (metrics) => {
if (!metrics) return { cpu: { display: '0m', value: 0 }, memory: { display: '0 Mi', value: 0 }, cpu_percentage: 0, memory_percentage: 0 };
return {
cpu: formatCPU(metrics.cpu),
memory: formatMemory(metrics.memory),
cpu_percentage: metrics.cpu_percentage || 0,
memory_percentage: metrics.memory_percentage || 0
};
};
export const parseCpuValue = (cpuStr) => {
if (!cpuStr) return 1;
const match = cpuStr.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[1]) : 1;
};
export const parseRamValue = (ramStr) => {
if (!ramStr) return 1;
const match = ramStr.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[1]) : 1;
};
export const parseStorageValue = (storageStr) => {
if (!storageStr) return 10;
const match = storageStr.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[1]) : 10;
};
// --- Components ---
export const PortTooltip = ({ portTooltip }) => {
return (
{portTooltip.name}
{portTooltip.description}
{portTooltip.connection}
);
};
export const UnifiedTooltip = ({
children,
content,
show = true,
placement = 'top',
id: idProp,
multiline = true,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const elementRef = useRef(null);
const hideTimeoutRef = useRef(null);
const reactId = useId();
const tooltipId = idProp || `dash-tip-${reactId.replace(/:/g, '')}`;
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
if (!show || !content) return children;
const updatePosition = () => {
if (elementRef.current) {
const rect = elementRef.current.getBoundingClientRect();
setPosition({
top: rect.top,
left: rect.left + rect.width / 2,
bottom: rect.bottom,
});
}
};
const showTip = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
updatePosition();
setIsVisible(true);
};
const hideTip = () => {
hideTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
hideTimeoutRef.current = null;
}, 120);
};
const handleMouseEnter = () => showTip();
const handleMouseLeave = () => hideTip();
const handleFocus = (e) => {
if (elementRef.current?.contains(e.target)) showTip();
};
const handleBlur = (e) => {
if (!elementRef.current?.contains(e.relatedTarget)) hideTip();
};
const getTransform = () =>
placement === 'bottom' ? 'translate(-50%, 10px)' : 'translate(-50%, calc(-100% - 10px))';
const getTopPosition = () =>
placement === 'bottom' ? `${position.bottom}px` : `${position.top}px`;
const wrapClass = multiline
? 'max-w-xs text-left leading-relaxed whitespace-normal break-words'
: 'whitespace-nowrap';
const tooltipContent = isVisible ? (
{content}
) : null;
return (
<>
{children}
{tooltipContent && createPortal(tooltipContent, document.body)}
>
);
};
export const CustomTooltip = ({ children, content, show, ...rest }) => (
{children}
);
export const ResourceGraph = ({ data, color, title, unit, maxValue, currentValue, percentage, serverStatus }) => {
// Ensure data is valid
const validData = Array.isArray(data) ? data : [];
// Fill missing data points to ensure graph spans full width if less than 60 points
const filledData = [...validData];
const maxPoints = 60;
if (filledData.length < maxPoints) {
const missing = maxPoints - filledData.length;
const filler = new Array(missing).fill({ value: 0, time: '', percentage: 0 });
filledData.unshift(...filler);
}
// Calculate points for SVG path
const pointsList = filledData.map((d, i) => {
const x = (i / (maxPoints - 1)) * 100;
const normalizedValue = Math.min(d.value, maxValue);
const y = 100 - ((normalizedValue / (maxValue || 1)) * 100);
return { x, y };
});
const pointsStr = pointsList.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ');
// Ensure the path starts at (0,100), goes to the points, and ends at (100,100) then closes
const areaPath = `M 0,100 L ${pointsList.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' L ')} L 100,100 Z`;
return (
{serverStatus === 'Running' && (
)}
{title}
{currentValue}
{percentage}% of {unit === 'Mi' ? formatFileSize(maxValue * 1024 * 1024) : `${maxValue / 1000} cores`}
{serverStatus === 'Running' ? (
) : (
Offline
)}
);
};
export const MinecraftSettings = ({ server, settings, onSave, isLoading }) => {
const [localSettings, setLocalSettings] = useState(settings || {});
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
if (settings) {
setLocalSettings(settings);
}
}, [settings]);
const handleChange = (key, value) => {
setLocalSettings(prev => ({
...prev,
[key]: value
}));
};
const handleToggle = (key) => {
const currentValue = localSettings[key] === 'true';
handleChange(key, (!currentValue).toString());
};
const handleSave = () => {
onSave(localSettings);
};
const settingGroups = [
{
title: 'General & World',
icon: Globe,
settings: [
{ key: 'level-name', label: 'World Name', type: 'text', description: 'The name of your world folder' },
{ key: 'level-seed', label: 'Level Seed', type: 'text', description: 'Seed for world generation' },
{ key: 'gamemode', label: 'Default Gamemode', type: 'select', options: [
{ label: 'Survival', value: 'survival' },
{ label: 'Creative', value: 'creative' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Spectator', value: 'spectator' }
]},
{ key: 'difficulty', label: 'Difficulty', type: 'select', options: [
{ label: 'Peaceful', value: 'peaceful' },
{ label: 'Easy', value: 'easy' },
{ label: 'Normal', value: 'normal' },
{ label: 'Hard', value: 'hard' }
]},
{ key: 'hardcore', label: 'Hardcore Mode', type: 'toggle', description: 'Players are banned on death' },
{ key: 'pvp', label: 'PVP Enabled', type: 'toggle' },
{ key: 'allow-flight', label: 'Allow Flight', type: 'toggle', description: 'Allow players to fly (requires fly mod or creative)' },
{ key: 'allow-nether', label: 'Allow Nether', type: 'toggle' },
]
},
{
title: 'Security & Access',
icon: Shield,
settings: [
{ key: 'online-mode', label: 'Online Mode (Premium)', type: 'toggle', description: 'Disable to allow non-premium/cracked players' },
{ key: 'white-list', label: 'Whitelist', type: 'toggle', description: 'Only allowed players can join' },
{ key: 'enforce-whitelist', label: 'Enforce Whitelist', type: 'toggle' },
{ key: 'spawn-protection', label: 'Spawn Protection', type: 'number', description: 'Radius of spawn protection in blocks' },
{ key: 'op-permission-level', label: 'OP Permission Level', type: 'select', options: [
{ label: 'Level 1 - Bypass Spawn Protection', value: '1' },
{ label: 'Level 2 - Basic Commands', value: '2' },
{ label: 'Level 3 - Management Commands', value: '3' },
{ label: 'Level 4 - All Commands', value: '4' }
]},
{ key: 'enable-command-block', label: 'Command Blocks', type: 'toggle' },
]
},
{
title: 'Server Information',
icon: Info,
settings: [
{ key: 'motd', label: 'MOTD', type: 'text', description: 'Message of the Day (shown in server list)' },
{ key: 'max-players', label: 'Max Players', type: 'number' },
{ key: 'view-distance', label: 'View Distance', type: 'number', description: 'Chunks sent to players (3-32)' },
{ key: 'simulation-distance', label: 'Simulation Distance', type: 'number', description: 'Chunks to tick (3-32)' },
]
},
{
title: 'Technical & Performance',
icon: BarChart3,
settings: [
{ key: 'sync-chunk-writes', label: 'Sync Chunk Writes', type: 'toggle' },
{ key: 'max-tick-time', label: 'Max Tick Time', type: 'number', description: 'Max ms before watchdog kills server (-1 to disable)' },
{ key: 'network-compression-threshold', label: 'Network Compression', type: 'number' },
{ key: 'entity-broadcast-range-percentage', label: 'Entity Range %', type: 'number' },
{ key: 'enable-query', label: 'Enable Query', type: 'toggle' },
{ key: 'enable-rcon', label: 'Enable RCON', type: 'toggle' },
]
}
];
// Map all current settings not in specific groups to an "Other" category if they aren't empty
const knownKeys = new Set(settingGroups.flatMap(g => g.settings.map(s => s.key)));
const otherSettings = Object.entries(localSettings)
.filter(([key]) => !knownKeys.has(key) && key.trim() !== '')
.map(([key, value]) => ({
key,
label: key.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
type: (value === 'true' || value === 'false') ? 'toggle' : (isNaN(value) || value === '' ? 'text' : 'number'),
description: 'Advanced property from server.properties'
}));
if (otherSettings.length > 0) {
settingGroups.push({
title: 'Other Settings',
icon: Settings,
settings: otherSettings
});
}
const filteredGroups = settingGroups.map(group => ({
...group,
settings: group.settings.filter(s =>
s.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.key.toLowerCase().includes(searchTerm.toLowerCase()) ||
(s.description && s.description.toLowerCase().includes(searchTerm.toLowerCase()))
)
})).filter(group => group.settings.length > 0);
return (
{/* Header with search and save */}
Direct control over server.properties
{isLoading ? (
) : (
)}
Save Configuration
{filteredGroups.map((group, gIdx) => (
{group.settings.map((s) => (
{s.key}
{s.type === 'toggle' && (
)}
{s.type === 'select' && (
handleChange(s.key, val)}
placeholder="Select..."
/>
)}
{s.type === 'text' && (
handleChange(s.key, e.target.value)}
className="bg-black/40 border border-dash-border rounded-xl px-4 py-3 text-sm outline-none focus:border-primary/50 focus:bg-black/60 w-full transition-all font-bold shadow-inner"
style={{ color: colors.brightWhite }}
/>
)}
{s.type === 'number' && (
handleChange(s.key, val)}
className="w-full h-12"
/>
)}
{s.description && (
{s.description}
)}
))}
))}
{filteredGroups.length === 0 && (
setSearchTerm('')}>
Clear Search
}
/>
)}
Restart Required
Most changes to server.properties require a server restart to take effect.
Use the Restart action in the header after saving your changes.
);
};
export const DomainManager = ({ userId, username, servers, sendMessage, showAlert, showConfirm }) => {
const [domains, setDomains] = useState([]);
const [customDomains, setCustomDomains] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [newDomainName, setNewDomainName] = useState('');
const [selectedServerForLink, setSelectedServerForLink] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const [selectedDomainForLink, setSelectedDomainForLink] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [actionInProgress, setActionInProgress] = useState(false);
// Custom domains state
const [showCustomDomainModal, setShowCustomDomainModal] = useState(false);
const [customDomainName, setCustomDomainName] = useState('');
const [verifyingCustomDomain, setVerifyingCustomDomain] = useState(false);
const [customDomainVerificationResult, setCustomDomainVerificationResult] = useState(null);
const [verificationStep, setVerificationStep] = useState('idle');
const linkableServers = useMemo(
() => servers.filter((s) => ['website', 'vps'].includes(s.type)),
[servers]
);
const serverLinkOptions = useMemo(
() => [
{ value: '', label: 'No server (link later)' },
...linkableServers.map((s) => ({ value: s.id, label: s.name })),
],
[linkableServers]
);
// Clear messages after timeout
useEffect(() => {
if (errorMessage || successMessage) {
const timer = setTimeout(() => {
setErrorMessage('');
setSuccessMessage('');
}, 5000);
return () => clearTimeout(timer);
}
}, [errorMessage, successMessage]);
// Load domains function
const loadDomains = useCallback(() => {
setIsLoading(true);
setErrorMessage('');
sendMessage({
type: 'list_domains',
includeServerless: true
});
}, [sendMessage]);
const loadCustomDomains = useCallback(() => {
sendMessage({
type: 'list_custom_domains'
});
}, [sendMessage]);
// Initial load and periodic refresh
useEffect(() => {
loadDomains();
loadCustomDomains();
const interval = setInterval(() => {
loadDomains();
loadCustomDomains();
}, 30000);
return () => clearInterval(interval);
}, [loadDomains, loadCustomDomains]);
// WebSocket message handler
useEffect(() => {
const handleMessage = (data) => {
switch (data.type) {
case 'domains_list':
setDomains(data.domains || []);
setIsLoading(false);
break;
case 'custom_domains_list':
setCustomDomains(data.domains || []);
break;
case 'domain_created':
setSuccessMessage(data.message || 'Domain created successfully!');
setShowCreateModal(false);
setNewDomainName('');
setActionInProgress(false);
loadDomains();
break;
case 'custom_domain_created':
setSuccessMessage(data.message || 'Custom domain added successfully!');
setShowCustomDomainModal(false);
setCustomDomainName('');
setCustomDomainVerificationResult(null);
setVerificationStep('idle');
setActionInProgress(false);
loadCustomDomains();
break;
case 'custom_domain_verified':
if (data.verified) {
setCustomDomainVerificationResult({
success: true,
message: 'Domain verified successfully!',
ipAddress: data.ip_address
});
setVerificationStep('success');
} else {
setCustomDomainVerificationResult({
success: false,
message: `Verification failed. Please update DNS records.`,
currentIp: data.current_ip
});
setVerificationStep('error');
}
setVerifyingCustomDomain(false);
break;
case 'domain_deleted':
setSuccessMessage(data.message || 'Domain deleted successfully!');
setActionInProgress(false);
loadDomains();
break;
case 'custom_domain_deleted':
setSuccessMessage(data.message || 'Custom domain deleted successfully!');
setActionInProgress(false);
loadCustomDomains();
break;
case 'domain_linked':
setSuccessMessage(data.message || 'Domain linked successfully!');
setShowLinkModal(false);
setSelectedDomainForLink(null);
setActionInProgress(false);
loadDomains();
loadCustomDomains();
break;
case 'domain_unlinked':
setSuccessMessage(data.message || 'Domain unlinked successfully!');
setActionInProgress(false);
loadDomains();
loadCustomDomains();
break;
case 'error':
if (data.error.includes('domain') || data.error.includes('Domain')) {
setErrorMessage(data.error || 'An error occurred');
setIsLoading(false);
setActionInProgress(false);
}
break;
default:
break;
}
};
window.__domainManagerHandler = handleMessage;
return () => {
delete window.__domainManagerHandler;
};
}, [loadDomains, loadCustomDomains]);
const createDomain = () => {
if (!newDomainName.trim()) {
setErrorMessage('Please enter a domain name');
return;
}
// Validate domain name format
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
if (!domainRegex.test(newDomainName)) {
setErrorMessage('Domain name can only contain letters, numbers, and hyphens. Cannot start or end with hyphen.');
return;
}
if (newDomainName.length > 63) {
setErrorMessage('Domain name must be 63 characters or less');
return;
}
setActionInProgress(true);
setErrorMessage('');
setSuccessMessage('');
sendMessage({
type: 'create_domain',
subdomain: newDomainName.trim().toLowerCase(),
serverId: selectedServerForLink || null
});
};
const deleteDomain = (domainId, isCustom = false) => {
showConfirm('Delete Domain?', 'Are you sure? This cannot be undone.', () => {
setActionInProgress(true);
sendMessage({
type: isCustom ? 'delete_custom_domain' : 'delete_domain',
domainId: domainId
});
});
};
const openLinkModal = (domain, isCustom = false) => {
setSelectedDomainForLink({ ...domain, isCustom });
setSelectedServerForLink(domain.server_id || '');
setShowLinkModal(true);
};
const confirmLinkDomain = () => {
if (!selectedDomainForLink) return;
if (!selectedServerForLink) {
setErrorMessage('Please select a server to link');
return;
}
setActionInProgress(true);
setErrorMessage('');
sendMessage({
type: 'link_domain',
domainId: selectedDomainForLink.id,
serverId: selectedServerForLink,
});
};
const unlinkDomain = (domainId) => {
showConfirm('Unlink Domain?', 'Remove the server association for this domain?', () => {
setActionInProgress(true);
sendMessage({ type: 'unlink_domain', domainId });
});
};
const getDomainStatus = (domain) => {
if (!domain.server_id) return { label: 'Unlinked', color: colors.yellow, bgColor: `${colors.yellow}20` };
const linkedServer = servers.find(s => s.id === domain.server_id);
if (!linkedServer) return { label: 'Server Not Found', color: colors.red, bgColor: `${colors.red}20` };
return { label: `Linked: ${linkedServer.name}`, color: colors.green, bgColor: `${colors.green}20` };
};
const NODE_IP = '85.237.179.121';
return (
{/* Summary Stats */}
{!isLoading && (domains.length > 0 || customDomains.length > 0) && (
d.server_id).length}
accentColor={colors.green}
/>
)}
{/* Delegated Domains */}
0 && (
setShowCreateModal(true)} variant="primary" className="!px-4 !py-2 !min-h-0 sm:!min-h-0">
Create Domain
)
}
>
{isLoading ? (
) : domains.length === 0 ? (
setShowCreateModal(true)}
/>
) : (
{domains.map(domain => {
const status = getDomainStatus(domain);
return (
{domain.server_id ? (
unlinkDomain(domain.id)}
variant="secondary"
className="!px-3 !py-2 !min-h-0"
>
) : (
openLinkModal(domain)}
variant="primary"
className="!px-3 !py-2 !min-h-0"
>
)}
deleteDomain(domain.id)} variant="danger" className="!px-3 !py-2 !min-h-0">
);
})}
)}
{/* Custom Domains Section */}
0 && (
setShowCustomDomainModal(true)} variant="primary" className="!px-4 !py-2 !min-h-0 sm:!min-h-0">
Add Custom
)
}
>
{customDomains.length === 0 ? (
setShowCustomDomainModal(true)}
/>
) : (
{customDomains.map(domain => {
const status = getDomainStatus(domain);
return (
{domain.server_id ? (
unlinkDomain(domain.id)}
variant="secondary"
className="!px-3 !py-2 !min-h-0"
>
) : (
openLinkModal(domain, true)}
variant="primary"
className="!px-3 !py-2 !min-h-0"
>
)}
deleteDomain(domain.id, true)} variant="danger" className="!px-3 !py-2 !min-h-0">
);
})}
)}
{/* Create Delegated Domain Modal */}
{showCreateModal && createPortal(
setShowCreateModal(false)} variant="secondary" className="flex-1">Cancel
{actionInProgress ? : 'Create Domain'}
,
document.body
)}
{/* Add Custom Domain Modal */}
{showCustomDomainModal && createPortal(
setCustomDomainName(e.target.value)}
placeholder="play.example.com"
className="w-full bg-black/40 border border-dash-border rounded-xl px-4 py-3 text-sm font-bold focus:outline-none focus:border-cyan-500/50 transition-colors"
style={{ color: colors.brightWhite }}
/>
Instructions
To link your domain, you must point its A record to {NODE_IP} before adding it here.
{customDomainVerificationResult && (
{customDomainVerificationResult.message}
)}
{verificationStep !== 'idle' && verifyingCustomDomain && (
Verifying DNS records...
)}
setShowCustomDomainModal(false)} variant="secondary" className="flex-1">Cancel
{
setActionInProgress(true);
sendMessage({ type: 'create_custom_domain', domainName: customDomainName.trim().toLowerCase() });
}}
disabled={actionInProgress || !customDomainName.trim()}
variant="primary"
className="flex-1"
>
{actionInProgress ? : 'Add Domain'}
,
document.body
)}
{/* Link Domain to Server Modal */}
{showLinkModal && selectedDomainForLink && createPortal(
{selectedDomainForLink.full_domain || selectedDomainForLink.domain_name}
0 ? linkableServers.map((s) => ({ value: s.id, label: s.name })) : [{ value: '', label: 'No eligible servers' }]}
onChange={setSelectedServerForLink}
placeholder="Select server..."
/>
{ setShowLinkModal(false); setSelectedDomainForLink(null); }} variant="secondary" className="flex-1">
Cancel
{actionInProgress ? : 'Link Domain'}
,
document.body
)}
);
};
export {
DashboardViewShell,
PageHeaderBar,
StatTile,
ActionButton,
DashboardEmptyState,
DashboardLoadingSpinner,
SettingsPanel,
SectionCard,
Slider,
NumberSpinner,
Dropdown,
InlineDropdown
} from './dashboardPrimitives';