Polish AnchorHost.eu neobrutalist interface
This commit is contained in:
1 parent
6d1ee3c128
commit
bc4542d0fb
25 files changed
+1488
-2854
No files matched your search
+17
-13
@@ -13,6 +13,7 @@ import PluginDetails from './features/plugins/PluginDetails.js'
|
||||
import DocumentationPage from './pages/DocumentationPage.js'
|
||||
import useWebSocket from './hooks/useWebSocket.js';
|
||||
import { colors } from './theme/colors';
|
||||
import { Dropdown } from './components/dashboardPrimitives';
|
||||
|
||||
import {
|
||||
X, AlertCircle, CheckCircle, AlertTriangle, Info, MapPin,
|
||||
@@ -697,7 +698,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" className="h-10 w-auto" />
|
||||
<img src="/assets/transparent_anchorhost.png" alt="AnchorHost.eu" className="h-10 w-auto" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>
|
||||
{is2faStep ? 'Verify Identity' : 'Welcome Back'}
|
||||
@@ -827,7 +828,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?{' '}
|
||||
New to AnchorHost.eu?{' '}
|
||||
<button onClick={onSwitchToRegister} className="text-primary hover:underline ml-1">Create Account</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1003,6 +1004,10 @@ function RegisterModal({
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const countryDropdownOptions = React.useMemo(
|
||||
() => [{ value: '', label: 'Select country...' }, ...countryOptions],
|
||||
[countryOptions],
|
||||
);
|
||||
|
||||
const detectUserLocation = useCallback(async () => {
|
||||
setDetectingLocation(true);
|
||||
@@ -1080,7 +1085,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-40 mt-2" style={{ color: colors.foreground }}>Join AnchorHost Cloud</p>
|
||||
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mt-2" style={{ color: colors.foreground }}>Join AnchorHost.eu</p>
|
||||
{error && (
|
||||
<p className="text-sm font-semibold mt-4" style={{ color: colors.brightRed }}>{error}</p>
|
||||
)}
|
||||
@@ -1096,16 +1101,15 @@ function RegisterModal({
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">Country</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<select
|
||||
value={selectedCountry}
|
||||
onChange={(e) => setSelectedCountry(e.target.value)}
|
||||
className="w-full rounded-2xl px-5 py-3.5 text-sm font-bold focus:outline-none transition-all duration-300 border bg-black/40 focus:border-primary/50 focus:ring-4 focus:ring-primary/10 appearance-none"
|
||||
style={{ borderColor: colors.brightBlack, color: colors.brightWhite }}
|
||||
>
|
||||
<option value="">Select country...</option>
|
||||
{countryOptions.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<div className="flex-1">
|
||||
<Dropdown
|
||||
ariaLabel="Country"
|
||||
placeholder="Select country..."
|
||||
value={selectedCountry}
|
||||
options={countryDropdownOptions}
|
||||
onChange={setSelectedCountry}
|
||||
icon={MapPin}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={detectUserLocation} className="p-4 rounded-2xl border bg-black/40 hover:bg-white/5 transition-all" style={{ borderColor: colors.brightBlack }}>
|
||||
{detectingLocation ? <RefreshCw className="w-4 h-4 animate-spin" /> : <MapPin className="w-4 h-4" />}
|
||||
|
||||
@@ -186,12 +186,12 @@ export const parseStorageValue = (storageStr) => {
|
||||
export const PortTooltip = ({ portTooltip }) => {
|
||||
return (
|
||||
<div
|
||||
className="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 rounded-xl border border-dash-border bg-dash-surfaceElevated/95 backdrop-blur-md shadow-lg shadow-black/40 text-left"
|
||||
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="font-semibold mb-1.5 text-sm text-primary">{portTooltip.name}</div>
|
||||
<div className="mb-2.5 text-xs text-dash-textMuted leading-relaxed">{portTooltip.description}</div>
|
||||
<div className="font-mono text-xs px-2.5 py-2 rounded-lg border border-dash-border bg-brightBlack/80 text-dash-text">
|
||||
<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>
|
||||
@@ -274,7 +274,7 @@ export const UnifiedTooltip = ({
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className={`fixed z-[12000] px-3.5 py-2.5 rounded-xl border border-dash-border bg-dash-surfaceElevated/95 backdrop-blur-md shadow-lg shadow-black/35 text-sm text-dash-text pointer-events-none ${wrapClass}`}
|
||||
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`,
|
||||
@@ -1291,4 +1291,4 @@ export {
|
||||
NumberSpinner,
|
||||
Dropdown,
|
||||
InlineDropdown
|
||||
} from './dashboardPrimitives';
|
||||
} from './dashboardPrimitives';
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RefreshCw, ChevronDown, ChevronUp, Check, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Check, X } from 'lucide-react';
|
||||
import { colors } from '../theme/colors';
|
||||
import { dashLayout } from '../theme/dashboardTokens';
|
||||
|
||||
/** Inset ring only — avoids ring-offset gaps that read as a light outline on dark UI */
|
||||
/** Keep a visible keyboard outline against the shared dark panel surface. */
|
||||
const focusRing =
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45';
|
||||
|
||||
@@ -29,19 +29,19 @@ export function PageHeaderBar({
|
||||
return (
|
||||
<div
|
||||
className={`${dashLayout.headerBar} ${className}`.trim()}
|
||||
style={{
|
||||
backgroundColor: `${accentColor}0D`,
|
||||
borderBottom: `1px solid ${accentColor}15`
|
||||
style={{
|
||||
borderBottom: `2px solid ${colors.brightBlack}`,
|
||||
borderLeft: `4px solid ${accentColor}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="p-2.5 rounded-xl shrink-0 shadow-lg shadow-black/20" style={{ backgroundColor: `${accentColor}18`, border: `1px solid ${accentColor}25` }}>
|
||||
<Icon className="w-6 h-6" style={{ color: accentColor }} aria-hidden />
|
||||
<div className="shrink-0 text-dash-textMuted/70">
|
||||
<Icon className="w-5 h-5" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-dash-text truncate" style={{ color: colors.brightWhite }}>{title}</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-dash-text truncate" style={{ color: colors.brightWhite }}>{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-dash-textMuted/90 mt-0.5 truncate">{description}</p>
|
||||
)}
|
||||
@@ -58,32 +58,33 @@ export function PageHeaderBar({
|
||||
export function StatTile({ icon: Icon, label, value, accentColor = colors.blue, className = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center p-5 rounded-2xl border text-center transition-all duration-300 hover:scale-[1.02] hover:bg-black/40 shadow-lg shadow-black/10 ${className}`.trim()}
|
||||
className={`flex flex-col items-start justify-center p-5 rounded-sm border-2 text-left bg-dash-surface shadow-[4px_4px_0_#2F2F2F] ${className}`.trim()}
|
||||
style={{
|
||||
backgroundColor: `${accentColor}0A`,
|
||||
borderColor: `${accentColor}20`,
|
||||
borderColor: colors.brightBlack,
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="w-6 h-6 mb-3 shrink-0" style={{ color: accentColor }} aria-hidden />}
|
||||
<p className="text-xs font-bold uppercase tracking-wider mb-1" style={{ color: `${accentColor}CC` }}>
|
||||
<p className="text-xs font-medium mb-1 text-dash-textMuted">
|
||||
{label}
|
||||
</p>
|
||||
<p className="font-bold text-2xl tabular-nums text-dash-text tracking-tight" style={{ color: colors.brightWhite }}>{value}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="w-4 h-4 shrink-0" style={{ color: accentColor }} aria-hidden />}
|
||||
<p className="font-semibold text-2xl tabular-nums text-dash-text tracking-tight" style={{ color: colors.brightWhite }}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionBase = `inline-flex items-center justify-center gap-2 rounded-xl text-sm font-bold transition-all duration-200 motion-reduce:transition-none ${focusRing} min-h-[44px] sm:min-h-[40px] px-6 py-3 sm:py-2.5 disabled:opacity-40 disabled:pointer-events-none active:scale-95`;
|
||||
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`;
|
||||
|
||||
/**
|
||||
* Inset panel for settings rows (matches dash border / surface).
|
||||
*/
|
||||
export function SettingsPanel({ title, description, children, className = '' }) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-dash-border bg-brightBlack/20 backdrop-blur-sm overflow-hidden ${className}`.trim()}>
|
||||
<div className={`rounded-sm border-2 border-dash-border bg-dash-surface overflow-hidden shadow-[4px_4px_0_#2F2F2F] ${className}`.trim()}>
|
||||
{(title || description) && (
|
||||
<div className="px-5 py-4 border-b border-dash-border bg-white/[0.02]">
|
||||
{title && <h4 className="text-sm font-bold tracking-wide text-dash-text" style={{ color: colors.brightWhite }}>{title}</h4>}
|
||||
<div className="px-5 py-4 border-b border-dash-border">
|
||||
{title && <h4 className="text-sm font-semibold text-dash-text" style={{ color: colors.brightWhite }}>{title}</h4>}
|
||||
{description && (
|
||||
<p className="text-xs text-dash-textMuted mt-1 leading-relaxed">{description}</p>
|
||||
)}
|
||||
@@ -97,14 +98,11 @@ export function SettingsPanel({ title, description, children, className = '' })
|
||||
export function ActionButton({ variant = 'secondary', children, className = '', type = 'button', ...props }) {
|
||||
const variants = {
|
||||
primary:
|
||||
'border-2 border-primary/40 bg-primary/90 text-brightWhite hover:bg-primary hover:border-primary/60 hover:shadow-[0_0_15px_rgba(90,124,167,0.3)]',
|
||||
secondary: 'border border-dash-border bg-black/30 text-dash-text hover:bg-brightBlack/80 hover:border-white/20',
|
||||
danger:
|
||||
'border border-brightRed/30 bg-brightRed/10 text-brightRed hover:bg-brightRed/20 hover:border-brightRed/50',
|
||||
success:
|
||||
'border border-brightGreen/30 bg-brightGreen/10 text-brightGreen hover:bg-brightGreen/20 hover:border-brightGreen/50',
|
||||
warning:
|
||||
'border border-brightYellow/30 bg-brightYellow/10 text-brightYellow hover:bg-brightYellow/20 hover:border-brightYellow/50',
|
||||
'border border-primary bg-primary text-background hover:bg-brightBlue hover:border-brightBlue',
|
||||
secondary: 'border border-dash-border bg-dash-surfaceElevated text-dash-text hover:bg-white/5',
|
||||
danger: 'border border-brightRed/50 bg-brightRed/10 text-brightRed hover:bg-brightRed/15',
|
||||
success: 'border border-brightGreen/45 bg-brightGreen/10 text-brightGreen hover:bg-brightGreen/15',
|
||||
warning: 'border border-brightYellow/45 bg-brightYellow/10 text-brightYellow hover:bg-brightYellow/15',
|
||||
ghost: 'border border-transparent text-foreground hover:bg-white/5',
|
||||
};
|
||||
return (
|
||||
@@ -115,27 +113,24 @@ export function ActionButton({ variant = 'secondary', children, className = '',
|
||||
}
|
||||
|
||||
export function DashboardEmptyState({ icon: Icon, title, description, action = null, onClick = null }) {
|
||||
const interactive = typeof onClick === 'function';
|
||||
const interactive = typeof onClick === 'function' && !action;
|
||||
const Wrapper = interactive ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
type={interactive ? 'button' : undefined}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
className={`w-full text-center py-20 px-6 rounded-3xl border-2 border-dashed border-white/5 bg-white/[0.01] backdrop-blur-sm transition-all duration-200 ${
|
||||
interactive ? 'cursor-pointer hover:border-primary/25 hover:bg-white/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40' : ''
|
||||
className={`w-full text-left py-12 px-6 rounded-sm border-2 border-dash-border bg-dash-surface transition-colors duration-150 ${interactive ? 'shadow-[4px_4px_0_#2F2F2F]' : ''} ${
|
||||
interactive ? 'cursor-pointer hover:border-primary/50 hover:bg-white/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40' : ''
|
||||
}`.trim()}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-6 border border-white/5">
|
||||
<Icon className="w-10 h-10 text-dash-textMuted opacity-40" aria-hidden />
|
||||
<div className="w-14 h-14 rounded-lg bg-white/5 flex items-center justify-center mb-5 border border-dash-border">
|
||||
<Icon className="w-7 h-7 text-dash-textMuted opacity-60" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-2xl font-bold text-dash-text mb-2" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
{description && <p className="text-base text-dash-textMuted max-w-md mx-auto mb-8 leading-relaxed">{description}</p>}
|
||||
{interactive && !action && (
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-primary/80">Click to add a domain</p>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold text-dash-text mb-2" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
{description && <p className="text-sm text-dash-textMuted max-w-xl mb-6 leading-relaxed">{description}</p>}
|
||||
{action && <div className="flex justify-center">{action}</div>}
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -144,15 +139,14 @@ export function DashboardEmptyState({ icon: Icon, title, description, action = n
|
||||
export function DashboardLoadingSpinner({ label = 'Loading...' }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col justify-center items-center gap-4 min-h-[20rem]"
|
||||
className="flex flex-col justify-center items-stretch gap-4 min-h-[20rem] max-w-xl mx-auto px-6"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="relative">
|
||||
<RefreshCw className="w-10 h-10 animate-spin text-primary" aria-hidden />
|
||||
<div className="absolute inset-0 w-10 h-10 animate-ping text-primary opacity-20" aria-hidden />
|
||||
</div>
|
||||
<span className="text-sm font-medium tracking-wide opacity-60" style={{ color: colors.foreground }}>{label}</span>
|
||||
<span className="text-sm font-medium" style={{ color: colors.foreground }}>{label}</span>
|
||||
<span className="dash-skeleton h-10 w-full rounded-lg" aria-hidden="true" />
|
||||
<span className="dash-skeleton h-10 w-4/5 rounded-lg" aria-hidden="true" />
|
||||
<span className="dash-skeleton h-10 w-3/5 rounded-lg" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,25 +158,19 @@ export function SectionCard({ title, icon: Icon, children, color = colors.blue,
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`group rounded-2xl p-6 border transition-all duration-300 bg-brightBlack/20 backdrop-blur-sm w-full shadow-xl shadow-black/10 ${className}`.trim()}
|
||||
className={`group rounded-sm p-5 md:p-6 border-2 border-dash-border bg-dash-surface w-full shadow-[4px_4px_0_#2F2F2F] ${className}`.trim()}
|
||||
style={{
|
||||
borderColor: `${color}25`,
|
||||
boxShadow: `0 10px 30px -15px rgba(0,0,0,0.5), 0 0 0 1px ${color}10`
|
||||
borderColor: colors.brightBlack,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 mb-8 pb-5 border-b border-white/5">
|
||||
<div className="flex items-center justify-between gap-4 mb-5 pb-4 border-b border-dash-border">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="p-3 rounded-xl shadow-lg shadow-black/20 border transition-all duration-300 group-hover:scale-110 shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${color}15`,
|
||||
borderColor: `${color}30`,
|
||||
boxShadow: `0 4px 15px -3px ${color}20`
|
||||
}}>
|
||||
<div className="shrink-0 text-dash-textMuted/70">
|
||||
<Icon className="w-5 h-5" style={{ color }} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xl font-bold tracking-tight truncate" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
<h3 className="text-lg font-semibold tracking-tight truncate" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2 shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
@@ -240,115 +228,171 @@ export function Slider({ value, min, max, step, onChange, label, unit, icon: Ico
|
||||
/**
|
||||
* Standard dropdown for general use.
|
||||
*/
|
||||
export function Dropdown({ value, options, onChange, placeholder, icon: Icon }) {
|
||||
export function Dropdown({ value, options, onChange, placeholder, icon: Icon, ariaLabel }) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0, width: 0 });
|
||||
const ref = React.useRef(null);
|
||||
const dropdownRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
ref.current && !ref.current.contains(e.target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollOrResize = (e) => {
|
||||
if (!isOpen) return;
|
||||
if (dropdownRef.current && (dropdownRef.current === e.target || dropdownRef.current.contains(e.target))) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
const [activeIndex, setActiveIndex] = React.useState(0);
|
||||
const [position, setPosition] = React.useState(null);
|
||||
const wrapperRef = React.useRef(null);
|
||||
const triggerRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const optionRefs = React.useRef([]);
|
||||
const id = React.useId();
|
||||
const menuId = `dropdown-options-${id}`;
|
||||
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||
const selected = selectedIndex >= 0 ? options[selectedIndex] : null;
|
||||
const typeaheadRef = React.useRef({ value: '', timestamp: 0 });
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
const close = React.useCallback((restoreFocus = false) => {
|
||||
setIsOpen(false);
|
||||
if (restoreFocus) requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const choose = React.useCallback((option) => {
|
||||
onChange(option.value);
|
||||
close(true);
|
||||
}, [close, onChange]);
|
||||
|
||||
const open = React.useCallback((index = selectedIndex >= 0 ? selectedIndex : 0) => {
|
||||
if (!options.length) return;
|
||||
setActiveIndex(Math.max(0, Math.min(index, options.length - 1)));
|
||||
setIsOpen(true);
|
||||
}, [options.length, selectedIndex]);
|
||||
|
||||
// Global listeners only exist for the one open popover; closed dropdowns are inert.
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const onPointerDown = (event) => {
|
||||
if (wrapperRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
}, [isOpen]);
|
||||
const onScrollOrResize = (event) => {
|
||||
if (event.type === 'scroll' && menuRef.current?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
window.addEventListener('scroll', onScrollOrResize, true);
|
||||
window.addEventListener('resize', onScrollOrResize);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
window.removeEventListener('scroll', onScrollOrResize, true);
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
};
|
||||
}, [close, isOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isOpen && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
if (!isOpen || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2);
|
||||
const left = Math.max(viewportPadding, Math.min(rect.left, window.innerWidth - width - viewportPadding));
|
||||
const below = window.innerHeight - rect.bottom - viewportPadding;
|
||||
const above = rect.top - viewportPadding;
|
||||
const opensAbove = below < Math.min(256, options.length * 46) && above > below;
|
||||
const maxHeight = Math.max(96, Math.min(256, opensAbove ? above : below));
|
||||
setPosition({
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
top: opensAbove ? undefined : rect.bottom + 6,
|
||||
bottom: opensAbove ? window.innerHeight - rect.top + 6 : undefined,
|
||||
});
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
const dropdownMenu = isOpen ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[11000] mt-2 rounded-2xl shadow-2xl border overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200 backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: `${colors.black}E6`,
|
||||
borderColor: `${colors.blue}40`,
|
||||
boxShadow: `0 10px 40px rgba(0, 0, 0, 0.7), 0 0 0 1px ${colors.blue}20`,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`
|
||||
}}
|
||||
React.useLayoutEffect(() => {
|
||||
if (isOpen) optionRefs.current[activeIndex]?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [activeIndex, isOpen]);
|
||||
|
||||
const onTriggerKeyDown = (event) => {
|
||||
if (!options.length) return;
|
||||
if (event.key.trim().length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const now = Date.now();
|
||||
const previous = now - typeaheadRef.current.timestamp < 700 ? typeaheadRef.current.value : '';
|
||||
const query = `${previous}${event.key}`.toLocaleLowerCase();
|
||||
typeaheadRef.current = { value: query, timestamp: now };
|
||||
const matchIndex = options.findIndex((option) => String(option.label).toLocaleLowerCase().startsWith(query));
|
||||
if (matchIndex >= 0) {
|
||||
setActiveIndex(matchIndex);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
open(event.key === 'ArrowUp' ? options.length - 1 : selectedIndex >= 0 ? selectedIndex : 0);
|
||||
return;
|
||||
}
|
||||
setActiveIndex((index) => (index + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length);
|
||||
} else if (isOpen && event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (isOpen && event.key === 'End') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(options.length - 1);
|
||||
} else if (isOpen && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (options[activeIndex]) choose(options[activeIndex]);
|
||||
} else if (isOpen && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close(true);
|
||||
} else if (isOpen && event.key === 'Tab') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownMenu = isOpen ? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel || placeholder || 'Options'}
|
||||
className="ah-dropdown-popover"
|
||||
style={position || undefined}
|
||||
>
|
||||
<div className="max-h-64 overflow-y-auto custom-scrollbar overscroll-contain p-1.5">
|
||||
{options.map((option) => (
|
||||
<div className="ah-dropdown-options" style={{ maxHeight: position?.maxHeight || 256 }}>
|
||||
{options.length ? options.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:bg-white/5 cursor-pointer rounded-xl"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${colors.blue}20` : 'transparent'
|
||||
}}
|
||||
id={`${menuId}-option-${index}`}
|
||||
key={String(option.value)}
|
||||
ref={(element) => { optionRefs.current[index] = element; }}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
className={`ah-dropdown-option ${index === activeIndex ? 'ah-dropdown-option--active' : ''}`}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(option)}
|
||||
>
|
||||
<span className="flex-1 pointer-events-none font-medium">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 flex-shrink-0 ml-2 pointer-events-none" style={{ color: colors.blue }} />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && <Check size={17} aria-hidden />}
|
||||
</div>
|
||||
))}
|
||||
)) : <div className="ah-dropdown-empty" role="status">No options available</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div ref={wrapperRef} className="ah-dropdown">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border border-dash-border bg-brightBlack/60 text-dash-text hover:bg-brightBlack/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40"
|
||||
style={{
|
||||
borderColor: isOpen ? colors.primary : undefined,
|
||||
boxShadow: isOpen ? `0 0 15px ${colors.primary}20` : 'none',
|
||||
}}
|
||||
role="combobox"
|
||||
aria-label={ariaLabel || placeholder || selected?.label || 'Select an option'}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? menuId : undefined}
|
||||
aria-activedescendant={isOpen && options[activeIndex] ? `${menuId}-option-${activeIndex}` : undefined}
|
||||
disabled={!options.length}
|
||||
onClick={() => (isOpen ? close() : open())}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
className="ah-dropdown-trigger"
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 flex-1 min-w-0 pointer-events-none">
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" style={{ color: colors.foreground, opacity: 0.7 }} />}
|
||||
<span className="text-sm font-bold truncate">{selected?.label || placeholder}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''} pointer-events-none`}
|
||||
style={{ color: colors.foreground, opacity: 0.6 }}
|
||||
/>
|
||||
<span className="ah-dropdown-trigger-value">
|
||||
{Icon && <Icon size={17} aria-hidden />}
|
||||
<span className="ah-dropdown-label">{selected?.label || placeholder || 'Choose an option'}</span>
|
||||
</span>
|
||||
<ChevronDown className={isOpen ? 'ah-dropdown-chevron ah-dropdown-chevron--open' : 'ah-dropdown-chevron'} size={18} aria-hidden />
|
||||
</button>
|
||||
|
||||
{dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
{dropdownMenu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -399,133 +443,16 @@ export function NumberSpinner({ value, onChange, step = 1, min = 0, color = colo
|
||||
/**
|
||||
* Inline dropdown for quick settings changes.
|
||||
*/
|
||||
export function InlineDropdown({ value, options, onChange, onSave, onCancel, placeholder, icon: Icon, color = colors.blue }) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0, width: 0 });
|
||||
const ref = React.useRef(null);
|
||||
const dropdownRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
ref.current && !ref.current.contains(e.target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollOrResize = (e) => {
|
||||
if (!isOpen) return;
|
||||
if (dropdownRef.current && (dropdownRef.current === e.target || dropdownRef.current.contains(e.target))) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isOpen && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 160)
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
const dropdownMenu = isOpen ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[11000] mt-2 rounded-2xl shadow-2xl border overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200 backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: `${colors.black}F2`,
|
||||
borderColor: `${color}40`,
|
||||
boxShadow: `0 10px 40px rgba(0, 0, 0, 0.7), 0 0 0 1px ${color}20`,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`
|
||||
}}
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar overscroll-contain p-1.5">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:bg-white/5 cursor-pointer rounded-xl"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${color}20` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 pointer-events-none font-medium">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 flex-shrink-0 ml-2 pointer-events-none" style={{ color }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
export function InlineDropdown({ value, options, onChange, onSave, onCancel, placeholder, icon: Icon }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div ref={ref} className="relative flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="focus-skip-global w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border bg-brightBlack/40 hover:bg-brightBlack/60 text-left outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-offset-0"
|
||||
style={{
|
||||
borderColor: isOpen ? `${color}55` : `${color}28`,
|
||||
boxShadow: isOpen ? `0 0 15px ${color}15` : 'none',
|
||||
'--tw-ring-color': color,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-bold truncate" style={{ color: colors.brightWhite }}>
|
||||
{selected?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
|
||||
style={{ color: colors.foreground, opacity: 0.5 }}
|
||||
/>
|
||||
<div className="ah-inline-dropdown">
|
||||
<Dropdown value={value} options={options} onChange={onChange} placeholder={placeholder} icon={Icon} ariaLabel={placeholder} />
|
||||
<div className="ah-inline-dropdown-actions">
|
||||
<button type="button" className="ah-dropdown-action ah-dropdown-action--save" onClick={onSave} aria-label="Save changes" title="Save changes">
|
||||
<Check size={17} aria-hidden />
|
||||
</button>
|
||||
{dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="focus-skip-global p-3 rounded-xl bg-green-500/15 hover:bg-green-500/25 text-green-400 transition-all active:scale-90 hover:scale-105 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-green-500/45 focus-visible:ring-offset-0"
|
||||
title="Save changes"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="focus-skip-global p-3 rounded-xl bg-red-500/15 hover:bg-red-500/25 text-red-400 transition-all active:scale-90 hover:scale-105 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/45 focus-visible:ring-offset-0"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<button type="button" className="ah-dropdown-action ah-dropdown-action--cancel" onClick={onCancel} aria-label="Cancel" title="Cancel">
|
||||
<X size={17} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
||||
const connectTerminal = useCallback(() => {
|
||||
if (!node || !sendMessage) return;
|
||||
setIsLoading(true);
|
||||
setTerminalOutput(prev => prev + `\r\n\x1b[36m[AnchorHost]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
|
||||
setTerminalOutput(prev => prev + `\r\n\x1b[36m[AnchorHost.eu]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
|
||||
sendMessage({
|
||||
type: 'node_terminal_connect',
|
||||
nodeName: node.name
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Calendar, Tag, ChevronRight, X, User } from "lucide-react";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import Beams from "../../components/backgrounds/Beams";
|
||||
import { colors } from "../../theme/colors";
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
|
||||
const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent scrolling when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousFocus = document.activeElement;
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key !== 'Tab' || !dialogRef.current) return;
|
||||
const controls = dialogRef.current.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocus?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!announcement) return null;
|
||||
|
||||
@@ -41,13 +62,10 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 backdrop-blur-xl bg-black/80 animate-in fade-in duration-300">
|
||||
<div
|
||||
className="relative w-full max-w-3xl max-h-[90vh] overflow-hidden rounded-3xl border border-white/10 shadow-2xl animate-in zoom-in-95 duration-300"
|
||||
style={{ backgroundColor: colors.backgroundSecondary || '#161616' }}
|
||||
>
|
||||
<div className="announcement-overlay">
|
||||
<div ref={dialogRef} className="announcement-dialog" role="dialog" aria-modal="true" aria-labelledby="announcement-title" tabIndex={-1}>
|
||||
{/* Header */}
|
||||
<div className="p-6 sm:p-8 border-b border-white/5 flex justify-between items-start sticky top-0 z-10" style={{ backgroundColor: '#161616' }}>
|
||||
<div className="announcement-dialog-header">
|
||||
<div className="flex-1 pr-8">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{announcement.tags && announcement.tags.map((tag, idx) => {
|
||||
@@ -55,7 +73,7 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 rounded-full text-[10px] uppercase font-bold tracking-widest flex items-center"
|
||||
className="announcement-tag"
|
||||
style={{ backgroundColor: `${tagColor}20`, color: tagColor, border: `1px solid ${tagColor}30` }}
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1.5" />
|
||||
@@ -64,33 +82,34 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
|
||||
<h2 id="announcement-title" className="text-2xl sm:text-3xl font-semibold leading-tight">
|
||||
{announcement.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 transition-colors text-white/60 hover:text-white"
|
||||
className="announcement-close"
|
||||
aria-label="Close announcement"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 sm:p-8 overflow-y-auto custom-scrollbar" style={{ maxHeight: 'calc(90vh - 180px)' }}>
|
||||
<div className="flex items-center space-x-6 mb-8 text-sm text-white/40 border-b border-white/5 pb-6">
|
||||
<div className="announcement-dialog-content">
|
||||
<div className="announcement-meta">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2 text-blue-400" />
|
||||
<Calendar className="w-4 h-4 mr-2" aria-hidden />
|
||||
{formatDate(announcement.created_at)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 mr-2 text-blue-400" />
|
||||
Posted by <span className="text-blue-400 font-semibold ml-1.5">{announcement.author_name || 'Administrator'}</span>
|
||||
<User className="w-4 h-4 mr-2" aria-hidden />
|
||||
Posted by <span className="font-semibold ml-1.5">{announcement.author_name || 'Administrator'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-white/80 text-base sm:text-lg leading-relaxed whitespace-pre-wrap selection:bg-blue-500/30"
|
||||
className="announcement-body"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{announcement.content}
|
||||
@@ -98,10 +117,10 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-white/5 text-center bg-black/20">
|
||||
<div className="announcement-dialog-footer">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-8 py-3 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all hover:scale-105 active:scale-95"
|
||||
className="site-button site-button--primary"
|
||||
>
|
||||
Close Announcement
|
||||
</button>
|
||||
@@ -156,28 +175,14 @@ const AnnouncementsSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTagColor = (tag) => {
|
||||
const lowerTag = tag.toLowerCase();
|
||||
if (lowerTag.includes('update') || lowerTag.includes('feature')) return colors.green;
|
||||
if (lowerTag.includes('alert') || lowerTag.includes('maintenance') || lowerTag.includes('downtime')) return colors.red;
|
||||
if (lowerTag.includes('technical') || lowerTag.includes('info')) return colors.blue;
|
||||
return colors.yellow;
|
||||
};
|
||||
|
||||
const displayedAnnouncements = showAll ? announcements : announcements.slice(0, 3);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="py-20 relative overflow-hidden bg-[#101010]">
|
||||
<div className="max-w-6xl mx-auto px-4 text-center">
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-12 w-64 bg-white/5 rounded-2xl mx-auto"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-80 bg-white/5 rounded-3xl border border-white/5"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<section className="site-section site-announcements" aria-busy="true" aria-label="Loading announcements">
|
||||
<div className="site-section-heading"><div><p className="site-kicker">Updates</p><h2>Latest announcements.</h2></div></div>
|
||||
<div className="site-announcement-list">
|
||||
{[1, 2, 3].map((item) => <div key={item} className="site-announcement-skeleton" />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -186,114 +191,30 @@ const AnnouncementsSection = () => {
|
||||
if (error || announcements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id="announcements" className="relative overflow-hidden py-12 sm:py-16 md:py-20">
|
||||
{/* Dynamic Background */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Beams
|
||||
beamWidth={2}
|
||||
beamHeight={15}
|
||||
beamNumber={12}
|
||||
lightColor="#33373acc"
|
||||
speed={2}
|
||||
noiseIntensity={1.75}
|
||||
scale={0.2}
|
||||
rotation={0}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#101010]/80" />
|
||||
<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>
|
||||
</div>
|
||||
<div className="site-announcement-list">
|
||||
{displayedAnnouncements.map((item) => (
|
||||
<button key={item.id} type="button" className="site-announcement-item" onClick={() => setSelectedAnnouncement(item)}>
|
||||
<span className="site-announcement-meta">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
{item.tags?.[0] && <span className="site-announcement-tag">{item.tags[0]}</span>}
|
||||
</span>
|
||||
<span className="site-announcement-title">{item.title}</span>
|
||||
<span className="site-announcement-summary">{item.content}</span>
|
||||
<span className="site-announcement-open">Read announcement <ChevronRight size={16} aria-hidden /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 sm:mb-12 md:mb-16">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold mb-4 sm:mb-6 bg-gradient-to-r from-white via-[#e0e7ff] to-[#8b7bff] bg-clip-text text-transparent">
|
||||
Latest News & Announcements
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg md:text-xl max-w-4xl mx-auto px-4" style={{ color: colors.blue }}>
|
||||
Stay informed about platform updates, maintenance, and new features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Announcements Grid */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 w-full ${announcements.length < 3 ? 'md:max-w-4xl' : ''}`}>
|
||||
{displayedAnnouncements.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => setSelectedAnnouncement(item)}
|
||||
className="group relative cursor-pointer border rounded-lg sm:rounded-xl shadow-lg transition-all duration-500 hover:-translate-y-1 sm:hover:-translate-y-2 hover:shadow-xl overflow-hidden flex flex-col h-full"
|
||||
style={{ backgroundColor: `${colors.black}80`, borderColor: colors.brightBlack }}
|
||||
>
|
||||
<CardContent className="p-6 sm:p-8 h-full flex flex-col relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center text-xs font-medium text-white/40">
|
||||
<Calendar className="w-3.5 h-3.5 mr-2" style={{ color: colors.blue }} />
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${getTagColor(item.tags[0])}20`,
|
||||
color: getTagColor(item.tags[0]),
|
||||
border: `1px solid ${getTagColor(item.tags[0])}30`
|
||||
}}
|
||||
>
|
||||
{item.tags[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg sm:text-xl font-bold mb-3 line-clamp-2 transition-colors group-hover:text-blue-400" style={{ color: colors.brightWhite }}>
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm line-clamp-4 mb-6 flex-grow" style={{ color: colors.foreground, lineHeight: '1.6' }}>
|
||||
{item.content}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto pt-4 border-t flex items-center justify-between" style={{ borderColor: `${colors.brightWhite}10` }}>
|
||||
<div className="flex items-center text-xs" style={{ color: colors.blue }}>
|
||||
<span className="opacity-70">By</span>
|
||||
<span className="ml-1 font-semibold">{item.author_name || 'Admin'}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs font-medium transition-colors" style={{ color: colors.brightBlue }}>
|
||||
Read more <ChevronRight className="w-3 h-3 ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
{announcements.length > 3 && (
|
||||
<div className="mt-12 text-center">
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="px-8 py-3 rounded-lg font-medium transition-all duration-200 hover:scale-105 border flex items-center space-x-2 mx-auto"
|
||||
style={{
|
||||
backgroundColor: `${colors.blue}20`,
|
||||
borderColor: `${colors.blue}30`,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.backgroundColor = `${colors.blue}30`;
|
||||
e.target.style.borderColor = colors.blue;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.backgroundColor = `${colors.blue}20`;
|
||||
e.target.style.borderColor = `${colors.blue}30`;
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{showAll ? 'Show Less' : `Explore All Updates (${announcements.length})`}
|
||||
</span>
|
||||
<ChevronRight className={`w-4 h-4 transition-transform duration-300 ${showAll ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{announcements.length > 3 && (
|
||||
<button type="button" className="site-button site-button--quiet site-announcement-more" onClick={() => setShowAll((value) => !value)}>
|
||||
{showAll ? 'Show fewer updates' : `Show all ${announcements.length} updates`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{selectedAnnouncement && (
|
||||
@@ -303,24 +224,8 @@ const AnnouncementsSection = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(37, 99, 235, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsSection;
|
||||
export default AnnouncementsSection;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, Star, Calendar, Package, ExternalLink, Tag, Server, ChevronDown } from 'lucide-react';
|
||||
import { ArrowLeft, Download, Star, Calendar, Package, ExternalLink, Tag, Server } from 'lucide-react';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { Dropdown } from '../../components/dashboardPrimitives';
|
||||
|
||||
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../../config/api';
|
||||
|
||||
@@ -15,9 +16,12 @@ function PluginDetails() {
|
||||
const [selectedVersion, setSelectedVersion] = useState(null);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [selectedServer, setSelectedServer] = useState(null);
|
||||
const [showServerDropdown, setShowServerDropdown] = useState(false);
|
||||
const [downloadingToServer, setDownloadingToServer] = useState(false);
|
||||
const [downloadStatus, setDownloadStatus] = useState('');
|
||||
const serverDropdownOptions = useMemo(
|
||||
() => servers.map((server) => ({ value: server.id, label: server.name })),
|
||||
[servers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPluginDetails = async () => {
|
||||
@@ -30,7 +34,7 @@ function PluginDetails() {
|
||||
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'AnchorHost/1.0'
|
||||
'User-Agent': 'AnchorHost.eu/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -407,51 +411,14 @@ function PluginDetails() {
|
||||
<div className="sticky bottom-4 space-y-3">
|
||||
{/* Server Selection Dropdown */}
|
||||
{servers.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowServerDropdown(!showServerDropdown)}
|
||||
className="w-full py-3 rounded-xl border font-medium transition-all hover:scale-105 flex items-center justify-between px-4"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>{selectedServer ? selectedServer.name : 'Select a server to download to'}</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showServerDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showServerDropdown && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 w-full rounded-xl border overflow-hidden z-10 max-h-60 overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderColor: colors.brightBlack
|
||||
}}
|
||||
>
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
onClick={() => {
|
||||
setSelectedServer(server);
|
||||
setShowServerDropdown(false);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-opacity-50 transition-all"
|
||||
style={{
|
||||
backgroundColor: selectedServer?.id === server.id ? colors.blue : 'transparent',
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-xs opacity-75">{server.version || 'Unknown version'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dropdown
|
||||
ariaLabel="Download target server"
|
||||
placeholder="Select a server to download to"
|
||||
value={selectedServer?.id || ''}
|
||||
options={serverDropdownOptions}
|
||||
onChange={(serverId) => setSelectedServer(servers.find((server) => server.id === serverId) || null)}
|
||||
icon={Server}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Download to Server Button */}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Package,
|
||||
Calendar
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { Dropdown } from '../../components/dashboardPrimitives';
|
||||
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
|
||||
@@ -88,73 +87,6 @@ const PluginStore = () => {
|
||||
return text.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
// Custom Dropdown Component (same style as ticket management)
|
||||
const Dropdown = ({ value, options, onChange, placeholder, icon: Icon }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border-2"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: isOpen ? colors.blue : 'transparent',
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{Icon && <Icon className="w-4 h-4" style={{ color: colors.foreground }} />}
|
||||
<span className="text-sm">{selected?.label || placeholder}</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-20 w-full mt-2 rounded-xl shadow-xl border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: colors.black,
|
||||
borderColor: colors.blue
|
||||
}}>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:opacity-80"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${colors.blue}20` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4" style={{ color: colors.blue }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-app antialiased" style={{ backgroundColor: colors.background }}>
|
||||
|
||||
@@ -34,7 +34,7 @@ const PluginStoreEmbedded = ({ serverId }) => {
|
||||
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'AnchorHost/1.0'
|
||||
'User-Agent': 'AnchorHost.eu/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -246,10 +246,8 @@ const GameConsole = ({ server, sendMessage, isActive }) => {
|
||||
|
||||
const tooltipContent = isVisible ? (
|
||||
<div
|
||||
className="fixed p-3 rounded-lg border text-xs z-[9999] shadow-lg whitespace-nowrap"
|
||||
className="ah-tooltip fixed p-3 text-xs z-[9999] whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary, borderColor: colors.blue,
|
||||
color: colors.foreground,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
transform: 'translate(-50%, calc(-100% - 8px))'
|
||||
@@ -492,4 +490,4 @@ const GameConsole = ({ server, sendMessage, isActive }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GameConsole;
|
||||
export default GameConsole;
|
||||
@@ -1,130 +1,16 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Monitor, Gamepad2, Globe, Plus, Coins, Cpu, MemoryStick, HardDrive,
|
||||
ChevronDown, Check, Zap, Crown, Award, Rocket, Server, Star, MapPin,
|
||||
Check, Zap, Crown, Award, Rocket, Server, Star, MapPin,
|
||||
Layers, Box, Settings, Database, RefreshCw
|
||||
} from "lucide-react";
|
||||
import { colors } from '../../theme/colors';
|
||||
import { dashLayout } from '../../theme/dashboardTokens';
|
||||
import { DashboardViewShell, PageHeaderBar, ActionButton } from '../../components/dashboardPrimitives';
|
||||
import { DashboardViewShell, PageHeaderBar, ActionButton, Dropdown } from '../../components/dashboardPrimitives';
|
||||
import { minecraftVersions } from '../../components/DashboardComponents';
|
||||
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
|
||||
// Shared Dropdown Component
|
||||
const Dropdown = ({ value, options, onChange, placeholder, icon: Icon }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const ref = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
ref.current && !ref.current.contains(e.target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollOrResize = (e) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (dropdownRef.current && (dropdownRef.current === e.target || dropdownRef.current.contains(e.target))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
const dropdownMenu = isOpen ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[9999] mt-1.5 rounded-xl shadow-xl border overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200"
|
||||
style={{
|
||||
backgroundColor: colors.black,
|
||||
borderColor: colors.blue,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`
|
||||
}}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto custom-scrollbar overscroll-contain">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${colors.blue}20` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 pointer-events-none">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 pointer-events-none" style={{ color: colors.blue }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border-2"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: isOpen ? colors.blue : 'transparent',
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2 pointer-events-none">
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" style={{ color: colors.foreground }} />}
|
||||
<span className="text-sm truncate">{selected?.label || placeholder}</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''} pointer-events-none`} />
|
||||
</button>
|
||||
|
||||
{dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Slider = ({ value, min, max, step, onChange, label, unit, icon: Icon, color }) => {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
return (
|
||||
|
||||
@@ -888,7 +888,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 firewall automatically opens all exposed ports in real-time.</p>
|
||||
<p className="text-[10px] opacity-40 leading-relaxed">The AnchorHost.eu firewall automatically opens all exposed ports in real-time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +203,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.
|
||||
Credits are the <span className="text-brightWhite font-bold">universal currency</span> of AnchorHost.eu.
|
||||
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">
|
||||
|
||||
+337
-8
@@ -15,11 +15,9 @@
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: clip;
|
||||
font-family: 'Poppins', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: clamp(0.9375rem, 0.88rem + 0.22vw, 1.0625rem);
|
||||
line-height: 1.55;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: theme('colors.foreground');
|
||||
background-color: theme('colors.background');
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -168,9 +166,7 @@ input[type="range"]::-moz-range-thumb:hover {
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.28;
|
||||
margin-top: 1.35em;
|
||||
margin-bottom: 0.65em;
|
||||
line-height: 1.22;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,3 +222,336 @@ a.focus-skip-global:focus-visible {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #20272d 0%, #283139 50%, #20272d 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: dash-skeleton-shift 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dash-skeleton-shift {
|
||||
50% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* AnchorHost.eu neobrutalist system: the colors.ini palette, visible rules, and
|
||||
offset shadows do the visual work. Keep data and controls easy to scan. */
|
||||
:root {
|
||||
--ah-background: #151515;
|
||||
--ah-panel: #181818;
|
||||
--ah-surface: #202020;
|
||||
--ah-hover: #282828;
|
||||
--ah-border: #2F2F2F;
|
||||
--ah-focus: #454545;
|
||||
--ah-text: #D0D0D0;
|
||||
--ah-muted: #757575;
|
||||
--ah-dim: #505050;
|
||||
--ah-emphasis: #F5F5F5;
|
||||
--ah-accent: #6C99BB;
|
||||
--ah-accent-secondary: #547C99;
|
||||
--ah-warning: #D3A04D;
|
||||
--ah-shadow: 4px 4px 0 var(--ah-border);
|
||||
}
|
||||
|
||||
body { color: var(--ah-text); background: var(--ah-background); }
|
||||
.site-page {
|
||||
--site-bg: var(--ah-background);
|
||||
--site-panel: var(--ah-panel);
|
||||
--site-panel-soft: var(--ah-surface);
|
||||
--site-rule: var(--ah-border);
|
||||
--site-copy: var(--ah-text);
|
||||
--site-strong: var(--ah-emphasis);
|
||||
--site-accent: var(--ah-accent);
|
||||
color: var(--ah-text);
|
||||
background: var(--ah-background);
|
||||
overflow: clip;
|
||||
}
|
||||
.site-page button, .site-page input, .site-page select { font: inherit; }
|
||||
.site-page a { color: inherit; text-decoration: none; }
|
||||
.site-page button { color: inherit; }
|
||||
.site-page :is(button, a, input, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 3px; }
|
||||
|
||||
.site-header { position: sticky; top: 0; z-index: 40; background: var(--ah-background); border-bottom: 2px solid var(--ah-border); }
|
||||
.site-nav { width: min(1200px, calc(100% - 48px)); min-height: 72px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||
.site-brand { display: inline-flex; align-items: center; gap: 10px; flex: 0 0 auto; color: var(--ah-emphasis) !important; font-size: 16px; font-weight: 800; letter-spacing: -.04em; }
|
||||
.site-brand img { display: block; width: 34px; height: 34px; object-fit: contain; }
|
||||
.site-nav-links, .site-nav-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.site-nav-links { margin-left: auto; }
|
||||
.site-nav-links > button, .site-nav-links > a, .site-user-name { padding: 9px 11px; color: var(--ah-text); font-size: 14px; font-weight: 650; }
|
||||
.site-nav-links > button { border: 0; background: transparent; cursor: pointer; }
|
||||
.site-nav-links > button:hover, .site-nav-links > a:hover { color: var(--ah-emphasis); background: var(--ah-surface); }
|
||||
.site-user-name { padding-right: 4px; color: var(--ah-muted); }
|
||||
.site-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 16px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-emphasis); font-size: 14px; font-weight: 750; line-height: 1.2; cursor: pointer; box-shadow: 3px 3px 0 var(--ah-border); transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease; }
|
||||
.site-button:hover { background: var(--ah-hover); border-color: var(--ah-focus); box-shadow: 2px 2px 0 var(--ah-focus); transform: translate(1px, 1px); }
|
||||
.site-button:active { box-shadow: none; transform: translate(3px, 3px); }
|
||||
.site-button--primary, .site-button--primary:hover { background: var(--ah-accent); border-color: var(--ah-border); color: var(--ah-background) !important; }
|
||||
.site-button--quiet, .site-button--quiet:hover { color: var(--ah-text) !important; border-color: transparent; box-shadow: none; }
|
||||
.site-button--outline { border-color: var(--ah-accent-secondary); }
|
||||
.site-button--large { min-height: 52px; padding: 0 19px; }
|
||||
.site-menu-toggle, .site-mobile-actions { display: none; }
|
||||
.site-menu-toggle { width: 44px; height: 44px; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-emphasis); box-shadow: 3px 3px 0 var(--ah-border); }
|
||||
|
||||
.site-hero { width: min(1200px, calc(100% - 48px)); min-height: 620px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, .94fr) minmax(360px, 1.06fr); align-items: center; gap: clamp(30px, 5.5vw, 76px); padding: 68px 0 84px; }
|
||||
.site-hero-copy { max-width: 580px; }
|
||||
.site-kicker { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; color: var(--ah-accent); font-size: 13px; font-weight: 750; letter-spacing: .02em; }
|
||||
.site-kicker > span { width: 10px; height: 10px; border-radius: 0; background: var(--ah-warning); }
|
||||
.site-hero h1 { max-width: 660px; margin: 0; color: var(--ah-emphasis); font-size: clamp(48px, 6.4vw, 78px); line-height: .98; letter-spacing: -.07em; font-weight: 850; }
|
||||
.site-hero-description { max-width: 470px; margin: 24px 0 0; color: var(--ah-text); font-size: 17px; line-height: 1.55; }
|
||||
.site-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 30px; }
|
||||
.site-hero-note { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 30px; color: var(--ah-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.site-hero-note i { display: inline-block; width: 5px; height: 5px; background: var(--ah-warning); }
|
||||
.site-hero-image { position: relative; min-height: 430px; margin: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; border: 2px solid var(--ah-border); border-radius: 2px; overflow: hidden; background: var(--ah-panel); box-shadow: 10px 10px 0 var(--ah-border); }
|
||||
.site-gallery-viewport { position: relative; min-height: 340px; overflow: hidden; background: var(--ah-background); }
|
||||
.site-gallery-viewport > img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; filter: saturate(.82) contrast(1.05); }
|
||||
.site-gallery-arrow { position: absolute; top: 50%; z-index: 1; width: 44px; height: 44px; display: grid; place-items: center; transform: translateY(-50%); border: 2px solid var(--ah-emphasis); border-radius: 2px; background: var(--ah-background); color: var(--ah-emphasis); box-shadow: 3px 3px 0 var(--ah-border); cursor: pointer; }
|
||||
.site-gallery-arrow:hover { background: var(--ah-accent); color: var(--ah-background); }
|
||||
.site-gallery-arrow--previous { left: 14px; }
|
||||
.site-gallery-arrow--next { right: 14px; }
|
||||
.site-hero-image figcaption { min-height: 76px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 14px; padding: 12px 14px; border-top: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-emphasis); font-size: 12px; }
|
||||
.site-gallery-caption { display: grid; gap: 3px; min-width: 0; }
|
||||
.site-gallery-caption strong { color: var(--ah-emphasis); font-size: 14px; }
|
||||
.site-gallery-caption span { color: var(--ah-muted); }
|
||||
.site-gallery-thumbnails { display: flex; gap: 6px; }
|
||||
.site-gallery-thumbnail { width: 48px; height: 36px; padding: 0; overflow: hidden; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-background); cursor: pointer; }
|
||||
.site-gallery-thumbnail--active { border-color: var(--ah-accent); box-shadow: 2px 2px 0 var(--ah-accent-secondary); }
|
||||
.site-gallery-thumbnail img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.site-gallery-counter { color: var(--ah-warning); font-variant-numeric: tabular-nums; font-weight: 800; white-space: nowrap; }
|
||||
|
||||
.site-section { width: min(1200px, calc(100% - 48px)); margin: 0 auto; padding: 82px 0; scroll-margin-top: 78px; }
|
||||
.site-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 30px; margin-bottom: 30px; }
|
||||
.site-section-heading .site-kicker { margin-bottom: 12px; }
|
||||
.site-section-heading h2, .site-estimate-intro h2, .site-platform-copy h2 { margin: 0; color: var(--ah-emphasis); font-size: clamp(30px, 4vw, 44px); line-height: 1.1; letter-spacing: -.055em; font-weight: 800; }
|
||||
.site-section-heading > p { max-width: 360px; margin: 0 0 4px; color: var(--ah-text); font-size: 15px; line-height: 1.55; }
|
||||
.site-service-list { border-top: 2px solid var(--ah-border); }
|
||||
.site-service-row { width: 100%; min-height: 104px; display: grid; grid-template-columns: 42px 48px 1fr 24px; align-items: center; gap: 18px; padding: 18px 10px; text-align: left; border: 0; border-bottom: 2px solid var(--ah-border); background: transparent; cursor: pointer; transition: background-color .15s ease, padding .15s ease; }
|
||||
.site-service-row:hover { padding-right: 16px; padding-left: 16px; background: var(--ah-surface); }
|
||||
.site-service-index { color: var(--ah-muted); font-size: 12px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
||||
.site-service-icon { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-accent); }
|
||||
.site-service-copy { display: grid; gap: 4px; }
|
||||
.site-service-copy strong { color: var(--ah-emphasis); font-size: 17px; font-weight: 750; }
|
||||
.site-service-copy span { color: var(--ah-text); font-size: 14px; }
|
||||
.site-service-arrow { color: var(--ah-warning); }
|
||||
|
||||
.site-estimate-section { display: grid; grid-template-columns: minmax(0, .8fr) minmax(440px, 1.2fr); align-items: center; gap: clamp(38px, 8vw, 110px); padding: 78px max(24px, calc((100% - 1200px) / 2)); background: var(--ah-panel); border-top: 2px solid var(--ah-border); border-bottom: 2px solid var(--ah-border); scroll-margin-top: 78px; }
|
||||
.site-estimate-intro { max-width: 430px; }
|
||||
.site-estimate-intro .site-kicker { margin-bottom: 16px; }
|
||||
.site-estimate-intro > p:not(.site-kicker) { margin: 18px 0 22px; color: var(--ah-text); font-size: 15px; line-height: 1.7; }
|
||||
.site-text-link { display: inline-flex; align-items: center; gap: 8px; color: var(--ah-accent) !important; font-size: 14px; font-weight: 750; }
|
||||
.site-text-link:hover { color: var(--ah-emphasis) !important; }
|
||||
.site-estimator { padding: 24px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-surface); box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.site-field { position: relative; display: grid; gap: 8px; color: var(--ah-text); font-size: 13px; font-weight: 700; }
|
||||
.site-field select, .site-number-control { width: 100%; min-height: 48px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-background); color: var(--ah-emphasis); }
|
||||
.site-field select { padding: 0 12px; }
|
||||
.site-number-control { display: flex; align-items: center; justify-content: space-between; padding: 0 10px; }
|
||||
.site-number-control input { width: 100%; min-width: 0; border: 0; outline: none; background: transparent; color: var(--ah-emphasis); font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||
.site-number-control input:focus-visible { outline: none; }
|
||||
.site-number-control span { flex: 0 0 auto; color: var(--ah-muted); font-size: 12px; }
|
||||
.site-field-icon { display: none; }
|
||||
.site-resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.site-estimate-total { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-top: 22px; padding-top: 20px; border-top: 2px solid var(--ah-border); }
|
||||
.site-estimate-total > div { display: grid; gap: 3px; }
|
||||
.site-estimate-total > div > span { color: var(--ah-muted); font-size: 12px; }
|
||||
.site-estimate-total strong { color: var(--ah-emphasis); font-size: 34px; line-height: 1.1; font-weight: 850; font-variant-numeric: tabular-nums; }
|
||||
.site-estimate-total small { color: var(--ah-muted); font-size: 13px; font-weight: 450; }
|
||||
|
||||
/* Shared custom listbox used across public, account, and dashboard screens. */
|
||||
.ah-dropdown { position: relative; width: 100%; min-width: 0; }
|
||||
.ah-dropdown-trigger { width: 100%; min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 13px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-background); color: var(--ah-emphasis); text-align: left; font: inherit; font-size: 14px; font-weight: 700; box-shadow: 3px 3px 0 var(--ah-border); cursor: pointer; }
|
||||
.ah-dropdown-trigger:hover { background: var(--ah-hover); }
|
||||
.ah-dropdown-trigger:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 3px; }
|
||||
.ah-dropdown-trigger:disabled { cursor: not-allowed; opacity: .6; box-shadow: none; }
|
||||
.ah-dropdown-trigger-value { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.ah-dropdown-trigger-value > svg { flex: 0 0 auto; color: var(--ah-accent); }
|
||||
.ah-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ah-dropdown-chevron { flex: 0 0 auto; color: var(--ah-warning); transition: transform .12s ease; }
|
||||
.ah-dropdown-chevron--open { transform: rotate(180deg); }
|
||||
.ah-dropdown-popover { position: fixed; z-index: 11000; overflow: hidden; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-text); box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.ah-dropdown-options { overflow-y: auto; overscroll-behavior: contain; padding: 4px; scrollbar-width: thin; scrollbar-color: var(--ah-accent-secondary) var(--ah-panel); }
|
||||
.ah-dropdown-option { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 11px; border: 1px solid transparent; border-radius: 0; background: transparent; color: var(--ah-text); text-align: left; font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.ah-dropdown-option:hover, .ah-dropdown-option--active { border-color: var(--ah-border); background: var(--ah-hover); color: var(--ah-emphasis); }
|
||||
.ah-dropdown-option[aria-selected="true"] { border-color: var(--ah-accent-secondary); background: var(--ah-accent); color: var(--ah-background); font-weight: 800; }
|
||||
.ah-dropdown-option[aria-selected="true"] svg { color: var(--ah-background); }
|
||||
.ah-dropdown-option:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: -2px; }
|
||||
.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--save { color: #7B9246; }
|
||||
.ah-dropdown-action--cancel { color: #D3A04D; }
|
||||
.ah-dropdown-action:hover { background: var(--ah-hover); }
|
||||
.ah-dropdown-action:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
|
||||
|
||||
.site-platform { width: min(1200px, calc(100% - 48px)); display: grid; grid-template-columns: 1fr 1fr; gap: clamp(42px, 8vw, 110px); align-items: center; margin: 0 auto; padding: 96px 0; }
|
||||
.site-platform-image { min-height: 360px; margin: 0; border: 2px solid var(--ah-border); border-radius: 2px; overflow: hidden; background: var(--ah-panel); box-shadow: 8px 8px 0 var(--ah-border); }
|
||||
.site-platform-image img { display: block; width: 100%; height: 100%; min-height: 360px; object-fit: cover; filter: saturate(.78) contrast(1.04); }
|
||||
.site-platform-copy { max-width: 480px; }
|
||||
.site-platform-copy > p:not(.site-kicker) { margin: 18px 0; color: var(--ah-text); font-size: 15px; line-height: 1.7; }
|
||||
.site-platform-copy ul { display: grid; gap: 12px; margin: 24px 0 28px; padding: 0; list-style: none; color: var(--ah-text); font-size: 14px; }
|
||||
.site-platform-copy li { display: flex; align-items: center; gap: 10px; }
|
||||
.site-platform-copy li svg { flex: 0 0 auto; color: var(--ah-warning); }
|
||||
|
||||
.site-showcase, .site-announcements, .site-faq { border-top: 2px solid var(--ah-border); }
|
||||
.site-showcase-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 2px solid var(--ah-border); }
|
||||
.site-showcase-list a { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 16px; border-right: 2px solid var(--ah-border); border-bottom: 2px solid var(--ah-border); color: var(--ah-emphasis); font-size: 14px; font-weight: 700; }
|
||||
.site-showcase-list a:hover { background: var(--ah-surface); }
|
||||
.site-showcase-list svg { flex: 0 0 auto; color: var(--ah-accent); }
|
||||
.site-announcement-list, .site-faq-list { border-top: 2px solid var(--ah-border); }
|
||||
.site-announcement-item { width: 100%; display: grid; grid-template-columns: minmax(150px, .45fr) minmax(180px, .9fr) minmax(220px, 1.6fr) minmax(130px, .5fr); align-items: center; gap: 20px; padding: 20px 8px; border: 0; border-bottom: 2px solid var(--ah-border); background: transparent; color: var(--ah-text); text-align: left; cursor: pointer; }
|
||||
.site-announcement-item:hover { background: var(--ah-surface); }
|
||||
.site-announcement-meta { display: grid; justify-items: start; gap: 6px; color: var(--ah-muted); font-size: 12px; }
|
||||
.site-announcement-tag { padding: 2px 7px; border: 1px solid var(--ah-border); border-radius: 2px; color: var(--ah-accent); font-size: 11px; font-weight: 750; }
|
||||
.site-announcement-title { color: var(--ah-emphasis); font-size: 15px; font-weight: 750; line-height: 1.45; }
|
||||
.site-announcement-summary { display: -webkit-box; overflow: hidden; color: var(--ah-text); font-size: 13px; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.site-announcement-open { display: inline-flex; align-items: center; justify-content: end; gap: 6px; color: var(--ah-accent); font-size: 13px; font-weight: 700; }
|
||||
.site-announcement-more { margin-top: 18px; }
|
||||
.site-announcement-skeleton { height: 78px; border-bottom: 2px solid var(--ah-border); background: var(--ah-surface); }
|
||||
|
||||
.announcement-overlay { position: fixed; inset: 0; z-index: 9999; display: grid; place-items: center; padding: 20px; background: rgba(0, 0, 0, .82); }
|
||||
.announcement-dialog { width: min(760px, 100%); max-height: min(760px, 88dvh); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-text); box-shadow: 8px 8px 0 var(--ah-border); }
|
||||
.announcement-dialog:focus { outline: none; }
|
||||
.announcement-dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 24px; border-bottom: 2px solid var(--ah-border); }
|
||||
.announcement-dialog-header h2 { margin: 0; color: var(--ah-emphasis); }
|
||||
.announcement-tag { display: inline-flex; align-items: center; padding: 4px 9px; border: 1px solid var(--ah-border); border-radius: 2px; font-size: 12px; }
|
||||
.announcement-close { width: 42px; height: 42px; display: grid; flex: 0 0 auto; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-surface); color: var(--ah-text); cursor: pointer; }
|
||||
.announcement-dialog-content { overflow: auto; padding: 24px; }
|
||||
.announcement-meta { display: flex; flex-wrap: wrap; gap: 10px 24px; margin-bottom: 22px; padding-bottom: 18px; border-bottom: 2px solid var(--ah-border); color: var(--ah-muted); font-size: 13px; }
|
||||
.announcement-meta > div { display: flex; align-items: center; }
|
||||
.announcement-meta svg { color: var(--ah-accent); }
|
||||
.announcement-meta span { color: var(--ah-emphasis); }
|
||||
.announcement-body { color: var(--ah-text); font-size: 16px; line-height: 1.7; white-space: pre-wrap; }
|
||||
.announcement-dialog-footer { display: flex; justify-content: end; padding: 16px 24px; border-top: 2px solid var(--ah-border); }
|
||||
|
||||
.site-faq-item { border-bottom: 2px solid var(--ah-border); }
|
||||
.site-faq-item h3 { margin: 0; }
|
||||
.site-faq-item button { width: 100%; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 4px; border: 0; background: transparent; color: var(--ah-emphasis); text-align: left; font-size: 16px; font-weight: 750; cursor: pointer; }
|
||||
.site-faq-item button svg { flex: 0 0 auto; color: var(--ah-warning); transition: transform .16s ease; }
|
||||
.site-faq-item--open button svg { transform: rotate(180deg); }
|
||||
.site-faq-item > p { max-width: 760px; margin: 0; padding: 0 36px 20px 4px; color: var(--ah-text); font-size: 15px; line-height: 1.7; }
|
||||
.site-footer { padding: 42px max(24px, calc((100% - 1200px) / 2)) 24px; border-top: 2px solid var(--ah-border); background: var(--ah-panel); }
|
||||
.site-footer-main { display: flex; justify-content: space-between; gap: 36px; padding-bottom: 35px; }
|
||||
.site-footer-main > div:first-child > p { margin: 13px 0 0; color: var(--ah-text); font-size: 14px; }
|
||||
.site-footer-links { display: grid; grid-template-columns: repeat(3, minmax(112px, 1fr)); align-content: start; gap: 8px; width: min(100%, 430px); }
|
||||
.site-footer-links a { min-height: 40px; display: flex; align-items: center; padding: 7px 10px; border: 1px solid var(--ah-border); background: var(--ah-surface); color: var(--ah-text); font-size: 13px; font-weight: 650; line-height: 1.25; }
|
||||
.site-footer-links a:hover { border-color: var(--ah-accent-secondary); background: var(--ah-hover); color: var(--ah-emphasis); }
|
||||
.site-footer-links a:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
|
||||
.site-footer-bottom { display: flex; justify-content: space-between; gap: 16px; padding-top: 18px; border-top: 2px solid var(--ah-border); color: var(--ah-muted); font-size: 12px; }
|
||||
.site-footer-bottom a { color: var(--ah-accent); font-weight: 750; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||
.site-footer-bottom a:hover { color: var(--ah-emphasis); }
|
||||
|
||||
/* Shared geometry across dashboard, admin, docs, legal and plugin routes. */
|
||||
.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 [class*="hover:scale"] { transform: none !important; }
|
||||
.app-container .uppercase { letter-spacing: .02em; }
|
||||
.app-container .text-\[10px\] { font-size: 12px; }
|
||||
.app-container :is(button, a, input, textarea, select) { border-radius: 2px; }
|
||||
.app-container :is(input, textarea, select) { background-color: var(--ah-background); border-color: var(--ah-border); color: var(--ah-emphasis); }
|
||||
.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) [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); }
|
||||
.dashboard-app .bg-dash-surface, .admin-app .bg-dash-surface { background-color: var(--ah-surface); }
|
||||
.dashboard-app .bg-dash-surfaceElevated, .admin-app .bg-dash-surfaceElevated { background-color: var(--ah-panel); }
|
||||
.dashboard-app .border-dash-border, .admin-app .border-dash-border { border-color: var(--ah-border); }
|
||||
.dashboard-app .rounded-xl.border-dash-border, .dashboard-app .rounded-2xl.border-dash-border,
|
||||
.admin-app .rounded-xl.border-dash-border, .admin-app .rounded-2xl.border-dash-border { box-shadow: var(--ah-shadow); }
|
||||
.dashboard-app .dash-skeleton, .admin-app .dash-skeleton { background: var(--ah-surface); background-image: none; animation: none; border: 1px solid var(--ah-border); }
|
||||
|
||||
/* Server tiles: compact, readable data panels with a single consistent hover state. */
|
||||
.ah-server-card { min-width: 0; overflow: hidden; border: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-text); box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.ah-server-card-content { display: flex; min-height: 100%; flex-direction: column; }
|
||||
.ah-server-card-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; min-width: 0; padding: 18px; border-bottom: 2px solid var(--ah-border); background: var(--ah-surface); }
|
||||
.ah-server-card-identity { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||
.ah-server-type-icon { display: grid; width: 42px; height: 42px; flex: 0 0 auto; place-items: center; border: 2px solid var(--server-accent); background: var(--ah-background); }
|
||||
.ah-server-card-heading { min-width: 0; flex: 1; }
|
||||
.ah-server-card-heading > .flex { min-width: 0; align-items: center; }
|
||||
.ah-server-name { min-width: 0; max-width: 100%; overflow: hidden; color: var(--ah-emphasis); font-size: 17px; font-weight: 800; line-height: 1.25; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ah-server-demo-tag { flex: 0 0 auto; padding: 2px 6px; border: 1px solid var(--ah-warning); background: var(--ah-background); color: var(--ah-warning); font-size: 10px; font-weight: 800; letter-spacing: .04em; }
|
||||
.ah-server-type-label { overflow: hidden; margin: 5px 0 0; color: var(--ah-muted); font-size: 11px; font-weight: 700; line-height: 1.3; text-overflow: ellipsis; text-transform: uppercase; letter-spacing: .035em; }
|
||||
.ah-server-demo-time { display: inline-flex; align-items: center; gap: 6px; width: fit-content; margin-top: 8px; padding: 4px 7px; border: 1px solid var(--ah-warning); background: var(--ah-background); }
|
||||
.ah-server-demo-time p { margin: 0; color: var(--ah-warning); font-size: 11px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
||||
.ah-server-status { display: inline-flex; min-height: 30px; align-items: center; justify-content: center; flex: 0 0 auto; padding: 4px 8px; border: 2px solid; font-size: 10px; font-weight: 850; line-height: 1; letter-spacing: .035em; text-align: center; text-transform: uppercase; white-space: nowrap; }
|
||||
.ah-server-resource-grid { display: grid; flex: 1 0 auto; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 16px; background: var(--ah-panel); }
|
||||
.ah-server-resource { display: flex; min-width: 0; min-height: 82px; flex-direction: column; justify-content: space-between; gap: 10px; padding: 11px 12px; border: 2px solid var(--ah-border); background: var(--ah-background); color: var(--ah-text); text-align: left; }
|
||||
.ah-server-resource-label { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ah-muted); font-size: 11px; font-weight: 750; line-height: 1.2; }
|
||||
.ah-server-resource-label svg { flex: 0 0 auto; color: var(--server-accent, var(--ah-accent)); }
|
||||
.ah-server-resource > p { overflow: hidden; margin: 0; color: var(--ah-emphasis); font-size: 17px; font-weight: 800; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.ah-server-cost { width: 100%; border-color: var(--ah-border); }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
.ah-tooltip-heading { margin-bottom: 7px; color: var(--ah-accent); font-size: 14px; font-weight: 850; line-height: 1.3; }
|
||||
.ah-tooltip-description { margin-bottom: 11px; color: var(--ah-text); font-size: 12px; line-height: 1.5; }
|
||||
.ah-tooltip-code { padding: 8px 9px; border: 1px solid var(--ah-border); background: var(--ah-background); color: var(--ah-emphasis); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-nav, .site-hero, .site-section, .site-platform { width: min(100% - 36px, 720px); }
|
||||
.site-hero { min-height: unset; grid-template-columns: 1fr; gap: 36px; padding: 66px 0 72px; }
|
||||
.site-hero-image { min-height: 350px; }
|
||||
.site-estimate-section { grid-template-columns: 1fr; gap: 32px; padding: 66px max(18px, calc((100% - 720px) / 2)); }
|
||||
.site-platform { gap: 34px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.site-nav { width: calc(100% - 32px); min-height: 62px; }
|
||||
.site-nav-actions { display: none; }
|
||||
.site-menu-toggle { display: grid; }
|
||||
.site-nav-links { position: absolute; top: 61px; right: 0; left: 0; display: none; align-items: stretch; gap: 2px; padding: 12px 16px 18px; border-bottom: 2px solid var(--ah-border); background: var(--ah-panel); }
|
||||
.site-nav-links--open { display: flex; flex-direction: column; }
|
||||
.site-nav-links > button, .site-nav-links > a { min-height: 44px; display: flex; align-items: center; padding: 8px 10px; text-align: left; }
|
||||
.site-mobile-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; padding-top: 12px; border-top: 2px solid var(--ah-border); }
|
||||
.site-hero { width: calc(100% - 36px); gap: 28px; padding: 54px 0 58px; }
|
||||
.site-hero h1 { font-size: clamp(42px, 12vw, 58px); }
|
||||
.site-hero-description { margin-top: 17px; font-size: 16px; }
|
||||
.site-hero-actions { align-items: stretch; }
|
||||
.site-hero-actions .site-button { width: 100%; }
|
||||
.site-hero-note { gap: 8px; font-size: 12px; }
|
||||
.site-hero-image { min-height: 0; box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.site-gallery-viewport { min-height: 230px; }
|
||||
.site-hero-image figcaption { grid-template-columns: minmax(0, 1fr) auto; gap: 9px; padding: 10px; }
|
||||
.site-gallery-thumbnails { grid-column: 1 / 2; grid-row: 2; }
|
||||
.site-gallery-thumbnail { width: 40px; height: 30px; }
|
||||
.site-gallery-counter { grid-column: 2; grid-row: 1 / 3; }
|
||||
.site-section, .site-platform { width: calc(100% - 36px); padding-top: 62px; padding-bottom: 62px; }
|
||||
.site-section-heading { align-items: start; flex-direction: column; gap: 12px; margin-bottom: 22px; }
|
||||
.site-section-heading h2, .site-estimate-intro h2, .site-platform-copy h2 { font-size: 33px; }
|
||||
.site-service-row { grid-template-columns: 26px 42px 1fr 18px; gap: 10px; min-height: 88px; padding: 13px 0; }
|
||||
.site-service-icon { width: 38px; height: 38px; }
|
||||
.site-service-copy strong { font-size: 15px; }
|
||||
.site-service-copy span { font-size: 13px; line-height: 1.45; }
|
||||
.site-estimate-section { padding: 58px 18px; }
|
||||
.site-estimator { padding: 16px; box-shadow: 4px 4px 0 var(--ah-border); }
|
||||
.site-resource-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.site-estimate-total { align-items: stretch; flex-direction: column; }
|
||||
.site-estimate-total .site-button { width: 100%; }
|
||||
.site-platform { grid-template-columns: 1fr; }
|
||||
.site-platform-image { min-height: 240px; box-shadow: 5px 5px 0 var(--ah-border); }
|
||||
.site-showcase-list { grid-template-columns: 1fr; }
|
||||
.site-showcase-list a { border-right: 0; }
|
||||
.site-announcement-item { grid-template-columns: 1fr auto; gap: 9px 12px; padding: 16px 4px; }
|
||||
.site-announcement-meta { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; }
|
||||
.site-announcement-title, .site-announcement-summary, .site-announcement-open { grid-column: 1 / -1; }
|
||||
.site-announcement-open { justify-content: start; }
|
||||
.announcement-dialog-header, .announcement-dialog-content { padding: 18px; }
|
||||
.announcement-dialog-footer { padding: 14px 18px; }
|
||||
.announcement-dialog-footer .site-button { width: 100%; }
|
||||
.announcement-dialog { box-shadow: 4px 4px 0 var(--ah-border); }
|
||||
.site-footer { padding: 34px 18px 20px; }
|
||||
.site-footer-main { flex-direction: column; gap: 26px; }
|
||||
.site-footer-links { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||
.site-footer-bottom { flex-direction: column; gap: 6px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -119,7 +119,7 @@ const AdminPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-brightRed/30 text-dash-textMuted antialiased">
|
||||
<div className="admin-app flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-textMuted antialiased">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 border-r border-dash-border bg-dash-surfaceElevated/50 backdrop-blur-md pt-[env(safe-area-inset-top,0px)] ${
|
||||
|
||||
+55
-80
@@ -1855,10 +1855,8 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
|
||||
const tooltipContent = isVisible ? (
|
||||
<div
|
||||
className="fixed w-72 p-4 rounded-lg border text-sm z-[9999] shadow-lg"
|
||||
className="ah-tooltip ah-tooltip-detail fixed w-72 max-w-[calc(100vw-2rem)] p-4 text-sm z-[9999]"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary, borderColor: colors.blue,
|
||||
color: colors.foreground,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
transform: 'translate(-50%, calc(-100% - 8px))'
|
||||
@@ -1866,9 +1864,9 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
onMouseEnter={handleTooltipMouseEnter}
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
>
|
||||
<div className="font-semibold mb-2 text-base" style={{ color: colors.blue }}>{portTooltip.name}</div>
|
||||
<div className="mb-3">{portTooltip.description}</div>
|
||||
<div className="font-mono text-sm p-2 rounded" style={{ backgroundColor: colors.brightBlack, color: colors.brightWhite }}>{portTooltip.connection}</div>
|
||||
<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>
|
||||
) : null;
|
||||
|
||||
@@ -1887,7 +1885,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Modern Server Card Component
|
||||
// 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 = {
|
||||
@@ -1949,116 +1947,93 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative rounded-3xl border transition-all duration-500 hover:scale-[1.02] hover:-translate-y-1 overflow-hidden h-full bg-brightBlack/20 backdrop-blur-md shadow-2xl shadow-black/20"
|
||||
className="ah-server-card relative h-full"
|
||||
style={{
|
||||
borderColor: `${serverColor}25`,
|
||||
'--server-accent': serverColor,
|
||||
}}
|
||||
>
|
||||
{/* Hover Glow */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
|
||||
style={{ background: `radial-gradient(circle at top right, ${serverColor}10 0%, transparent 60%)` }} />
|
||||
|
||||
<div className="p-0 h-full flex flex-col relative z-10">
|
||||
<div className="p-6 border-b border-white/5 bg-white/[0.01]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3.5 rounded-2xl shadow-lg border transition-transform duration-500 group-hover:rotate-3"
|
||||
style={{ backgroundColor: `${serverColor}15`, borderColor: `${serverColor}30` }}>
|
||||
<ServerIcon className="w-6 h-6" style={{ color: 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="min-w-0">
|
||||
<div className="ah-server-card-heading">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-xl font-black tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.name}</h3>
|
||||
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
|
||||
{isDemo && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-widest border border-yellow-500/30"
|
||||
style={{ backgroundColor: `${colors.brightYellow}15`, color: colors.brightYellow }}>
|
||||
<span className="ah-server-demo-tag">
|
||||
DEMO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mt-1" style={{ color: colors.foreground }}>
|
||||
<p className="ah-server-type-label">
|
||||
{isDatabase ? `${dbType} db` : `${server.type} node`}
|
||||
</p>
|
||||
{isDemo && timeRemaining && (
|
||||
<div className="flex items-center gap-1.5 mt-2 px-2 py-1 rounded-lg bg-yellow-500/10 border border-yellow-500/20 w-fit">
|
||||
<div className="ah-server-demo-time">
|
||||
<Clock className="w-3 h-3" style={{ color: colors.brightYellow }} />
|
||||
<p className="text-[10px] font-black tracking-tighter" style={{ color: colors.brightYellow }}>
|
||||
<p>
|
||||
{timeRemaining === 'Expired' ? 'EXPIRED' : timeRemaining}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span
|
||||
className="px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest border"
|
||||
<div className="ah-server-status"
|
||||
aria-label={`Server status: ${statusInfo.label}`}
|
||||
style={{
|
||||
backgroundColor: `${statusInfo.color}15`,
|
||||
borderColor: `${statusInfo.color}30`,
|
||||
borderColor: statusInfo.color,
|
||||
color: statusInfo.color,
|
||||
boxShadow: `0 0 15px ${statusInfo.color}10`
|
||||
}}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 bg-gradient-to-b from-transparent to-black/20">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat hover:border-primary/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Compute</span>
|
||||
<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 className="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.cpu}</p>
|
||||
<p>{server.cpu}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat hover:border-primary/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Memory</span>
|
||||
<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 className="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.ram}</p>
|
||||
<p>{server.ram}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat hover:border-primary/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HardDrive className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Storage</span>
|
||||
<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 className="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.storage}</p>
|
||||
<p>{server.storage}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat transition-all duration-500 hover:border-primary/60 relative overflow-hidden cursor-pointer"
|
||||
onClick={() => setActiveTab('store')}>
|
||||
<div className="transition-all duration-500 group-hover/stat:opacity-0 group-hover/stat:scale-95 group-hover/stat:-translate-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Coins className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Monthly Cost</span>
|
||||
<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="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>
|
||||
<p className="ah-server-cost-value">
|
||||
{isDemo ? 'FREE' : `${calculateCurrencyValue(serverMonthlyCost)} ${getCurrencySymbol()}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 p-4 flex flex-col items-center justify-center transition-all duration-500 opacity-0 scale-110 translate-y-2 group-hover/stat:opacity-100 group-hover/stat:scale-100 group-hover/stat:translate-y-0 bg-primary/10">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-1">Low Balance?</p>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-white uppercase tracking-tighter">
|
||||
<span>Refill Credits</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-white/5 flex gap-3 bg-black/40">
|
||||
<div className="ah-server-card-actions">
|
||||
{isDemo && user?.role !== 'admin' ? (
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => handleConvertDemoToPaid(server.id)}
|
||||
className="flex-1 !min-h-0 !py-3 shadow-xl shadow-yellow-500/10"
|
||||
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" />
|
||||
@@ -2073,7 +2048,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
setActiveServerDetailTab('overview');
|
||||
fetchServerLogs(server.id);
|
||||
}}
|
||||
className="flex-1 !min-h-0 !py-3 hover:border-primary/40"
|
||||
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>
|
||||
@@ -2083,7 +2058,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ActionButton
|
||||
variant="warning"
|
||||
onClick={() => handleConvertDemoToPaid(server.id)}
|
||||
className="aspect-square !p-0 !min-w-[48px]"
|
||||
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" />
|
||||
@@ -2094,7 +2069,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
onClick={() => handleServerAction(server.id, 'stop')}
|
||||
className="aspect-square !p-0 !min-w-[48px]"
|
||||
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</ActionButton>
|
||||
@@ -2102,7 +2077,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ActionButton
|
||||
variant="success"
|
||||
onClick={() => handleServerAction(server.id, 'start')}
|
||||
className="aspect-square !p-0 !min-w-[48px]"
|
||||
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
|
||||
>
|
||||
<Play className="w-4 h-4 fill-current" />
|
||||
</ActionButton>
|
||||
@@ -2133,7 +2108,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-text antialiased"
|
||||
className="dashboard-app flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-text antialiased"
|
||||
>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 transform transition-all duration-300 ease-in-out md:relative md:translate-x-0 border-r border-dash-border bg-dash-surfaceElevated/50 backdrop-blur-xl pt-[env(safe-area-inset-top,0px)] ${
|
||||
@@ -2573,7 +2548,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 password — you sign in with Google.
|
||||
No AnchorHost.eu password — you sign in with Google.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -2589,7 +2564,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<UnifiedTooltip
|
||||
content={
|
||||
user?.google_id
|
||||
? 'Google sign-in accounts do not use an AnchorHost password. Manage security in Google.'
|
||||
? 'Google sign-in accounts do not use an AnchorHost.eu password. Manage security in Google.'
|
||||
: 'Choose a new password. You will need your current password to confirm.'
|
||||
}
|
||||
multiline
|
||||
@@ -3605,7 +3580,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 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.
|
||||
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.
|
||||
</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 }}>
|
||||
@@ -3617,7 +3592,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 gateway</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">AnchorHost.eu 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>
|
||||
@@ -3678,7 +3653,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
icon: Shield,
|
||||
iconColor: colors.brightGreen,
|
||||
title: 'Firewall opens automatically',
|
||||
body: 'When you expose a port, AnchorHost opens it on the edge firewall in real time — no manual rules needed.',
|
||||
body: 'When you expose a port, AnchorHost.eu opens it on the edge firewall in real time — no manual rules needed.',
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
@@ -3875,7 +3850,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 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. AnchorHost.eu assigns a public external port automatically.</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submitNetworkExposePort(); }}
|
||||
|
||||
@@ -45,7 +45,7 @@ function DocumentationPage() {
|
||||
title: 'Creating Your Account',
|
||||
content: `# Creating Your Account
|
||||
|
||||
Creating an account on AnchorHost gives you access to our Kubernetes-based cloud hosting platform.
|
||||
Creating an account on AnchorHost.eu 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.
|
||||
@@ -69,7 +69,7 @@ When you click "Create Account":
|
||||
title: 'Authentication and Security',
|
||||
content: `# Authentication and Security
|
||||
|
||||
AnchorHost utilizes a hybrid approach of REST and WebSockets for secure, real-time management.
|
||||
AnchorHost.eu 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 +112,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, you will almost always use this format:
|
||||
When connecting to anything on AnchorHost.eu, 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.`
|
||||
@@ -258,7 +258,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, 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 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.`
|
||||
},
|
||||
{
|
||||
id: 'server-types',
|
||||
@@ -290,7 +290,7 @@ Managed containers for application stacks.
|
||||
title: 'How Billing Works',
|
||||
content: `# How Billing Works
|
||||
|
||||
AnchorHost operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices.
|
||||
AnchorHost.eu 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 +337,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 provides a specialized environment for Minecraft servers using high-performance containers.
|
||||
AnchorHost.eu provides a specialized environment for Minecraft servers using high-performance containers.
|
||||
|
||||
## Installation
|
||||
1. Go to **Create Server**.
|
||||
@@ -479,7 +479,7 @@ We provide an integrated **Plugin Store** powered by the Modrinth API.
|
||||
title: 'Network Architecture',
|
||||
content: `# Network Architecture
|
||||
|
||||
All servers deployed on AnchorHost share a high-performance static IP but utilize unique entry points.
|
||||
All servers deployed on AnchorHost.eu share a high-performance static IP but utilize unique entry points.
|
||||
|
||||
## External Connectivity
|
||||
- **Global IP**: \`85.237.179.121\`
|
||||
@@ -607,7 +607,7 @@ Managing the state of your Virtual Machine is done directly through the dashboar
|
||||
title: 'Scaling Resources',
|
||||
content: `# Scaling Resources
|
||||
|
||||
AnchorHost allows you to adjust your VPS specifications as your needs grow.
|
||||
AnchorHost.eu 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 +673,7 @@ passwd
|
||||
title: 'Getting Started with Databases',
|
||||
content: `# Managed Database Hosting
|
||||
|
||||
AnchorHost provides pre-configured database containers for rapid development.
|
||||
AnchorHost.eu provides pre-configured database containers for rapid development.
|
||||
|
||||
## Supported Engines
|
||||
- **MySQL**: The world's most popular open-source database.
|
||||
@@ -754,7 +754,7 @@ Use these templates to connect your applications. Replace \`IP\`, \`PORT\`, and
|
||||
title: 'Managing Subdomains',
|
||||
content: `# Managing Your Subdomains
|
||||
|
||||
Every user on AnchorHost has their own personal network space based on their username.
|
||||
Every user on AnchorHost.eu has their own personal network space based on their username.
|
||||
|
||||
## How to create a Subdomain
|
||||
1. Go to the **Domains** tab.
|
||||
@@ -775,7 +775,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 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.
|
||||
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.
|
||||
|
||||
## How to Fix
|
||||
Usually, this is handled automatically within 5-10 minutes of creating your account. If the warning persists:
|
||||
|
||||
+351
-1965
File diff suppressed because it is too large.
Load diff
+29
-38
@@ -1,40 +1,31 @@
|
||||
/**
|
||||
* Canonical AnchorHost palette — keep in sync with tailwind.config.js theme.extend.colors
|
||||
*
|
||||
* Dashboard semantic UI colors (surface, borders, text emphasis) live as `theme.colors.dash`
|
||||
* in tailwind.config.js and in [dashboardTokens.js](./dashboardTokens.js) for JS references.
|
||||
*/
|
||||
/** AnchorHost.eu palette: colors.ini is the canonical source; keep Tailwind aligned. */
|
||||
export const colors = {
|
||||
/** Body / secondary UI (improved contrast on #101010) */
|
||||
foreground: '#c8c8d0',
|
||||
background: '#101010',
|
||||
backgroundSecondary: '#161616',
|
||||
black: '#121212',
|
||||
red: '#8b2d32',
|
||||
green: '#5a6b47',
|
||||
yellow: '#9c6d2f',
|
||||
/** Alias used by server status / accents (was implicit on modal) */
|
||||
orange: '#b8843f',
|
||||
blue: '#4a6385',
|
||||
magenta: '#6d4779',
|
||||
cyan: '#45615b',
|
||||
white: '#8a8a8a',
|
||||
brightBlack: '#1a1a1a',
|
||||
brightRed: '#a83a40',
|
||||
brightGreen: '#6b7d55',
|
||||
brightYellow: '#b8843f',
|
||||
brightBlue: '#5a7ca7',
|
||||
brightMagenta: '#8a4a9a',
|
||||
brightCyan: '#4a8a82',
|
||||
/** Headings on dark surfaces */
|
||||
brightWhite: '#e8e8ec',
|
||||
primary: '#5a7ca7',
|
||||
/** Muted accents for marketing sections, hovers, and secondary text */
|
||||
darkBlue: '#4e6e8f',
|
||||
darkCyan: '#4d7976',
|
||||
darkWhite: '#7a7a7a',
|
||||
darkRed: '#6b2528',
|
||||
darkGreen: '#4a5a3c',
|
||||
darkYellow: '#7a5526',
|
||||
darkMagenta: '#52385a',
|
||||
foreground: '#D0D0D0',
|
||||
background: '#151515',
|
||||
backgroundSecondary: '#181818',
|
||||
black: '#151515',
|
||||
red: '#A53C23',
|
||||
green: '#7B9246',
|
||||
yellow: '#D3A04D',
|
||||
orange: '#D3A04D',
|
||||
blue: '#547C99',
|
||||
magenta: '#547C99',
|
||||
cyan: '#5EA8A2',
|
||||
white: '#757575',
|
||||
brightBlack: '#2F2F2F',
|
||||
brightRed: '#A53C23',
|
||||
brightGreen: '#7B9246',
|
||||
brightYellow: '#D3A04D',
|
||||
brightBlue: '#6C99BB',
|
||||
brightMagenta: '#547C99',
|
||||
brightCyan: '#5EA8A2',
|
||||
brightWhite: '#F5F5F5',
|
||||
primary: '#6C99BB',
|
||||
darkBlue: '#547C99',
|
||||
darkCyan: '#5EA8A2',
|
||||
darkWhite: '#505050',
|
||||
darkRed: '#A53C23',
|
||||
darkGreen: '#7B9246',
|
||||
darkYellow: '#D3A04D',
|
||||
darkMagenta: '#547C99',
|
||||
};
|
||||
@@ -5,23 +5,23 @@
|
||||
import { colors } from './colors';
|
||||
|
||||
export const dash = {
|
||||
surface: colors.background,
|
||||
surface: '#202020',
|
||||
surfaceElevated: colors.backgroundSecondary,
|
||||
border: '#2c2c32',
|
||||
border: '#2F2F2F',
|
||||
text: colors.brightWhite,
|
||||
textMuted: colors.foreground,
|
||||
textMuted: '#D0D0D0',
|
||||
accent: colors.primary,
|
||||
danger: colors.brightRed,
|
||||
success: colors.brightGreen,
|
||||
warning: colors.brightYellow,
|
||||
danger: '#A53C23',
|
||||
success: '#7B9246',
|
||||
warning: '#D3A04D',
|
||||
};
|
||||
|
||||
/** Shared layout class strings for panel chrome */
|
||||
export const dashLayout = {
|
||||
/** Outer view container: one border, subtle fill, no heavy shadow */
|
||||
viewShell: 'w-full rounded-2xl border border-dash-border bg-dash-surfaceElevated/95 overflow-hidden backdrop-blur-sm',
|
||||
/** Outer panel uses hard corners and a stamped offset outline. */
|
||||
viewShell: 'w-full rounded-sm border-2 border-dash-border bg-dash-surfaceElevated overflow-hidden',
|
||||
/** Header strip under accent tint */
|
||||
headerBar: 'p-6 border-b border-dash-border flex flex-col md:flex-row md:items-center justify-between gap-4',
|
||||
headerBar: 'px-5 py-5 md:px-7 border-b border-dash-border flex flex-col md:flex-row md:items-center justify-between gap-4',
|
||||
/** Main content padding inside shell */
|
||||
bodyPadding: 'p-6 space-y-6',
|
||||
bodyPadding: 'p-5 md:p-7 space-y-6',
|
||||
};
|
||||
Reference in new issue
Block a user