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:
2026-03-18 11:42:34 +01:00
parent 74d8fc28cc
commit 1ddcbf0b14
7 changed files with 681 additions and 265 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}