Redesign dashboard with steampunk neon theme and live gauges.
Ambient background, 3D neon cards, brass HUD styling, glowing charts, gauge rings, and refreshed Command Deck, Fleet Roster, Forge, and Calibrate pages.
This commit is contained in:
103
server/web/src/components/Ambient/AmbientBackground.css
Normal file
103
server/web/src/components/Ambient/AmbientBackground.css
Normal file
@@ -0,0 +1,103 @@
|
||||
.ambient-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ambient-grid {
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
background-image:
|
||||
linear-gradient(rgba(201, 162, 39, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(201, 162, 39, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(rgba(0, 245, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 80px 80px, 80px 80px, 20px 20px, 20px 20px;
|
||||
animation: grid-drift 40s linear infinite;
|
||||
transform: perspective(500px) rotateX(60deg) scale(2);
|
||||
transform-origin: center top;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ambient-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 0%, var(--bg-void) 75%);
|
||||
}
|
||||
|
||||
.ambient-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: float-orb 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-orb-cyan {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: rgba(0, 245, 255, 0.12);
|
||||
top: 10%;
|
||||
right: 5%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.ambient-orb-magenta {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: rgba(255, 45, 166, 0.08);
|
||||
bottom: 20%;
|
||||
left: 10%;
|
||||
animation-delay: -4s;
|
||||
}
|
||||
|
||||
.ambient-orb-amber {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: rgba(255, 176, 32, 0.06);
|
||||
top: 50%;
|
||||
left: 40%;
|
||||
animation-delay: -8s;
|
||||
}
|
||||
|
||||
.ambient-scanline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 245, 255, 0.15), transparent);
|
||||
animation: scanline 8s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ambient-gear {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 3px dashed rgba(201, 162, 39, 0.15);
|
||||
border-radius: 50%;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ambient-gear::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 20%;
|
||||
border: 2px solid rgba(201, 162, 39, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ambient-gear-1 {
|
||||
top: 15%;
|
||||
left: 5%;
|
||||
animation: gear-spin 60s linear infinite;
|
||||
}
|
||||
|
||||
.ambient-gear-2 {
|
||||
bottom: 10%;
|
||||
right: 8%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
animation: gear-spin 45s linear infinite reverse;
|
||||
}
|
||||
16
server/web/src/components/Ambient/AmbientBackground.tsx
Normal file
16
server/web/src/components/Ambient/AmbientBackground.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import './AmbientBackground.css';
|
||||
|
||||
export default function AmbientBackground() {
|
||||
return (
|
||||
<div className="ambient-bg" aria-hidden>
|
||||
<div className="ambient-grid" />
|
||||
<div className="ambient-vignette" />
|
||||
<div className="ambient-orb ambient-orb-cyan" />
|
||||
<div className="ambient-orb ambient-orb-magenta" />
|
||||
<div className="ambient-orb ambient-orb-amber" />
|
||||
<div className="ambient-scanline" />
|
||||
<div className="ambient-gear ambient-gear-1" />
|
||||
<div className="ambient-gear ambient-gear-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
server/web/src/components/Charts/GaugeRing.css
Normal file
56
server/web/src/components/Charts/GaugeRing.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.gauge-ring {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gauge-ring-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.gauge-ring-track {
|
||||
fill: none;
|
||||
stroke: rgba(201, 162, 39, 0.15);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.gauge-ring-fill {
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.8s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.gauge-ring-center {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.gauge-ring-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--neon-cyan);
|
||||
text-shadow: 0 0 12px rgba(0, 245, 255, 0.5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gauge-ring-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--brass-light);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.gauge-ring-sub {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
66
server/web/src/components/Charts/GaugeRing.tsx
Normal file
66
server/web/src/components/Charts/GaugeRing.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import './GaugeRing.css';
|
||||
|
||||
interface GaugeRingProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function GaugeRing({
|
||||
value,
|
||||
max = 100,
|
||||
label,
|
||||
sublabel,
|
||||
color = 'var(--neon-cyan)',
|
||||
size = 100,
|
||||
}: GaugeRingProps) {
|
||||
const pct = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
const circumference = 2 * Math.PI * 42;
|
||||
const offset = circumference - (pct / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="gauge-ring" style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 100 100" className="gauge-ring-svg">
|
||||
<defs>
|
||||
<linearGradient id={`gauge-grad-${label.replace(/\s/g, '')}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="var(--brass)" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
<filter id="gauge-glow">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle className="gauge-ring-track" cx="50" cy="50" r="42" />
|
||||
<circle
|
||||
className="gauge-ring-fill"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="42"
|
||||
stroke={`url(#gauge-grad-${label.replace(/\s/g, '')})`}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
filter="url(#gauge-glow)"
|
||||
/>
|
||||
</svg>
|
||||
<div className="gauge-ring-center">
|
||||
<span className="gauge-ring-value font-tech">{formatValue(value, max)}</span>
|
||||
<span className="gauge-ring-label">{label}</span>
|
||||
{sublabel && <span className="gauge-ring-sub">{sublabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(v: number, max: number): string {
|
||||
if (max <= 100 && max > 1) return `${v.toFixed(0)}%`;
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||
return v.toFixed(0);
|
||||
}
|
||||
74
server/web/src/components/Charts/HashrateChart.css
Normal file
74
server/web/src/components/Charts/HashrateChart.css
Normal file
@@ -0,0 +1,74 @@
|
||||
.neon-chart-panel {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--brass-light);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-live {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--neon-green);
|
||||
text-shadow: 0 0 8px rgba(57, 255, 20, 0.6);
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 260px;
|
||||
color: var(--text-muted);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-empty-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--brass);
|
||||
opacity: 0.5;
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.neon-tooltip {
|
||||
background: linear-gradient(145deg, rgba(20, 16, 12, 0.98), rgba(8, 6, 4, 0.99));
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 2px;
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: 0 0 20px rgba(0, 245, 255, 0.15), var(--shadow-panel);
|
||||
}
|
||||
|
||||
.neon-tooltip-time {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.neon-tooltip-value {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.neon-tooltip-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--brass-light);
|
||||
margin-top: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import './HashrateChart.css';
|
||||
|
||||
export interface ChartPoint {
|
||||
time: string;
|
||||
@@ -19,48 +20,149 @@ interface HashrateChartProps {
|
||||
title?: string;
|
||||
color?: string;
|
||||
unit?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function HashrateChart({ data, title, color = '#06b6d4', unit = 'H/s' }: HashrateChartProps) {
|
||||
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||
|
||||
function colorToId(color: string): string {
|
||||
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff') return 'cyan';
|
||||
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
|
||||
if (color.includes('b020') || color.includes('eab308')) return 'amber';
|
||||
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
|
||||
return 'purple';
|
||||
}
|
||||
|
||||
export default function HashrateChart({
|
||||
data,
|
||||
title,
|
||||
color = '#00f5ff',
|
||||
unit = 'H/s',
|
||||
height = 280,
|
||||
}: HashrateChartProps) {
|
||||
const gradId = colorToId(color);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="chart-empty card">
|
||||
<p>{title || 'Chart'} — waiting for data...</p>
|
||||
<div className="chart-empty neon-chart-panel">
|
||||
<div className="chart-empty-icon">◈</div>
|
||||
<p className="font-tech">{title || 'Telemetry'}</p>
|
||||
<span>Awaiting signal from fleet...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
{title && <h3 className="chart-title">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={data}>
|
||||
<div className="chart-wrap neon-chart-panel">
|
||||
{title && (
|
||||
<div className="chart-header">
|
||||
<h3 className="chart-title font-display">{title}</h3>
|
||||
<span className="chart-live pulse">● LIVE</span>
|
||||
</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${color}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
<linearGradient id={`area-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.55} />
|
||||
<stop offset="50%" stopColor={color} stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${gradId}`}>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#243049" />
|
||||
<XAxis dataKey="time" stroke="#64748b" fontSize={11} tickLine={false} />
|
||||
<YAxis stroke="#64748b" fontSize={11} tickLine={false} tickFormatter={(v) => formatShort(v, unit)} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #2a3a5c', borderRadius: 8 }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
formatter={(value: number) => [formatShort(value, unit), title || 'Value']}
|
||||
<CartesianGrid strokeDasharray="4 8" stroke="rgba(201, 162, 39, 0.12)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="rgba(196, 181, 160, 0.5)"
|
||||
fontSize={10}
|
||||
fontFamily="Orbitron, monospace"
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'rgba(201, 162, 39, 0.25)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="rgba(196, 181, 160, 0.5)"
|
||||
fontSize={10}
|
||||
fontFamily="Orbitron, monospace"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => formatShort(v, unit)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => (
|
||||
<NeonTooltip
|
||||
active={props.active}
|
||||
payload={props.payload as { value: number }[] | undefined}
|
||||
label={props.label as string | undefined}
|
||||
unit={unit}
|
||||
title={title}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
fill={`url(#area-fill-${gradId})`}
|
||||
filter={`url(#glow-${gradId})`}
|
||||
animationDuration={800}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
<Area type="monotone" dataKey="value" stroke={color} fill={`url(#grad-${color})`} strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeonTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
unit,
|
||||
title,
|
||||
color,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: { value: number }[];
|
||||
label?: string;
|
||||
unit: string;
|
||||
title?: string;
|
||||
color: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const v = payload[0].value;
|
||||
return (
|
||||
<div className="neon-tooltip">
|
||||
<div className="neon-tooltip-time font-tech">{label}</div>
|
||||
<div className="neon-tooltip-value" style={{ color, textShadow: `0 0 12px ${color}` }}>
|
||||
{formatFull(v, unit)}
|
||||
</div>
|
||||
<div className="neon-tooltip-label">{title || 'Reading'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatShort(v: number, unit: string): string {
|
||||
if (unit === 'H/s') {
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||
return `${v.toFixed(0)}`;
|
||||
}
|
||||
return `${v.toFixed(1)}${unit === '%' ? '%' : ''}`;
|
||||
return `${v.toFixed(0)}${unit === '%' ? '%' : ''}`;
|
||||
}
|
||||
|
||||
function formatFull(v: number, unit: string): string {
|
||||
if (unit === 'H/s') {
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(3)} MH/s`;
|
||||
if (v >= 1_000) return `${(v / 1_000).toFixed(2)} KH/s`;
|
||||
return `${v.toFixed(0)} H/s`;
|
||||
}
|
||||
return `${v.toFixed(1)}${unit}`;
|
||||
}
|
||||
|
||||
@@ -12,34 +12,28 @@
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 245, 255, 0.1);
|
||||
border: 1px solid rgba(0, 245, 255, 0.35);
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help-tip-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cheat-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 0 8px rgba(0, 245, 255, 0.2);
|
||||
}
|
||||
|
||||
.cheat-sheet-item {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 6, 4, 0.6);
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 2px;
|
||||
border-left: 3px solid var(--neon-cyan);
|
||||
}
|
||||
|
||||
.cheat-sheet-item h4 {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--neon-cyan);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
width: 260px;
|
||||
background: linear-gradient(180deg, rgba(16, 12, 8, 0.97) 0%, rgba(8, 6, 4, 0.98) 100%);
|
||||
border-right: 1px solid var(--border-brass);
|
||||
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.5), inset -1px 0 0 rgba(0, 245, 255, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
@@ -14,114 +16,217 @@
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.sidebar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--neon-cyan), transparent 30%, transparent 70%, var(--neon-magenta));
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-brass);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
.logo-emblem {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-gear {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed rgba(201, 162, 39, 0.4);
|
||||
border-radius: 50%;
|
||||
animation: gear-spin 20s linear infinite;
|
||||
}
|
||||
|
||||
.logo-core {
|
||||
font-size: 1.5rem;
|
||||
filter: drop-shadow(0 0 8px var(--neon-amber));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-text-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
background: linear-gradient(135deg, var(--brass-light) 0%, var(--neon-cyan) 50%, var(--brass) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.logo-tagline {
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0.75rem 0.5rem;
|
||||
padding: 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 2px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: rgba(201, 162, 39, 0.08);
|
||||
color: var(--brass-light);
|
||||
border-color: rgba(201, 162, 39, 0.2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--accent-blue);
|
||||
background: linear-gradient(90deg, rgba(0, 245, 255, 0.12), transparent);
|
||||
color: var(--neon-cyan);
|
||||
border-color: rgba(0, 245, 255, 0.35);
|
||||
box-shadow: inset 0 0 20px rgba(0, 245, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.125rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
white-space: nowrap;
|
||||
.nav-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-glow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px var(--neon-cyan);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 1.25rem;
|
||||
border-top: 1px solid var(--border-brass);
|
||||
}
|
||||
|
||||
.power-meter {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.power-label {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.power-bar {
|
||||
height: 4px;
|
||||
background: rgba(201, 162, 39, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.power-fill {
|
||||
height: 100%;
|
||||
width: 78%;
|
||||
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
|
||||
box-shadow: 0 0 8px rgba(0, 245, 255, 0.4);
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 240px;
|
||||
padding: 1.5rem 2rem;
|
||||
margin-left: 260px;
|
||||
padding: 2rem 2.5rem;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.logo-text,
|
||||
.logo-text-block,
|
||||
.nav-label,
|
||||
.sidebar-footer {
|
||||
.power-meter,
|
||||
.version-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem 0.75rem;
|
||||
padding: 1rem 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 60px;
|
||||
margin-left: 72px;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,100 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||
{ to: '/builder', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
] as const;
|
||||
|
||||
function NavIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case 'deck':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 19V5h16v14M4 9h16M8 5v4M16 5v4" />
|
||||
<circle cx="12" cy="14" r="2" />
|
||||
</svg>
|
||||
);
|
||||
case 'fleet':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="6" width="20" height="12" rx="1" />
|
||||
<path d="M6 10h4M14 10h4M6 14h2M16 14h2" />
|
||||
</svg>
|
||||
);
|
||||
case 'forge':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M14 4l6 6-8 8H6v-6l8-8z" />
|
||||
<path d="M8 16l-2 4 4-2" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<AmbientBackground />
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<span className="logo-icon">⛏️</span>
|
||||
<span className="logo-text">MinerCMD</span>
|
||||
<div className="logo-emblem">
|
||||
<span className="logo-gear" />
|
||||
<span className="logo-core">⛏</span>
|
||||
</div>
|
||||
<div className="logo-text-block">
|
||||
<span className="logo-text">AetherForge</span>
|
||||
<span className="logo-tagline font-tech">LAN MINING COMMAND</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-nav">
|
||||
<NavLink to="/dashboard" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">📊</span>
|
||||
<span className="nav-label">Dashboard</span>
|
||||
</NavLink>
|
||||
<NavLink to="/agents" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">🖥️</span>
|
||||
<span className="nav-label">Agents</span>
|
||||
</NavLink>
|
||||
<NavLink to="/builder" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">🔨</span>
|
||||
<span className="nav-label">Miner Builder</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="nav-icon">⚙️</span>
|
||||
<span className="nav-label">Settings</span>
|
||||
</NavLink>
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<NavIcon type={item.icon} />
|
||||
</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{location.pathname === item.to && <span className="nav-glow" />}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="version-badge">v1.0.0</div>
|
||||
<div className="power-meter">
|
||||
<span className="power-label font-tech">SYSTEM</span>
|
||||
<div className="power-bar">
|
||||
<div className="power-fill" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="version-badge font-tech">MK.I · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="main-content">
|
||||
{children}
|
||||
</main>
|
||||
<main className="main-content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
53
server/web/src/components/NeonCard/NeonCard.css
Normal file
53
server/web/src/components/NeonCard/NeonCard.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.neon-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.neon-card-rim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--brass-light) 0%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--neon-cyan) 100%
|
||||
);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.neon-card-cyan .neon-card-rim {
|
||||
background: linear-gradient(135deg, var(--neon-cyan), transparent 40%, var(--brass) 100%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.neon-card-magenta .neon-card-rim {
|
||||
background: linear-gradient(135deg, var(--neon-magenta), transparent 50%, var(--brass) 100%);
|
||||
}
|
||||
|
||||
.neon-card-amber .neon-card-rim {
|
||||
background: linear-gradient(135deg, var(--neon-amber), var(--brass-dark) 100%);
|
||||
}
|
||||
|
||||
.neon-card-green .neon-card-rim {
|
||||
background: linear-gradient(135deg, var(--neon-green), transparent 50%, var(--brass) 100%);
|
||||
}
|
||||
|
||||
.neon-card-purple .neon-card-rim {
|
||||
background: linear-gradient(135deg, var(--neon-purple), var(--neon-cyan) 100%);
|
||||
}
|
||||
|
||||
.neon-card-cyan:hover {
|
||||
box-shadow: var(--shadow-panel), var(--shadow-neon-cyan);
|
||||
}
|
||||
|
||||
.neon-card-magenta:hover {
|
||||
box-shadow: var(--shadow-panel), var(--shadow-neon-magenta);
|
||||
}
|
||||
32
server/web/src/components/NeonCard/NeonCard.tsx
Normal file
32
server/web/src/components/NeonCard/NeonCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
import './NeonCard.css';
|
||||
|
||||
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass';
|
||||
|
||||
interface NeonCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
accent?: Accent;
|
||||
tilt3d?: boolean;
|
||||
hud?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function NeonCard({
|
||||
children,
|
||||
className = '',
|
||||
accent = 'brass',
|
||||
tilt3d = true,
|
||||
hud = false,
|
||||
style,
|
||||
}: NeonCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`neon-card neon-card-${accent} ${tilt3d ? 'neon-card-3d' : ''} ${hud ? 'hud-corners' : ''} ${className}`}
|
||||
style={style}
|
||||
>
|
||||
<div className="neon-card-rim" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user