Modernize frontend and centralize branding

This commit is contained in:
wfalkowski committed 2026-10-18 20:35:12 +00:00
1 parent bc4542d0fb
commit 2d61d80550
55 files changed
+1901 -14446

No files matched your search

+14 -13
View File
@@ -1,16 +1,16 @@
import React, { useState, useEffect, useCallback, Suspense } from 'react';
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
import { createPortal } from 'react-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import DashboardPage from './pages/DashboardPage.js'
import HomePage from './pages/HomePage.js'
import AdminPage from './pages/AdminPage.js'
import VerifyEmail from './pages/VerifyEmail.js'
import TermsOfService from './pages/TermsOfService.js'
import PrivacyPolicy from './pages/PrivacyPolicy.js'
import PluginStore from './features/plugins/PluginStore.js'
import PluginDetails from './features/plugins/PluginDetails.js'
import DocumentationPage from './pages/DocumentationPage.js'
const DashboardPage = lazy(() => import('./pages/DashboardPage.jsx'));
const HomePage = lazy(() => import('./pages/HomePage.jsx'));
const AdminPage = lazy(() => import('./pages/AdminPage.jsx'));
const VerifyEmail = lazy(() => import('./pages/VerifyEmail.jsx'));
const TermsOfService = lazy(() => import('./pages/TermsOfService.jsx'));
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy.jsx'));
const PluginStore = lazy(() => import('./features/plugins/PluginStore.jsx'));
const PluginDetails = lazy(() => import('./features/plugins/PluginDetails.jsx'));
const DocumentationPage = lazy(() => import('./pages/DocumentationPage.jsx'));
import useWebSocket from './hooks/useWebSocket.js';
import { colors } from './theme/colors';
import { Dropdown } from './components/dashboardPrimitives';
@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { API_BASE_URL, WS_BASE_URL as WS_URL } from './config/api';
import branding from './branding';
// Auth context for user management
const AuthContext = React.createContext({
@@ -698,7 +699,7 @@ function LoginModal({ onClose, onLogin, onSwitchToRegister, colors, authConfig }
<div className="p-10">
<div className="flex flex-col items-center text-center mb-10">
<div className="p-4 rounded-3xl mb-6 shadow-xl border" style={{ backgroundColor: `${colors.blue}15`, borderColor: `${colors.blue}30` }}>
<img src="/assets/transparent_anchorhost.png" alt="AnchorHost.eu" className="h-10 w-auto" />
<img src={branding.logoPath} alt={branding.name} className="h-10 w-auto" />
</div>
<h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>
{is2faStep ? 'Verify Identity' : 'Welcome Back'}
@@ -828,7 +829,7 @@ function LoginModal({ onClose, onLogin, onSwitchToRegister, colors, authConfig }
<div className="mt-8 text-center">
<p className="text-xs font-bold" style={{ color: colors.foreground, opacity: 0.5 }}>
New to AnchorHost.eu?{' '}
New to {branding.name}?{' '}
<button onClick={onSwitchToRegister} className="text-primary hover:underline ml-1">Create Account</button>
</p>
</div>
@@ -1085,7 +1086,7 @@ function RegisterModal({
<Shield className="w-10 h-10" style={{ color: colors.brightGreen }} />
</div>
<h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>Create Account</h3>
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mt-2" style={{ color: colors.foreground }}>Join AnchorHost.eu</p>
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mt-2" style={{ color: colors.foreground }}>Join {branding.name}</p>
{error && (
<p className="text-sm font-semibold mt-4" style={{ color: colors.brightRed }}>{error}</p>
)}
+7
View File
@@ -0,0 +1,7 @@
/**
* Shared public identity for the app. To rebrand a deployment, update the
* root branding.json and replace the logo at the configured public path.
*/
import branding from '../branding.json';
export default branding;
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useId, useCallback, useMemo } from 'react';
import branding from '../branding';
import { createPortal } from 'react-dom';
import {
CheckCircle, Search,
@@ -270,11 +271,12 @@ export const UnifiedTooltip = ({
? 'max-w-xs text-left leading-relaxed whitespace-normal break-words'
: 'whitespace-nowrap';
const tooltipContent = isVisible ? (
const tooltipContent = (
<div
id={tooltipId}
role="tooltip"
className={`ah-tooltip fixed z-[12000] px-3.5 py-2.5 text-sm pointer-events-none ${wrapClass}`}
hidden={!isVisible}
style={{
top: getTopPosition(),
left: `${position.left}px`,
@@ -283,7 +285,14 @@ export const UnifiedTooltip = ({
>
{content}
</div>
) : null;
);
const describedChildren = React.Children.map(children, (child) => {
if (!React.isValidElement(child) || child.type === React.Fragment) return child;
const describedBy = [child.props['aria-describedby'], tooltipId]
.filter(Boolean)
.join(' ');
return React.cloneElement(child, { 'aria-describedby': describedBy || undefined });
});
return (
<>
@@ -295,7 +304,7 @@ export const UnifiedTooltip = ({
onFocus={handleFocus}
onBlur={handleBlur}
>
{children}
{describedChildren}
</div>
{tooltipContent && createPortal(tooltipContent, document.body)}
</>
@@ -1115,7 +1124,7 @@ export const DomainManager = ({ userId, username, servers, sendMessage, showAler
</button>
</div>
<div className="p-6 space-y-6">
<SettingsPanel title="Domain Details" description="Choose a unique subdomain. It will end with .anchorhost.eu">
<SettingsPanel title="Domain Details" description={`Choose a unique subdomain. It will end with .${branding.domain}`}>
<div className="space-y-4">
<div className="relative">
<input
@@ -1127,7 +1136,7 @@ export const DomainManager = ({ userId, username, servers, sendMessage, showAler
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
.{branding.domain}
</div>
</div>
</div>
File renamed without changes.
@@ -74,7 +74,7 @@ export function StatTile({ icon: Icon, label, value, accentColor = colors.blue,
);
}
const actionBase = `inline-flex items-center justify-center gap-2 rounded-sm text-sm font-semibold transition-colors duration-150 motion-reduce:transition-none ${focusRing} min-h-[44px] sm:min-h-[40px] px-5 py-3 sm:py-2.5 shadow-[3px_3px_0_#2F2F2F] disabled:opacity-40 disabled:pointer-events-none active:translate-x-[3px] active:translate-y-[3px] active:shadow-none`;
const actionBase = `inline-flex items-center justify-center gap-2 rounded-sm text-sm font-semibold transition-colors duration-150 motion-reduce:transition-none ${focusRing} min-h-[44px] sm:min-h-[40px] px-5 py-3 sm:py-2.5 disabled:opacity-40 disabled:pointer-events-none`;
/**
* Inset panel for settings rows (matches dash border / surface).
File renamed without changes.
File renamed without changes.
+6 -4
View File
@@ -1,17 +1,19 @@
/**
* API / WebSocket base URLs for local dev and production.
* Set REACT_APP_API_URL and REACT_APP_WS_URL in .env (see .env.example).
* Set VITE_API_URL, VITE_WS_URL, and VITE_SITE_URL in .env (see .env.example).
*/
import branding from '../branding';
const trimTrailingSlash = (url) => (url || '').replace(/\/$/, '');
export const API_BASE_URL = trimTrailingSlash(
process.env.REACT_APP_API_URL || 'https://anchorhost.eu'
import.meta.env.VITE_API_URL || branding.siteUrl
);
export const WS_BASE_URL = trimTrailingSlash(
process.env.REACT_APP_WS_URL || 'wss://anchorhost.eu/ws'
import.meta.env.VITE_WS_URL || `wss://${branding.domain}/ws`
);
export const SITE_BASE_URL = trimTrailingSlash(
process.env.REACT_APP_SITE_URL || API_BASE_URL
import.meta.env.VITE_SITE_URL || branding.siteUrl
);
@@ -13,6 +13,7 @@ import ServerDetailsModal from '../servers/ServerDetailsModal';
import NodeTerminalModal from './NodeTerminalModal';
import TicketSystem from '../support/TicketSystem';
import { colors } from '../../theme/colors';
import branding from '../../branding';
import { API_BASE_URL } from '../../config/api';
import {
StatTile,
@@ -1121,7 +1122,7 @@ const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => {
>
<div className="mb-8">
<p className="text-sm text-dash-textMuted leading-relaxed max-w-2xl">
Select three customer sites to feature on the public homepage. Use full URLs (e.g., https://site.anchorhost.eu). Leaving a URL empty will hide that slot.
Select three customer sites to feature on the public homepage. Use full URLs (e.g., https://site.{branding.domain}). Leaving a URL empty will hide that slot.
</p>
</div>
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import branding from '../../branding';
import { createPortal } from 'react-dom';
import {
Terminal as TerminalIcon,
@@ -58,7 +59,7 @@ const NodeTerminalModal = ({ node, onClose }) => {
const connectTerminal = useCallback(() => {
if (!node || !sendMessage) return;
setIsLoading(true);
setTerminalOutput(prev => prev + `\r\n\x1b[36m[AnchorHost.eu]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
setTerminalOutput(prev => prev + `\r\n\x1b[36m[${branding.name}]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
sendMessage({
type: 'node_terminal_connect',
nodeName: node.name
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { Calendar, Tag, ChevronRight, X, User } from "lucide-react";
import { colors } from "../../theme/colors";
import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
const AnnouncementModal = ({ announcement, onClose }) => {
const dialogRef = useRef(null);
@@ -194,7 +195,7 @@ const AnnouncementsSection = () => {
<section id="announcements" className="site-section site-announcements">
<div className="site-section-heading">
<div><p className="site-kicker">Platform updates</p><h2>Latest announcements.</h2></div>
<p>Service changes and notes from the AnchorHost.eu team.</p>
<p>Service changes and notes from the {branding.name} team.</p>
</div>
<div className="site-announcement-list">
{displayedAnnouncements.map((item) => (
@@ -4,6 +4,7 @@ import { ArrowLeft } from 'lucide-react';
import { DashboardViewShell, PageHeaderBar, ActionButton } from '../../components/dashboardPrimitives';
import { dashLayout } from '../../theme/dashboardTokens';
import { colors } from '../../theme/colors';
import branding from '../../branding';
/**
* Shared chrome for Terms / Privacy: dashboard shell, dash borders, typography.
@@ -26,9 +27,9 @@ export function LegalPageShell({ title, icon: Icon, accentColor = colors.blue, l
className="flex items-center gap-3 min-w-0 text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 group"
>
<div className="p-2 rounded-lg bg-white/5 border border-white/10 group-hover:scale-110 transition-transform">
<img src="./assets/transparent_anchorhost.png" alt="" className="h-6 w-auto shrink-0" />
<img src={branding.logoPath} alt="" className="h-6 w-auto shrink-0" />
</div>
<span className="text-xl font-bold text-dash-text truncate tracking-tight">AnchorHost.eu</span>
<span className="text-xl font-bold text-dash-text truncate tracking-tight">{branding.name}</span>
</button>
<ActionButton variant="secondary" className="!min-h-0 !px-4 !py-2.5 !text-sm !font-bold !rounded-xl" onClick={() => navigate('/')}>
<ArrowLeft className="w-4 h-4 shrink-0 mr-2" aria-hidden />
@@ -51,7 +52,7 @@ export function LegalPageShell({ title, icon: Icon, accentColor = colors.blue, l
<div className="text-center pt-8 border-t border-dash-border">
<p className="text-xs font-bold text-dash-textMuted uppercase tracking-[0.3em] opacity-40">
© {new Date().getFullYear()} AnchorHost.eu • High-Performance Cloud Solutions
© {new Date().getFullYear()} {branding.name} • {branding.footerDescription}
</p>
</div>
</div>
@@ -5,6 +5,7 @@ import { colors } from '../../theme/colors';
import { Dropdown } from '../../components/dashboardPrimitives';
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../../config/api';
import branding from '../../branding';
function PluginDetails() {
const { pluginId } = useParams();
@@ -34,7 +35,7 @@ function PluginDetails() {
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
{
headers: {
'User-Agent': 'AnchorHost.eu/1.0'
'User-Agent': `${branding.name}/1.0`
}
}
);
File renamed without changes.
@@ -5,6 +5,7 @@ import {
import { colors, Dropdown, SectionCard, minecraftVersions } from '../../components/DashboardComponents';
import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
// Embedded Plugin Store Component for Dashboard
const PluginStoreEmbedded = ({ serverId }) => {
@@ -34,7 +35,7 @@ const PluginStoreEmbedded = ({ serverId }) => {
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
{
headers: {
'User-Agent': 'AnchorHost.eu/1.0'
'User-Agent': `${branding.name}/1.0`
}
}
);
@@ -398,4 +399,4 @@ const PluginStoreEmbedded = ({ serverId }) => {
);
};
export default PluginStoreEmbedded;
export default PluginStoreEmbedded;
File renamed without changes.
@@ -4,7 +4,7 @@ import {
Folder, File, FolderOpen, Upload, Download, Edit, FolderPlus, FileText, Archive, Image,
ChevronLeft, ChevronRight, Search, Check, Trash2, Copy, X, RefreshCw, CheckCircle
} from "lucide-react";
import { colors, UnifiedTooltip, CustomTooltip } from '../../components/DashboardComponents.js';
import { colors, UnifiedTooltip, CustomTooltip } from '../../components/DashboardComponents.jsx';
// File Manager Component with forwardRef
const FileManager = forwardRef(({ serverId, serverImage, sendMessage, isActive, connectionStatus, showAlert }, ref) => {
File renamed without changes.
+196
View File
@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import {
Clock,
Coins,
Cpu,
Database,
Gamepad2,
Globe,
HardDrive,
MemoryStick,
Play,
Server,
Square,
Terminal,
} from 'lucide-react';
import { ActionButton, getServerStatusInfo } from '../../components/DashboardComponents.jsx';
import { colors } from '../../theme/colors.js';
const serverTypeIcons = {
vps: { icon: Server, color: colors.blue },
game: { icon: Gamepad2, color: colors.red },
website: { icon: Globe, color: colors.green },
database: { icon: Database, color: colors.magenta },
};
function calculateServerCost(server, pricing) {
if (server.is_demo === true || server.is_demo === 1) return 0;
const cpuCost = (parseInt(server.cpu, 10) || 0) * (pricing.price_cpu || 35);
const ramCost = (parseInt(server.ram, 10) || 0) * (pricing.price_ram || 35);
const storageCost = (parseInt(server.storage, 10) || 0) * (pricing.price_storage || 5);
const typeMultiplier = {
vps: pricing.multiplier_vps || 1,
game: pricing.multiplier_game || 0.8,
website: pricing.multiplier_website || 1.5,
database: pricing.multiplier_database || 1.3,
}[server.type] || 1;
return Math.round((cpuCost + ramCost + storageCost) * typeMultiplier);
}
export default function ServerCard({
server,
pricing,
userRole,
calculateCurrencyValue,
getCurrencySymbol,
onConvertDemo,
onOpenConsole,
onServerAction,
}) {
const statusInfo = getServerStatusInfo(server.status);
const { icon: ServerIcon, color: serverColor } = serverTypeIcons[server.type] || serverTypeIcons.vps;
const isDatabase = server.type === 'database';
const dbType = server.image || 'Unknown';
const isDemo = server.is_demo === true || server.is_demo === 1;
const monthlyCost = calculateServerCost(server, pricing);
const [timeRemaining, setTimeRemaining] = useState(null);
useEffect(() => {
if (!isDemo || !server.demo_expires_at) {
setTimeRemaining(null);
return undefined;
}
const updateTimer = () => {
const expiresAt = new Date(server.demo_expires_at);
const difference = expiresAt.getTime() - Date.now();
if (Number.isNaN(expiresAt.getTime())) {
setTimeRemaining(null);
} else if (difference <= 0) {
setTimeRemaining('Expired');
} else {
const hours = Math.floor(difference / 3_600_000);
const minutes = Math.floor((difference % 3_600_000) / 60_000);
const seconds = Math.floor((difference % 60_000) / 1000);
setTimeRemaining(`${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
}
};
updateTimer();
const interval = window.setInterval(updateTimer, 1000);
return () => window.clearInterval(interval);
}, [isDemo, server.demo_expires_at]);
return (
<article className="ah-server-card relative h-full" style={{ '--server-accent': serverColor }}>
<div className="ah-server-card-content">
<header className="ah-server-card-header">
<div className="ah-server-card-identity">
<div className="ah-server-type-icon" aria-hidden="true">
<ServerIcon className="w-5 h-5" style={{ color: serverColor }} />
</div>
<div className="ah-server-card-heading">
<div className="flex min-w-0 items-center gap-2">
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
{isDemo && <span className="ah-server-demo-tag">DEMO</span>}
</div>
<p className="ah-server-type-label">{isDatabase ? `${dbType} db` : `${server.type} node`}</p>
{isDemo && timeRemaining && (
<div className="ah-server-demo-time">
<Clock className="w-3 h-3" aria-hidden="true" />
<p>{timeRemaining === 'Expired' ? 'EXPIRED' : timeRemaining}</p>
</div>
)}
</div>
</div>
<div
className="ah-server-status"
aria-label={`Server status: ${statusInfo.label}`}
style={{
backgroundColor: `${statusInfo.color}15`,
borderColor: statusInfo.color,
color: statusInfo.color,
}}
>
{statusInfo.label}
</div>
</header>
<div className="ah-server-resource-grid">
<div className="ah-server-resource">
<div className="ah-server-resource-label"><Cpu className="w-4 h-4" aria-hidden="true" /><span>CPU</span></div>
<p>{server.cpu}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label"><MemoryStick className="w-4 h-4" aria-hidden="true" /><span>Memory</span></div>
<p>{server.ram}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label"><HardDrive className="w-4 h-4" aria-hidden="true" /><span>Storage</span></div>
<p>{server.storage}</p>
</div>
<div className="ah-server-resource ah-server-cost">
<div className="ah-server-resource-label"><Coins className="w-4 h-4" aria-hidden="true" /><span>Monthly cost</span></div>
<p className="ah-server-cost-value">
{isDemo ? 'FREE' : `${calculateCurrencyValue(monthlyCost)} ${getCurrencySymbol()}`}
</p>
</div>
</div>
<div className="ah-server-card-actions">
{isDemo && userRole !== 'admin' ? (
<ActionButton
variant="primary"
onClick={() => onConvertDemo(server.id)}
className="flex-1 !min-h-0 !py-3"
style={{ backgroundColor: colors.brightYellow, borderColor: colors.brightYellow, color: colors.black }}
>
<Coins className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Convert to paid</span>
</ActionButton>
) : (
<>
<ActionButton variant="secondary" onClick={() => onOpenConsole(server)} className="flex-1 !min-h-0 !py-3">
<Terminal className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Console</span>
</ActionButton>
{isDemo && (
<ActionButton
variant="warning"
onClick={() => onConvertDemo(server.id)}
className="aspect-square !p-0 !min-w-[48px]"
title="Convert demo to paid"
aria-label={`Convert ${server.name} demo to paid`}
>
<Coins className="w-4 h-4 text-yellow-400" />
</ActionButton>
)}
{server.status === 'Running' ? (
<ActionButton
variant="danger"
onClick={() => onServerAction(server.id, 'stop')}
className="aspect-square !p-0 !min-w-[48px]"
title={`Stop ${server.name}`}
aria-label={`Stop ${server.name}`}
>
<Square className="w-4 h-4 fill-current" />
</ActionButton>
) : (
<ActionButton
variant="success"
onClick={() => onServerAction(server.id, 'start')}
className="aspect-square !p-0 !min-w-[48px]"
title={`Start ${server.name}`}
aria-label={`Start ${server.name}`}
>
<Play className="w-4 h-4 fill-current" />
</ActionButton>
)}
</>
)}
</div>
</div>
</article>
);
}
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import branding from '../../branding';
import { createPortal } from 'react-dom';
import {
Server, Terminal, Play, Square, RefreshCw, Trash2, Maximize2, Minimize2, X,
@@ -888,7 +889,7 @@ const ServerDetailsModal = ({ server, onClose, onServerAction, showAlert }) => {
</div>
<div>
<p className="text-xs font-bold text-white">Automated Firewall</p>
<p className="text-[10px] opacity-40 leading-relaxed">The AnchorHost.eu firewall automatically opens all exposed ports in real-time.</p>
<p className="text-[10px] opacity-40 leading-relaxed">The {branding.name} firewall automatically opens all exposed ports in real-time.</p>
</div>
</div>
</div>
File renamed without changes.
@@ -16,6 +16,7 @@ import {
} from '../../components/DashboardComponents';
import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
const currencyFlags = {
EUR: '🇪🇺',
@@ -203,7 +204,7 @@ function StoreSection({ userCredits, onAddCredits, onRefreshCredits, selectedCur
<SectionCard title="What are Credits?" icon={Info} color={colors.blue}>
<div className="space-y-4">
<p className="text-sm leading-relaxed" style={{ color: colors.foreground }}>
Credits are the <span className="text-brightWhite font-bold">universal currency</span> of AnchorHost.eu.
Credits are the <span className="text-brightWhite font-bold">universal currency</span> of {branding.name}.
They allow you to pay for all our services with maximum flexibility.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -1,7 +1,7 @@
// TicketSystem.js - Updated with auto-reloading feature
// TicketSystem.jsx - Updated with auto-reloading feature
import React, { useState, useEffect, useContext, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { AuthContext } from '../../App.js';
import { AuthContext } from '../../App.jsx';
import useWebSocket from '../../hooks/useWebSocket.js';
import {
MessageCircle, Plus, Search, Filter, Clock, AlertCircle, CheckCircle,
+9 -11
View File
@@ -1,6 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@config "../tailwind.config.js";
@layer base {
html {
@@ -244,11 +243,11 @@ a.focus-skip-global:focus-visible {
--ah-border: #2F2F2F;
--ah-focus: #454545;
--ah-text: #D0D0D0;
--ah-muted: #757575;
--ah-muted: #898989;
--ah-dim: #505050;
--ah-emphasis: #F5F5F5;
--ah-accent: #6C99BB;
--ah-accent-secondary: #547C99;
--ah-accent-secondary: #6C99BB;
--ah-warning: #D3A04D;
--ah-shadow: 4px 4px 0 var(--ah-border);
}
@@ -375,7 +374,7 @@ body { color: var(--ah-text); background: var(--ah-background); }
.ah-dropdown-empty { padding: 14px 12px; color: var(--ah-muted); font-size: 13px; }
.ah-inline-dropdown { display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; }
.ah-inline-dropdown-actions { display: flex; flex: 0 0 auto; gap: 6px; }
.ah-dropdown-action { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); box-shadow: 3px 3px 0 var(--ah-border); cursor: pointer; }
.ah-dropdown-action { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); cursor: pointer; }
.ah-dropdown-action--save { color: #7B9246; }
.ah-dropdown-action--cancel { color: #D3A04D; }
.ah-dropdown-action:hover { background: var(--ah-hover); }
@@ -442,7 +441,7 @@ body { color: var(--ah-text); background: var(--ah-background); }
.app-container { background: var(--ah-background); color: var(--ah-text); }
.app-container [class*="backdrop-blur"] { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
.app-container [class*="rounded-"] { border-radius: 2px !important; }
.app-container [class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
.app-container :not(button):not(a)[class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
.app-container [class*="hover:scale"] { transform: none !important; }
.app-container .uppercase { letter-spacing: .02em; }
.app-container .text-\[10px\] { font-size: 12px; }
@@ -451,7 +450,8 @@ body { color: var(--ah-text); background: var(--ah-background); }
.app-container :is(button, a):focus-visible, .app-container :is(input, textarea, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
/* React portals (menus and dialogs) mount beside #root, so carry the same theme there. */
body > div:not(#root) [class*="rounded-"] { border-radius: 2px !important; }
body > div:not(#root) [class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
body > div:not(#root) :not(button):not(a)[class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
body > div:not(#root) :is(button, a)[class*="shadow-"] { box-shadow: none !important; transform: none !important; }
body > div:not(#root) [class*="backdrop-blur"] { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
body > div:not(#root) :is(button, a, input, textarea, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
.dashboard-app, .admin-app { background: var(--ah-background) !important; color: var(--ah-text); }
@@ -485,9 +485,7 @@ body > div:not(#root) :is(button, a, input, textarea, select):focus-visible { ou
.ah-server-cost-value { color: var(--ah-emphasis) !important; }
.ah-server-card-actions { display: flex; gap: 8px; padding: 14px 16px; border-top: 2px solid var(--ah-border); background: var(--ah-surface); }
.ah-server-card-actions > button { min-height: 42px; }
.app-container .ah-server-card-actions .ah-server-action-button { box-shadow: none !important; transform: none !important; }
.app-container .ah-server-card-actions .ah-server-action-button:hover { box-shadow: none !important; transform: none !important; }
.app-container .ah-server-card-actions .ah-server-action-button:active { box-shadow: none !important; transform: none !important; }
.app-container :is(button, a)[class*="shadow-"] { box-shadow: none !important; transform: none !important; }
/* Opaque tooltip panels use the same ink border and offset shadow as other surfaces. */
.ah-tooltip { max-width: min(24rem, calc(100vw - 2rem)); border: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-emphasis); box-shadow: 5px 5px 0 var(--ah-border); line-height: 1.5; overflow-wrap: anywhere; }
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -26,6 +26,7 @@ import AdminSection from '../features/admin/AdminSection';
import { colors } from '../theme/colors';
import useWebSocket from '../hooks/useWebSocket';
import { API_BASE_URL, WS_BASE_URL } from '../config/api';
import branding from '../branding';
import {
ActionButton,
UnifiedTooltip
@@ -130,7 +131,7 @@ const AdminPage = () => {
<div className="p-6 border-b border-dash-border bg-black/10">
<div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}>
<div className="p-2 rounded-xl bg-brightRed/10 border border-brightRed/20 transition-all group-hover:scale-110 shadow-lg shadow-brightRed/5">
<img src="/assets/transparent_anchorhost.png" alt="" className="h-7 w-auto shrink-0" />
<img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
</div>
<div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight text-dash-text truncate">
@@ -23,12 +23,13 @@ import {
ArrowRight,
} from "lucide-react";
import ServerCreationForm from '../features/servers/ServerCreationForm.js'
import StoreSection from '../features/store/StoreSection.js'
import TicketSystem from '../features/support/TicketSystem.js'
import ServerCreationForm from '../features/servers/ServerCreationForm.jsx'
import ServerCard from '../features/servers/ServerCard.jsx'
import StoreSection from '../features/store/StoreSection.jsx'
import TicketSystem from '../features/support/TicketSystem.jsx'
import useWebSocket from '../hooks/useWebSocket.js';
import { AuthContext } from '../App.js';
import GameConsole from '../features/servers/GameConsole.js';
import { AuthContext } from '../App.jsx';
import GameConsole from '../features/servers/GameConsole.jsx';
import {
colors,
parseServerDate,
@@ -51,15 +52,16 @@ import {
DashboardEmptyState,
DashboardLoadingSpinner,
SettingsPanel,
} from '../components/DashboardComponents.js';
} from '../components/DashboardComponents.jsx';
import { dashLayout } from '../theme/dashboardTokens.js';
import FileManager from '../features/servers/FileManager.js';
import BackupSection from '../features/servers/BackupSection.js';
import PluginStoreEmbedded from '../features/plugins/PluginStoreEmbedded.js';
import FileManager from '../features/servers/FileManager.jsx';
import BackupSection from '../features/servers/BackupSection.jsx';
import PluginStoreEmbedded from '../features/plugins/PluginStoreEmbedded.jsx';
import { usePanelCurrency } from '../hooks/usePanelCurrency.js';
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../config/api';
import branding from '../branding';
const NODE_IP = '85.237.179.121';
@@ -1885,212 +1887,6 @@ const handleWebSocketMessage = useCallback((data) => {
);
};
// Server tiles keep the name and status in separate columns so long names cannot hide state.
const ServerCard = ({ server }) => {
const statusInfo = getServerStatusInfo(server.status);
const serverTypeIcons = {
'vps': { icon: Server, color: colors.blue },
'game': { icon: Gamepad2, color: colors.red },
'website': { icon: Globe, color: colors.green },
'database': { icon: Database, color: colors.magenta }
};
const ServerIcon = serverTypeIcons[server.type]?.icon || Server;
const serverColor = serverTypeIcons[server.type]?.color || colors.blue;
const isDatabase = server.type === 'database';
const dbType = server.image || 'Unknown';
const isDemo = server.is_demo === true || server.is_demo === 1;
const calculateServerCost = (server) => {
if (isDemo) return 0;
const cpuCores = parseInt(server.cpu) || 0;
const ramGb = parseInt(server.ram) || 0;
const storageGb = parseInt(server.storage) || 0;
const cpuCost = cpuCores * (pricing.price_cpu || 35);
const ramCost = ramGb * (pricing.price_ram || 35);
const storageCost = storageGb * (pricing.price_storage || 5);
const typeMultiplier = {
'vps': pricing.multiplier_vps || 1.0,
'game': pricing.multiplier_game || 0.8,
'website': pricing.multiplier_website || 1.5,
'database': pricing.multiplier_database || 1.3
}[server.type] || 1.0;
return Math.round((cpuCost + ramCost + storageCost) * typeMultiplier);
};
const serverMonthlyCost = calculateServerCost(server);
// Demo countdown timer
const [timeRemaining, setTimeRemaining] = useState(null);
useEffect(() => {
if (!isDemo || !server.demo_expires_at) {
setTimeRemaining(null);
return;
}
const updateTimer = () => {
const expiresAt = new Date(server.demo_expires_at);
const now = new Date();
if (isNaN(expiresAt.getTime())) { setTimeRemaining(null); return; }
const diff = expiresAt - now;
if (diff <= 0) { setTimeRemaining('Expired'); return; }
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setTimeRemaining(`${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [isDemo, server.demo_expires_at]);
return (
<div
className="ah-server-card relative h-full"
style={{
'--server-accent': serverColor,
}}
>
<div className="ah-server-card-content">
<div className="ah-server-card-header">
<div className="ah-server-card-identity">
<div className="ah-server-type-icon" aria-hidden="true">
<ServerIcon className="w-5 h-5" style={{ color: serverColor }} />
</div>
<div className="ah-server-card-heading">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
{isDemo && (
<span className="ah-server-demo-tag">
DEMO
</span>
)}
</div>
<p className="ah-server-type-label">
{isDatabase ? `${dbType} db` : `${server.type} node`}
</p>
{isDemo && timeRemaining && (
<div className="ah-server-demo-time">
<Clock className="w-3 h-3" style={{ color: colors.brightYellow }} />
<p>
{timeRemaining === 'Expired' ? 'EXPIRED' : timeRemaining}
</p>
</div>
)}
</div>
</div>
<div className="ah-server-status"
aria-label={`Server status: ${statusInfo.label}`}
style={{
backgroundColor: `${statusInfo.color}15`,
borderColor: statusInfo.color,
color: statusInfo.color,
}}
>
{statusInfo.label}
</div>
</div>
<div className="ah-server-resource-grid">
<div className="ah-server-resource">
<div className="ah-server-resource-label">
<Cpu className="w-4 h-4" aria-hidden="true" />
<span>CPU</span>
</div>
<p>{server.cpu}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label">
<MemoryStick className="w-4 h-4" aria-hidden="true" />
<span>Memory</span>
</div>
<p>{server.ram}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label">
<HardDrive className="w-4 h-4" aria-hidden="true" />
<span>Storage</span>
</div>
<p>{server.storage}</p>
</div>
<div className="ah-server-resource ah-server-cost">
<div className="ah-server-resource-label">
<Coins className="w-4 h-4" aria-hidden="true" />
<span>Monthly cost</span>
</div>
<p className="ah-server-cost-value">
{isDemo ? 'FREE' : `${calculateCurrencyValue(serverMonthlyCost)} ${getCurrencySymbol()}`}
</p>
</div>
</div>
<div className="ah-server-card-actions">
{isDemo && user?.role !== 'admin' ? (
<ActionButton
variant="primary"
onClick={() => handleConvertDemoToPaid(server.id)}
className="ah-server-action-button flex-1 !min-h-0 !py-3"
style={{ backgroundColor: colors.brightYellow, borderColor: colors.brightYellow, color: colors.black }}
>
<Coins className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Convert to paid</span>
</ActionButton>
) : (
<>
<ActionButton
variant="secondary"
onClick={() => {
setSelectedServer(server);
setActiveServerDetailTab('overview');
fetchServerLogs(server.id);
}}
className="ah-server-action-button flex-1 !min-h-0 !py-3"
>
<Terminal className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Console</span>
</ActionButton>
{isDemo && (
<ActionButton
variant="warning"
onClick={() => handleConvertDemoToPaid(server.id)}
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
title="Convert demo to paid"
>
<Coins className="w-4 h-4 text-yellow-400" />
</ActionButton>
)}
{server.status === 'Running' ? (
<ActionButton
variant="danger"
onClick={() => handleServerAction(server.id, 'stop')}
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
>
<Square className="w-4 h-4 fill-current" />
</ActionButton>
) : (
<ActionButton
variant="success"
onClick={() => handleServerAction(server.id, 'start')}
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
>
<Play className="w-4 h-4 fill-current" />
</ActionButton>
)}
</>
)}
</div>
</div>
</div>
);
};
const dashboardSidebarItems = useMemo(
() => [
{ id: 'servers', label: 'My Servers', icon: Server, color: colors.blue },
@@ -2119,7 +1915,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="p-6 border-b border-dash-border bg-black/10">
<div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}>
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5">
<img src="/assets/transparent_anchorhost.png" alt="" className="h-7 w-auto shrink-0" />
<img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
</div>
<div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Dashboard</h1>
@@ -2279,7 +2075,21 @@ const handleWebSocketMessage = useCallback((data) => {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map((server) => (
<ServerCard key={server.id} server={server} />
<ServerCard
key={server.id}
server={server}
pricing={pricing}
userRole={user?.role}
calculateCurrencyValue={calculateCurrencyValue}
getCurrencySymbol={getCurrencySymbol}
onConvertDemo={handleConvertDemoToPaid}
onOpenConsole={(targetServer) => {
setSelectedServer(targetServer);
setActiveServerDetailTab('overview');
fetchServerLogs(targetServer.id);
}}
onServerAction={handleServerAction}
/>
))}
</div>
)}
@@ -2548,7 +2358,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="rounded-lg border border-dash-border bg-brightBlack/40 px-3 py-2.5 text-sm">
{user?.google_id ? (
<p className="text-dash-textMuted">
No AnchorHost.eu password — you sign in with Google.
No {branding.name} password — you sign in with Google.
</p>
) : (
<>
@@ -2564,7 +2374,7 @@ const handleWebSocketMessage = useCallback((data) => {
<UnifiedTooltip
content={
user?.google_id
? 'Google sign-in accounts do not use an AnchorHost.eu password. Manage security in Google.'
? `Google sign-in accounts do not use a ${branding.name} password. Manage security in Google.`
: 'Choose a new password. You will need your current password to confirm.'
}
multiline
@@ -2846,33 +2656,33 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
<span style={{ color: colors.foreground }}>Email Support:</span>
<a
href="mailto:support@anchorhost.eu"
href={`mailto:${branding.supportEmailAddress}`}
className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
style={{ color: colors.magenta }}
>
support@anchorhost.eu
{branding.supportEmailAddress}
</a>
</div>
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
<span style={{ color: colors.foreground }}>Sales Inquiries:</span>
<a
href="mailto:sales@anchorhost.eu"
href={`mailto:${branding.salesEmail}`}
className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
style={{ color: colors.magenta }}
>
sales@anchorhost.eu
{branding.salesEmail}
</a>
</div>
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
<span style={{ color: colors.foreground }}>Business Contact:</span>
<a
href="mailto:contact@anchorhost.eu"
href={`mailto:${branding.supportEmail}`}
className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
style={{ color: colors.magenta }}
>
contact@anchorhost.eu
{branding.supportEmail}
</a>
</div>
</div>
@@ -2902,7 +2712,7 @@ const handleWebSocketMessage = useCallback((data) => {
</button>
<a
href="https://docs.anchorhost.eu"
href={branding.documentationUrl}
target="_blank"
rel="noopener noreferrer"
className="p-4 rounded-xl border transition-all duration-200 hover:scale-105 text-center"
@@ -3580,7 +3390,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="px-5 sm:px-6 pb-6 border-t border-white/5 animate-in slide-in-from-top-2 duration-200">
<p className="text-sm leading-relaxed opacity-60 mb-6 mt-5 max-w-3xl">
Your server listens on an <span className="text-white font-semibold">internal port</span> inside its container.
AnchorHost.eu maps it to a unique <span className="text-white font-semibold">external port</span> on our public IP so people on the internet can reach it — without exposing your server directly.
{branding.name} maps it to a unique <span className="text-white font-semibold">external port</span> on our public IP so people on the internet can reach it — without exposing your server directly.
</p>
<div className="flex flex-col lg:flex-row items-stretch justify-center gap-3 lg:gap-4">
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
@@ -3592,7 +3402,7 @@ const handleWebSocketMessage = useCallback((data) => {
<ArrowRight className="w-5 h-5 text-primary rotate-90 lg:rotate-0 shrink-0 opacity-60" />
</div>
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}25` }}>
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">AnchorHost.eu gateway</p>
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">{branding.name} gateway</p>
<NetworkIcon className="w-6 h-6 mx-auto text-primary mb-1" />
<p className="text-[10px] opacity-50 mt-2 leading-relaxed">Secure NodePort + firewall</p>
</div>
@@ -3653,7 +3463,7 @@ const handleWebSocketMessage = useCallback((data) => {
icon: Shield,
iconColor: colors.brightGreen,
title: 'Firewall opens automatically',
body: 'When you expose a port, AnchorHost.eu opens it on the edge firewall in real time — no manual rules needed.',
body: `When you expose a port, ${branding.name} opens it on the edge firewall in real time — no manual rules needed.`,
},
{
icon: Lock,
@@ -3850,7 +3660,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="p-6 sm:p-8 rounded-2xl border-2 border-dashed flex flex-col items-center justify-center space-y-5 w-full" style={{ borderColor: `${colors.primary}25`, backgroundColor: `${colors.primary}05` }}>
<div className="text-center">
<h5 className="text-sm font-black uppercase tracking-[0.2em] mb-2" style={{ color: colors.primary }}>Expose New Port</h5>
<p className="text-xs opacity-45 font-medium max-w-md">Enter the port your app listens on inside the server. AnchorHost.eu assigns a public external port automatically.</p>
<p className="text-xs opacity-45 font-medium max-w-md">Enter the port your app listens on inside the server. {branding.name} assigns a public external port automatically.</p>
</div>
<form
onSubmit={(e) => { e.preventDefault(); submitNetworkExposePort(); }}
@@ -6,6 +6,7 @@ import {
import { Link, useNavigate } from 'react-router-dom';
import { AuthContext } from '../App';
import { colors } from '../theme/colors';
import branding from '../branding';
import {
ActionButton
} from '../components/DashboardComponents';
@@ -45,13 +46,13 @@ function DocumentationPage() {
title: 'Creating Your Account',
content: `# Creating Your Account
Creating an account on AnchorHost.eu gives you access to our Kubernetes-based cloud hosting platform.
Creating an account on ${branding.name} gives you access to our Kubernetes-based cloud hosting platform.
## Step 1: Registration
Navigate to AnchorHost.eu and click the Register button. You can also sign up using Google or Apple for a faster experience.
Navigate to ${branding.name} and click the Register button. You can also sign up using Google or Apple for a faster experience.
## Step 2: Required Information
- **Username**: Must be unique. This determines your dedicated subdomain: \`u-{username}.anchorhost.eu\`.
- **Username**: Must be unique. This determines your dedicated subdomain: \`u-{username}.${branding.domain}\`.
- **Email Address**: Used for verification and critical alerts.
- **Password**: Securely hashed using bcrypt.
@@ -69,7 +70,7 @@ When you click "Create Account":
title: 'Authentication and Security',
content: `# Authentication and Security
AnchorHost.eu utilizes a hybrid approach of REST and WebSockets for secure, real-time management.
${branding.name} utilizes a hybrid approach of REST and WebSockets for secure, real-time management.
## Authentication Flow
1. Credentials are verified via secure hash comparison.
@@ -112,7 +113,7 @@ Think of our main server as a **Large Apartment Building**.
**Without the Apartment Number (Port), you are just standing at the front door of a building with 10,000 people. You will never find your room!**
## The Format
When connecting to anything on AnchorHost.eu, you will almost always use this format:
When connecting to anything on ${branding.name}, you will almost always use this format:
\`85.237.179.121:PORT\`
> **Idiot-Proof Tip**: Look at your Server Card in the Dashboard. The number after the colon (\`:\`) is your unique Port. You **MUST** include it.`
@@ -125,7 +126,7 @@ When connecting to anything on AnchorHost.eu, you will almost always use this fo
Websites can be accessed via a custom subdomain or directly via IP.
## Method 1: Using a Subdomain (Recommended)
You can register a free subdomain (e.g., \`mysite.u-username.anchorhost.eu\`) via the **Domains** tab.
You can register a free subdomain (e.g., \`mysite.u-username.${branding.domain}\`) via the **Domains** tab.
1. Go to the **Domains** tab in your Dashboard.
2. Register your preferred subdomain.
3. Once registered, you can simply type it into your browser's address bar.
@@ -258,7 +259,7 @@ Imagine a large apartment building. The building itself is the physical server.
- You have your own kitchen and bathroom (dedicated resources like CPU and RAM).
- What you do in your apartment doesn't affect your neighbors, and vice-versa.
On AnchorHost.eu, our VPS instances are powered by **KubeVirt**, giving you a real virtual machine with its own kernel, allowing you to install any OS or software you need with full root access.`
On ${branding.name}, our VPS instances are powered by **KubeVirt**, giving you a real virtual machine with its own kernel, allowing you to install any OS or software you need with full root access.`
},
{
id: 'server-types',
@@ -290,7 +291,7 @@ Managed containers for application stacks.
title: 'How Billing Works',
content: `# How Billing Works
AnchorHost.eu operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices.
${branding.name} operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices.
## Credits and Value
- **100 credits = 1.00 EUR**.
@@ -337,7 +338,7 @@ If you wish to host **Minecraft Bedrock** (for Console, Mobile, or Windows 10 Ed
title: 'Installing and Managing Minecraft',
content: `# Minecraft Server Management
AnchorHost.eu provides a specialized environment for Minecraft servers using high-performance containers.
${branding.name} provides a specialized environment for Minecraft servers using high-performance containers.
## Installation
1. Go to **Create Server**.
@@ -479,7 +480,7 @@ We provide an integrated **Plugin Store** powered by the Modrinth API.
title: 'Network Architecture',
content: `# Network Architecture
All servers deployed on AnchorHost.eu share a high-performance static IP but utilize unique entry points.
All servers deployed on ${branding.name} share a high-performance static IP but utilize unique entry points.
## External Connectivity
- **Global IP**: \`85.237.179.121\`
@@ -488,7 +489,7 @@ All servers deployed on AnchorHost.eu share a high-performance static IP but uti
## Subdomain System
We provide automatic DNS management via the **deSEC API**.
- **Format**: \`{label}.u-{username}.anchorhost.eu\`
- **Format**: \`{label}.u-{username}.${branding.domain}\`
- **SSL**: Automatic Let's Encrypt certificates are provisioned for all subdomains.
- **Custom Domains**: You can point your own domain (e.g., example.com) by creating an **A Record** pointing to \`85.237.179.121\`.`
},
@@ -607,7 +608,7 @@ Managing the state of your Virtual Machine is done directly through the dashboar
title: 'Scaling Resources',
content: `# Scaling Resources
AnchorHost.eu allows you to adjust your VPS specifications as your needs grow.
${branding.name} allows you to adjust your VPS specifications as your needs grow.
## CPU and RAM
You can increase or decrease your allocated CPU cores and RAM from the **Settings** tab.
@@ -673,7 +674,7 @@ passwd
title: 'Getting Started with Databases',
content: `# Managed Database Hosting
AnchorHost.eu provides pre-configured database containers for rapid development.
${branding.name} provides pre-configured database containers for rapid development.
## Supported Engines
- **MySQL**: The world's most popular open-source database.
@@ -754,13 +755,13 @@ Use these templates to connect your applications. Replace \`IP\`, \`PORT\`, and
title: 'Managing Subdomains',
content: `# Managing Your Subdomains
Every user on AnchorHost.eu has their own personal network space based on their username.
Every user on ${branding.name} has their own personal network space based on their username.
## How to create a Subdomain
1. Go to the **Domains** tab.
2. Enter your desired name (e.g., \`play\`).
3. Click **Register**.
4. Your full domain will be \`play.u-username.anchorhost.eu\`.
4. Your full domain will be \`play.u-username.${branding.domain}\`.
## Linking to a Server
Once a domain is created, you can "point" it to one of your active servers.
@@ -775,7 +776,7 @@ Once a domain is created, you can "point" it to one of your active servers.
If you see a warning saying **"No NS records found for child domain"** or **"DNSSEC configuration issues"**, don't panic!
## Why is this happening?
AnchorHost.eu uses high-security DNSSEC to protect your subdomains from being hijacked. For this to work, our main system needs to confirm that your specific "child" space is ready.
${branding.name} uses high-security DNSSEC to protect your subdomains from being hijacked. For this to work, our main system needs to confirm that your specific "child" space is ready.
## How to Fix
Usually, this is handled automatically within 5-10 minutes of creating your account. If the warning persists:
@@ -1059,7 +1060,7 @@ Restart Apache: \`sudo systemctl restart apache2\`. Visit your IP in the browser
<div className="p-6 border-b border-dash-border bg-black/10">
<Link to="/" className="flex items-center space-x-3 group">
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5">
<img src="/assets/transparent_anchorhost.png" alt="" className="h-7 w-auto shrink-0" />
<img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Docs</h1>
@@ -4,10 +4,11 @@ import {
ArrowDown, ArrowLeft, ArrowRight, Check, ChevronDown, Cpu, Database, ExternalLink,
Gamepad2, Globe2, Menu, Monitor, X,
} from 'lucide-react';
import { AuthContext } from '../App.js';
import { AuthContext } from '../App.jsx';
import AnnouncementsSection from '../features/announcements/AnnouncementsSection';
import { Dropdown } from '../components/dashboardPrimitives';
import { API_BASE_URL, SITE_BASE_URL } from '../config/api';
import branding from '../branding';
const serviceOptions = [
{ id: 'game', label: 'Game server', description: 'Run a game server and manage it from the control panel.', icon: Gamepad2 },
@@ -19,7 +20,7 @@ const serviceDropdownOptions = serviceOptions.map(({ id, label }) => ({ value: i
const questions = [
{
question: 'What can I host with AnchorHost.eu?',
question: `What can I host with ${branding.name}?`,
answer: 'The panel supports game servers, virtual private servers, websites, and databases. The available options depend on your account and current platform capacity.',
},
{
@@ -44,13 +45,13 @@ const heroGallery = [
},
{
src: '/assets/server-2.jpg',
alt: 'Rows of server equipment in a data center',
title: 'Infrastructure, ready',
alt: 'Network hardware and cabling inside a blue server rack',
title: 'Servers and network hardware',
},
{
src: '/assets/pricing-bg.jpg',
alt: 'Calculator and notes beside a pricing plan',
title: 'Clear costs up front',
alt: 'Ethernet cables connected to a network switch',
title: 'Connections, made visible',
},
];
@@ -154,9 +155,9 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<main className="site-page">
<header className={`site-header ${scrolled ? 'site-header--scrolled' : ''}`}>
<div className="site-nav">
<a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }} aria-label="AnchorHost.eu home">
<img src="/assets/transparent_anchorhost.png" alt="" />
<span>AnchorHost.eu</span>
<a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }} aria-label={`${branding.name} home`}>
<img src={branding.logoPath} alt="" />
<span>{branding.name}</span>
</a>
<nav className={`site-nav-links ${menuOpen ? 'site-nav-links--open' : ''}`} aria-label="Main navigation">
@@ -206,10 +207,10 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<section className="site-hero" id="top">
<div className="site-hero-copy">
<p className="site-kicker"><span /> Hosting, with room to work</p>
<h1>A control panel for the things you host.</h1>
<p className="site-kicker"><span /> {branding.tagline}</p>
<h1>{branding.heroTitle}</h1>
<p className="site-hero-description">
Run game servers, virtual machines, websites, and databases from one place. Keep the tools close and the busywork out of the way.
{branding.heroDescription}
</p>
<div className="site-hero-actions">
<button type="button" className="site-button site-button--primary site-button--large" onClick={start}>
@@ -225,7 +226,7 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
{accountCount !== null && <><i aria-hidden="true" /><span>{accountCount.toLocaleString()} accounts</span></>}
</div>
</div>
<figure className="site-hero-image" aria-label="AnchorHost.eu hosting gallery">
<figure className="site-hero-image" aria-label={`${branding.name} hosting gallery`}>
<div className="site-gallery-viewport">
<img src={currentGalleryImage.src} alt={currentGalleryImage.alt} />
<button type="button" className="site-gallery-arrow site-gallery-arrow--previous" onClick={() => showGalleryImage(galleryIndex - 1)} aria-label="Show previous photo">
@@ -238,7 +239,7 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<figcaption>
<div className="site-gallery-caption" aria-live="polite" aria-atomic="true">
<strong>{currentGalleryImage.title}</strong>
<span>AnchorHost.eu infrastructure</span>
<span>{branding.name} infrastructure</span>
</div>
<div className="site-gallery-thumbnails" aria-label="Choose a gallery photo">
{heroGallery.map((photo, index) => (
@@ -401,21 +402,21 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<div className="site-footer-main">
<div>
<a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }}>
<img src="/assets/transparent_anchorhost.png" alt="" /><span>AnchorHost.eu</span>
<img src={branding.logoPath} alt="" /><span>{branding.name}</span>
</a>
<p>Hosting services managed from one place.</p>
<p>{branding.footerTagline}</p>
</div>
<nav className="site-footer-links" aria-label="Footer">
<a href={`${SITE_BASE_URL}/discord`} target="_blank" rel="noopener noreferrer">Discord</a>
<a href="https://git.anchorhost.eu/anchorhost" target="_blank" rel="noopener noreferrer">Source Code</a>
<a href="mailto:contact@anchorhost.eu">Email support</a>
<a href={branding.sourceCodeUrl} target="_blank" rel="noopener noreferrer">Source Code</a>
<a href={`mailto:${branding.supportEmail}`}>Email support</a>
<Link to="/documentation">Documentation</Link>
<Link to="/terms">Terms</Link>
<Link to="/privacy">Privacy</Link>
</nav>
</div>
<div className="site-footer-bottom">
<span>© {new Date().getFullYear()} AnchorHost.eu by <a href="https://kvj.ovh" target="_blank" rel="noopener noreferrer">Wiktor Falkowski</a></span>
<span>© {new Date().getFullYear()} {branding.name} by <a href={branding.footerCredit.url} target="_blank" rel="noopener noreferrer">{branding.footerCredit.name}</a></span>
<span>Account and service controls live in the dashboard.</span>
</div>
</footer>
@@ -2,6 +2,7 @@ import React from 'react';
import { Shield } from 'lucide-react';
import { colors } from '../theme/colors';
import { LegalPageShell, LegalSection, LegalList, LegalLink } from '../features/legal/LegalDocumentLayout';
import branding from '../branding';
const LAST_UPDATED = 'April 5, 2026';
@@ -14,16 +15,16 @@ function PrivacyPolicy() {
lastUpdated={LAST_UPDATED}
>
<LegalSection title="1. General Information">
<p>This Privacy Policy explains how AnchorHost.eu processes personal data.</p>
<p>This Privacy Policy explains how {branding.name} processes personal data.</p>
<p>
<strong className="text-dash-text">Data Controller:</strong>
<br />
Wiktor Falkowski (unregistered business activity under Polish law)
{branding.legalProviderName} ({branding.legalProviderStatus})
</p>
<p>
<strong className="text-dash-text">Contact:</strong>
<br />
<LegalLink href="mailto:privacy@anchorhost.eu">privacy@anchorhost.eu</LegalLink>
<LegalLink href={`mailto:${branding.privacyEmail}`}>{branding.privacyEmail}</LegalLink>
</p>
<p>We process data in accordance with GDPR.</p>
</LegalSection>
@@ -99,7 +100,7 @@ function PrivacyPolicy() {
]}
/>
<p>
Requests: <LegalLink href="mailto:privacy@anchorhost.eu">privacy@anchorhost.eu</LegalLink>
Requests: <LegalLink href={`mailto:${branding.privacyEmail}`}>{branding.privacyEmail}</LegalLink>
</p>
</LegalSection>
@@ -124,7 +125,7 @@ function PrivacyPolicy() {
<LegalSection title="12. Contact">
<p>
<LegalLink href="mailto:privacy@anchorhost.eu">privacy@anchorhost.eu</LegalLink>
<LegalLink href={`mailto:${branding.privacyEmail}`}>{branding.privacyEmail}</LegalLink>
</p>
</LegalSection>
</LegalPageShell>
@@ -2,6 +2,7 @@ import React from 'react';
import { FileText } from 'lucide-react';
import { colors } from '../theme/colors';
import { LegalPageShell, LegalSection, LegalList, LegalLink } from '../features/legal/LegalDocumentLayout';
import branding from '../branding';
const LAST_UPDATED = 'April 5, 2026';
@@ -15,18 +16,18 @@ function TermsOfService() {
>
<LegalSection title="1. General Information">
<p>
These Terms of Service (&quot;Terms&quot;) govern the use of services provided under the brand AnchorHost.eu via the website{' '}
<LegalLink href="https://www.anchorhost.eu">https://www.anchorhost.eu</LegalLink>.
These Terms of Service (&quot;Terms&quot;) govern the use of services provided under the brand {branding.name} via the website{' '}
<LegalLink href={branding.siteUrl}>{branding.siteUrl}</LegalLink>.
</p>
<p>
<strong className="text-dash-text">Service Provider:</strong>
<br />
Wiktor Falkowski (unregistered business activity under Polish law)
{branding.legalProviderName} ({branding.legalProviderStatus})
</p>
<p>
<strong className="text-dash-text">Contact:</strong>
<br />
<LegalLink href="mailto:legal@anchorhost.eu">legal@anchorhost.eu</LegalLink>
<LegalLink href={`mailto:${branding.legalEmail}`}>{branding.legalEmail}</LegalLink>
</p>
<p>By using the Service, you agree to these Terms.</p>
</LegalSection>
@@ -191,7 +192,7 @@ function TermsOfService() {
<LegalSection title="17. Contact">
<p>
<LegalLink href="mailto:legal@anchorhost.eu">legal@anchorhost.eu</LegalLink>
<LegalLink href={`mailto:${branding.legalEmail}`}>{branding.legalEmail}</LegalLink>
</p>
</LegalSection>
</LegalPageShell>
File renamed without changes.
-13
View File
@@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+7 -7
View File
@@ -1,4 +1,4 @@
/** AnchorHost.eu palette: colors.ini is the canonical source; keep Tailwind aligned. */
/** Product palette: colors.ini is the canonical source; keep Tailwind aligned. */
export const colors = {
foreground: '#D0D0D0',
background: '#151515',
@@ -8,24 +8,24 @@ export const colors = {
green: '#7B9246',
yellow: '#D3A04D',
orange: '#D3A04D',
blue: '#547C99',
magenta: '#547C99',
blue: '#6C99BB',
magenta: '#6C99BB',
cyan: '#5EA8A2',
white: '#757575',
white: '#898989',
brightBlack: '#2F2F2F',
brightRed: '#A53C23',
brightGreen: '#7B9246',
brightYellow: '#D3A04D',
brightBlue: '#6C99BB',
brightMagenta: '#547C99',
brightMagenta: '#6C99BB',
brightCyan: '#5EA8A2',
brightWhite: '#F5F5F5',
primary: '#6C99BB',
darkBlue: '#547C99',
darkBlue: '#6C99BB',
darkCyan: '#5EA8A2',
darkWhite: '#505050',
darkRed: '#A53C23',
darkGreen: '#7B9246',
darkYellow: '#D3A04D',
darkMagenta: '#547C99',
darkMagenta: '#6C99BB',
};