1295 lines
54 KiB
JavaScript
1295 lines
54 KiB
JavaScript
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 (
|
|
<div
|
|
className="ah-tooltip ah-tooltip-detail absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 max-w-[min(16rem,calc(100vw-2rem))] p-3.5 text-left"
|
|
role="tooltip"
|
|
>
|
|
<div className="ah-tooltip-heading">{portTooltip.name}</div>
|
|
<div className="ah-tooltip-description">{portTooltip.description}</div>
|
|
<div className="ah-tooltip-code">
|
|
{portTooltip.connection}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 ? (
|
|
<div
|
|
id={tooltipId}
|
|
role="tooltip"
|
|
className={`ah-tooltip fixed z-[12000] px-3.5 py-2.5 text-sm pointer-events-none ${wrapClass}`}
|
|
style={{
|
|
top: getTopPosition(),
|
|
left: `${position.left}px`,
|
|
transform: getTransform(),
|
|
}}
|
|
>
|
|
{content}
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={elementRef}
|
|
className="inline-flex max-w-full"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
>
|
|
{children}
|
|
</div>
|
|
{tooltipContent && createPortal(tooltipContent, document.body)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const CustomTooltip = ({ children, content, show, ...rest }) => (
|
|
<UnifiedTooltip content={content} show={show} {...rest}>
|
|
{children}
|
|
</UnifiedTooltip>
|
|
);
|
|
|
|
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 (
|
|
<div className="rounded-2xl p-6 border border-dash-border bg-brightBlack/20 backdrop-blur-sm w-full transition-all duration-300 hover:scale-[1.01] hover:bg-brightBlack/30 shadow-xl shadow-black/20"
|
|
style={{ borderColor: `${color}20` }}>
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative">
|
|
<div className="w-3 h-3 rounded-full shadow-[0_0_10px_rgba(255,255,255,0.2)]" style={{ backgroundColor: serverStatus === 'Running' ? color : colors.foreground }} />
|
|
{serverStatus === 'Running' && (
|
|
<div className="absolute inset-0 w-3 h-3 rounded-full blur-[6px] animate-pulse" style={{ backgroundColor: color }} />
|
|
)}
|
|
</div>
|
|
<span className="text-sm font-bold uppercase tracking-widest opacity-60" style={{ color: colors.foreground }}>{title}</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>
|
|
{currentValue}
|
|
</div>
|
|
<div className="text-[10px] font-bold opacity-40 uppercase tracking-widest mt-1" style={{ color: colors.foreground }}>
|
|
{percentage}% of {unit === 'Mi' ? formatFileSize(maxValue * 1024 * 1024) : `${maxValue / 1000} cores`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-36 w-full relative overflow-hidden rounded-2xl border border-white/5 shadow-inner transition-all duration-500 bg-black/40 group/graph">
|
|
{serverStatus === 'Running' ? (
|
|
<svg className="w-full h-full" preserveAspectRatio="none" viewBox="0 0 100 100" style={{ overflow: 'visible' }}>
|
|
<defs>
|
|
<linearGradient id={`gradient-${String(title || 'graph').replace(/\s+/g, '-')}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity="0.6" />
|
|
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
|
|
</linearGradient>
|
|
<filter id="glow-graph" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
|
</filter>
|
|
</defs>
|
|
<path
|
|
d={areaPath}
|
|
fill={`url(#gradient-${String(title || 'graph').replace(/\s+/g, '-')})`}
|
|
className="transition-all duration-700"
|
|
/>
|
|
{/* Glow effect line */}
|
|
<path
|
|
d={`M ${pointsList[0].x.toFixed(2)},${pointsList[0].y.toFixed(2)} L ${pointsList.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' L ')}`}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="6"
|
|
strokeOpacity="0.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
vectorEffect="non-scaling-stroke"
|
|
filter="url(#glow-graph)"
|
|
/>
|
|
{/* Main line */}
|
|
<polyline
|
|
points={pointsStr}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-2">
|
|
<RefreshCw className="w-6 h-6 opacity-10" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest opacity-20" style={{ color: colors.foreground }}>Offline</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
|
{/* Header with search and save */}
|
|
<div className="pb-4">
|
|
<SectionCard title="Quick Settings" icon={Settings} color={colors.blue}>
|
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6">
|
|
<div>
|
|
<p className="text-sm font-medium opacity-60">Direct control over server.properties</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row w-full lg:w-auto items-stretch sm:items-center gap-4">
|
|
<div className="relative flex-1 sm:w-80">
|
|
<input
|
|
type="text"
|
|
placeholder="Search settings..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-black/40 border border-dash-border rounded-xl px-4 py-3 pl-11 text-sm outline-none focus:border-primary/50 focus:ring-4 focus:ring-primary/10 transition-all font-bold"
|
|
style={{ color: colors.brightWhite }}
|
|
/>
|
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 opacity-40">
|
|
<Search className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
|
|
<ActionButton
|
|
onClick={handleSave}
|
|
disabled={isLoading}
|
|
variant="primary"
|
|
className="!px-6 !py-2.5 !min-h-0 sm:!min-h-0 !text-[11px] uppercase tracking-widest shadow-xl"
|
|
>
|
|
{isLoading ? (
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle className="w-4 h-4" />
|
|
)}
|
|
<span>Save Configuration</span>
|
|
</ActionButton> </div>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 pb-8">
|
|
{filteredGroups.map((group, gIdx) => (
|
|
<SectionCard key={gIdx} title={group.title} icon={group.icon} color={colors.blue}>
|
|
<div className="space-y-4">
|
|
{group.settings.map((s) => (
|
|
<div key={s.key}
|
|
className="group flex flex-col space-y-4 p-5 rounded-2xl border border-dash-border transition-all duration-200 bg-white/[0.01] hover:bg-white/[0.03] hover:border-white/10">
|
|
<div className="flex justify-between items-start gap-4">
|
|
<div className="flex flex-col space-y-1 min-w-0">
|
|
<label className="text-sm font-black tracking-tight" style={{ color: colors.brightWhite }}>
|
|
{s.label}
|
|
</label>
|
|
<code className="text-[9px] px-1.5 py-0.5 rounded bg-black/40 text-primary/60 font-mono w-fit border border-white/5">{s.key}</code>
|
|
</div>
|
|
|
|
<div className="flex-shrink-0">
|
|
{s.type === 'toggle' && (
|
|
<button
|
|
onClick={() => handleToggle(s.key)}
|
|
className={`w-12 h-6 rounded-full transition-all duration-300 relative border ${
|
|
localSettings[s.key] === 'true' ? 'border-primary bg-primary shadow-[0_0_10px_rgba(90,124,167,0.4)]' : 'border-white/10 bg-black/40'
|
|
}`}
|
|
>
|
|
<div className={`absolute top-0.5 w-4 h-4 rounded-full transition-all duration-500 shadow-md ${
|
|
localSettings[s.key] === 'true' ? 'translate-x-6 bg-brightWhite' : 'translate-x-0.5 bg-gray-400'
|
|
}`} />
|
|
</button>
|
|
)}
|
|
{s.type === 'select' && (
|
|
<div className="w-56">
|
|
<Dropdown
|
|
value={localSettings[s.key] || ''}
|
|
options={s.options}
|
|
onChange={(val) => handleChange(s.key, val)}
|
|
placeholder="Select..."
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{s.type === 'text' && (
|
|
<input
|
|
type="text"
|
|
value={localSettings[s.key] || ''}
|
|
onChange={(e) => 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' && (
|
|
<NumberSpinner
|
|
value={localSettings[s.key] || '0'}
|
|
onChange={(val) => handleChange(s.key, val)}
|
|
className="w-full h-12"
|
|
/>
|
|
)}
|
|
|
|
{s.description && (
|
|
<p className="text-[11px] opacity-40 font-medium leading-relaxed border-l-2 border-primary/20 pl-3">{s.description}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
))}
|
|
</div>
|
|
|
|
{filteredGroups.length === 0 && (
|
|
<DashboardEmptyState
|
|
icon={Search}
|
|
title="No settings found"
|
|
description={`We couldn't find any settings matching "${searchTerm}". Try a different search term.`}
|
|
action={
|
|
<ActionButton variant="secondary" onClick={() => setSearchTerm('')}>
|
|
Clear Search
|
|
</ActionButton>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<div className="bg-primary/5 border border-primary/10 p-6 rounded-2xl flex items-start space-x-5 mb-8 backdrop-blur-sm">
|
|
<div className="p-2.5 bg-primary/10 rounded-xl border border-primary/20 shadow-lg shadow-primary/5">
|
|
<RefreshCw className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h5 className="font-black text-sm mb-1 uppercase tracking-wider" style={{ color: colors.brightWhite }}>Restart Required</h5>
|
|
<p className="text-xs text-primary/70 leading-relaxed font-medium">
|
|
Most changes to <code className="bg-black/40 px-1.5 py-0.5 rounded text-[10px] border border-white/5 font-mono">server.properties</code> require a server restart to take effect.
|
|
Use the <span className="font-bold text-primary">Restart</span> action in the header after saving your changes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Summary Stats */}
|
|
{!isLoading && (domains.length > 0 || customDomains.length > 0) && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatTile
|
|
icon={Globe}
|
|
label="Total Domains"
|
|
value={domains.length + customDomains.length}
|
|
accentColor={colors.blue}
|
|
/>
|
|
<StatTile
|
|
icon={Shield}
|
|
label="Linked Domains"
|
|
value={[...domains, ...customDomains].filter(d => d.server_id).length}
|
|
accentColor={colors.green}
|
|
/>
|
|
<StatTile
|
|
icon={Plus}
|
|
label="Available Slots"
|
|
value="Unlimited"
|
|
accentColor={colors.cyan}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delegated Domains */}
|
|
<SectionCard
|
|
title="Delegated Domains"
|
|
icon={Globe}
|
|
color={colors.blue}
|
|
actions={
|
|
!isLoading && domains.length > 0 && (
|
|
<ActionButton onClick={() => setShowCreateModal(true)} variant="primary" className="!px-4 !py-2 !min-h-0 sm:!min-h-0">
|
|
<Plus className="w-4 h-4 shrink-0" aria-hidden />
|
|
<span>Create Domain</span>
|
|
</ActionButton>
|
|
)
|
|
}
|
|
>
|
|
{isLoading ? (
|
|
<DashboardLoadingSpinner label="Loading domains..." />
|
|
) : domains.length === 0 ? (
|
|
<DashboardEmptyState
|
|
icon={Globe}
|
|
title="No domains found"
|
|
description="Create your first subdomain to point to your servers."
|
|
onClick={() => setShowCreateModal(true)}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{domains.map(domain => {
|
|
const status = getDomainStatus(domain);
|
|
return (
|
|
<div key={domain.id} className="group relative p-5 rounded-2xl border transition-all duration-300 bg-black/40 hover:bg-brightBlack/20 hover:border-primary/30 shadow-lg shadow-black/10 overflow-hidden"
|
|
style={{ borderColor: colors.brightBlack }}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between mb-4 gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20 shrink-0">
|
|
<Globe className="w-4 h-4 text-primary" />
|
|
</div>
|
|
<p className="font-black text-lg truncate text-dash-text" style={{ color: colors.brightWhite }}>{domain.full_domain}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{domain.server_id ? (
|
|
<UnifiedTooltip content="Unlink server">
|
|
<ActionButton
|
|
onClick={() => unlinkDomain(domain.id)}
|
|
variant="secondary"
|
|
className="!px-3 !py-2 !min-h-0"
|
|
>
|
|
<Unlink className="w-4 h-4" />
|
|
</ActionButton>
|
|
</UnifiedTooltip>
|
|
) : (
|
|
<UnifiedTooltip content="Link to server">
|
|
<ActionButton
|
|
onClick={() => openLinkModal(domain)}
|
|
variant="primary"
|
|
className="!px-3 !py-2 !min-h-0"
|
|
>
|
|
<Link className="w-4 h-4" />
|
|
</ActionButton>
|
|
</UnifiedTooltip>
|
|
)}
|
|
<UnifiedTooltip content="Delete domain">
|
|
<ActionButton onClick={() => deleteDomain(domain.id)} variant="danger" className="!px-3 !py-2 !min-h-0">
|
|
<Trash2 className="w-4 h-4" />
|
|
</ActionButton>
|
|
</UnifiedTooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-auto pt-4 border-t border-white/5 flex items-center justify-between">
|
|
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-full"
|
|
style={{ backgroundColor: status.bgColor, color: status.color }}>
|
|
{status.label}
|
|
</span>
|
|
<div className="flex items-center gap-2 opacity-40 text-[10px] font-bold uppercase tracking-tighter">
|
|
<Shield className="w-3 h-3" />
|
|
<span>Protected</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Custom Domains Section */}
|
|
<SectionCard
|
|
title="Custom Domains"
|
|
icon={ExternalLink}
|
|
color={colors.cyan}
|
|
actions={
|
|
!isLoading && customDomains.length > 0 && (
|
|
<ActionButton onClick={() => setShowCustomDomainModal(true)} variant="primary" className="!px-4 !py-2 !min-h-0 sm:!min-h-0">
|
|
<Plus className="w-4 h-4 shrink-0" aria-hidden />
|
|
<span>Add Custom</span>
|
|
</ActionButton>
|
|
)
|
|
}
|
|
>
|
|
{customDomains.length === 0 ? (
|
|
<DashboardEmptyState
|
|
icon={Globe}
|
|
title="No custom domains"
|
|
description="Add your own domain and point it to our infrastructure."
|
|
onClick={() => setShowCustomDomainModal(true)}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{customDomains.map(domain => {
|
|
const status = getDomainStatus(domain);
|
|
return (
|
|
<div key={domain.id} className="group relative p-5 rounded-2xl border transition-all duration-300 bg-black/40 hover:bg-brightBlack/20 hover:border-primary/30 shadow-lg shadow-black/10 overflow-hidden"
|
|
style={{ borderColor: colors.brightBlack }}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between mb-4 gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="p-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 shrink-0">
|
|
<ExternalLink className="w-4 h-4 text-cyan-400" />
|
|
</div>
|
|
<p className="font-black text-lg truncate text-dash-text" style={{ color: colors.brightWhite }}>{domain.domain_name}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{domain.server_id ? (
|
|
<UnifiedTooltip content="Unlink server">
|
|
<ActionButton
|
|
onClick={() => unlinkDomain(domain.id)}
|
|
variant="secondary"
|
|
className="!px-3 !py-2 !min-h-0"
|
|
>
|
|
<Unlink className="w-4 h-4" />
|
|
</ActionButton>
|
|
</UnifiedTooltip>
|
|
) : (
|
|
<UnifiedTooltip content="Link to server">
|
|
<ActionButton
|
|
onClick={() => openLinkModal(domain, true)}
|
|
variant="primary"
|
|
className="!px-3 !py-2 !min-h-0"
|
|
>
|
|
<Link className="w-4 h-4" />
|
|
</ActionButton>
|
|
</UnifiedTooltip>
|
|
)}
|
|
<UnifiedTooltip content="Delete domain">
|
|
<ActionButton onClick={() => deleteDomain(domain.id, true)} variant="danger" className="!px-3 !py-2 !min-h-0">
|
|
<Trash2 className="w-4 h-4" />
|
|
</ActionButton>
|
|
</UnifiedTooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-auto pt-4 border-t border-white/5 flex items-center justify-between">
|
|
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-full"
|
|
style={{ backgroundColor: status.bgColor, color: status.color }}>
|
|
{status.label}
|
|
</span>
|
|
<div className="flex items-center gap-2 opacity-40 text-[10px] font-bold uppercase tracking-tighter">
|
|
<CheckCircle className="w-3 h-3 text-cyan-400" />
|
|
<span>Verified</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Create Delegated Domain Modal */}
|
|
{showCreateModal && createPortal(
|
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
|
<div className="w-full max-w-md bg-dash-surfaceElevated border border-dash-border rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
|
<div className="p-6 border-b border-dash-border flex items-center justify-between bg-primary/5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-xl bg-primary/20">
|
|
<Globe className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<h3 className="text-xl font-black tracking-tight text-dash-text" style={{ color: colors.brightWhite }}>New Subdomain</h3>
|
|
</div>
|
|
<button onClick={() => setShowCreateModal(false)} className="p-2 rounded-xl hover:bg-white/5 transition-colors">
|
|
<X className="w-5 h-5 opacity-40" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
<SettingsPanel title="Domain Details" description="Choose a unique subdomain. It will end with .anchorhost.eu">
|
|
<div className="space-y-4">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={newDomainName}
|
|
onChange={(e) => setNewDomainName(e.target.value)}
|
|
placeholder="myserver"
|
|
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-primary/50 transition-colors pr-32"
|
|
style={{ color: colors.brightWhite }}
|
|
/>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-black opacity-30 uppercase tracking-widest pointer-events-none">
|
|
.anchorhost.eu
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsPanel>
|
|
|
|
<SettingsPanel title="Link to Server (Optional)" description="Associate this subdomain with a website or VPS server">
|
|
<Dropdown
|
|
value={selectedServerForLink}
|
|
options={serverLinkOptions}
|
|
onChange={setSelectedServerForLink}
|
|
placeholder="Select server..."
|
|
/>
|
|
</SettingsPanel>
|
|
|
|
<div className="flex gap-3">
|
|
<ActionButton onClick={() => setShowCreateModal(false)} variant="secondary" className="flex-1">Cancel</ActionButton>
|
|
<ActionButton onClick={createDomain} variant="primary" className="flex-1" disabled={actionInProgress || !newDomainName.trim()}>
|
|
{actionInProgress ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Create Domain'}
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Add Custom Domain Modal */}
|
|
{showCustomDomainModal && createPortal(
|
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
|
<div className="w-full max-w-md bg-dash-surfaceElevated border border-dash-border rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
|
<div className="p-6 border-b border-dash-border flex items-center justify-between bg-cyan-500/5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-xl bg-cyan-500/20">
|
|
<ExternalLink className="w-5 h-5 text-cyan-400" />
|
|
</div>
|
|
<h3 className="text-xl font-black tracking-tight text-dash-text" style={{ color: colors.brightWhite }}>Custom Domain</h3>
|
|
</div>
|
|
<button onClick={() => setShowCustomDomainModal(false)} className="p-2 rounded-xl hover:bg-white/5 transition-colors">
|
|
<X className="w-5 h-5 opacity-40" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
<SettingsPanel title="Your Domain" description="Enter the full domain you want to link (e.g., play.example.com)">
|
|
<input
|
|
type="text"
|
|
value={customDomainName}
|
|
onChange={(e) => 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 }}
|
|
/>
|
|
</SettingsPanel>
|
|
|
|
<div className="p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-cyan-400 mb-2">Instructions</p>
|
|
<p className="text-xs opacity-70 leading-relaxed" style={{ color: colors.foreground }}>
|
|
To link your domain, you must point its <span className="font-bold text-white">A record</span> to <span className="font-mono font-bold text-white">{NODE_IP}</span> before adding it here.
|
|
</p>
|
|
</div>
|
|
|
|
{customDomainVerificationResult && (
|
|
<div
|
|
className="p-4 rounded-xl border text-sm"
|
|
style={{
|
|
borderColor: customDomainVerificationResult.success ? `${colors.green}40` : `${colors.red}40`,
|
|
backgroundColor: customDomainVerificationResult.success ? `${colors.green}10` : `${colors.red}10`,
|
|
color: customDomainVerificationResult.success ? colors.brightGreen : colors.brightRed,
|
|
}}
|
|
>
|
|
{customDomainVerificationResult.message}
|
|
</div>
|
|
)}
|
|
|
|
{verificationStep !== 'idle' && verifyingCustomDomain && (
|
|
<p className="text-xs font-bold uppercase tracking-widest opacity-60" style={{ color: colors.foreground }}>
|
|
Verifying DNS records...
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<ActionButton onClick={() => setShowCustomDomainModal(false)} variant="secondary" className="flex-1">Cancel</ActionButton>
|
|
<ActionButton
|
|
onClick={() => {
|
|
setActionInProgress(true);
|
|
sendMessage({ type: 'create_custom_domain', domainName: customDomainName.trim().toLowerCase() });
|
|
}}
|
|
disabled={actionInProgress || !customDomainName.trim()}
|
|
variant="primary"
|
|
className="flex-1"
|
|
>
|
|
{actionInProgress ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Add Domain'}
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Link Domain to Server Modal */}
|
|
{showLinkModal && selectedDomainForLink && createPortal(
|
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
|
<div className="w-full max-w-md bg-dash-surfaceElevated border border-dash-border rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
|
<div className="p-6 border-b border-dash-border flex items-center justify-between bg-primary/5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-xl bg-primary/20">
|
|
<Link className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<h3 className="text-xl font-black tracking-tight text-dash-text" style={{ color: colors.brightWhite }}>
|
|
Link Domain
|
|
</h3>
|
|
</div>
|
|
<button onClick={() => { setShowLinkModal(false); setSelectedDomainForLink(null); }} className="p-2 rounded-xl hover:bg-white/5 transition-colors">
|
|
<X className="w-5 h-5 opacity-40" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
<p className="text-sm font-bold" style={{ color: colors.foreground }}>
|
|
{selectedDomainForLink.full_domain || selectedDomainForLink.domain_name}
|
|
</p>
|
|
<SettingsPanel title="Target Server" description="Only website and VPS servers can be linked to a domain">
|
|
<Dropdown
|
|
value={selectedServerForLink}
|
|
options={linkableServers.length > 0 ? linkableServers.map((s) => ({ value: s.id, label: s.name })) : [{ value: '', label: 'No eligible servers' }]}
|
|
onChange={setSelectedServerForLink}
|
|
placeholder="Select server..."
|
|
/>
|
|
</SettingsPanel>
|
|
<div className="flex gap-3">
|
|
<ActionButton onClick={() => { setShowLinkModal(false); setSelectedDomainForLink(null); }} variant="secondary" className="flex-1">
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
onClick={confirmLinkDomain}
|
|
variant="primary"
|
|
className="flex-1"
|
|
disabled={actionInProgress || !selectedServerForLink || linkableServers.length === 0}
|
|
>
|
|
{actionInProgress ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Link Domain'}
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export {
|
|
DashboardViewShell,
|
|
PageHeaderBar,
|
|
StatTile,
|
|
ActionButton,
|
|
DashboardEmptyState,
|
|
DashboardLoadingSpinner,
|
|
SettingsPanel,
|
|
SectionCard,
|
|
Slider,
|
|
NumberSpinner,
|
|
Dropdown,
|
|
InlineDropdown
|
|
} from './dashboardPrimitives';
|