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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user