refactor: extract UI components and redesign desktop client interface with improved visual hierarchy
Extract App.tsx logic into reusable components: AppHeader, ResourcePanel, StatusCard, StatTile, and ActionButton. Replace inline markup with component composition and props-based data flow. Redesign visual system with enhanced gradients, refined color palette, and improved spacing. Update app-shell grid layout with 18px gaps and 1140px max width. Add radial gradient overlays and linear background
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { AppHeader } from "./components/AppHeader";
|
||||||
|
import { ResourcePanel } from "./components/ResourcePanel";
|
||||||
|
import { StatusCard } from "./components/StatusCard";
|
||||||
|
|
||||||
type EnrollmentState = {
|
type EnrollmentState = {
|
||||||
assignedIp: string;
|
assignedIp: string;
|
||||||
@@ -191,40 +194,23 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-shell">
|
<div className="client-shell">
|
||||||
<div className="app-frame">
|
<div className="app-shell">
|
||||||
<div className="top-strip">
|
<AppHeader
|
||||||
<div className="brand-lockup">
|
connected={connected}
|
||||||
<img src="/icon.png" alt="NexaVPN" />
|
enrolled={Boolean(state)}
|
||||||
<div className="brand-copy">
|
onLogout={resetEnrollment}
|
||||||
<p className="eyebrow">NexaVPN</p>
|
onSync={onSyncProfile}
|
||||||
<h1>VPN Client</h1>
|
onToggleConnection={toggleConnection}
|
||||||
<p>{state ? "Private access client" : "Sign in to add this device"}</p>
|
syncing={syncing}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="top-actions">
|
|
||||||
{state ? (
|
|
||||||
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
|
|
||||||
{syncing ? "Syncing..." : "Sync"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{state ? (
|
|
||||||
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
|
|
||||||
{!state ? "Provision first" : connected ? "Disconnect" : "Connect"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="body-grid">
|
<div className="body-grid">
|
||||||
{!state ? (
|
{!state ? (
|
||||||
<section className="login-panel">
|
<section className="login-card">
|
||||||
<div className="surface-header">
|
<div className="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Sign in</p>
|
<p className="section-eyebrow">Sign in</p>
|
||||||
<h3>Provision device</h3>
|
<h2>Provision device</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
@@ -241,75 +227,31 @@ export function App() {
|
|||||||
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
{error ? <div className="error">{error}</div> : null}
|
{error ? <div className="error">{error}</div> : null}
|
||||||
<div className="login-card-actions">
|
<div className="login-actions">
|
||||||
<button className="shell-button" disabled={loading} type="submit">
|
<button className="action-button action-button-primary" disabled={loading} type="submit">
|
||||||
{loading ? "Provisioning..." : "Sign in"}
|
{loading ? "Provisioning..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="status-panel">
|
<div className="main-column">
|
||||||
<div className="status-top">
|
<StatusCard
|
||||||
<div>
|
accessLabel={profileLabel}
|
||||||
<p className="eyebrow">Status</p>
|
assignedIp={state.assignedIp}
|
||||||
<h3>{connected ? "Connected" : "Disconnected"}</h3>
|
connected={connected}
|
||||||
<div className="status-state">
|
gatewayEndpoint={state.gatewayEndpoint}
|
||||||
<span className={`status-dot ${connected ? "online" : ""}`} />
|
lastSyncTime={state.lastSyncTime}
|
||||||
{connected ? "Tunnel active" : "Ready"}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface">
|
|
||||||
<div className="surface-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Connection</p>
|
|
||||||
<h4>Overview</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="status-grid">
|
|
||||||
<div className="detail-card">
|
|
||||||
<span>Assigned VPN IP</span>
|
|
||||||
<strong>{state.assignedIp}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="detail-card">
|
|
||||||
<span>Gateway endpoint</span>
|
|
||||||
<strong>{state.gatewayEndpoint}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="detail-card">
|
|
||||||
<span>Access</span>
|
|
||||||
<strong>{profileLabel}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="detail-card">
|
|
||||||
<span>Last sync</span>
|
|
||||||
<strong>{state.lastSyncTime}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? <div className="error">{error}</div> : null}
|
{error ? <div className="error">{error}</div> : null}
|
||||||
</section>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside className="status-panel">
|
<ResourcePanel
|
||||||
<div className="surface-header">
|
onReset={resetEnrollment}
|
||||||
<div>
|
profileLabel={profileLabel}
|
||||||
<p className="eyebrow">Access</p>
|
resources={state?.resources ?? []}
|
||||||
<h4>Resources</h4>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="resource-stack">
|
|
||||||
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
|
|
||||||
<li key={resource}>{resource}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{state ? (
|
|
||||||
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
|
|
||||||
Neu einbinden
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
desktop-client/src/components/ActionButton.tsx
Normal file
28
desktop-client/src/components/ActionButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type ActionButtonProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
type?: "button" | "submit";
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
variant = "secondary"
|
||||||
|
}: ActionButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`action-button action-button-${variant}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
type={type}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
desktop-client/src/components/AppHeader.tsx
Normal file
56
desktop-client/src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ActionButton } from "./ActionButton";
|
||||||
|
|
||||||
|
type AppHeaderProps = {
|
||||||
|
enrolled: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
syncing: boolean;
|
||||||
|
onSync: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onToggleConnection: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppHeader({
|
||||||
|
enrolled,
|
||||||
|
connected,
|
||||||
|
syncing,
|
||||||
|
onSync,
|
||||||
|
onLogout,
|
||||||
|
onToggleConnection
|
||||||
|
}: AppHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand-block">
|
||||||
|
<div className="brand-icon-shell">
|
||||||
|
<img className="brand-icon" src="/icon.png" alt="NexaVPN" />
|
||||||
|
</div>
|
||||||
|
<div className="brand-text">
|
||||||
|
<p className="section-eyebrow">NexaVPN</p>
|
||||||
|
<h1>Secure Access Client</h1>
|
||||||
|
<p className="brand-subtitle">
|
||||||
|
Enterprise-grade private network access with policy-based resources.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
{enrolled ? (
|
||||||
|
<>
|
||||||
|
<ActionButton disabled={syncing} onClick={onSync} variant="secondary">
|
||||||
|
{syncing ? "Syncing..." : "Sync"}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton onClick={onLogout} variant="ghost">
|
||||||
|
Logout
|
||||||
|
</ActionButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<ActionButton
|
||||||
|
disabled={!enrolled}
|
||||||
|
onClick={onToggleConnection}
|
||||||
|
variant={connected ? "danger" : "primary"}
|
||||||
|
>
|
||||||
|
{!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
desktop-client/src/components/ResourcePanel.tsx
Normal file
49
desktop-client/src/components/ResourcePanel.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { ActionButton } from "./ActionButton";
|
||||||
|
|
||||||
|
function ResourceListItem({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<li className="resource-item">
|
||||||
|
<span className="resource-item-dot" />
|
||||||
|
<span className="resource-item-text">{value}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourcePanelProps = {
|
||||||
|
resources: string[];
|
||||||
|
profileLabel: string;
|
||||||
|
onReset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourcePanel({ resources, profileLabel, onReset }: ResourcePanelProps) {
|
||||||
|
const effectiveResources = resources.length > 0 ? resources : ["Keine Ressourcen zugewiesen"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="resource-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Access</p>
|
||||||
|
<h2>Ressourcen</h2>
|
||||||
|
</div>
|
||||||
|
<span className="resource-count">{resources.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="resource-meta">
|
||||||
|
<span className="resource-meta-label">Zugriffsprofil</span>
|
||||||
|
<strong>{profileLabel}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="resource-list">
|
||||||
|
{effectiveResources.map((resource) => (
|
||||||
|
<ResourceListItem key={resource} value={resource} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="resource-footer">
|
||||||
|
<ActionButton onClick={onReset} variant="secondary">
|
||||||
|
Neu einbinden
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
desktop-client/src/components/StatTile.tsx
Normal file
17
desktop-client/src/components/StatTile.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
type StatTileProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatTile({ label, value, icon }: StatTileProps) {
|
||||||
|
return (
|
||||||
|
<div className="stat-tile">
|
||||||
|
<div className="stat-tile-icon">{icon}</div>
|
||||||
|
<div className="stat-tile-copy">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
desktop-client/src/components/StatusCard.tsx
Normal file
86
desktop-client/src/components/StatusCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { StatTile } from "./StatTile";
|
||||||
|
|
||||||
|
function DotGridIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 20 20">
|
||||||
|
<circle cx="5" cy="5" r="2" />
|
||||||
|
<circle cx="15" cy="5" r="2" />
|
||||||
|
<circle cx="5" cy="15" r="2" />
|
||||||
|
<circle cx="15" cy="15" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShieldIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 2.5 4.5 4.7v4.2c0 3.4 2.1 6.5 5.5 8.6 3.4-2.1 5.5-5.2 5.5-8.6V4.7Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM4 14.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 0a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6.1 14l2.2-3.1m5.6 3.1-2.2-3.1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClockIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14Zm0 3v4l2.8 1.7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusCardProps = {
|
||||||
|
connected: boolean;
|
||||||
|
assignedIp: string;
|
||||||
|
gatewayEndpoint: string;
|
||||||
|
accessLabel: string;
|
||||||
|
lastSyncTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusCard({
|
||||||
|
connected,
|
||||||
|
assignedIp,
|
||||||
|
gatewayEndpoint,
|
||||||
|
accessLabel,
|
||||||
|
lastSyncTime
|
||||||
|
}: StatusCardProps) {
|
||||||
|
return (
|
||||||
|
<section className="status-card">
|
||||||
|
<div className="status-hero">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Status</p>
|
||||||
|
<h2>{connected ? "Connected" : "Disconnected"}</h2>
|
||||||
|
<p className="status-support">
|
||||||
|
{connected ? "Secure tunnel established" : "Client is ready to establish a secure tunnel"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`status-pill ${connected ? "is-online" : ""}`}>
|
||||||
|
<span className="status-pulse" />
|
||||||
|
{connected ? "Tunnel active" : "Ready"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="status-details">
|
||||||
|
<div className="panel-head panel-head-compact">
|
||||||
|
<div>
|
||||||
|
<p className="section-eyebrow">Connection</p>
|
||||||
|
<h3>Overview</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-grid">
|
||||||
|
<StatTile icon={<ShieldIcon />} label="Assigned VPN IP" value={assignedIp} />
|
||||||
|
<StatTile icon={<NetworkIcon />} label="Gateway endpoint" value={gatewayEndpoint} />
|
||||||
|
<StatTile icon={<DotGridIcon />} label="Access mode" value={accessLabel} />
|
||||||
|
<StatTile icon={<ClockIcon />} label="Last sync" value={lastSyncTime} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
font-family: "Segoe UI Variable", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
color: #eef4ff;
|
color: #eef6ff;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 12% 10%, rgba(50, 196, 167, 0.2), transparent 24%),
|
radial-gradient(circle at 12% 12%, rgba(45, 204, 188, 0.18), transparent 24%),
|
||||||
radial-gradient(circle at 90% 18%, rgba(89, 133, 255, 0.16), transparent 22%),
|
radial-gradient(circle at 88% 16%, rgba(64, 116, 255, 0.16), transparent 22%),
|
||||||
linear-gradient(180deg, #07101c 0%, #0c1524 100%);
|
linear-gradient(180deg, #06101d 0%, #0a1423 52%, #0c1523 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100vh;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -28,292 +29,529 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-shell {
|
.client-shell {
|
||||||
height: 100vh;
|
width: 100%;
|
||||||
padding: 12px;
|
height: 100%;
|
||||||
|
padding: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-frame {
|
.app-shell {
|
||||||
width: min(920px, 100%);
|
width: min(1140px, 100%);
|
||||||
height: calc(100vh - 24px);
|
height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
gap: 10px;
|
gap: 18px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-strip {
|
.app-header,
|
||||||
|
.status-card,
|
||||||
|
.resource-panel,
|
||||||
|
.login-card,
|
||||||
|
.error {
|
||||||
|
background: linear-gradient(180deg, rgba(14, 24, 40, 0.88) 0%, rgba(11, 19, 33, 0.9) 100%);
|
||||||
|
border: 1px solid rgba(142, 174, 226, 0.13);
|
||||||
|
box-shadow: 0 28px 80px rgba(2, 8, 18, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 22px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 24px;
|
||||||
|
min-height: 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup {
|
.brand-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon-shell {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 45, 64, 0.92) 0%, rgba(11, 24, 36, 0.88) 100%);
|
||||||
|
border: 1px solid rgba(108, 211, 200, 0.14);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: #73dfc0;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text h1,
|
||||||
|
.panel-head h2,
|
||||||
|
.status-hero h2,
|
||||||
|
.panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: #9fb3d4;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.brand-lockup img {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0;
|
|
||||||
color: #75e3ba;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-copy h1,
|
|
||||||
.status-panel h3 {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-copy h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-copy p {
|
|
||||||
font-size: 0.98rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-copy p,
|
|
||||||
.hero-copy p,
|
|
||||||
.status-panel p,
|
|
||||||
.surface-header p,
|
|
||||||
.detail-card span,
|
|
||||||
.profile-card span {
|
|
||||||
margin: 0;
|
|
||||||
color: #9eb1d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-button,
|
.action-button {
|
||||||
.shell-button-secondary {
|
border: 1px solid transparent;
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 9px 15px;
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 160ms ease;
|
transition:
|
||||||
|
background-color 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
opacity 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-button {
|
.action-button:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%);
|
transform: translateY(-1px);
|
||||||
color: #04131a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-button-secondary {
|
.action-button:active:not(:disabled) {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
transform: translateY(0);
|
||||||
color: #eef4ff;
|
|
||||||
border: 1px solid rgba(177, 197, 229, 0.16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-button:disabled,
|
.action-button:disabled {
|
||||||
.shell-button-secondary:disabled {
|
opacity: 0.55;
|
||||||
opacity: 0.58;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface,
|
.action-button-primary {
|
||||||
.status-panel,
|
color: #04131a;
|
||||||
.login-panel {
|
background: linear-gradient(135deg, #73e0c7 0%, #30b89a 100%);
|
||||||
background: rgba(11, 20, 35, 0.78);
|
box-shadow: 0 12px 28px rgba(43, 188, 151, 0.18);
|
||||||
border: 1px solid rgba(177, 197, 229, 0.12);
|
}
|
||||||
box-shadow: 0 24px 70px rgba(2, 8, 18, 0.32);
|
|
||||||
backdrop-filter: blur(18px);
|
.action-button-secondary {
|
||||||
|
color: #eef6ff;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(176, 198, 228, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-secondary:hover:not(:disabled),
|
||||||
|
.action-button-ghost:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: rgba(176, 198, 228, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-ghost {
|
||||||
|
color: #c0d1ea;
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(176, 198, 228, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button-danger {
|
||||||
|
color: #f8fbff;
|
||||||
|
background: linear-gradient(135deg, #1f4a54 0%, #107a6e 100%);
|
||||||
|
border-color: rgba(117, 225, 204, 0.22);
|
||||||
|
box-shadow: 0 10px 28px rgba(17, 137, 121, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-grid {
|
.body-grid {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 250px;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 290px;
|
||||||
|
gap: 18px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-panel,
|
.main-column {
|
||||||
.status-panel {
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
display: grid;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-panel > p {
|
.status-card {
|
||||||
margin: 0;
|
min-height: 0;
|
||||||
color: #9eb1d1;
|
border-radius: 30px;
|
||||||
line-height: 1.5;
|
padding: 22px;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-top {
|
.status-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-state {
|
.status-hero h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-support {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #9db3d6;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 6px 10px;
|
padding: 10px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.045);
|
||||||
color: #c8d6ee;
|
color: #d7e3f5;
|
||||||
|
border: 1px solid rgba(176, 198, 228, 0.1);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-pill.is-online {
|
||||||
|
background: rgba(46, 188, 156, 0.12);
|
||||||
|
border-color: rgba(111, 226, 194, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pulse {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #ff8a7d;
|
background: #ff8e84;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 142, 132, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.online {
|
.status-pill.is-online .status-pulse {
|
||||||
background: #74e0b8;
|
background: #73dfc0;
|
||||||
|
animation: pulse 1.8s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface {
|
@keyframes pulse {
|
||||||
border-radius: 24px;
|
0% {
|
||||||
padding: 12px;
|
box-shadow: 0 0 0 0 rgba(115, 223, 192, 0.36);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(115, 223, 192, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(115, 223, 192, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-details {
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
border: 1px solid rgba(154, 181, 228, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-header {
|
.panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-header h4 {
|
.panel-head h2 {
|
||||||
margin: 0;
|
font-size: 1.3rem;
|
||||||
font-size: 0.95rem;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-grid,
|
.panel-head h3 {
|
||||||
.profile-grid {
|
font-size: 1rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head-compact {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
align-content: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card,
|
.stat-tile {
|
||||||
.profile-card {
|
min-height: 86px;
|
||||||
padding: 10px 12px;
|
padding: 14px;
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(177, 197, 229, 0.1);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
grid-template-columns: 34px 1fr;
|
||||||
min-height: 72px;
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
border: 1px solid rgba(154, 181, 228, 0.08);
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
background-color 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card strong,
|
.stat-tile:hover {
|
||||||
.profile-card strong {
|
transform: translateY(-1px);
|
||||||
font-size: 0.92rem;
|
border-color: rgba(118, 218, 200, 0.16);
|
||||||
word-break: break-word;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card span {
|
.stat-tile-icon {
|
||||||
font-size: 0.9rem;
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(180deg, rgba(30, 71, 88, 0.75) 0%, rgba(17, 31, 48, 0.72) 100%);
|
||||||
|
border: 1px solid rgba(111, 226, 194, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-stack {
|
.stat-tile-icon svg {
|
||||||
margin: 0;
|
width: 16px;
|
||||||
padding: 0;
|
height: 16px;
|
||||||
list-style: none;
|
fill: none;
|
||||||
|
stroke: #80e4cc;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tile-copy {
|
||||||
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-content: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-stack li {
|
.stat-tile-copy span {
|
||||||
padding: 10px 12px;
|
color: #8fa6ca;
|
||||||
border-radius: 14px;
|
font-size: 0.78rem;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
text-transform: uppercase;
|
||||||
border: 1px solid rgba(177, 197, 229, 0.1);
|
letter-spacing: 0.08em;
|
||||||
color: #eef4ff;
|
}
|
||||||
|
|
||||||
|
.stat-tile-copy strong {
|
||||||
|
color: #f4f8ff;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
min-height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.94rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-panel form {
|
.resource-panel,
|
||||||
|
.login-card {
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-panel label {
|
.resource-panel {
|
||||||
|
grid-template-rows: auto auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-count {
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #80e4cc;
|
||||||
|
background: rgba(23, 62, 71, 0.8);
|
||||||
|
border: 1px solid rgba(111, 226, 194, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(154, 181, 228, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-meta-label {
|
||||||
|
color: #8fa6ca;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
border: 1px solid rgba(154, 181, 228, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #73dfc0;
|
||||||
|
box-shadow: 0 0 0 4px rgba(115, 223, 192, 0.08);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #eff6ff;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-footer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-footer .action-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #c2cfe5;
|
color: #c2cfe5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-panel input {
|
.login-card input {
|
||||||
border: 1px solid rgba(177, 197, 229, 0.16);
|
border: 1px solid rgba(177, 197, 229, 0.16);
|
||||||
background: rgba(7, 14, 27, 0.9);
|
background: rgba(7, 14, 27, 0.9);
|
||||||
color: #f5f7fb;
|
color: #f5f7fb;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 15px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card-actions {
|
.login-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 115, 115, 0.08);
|
background: rgba(255, 115, 115, 0.08);
|
||||||
border: 1px solid rgba(255, 115, 115, 0.16);
|
border-color: rgba(255, 115, 115, 0.15);
|
||||||
color: #ffc3c3;
|
color: #ffc3c3;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 0.95rem;
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.client-shell {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: 100%;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 260px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.body-grid {
|
body {
|
||||||
grid-template-columns: 1fr;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-strip,
|
.client-shell {
|
||||||
.status-top,
|
height: auto;
|
||||||
.surface-header {
|
min-height: 100%;
|
||||||
align-items: flex-start;
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
height: auto;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header,
|
||||||
|
.status-hero,
|
||||||
|
.panel-head {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-grid,
|
.header-actions,
|
||||||
.profile-grid {
|
.resource-footer,
|
||||||
|
.login-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .action-button,
|
||||||
|
.resource-footer .action-button,
|
||||||
|
.login-actions .action-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-grid,
|
||||||
|
.stat-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user