feat: add windows client UI
This commit is contained in:
@@ -7,6 +7,7 @@ import { Sidebar } from './components/Sidebar.jsx';
|
||||
import { StatusPane } from './components/StatusPane.jsx';
|
||||
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||
import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';
|
||||
import { ServersPage } from './components/ServersPage.jsx';
|
||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||
import { LogsPage } from './components/LogsPage.jsx';
|
||||
@@ -97,6 +98,9 @@ function App() {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
if (state?.mode === 'windows' && (page === 'servers' || page === 'routing')) {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
@@ -381,6 +385,7 @@ function App() {
|
||||
[servers, state?.selectedTag],
|
||||
);
|
||||
const isClientMode = state?.mode === 'client';
|
||||
const isWindowsMode = state?.mode === 'windows';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
@@ -409,11 +414,14 @@ function App() {
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}${isWindowsMode ? ' windows-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<main className="app-main">
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
{page === 'overview' && isWindowsMode && (
|
||||
<WindowsOverviewPage pushToast={pushToast} />
|
||||
)}
|
||||
{(page === 'overview' || isClientMode) && !isWindowsMode && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
@@ -447,7 +455,7 @@ function App() {
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && !isClientMode && (
|
||||
{page === 'servers' && !isClientMode && !isWindowsMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
@@ -463,7 +471,7 @@ function App() {
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && !isClientMode && (
|
||||
{page === 'routing' && !isClientMode && !isWindowsMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
@@ -497,7 +505,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* Sticky bar — для routing/servers */}
|
||||
{(page === 'routing' && dirtyRouting) && (
|
||||
{(page === 'routing' && dirtyRouting && !isWindowsMode) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
@@ -522,7 +530,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(page === 'servers' && dirtyServer) && (
|
||||
{(page === 'servers' && dirtyServer && !isWindowsMode) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className="dot warning" />
|
||||
@@ -539,7 +547,7 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!isClientMode && (
|
||||
{!isClientMode && !isWindowsMode && (
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
|
||||
@@ -123,5 +123,40 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
windows: {
|
||||
status: () => request("/api/windows/status"),
|
||||
profiles: {
|
||||
get: () => request("/api/windows/profiles"),
|
||||
save: (profiles) =>
|
||||
request("/api/windows/profiles", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ profiles }),
|
||||
}),
|
||||
scan: (profiles) =>
|
||||
request("/api/windows/profiles/scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profiles }),
|
||||
}),
|
||||
apply: () =>
|
||||
request("/api/windows/profiles/apply", {
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
targets: {
|
||||
get: () => request("/api/windows/targets"),
|
||||
save: (targets) =>
|
||||
request("/api/windows/targets", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ targets }),
|
||||
}),
|
||||
},
|
||||
service: (service, action) =>
|
||||
request("/api/windows/service", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service, action }),
|
||||
}),
|
||||
logs: () => request("/api/windows/logs"),
|
||||
},
|
||||
|
||||
configValidate: () => request("/api/config/validate", { method: "POST" }),
|
||||
};
|
||||
|
||||
@@ -8,10 +8,18 @@ const NAV = [
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
const WINDOWS_NAV = [
|
||||
{ id: 'overview', label: 'Overview', ico: 'O' },
|
||||
{ id: 'logs', label: 'Logs', ico: 'L' },
|
||||
{ id: 'settings', label: 'Settings', ico: 'S' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
const items = mode === 'windows'
|
||||
? WINDOWS_NAV
|
||||
: mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
|
||||
@@ -26,17 +26,22 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
: null;
|
||||
|
||||
const isClient = state?.mode === 'client';
|
||||
const isWindows = state?.mode === 'windows';
|
||||
const brand = isWindows ? 'VPN Proxy Windows' : isClient ? 'VPN Client' : 'VPN Gateway';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<span className="logo-dot" />
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
{brand}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
<StatusBadge status={status} />
|
||||
{activeServer && (
|
||||
{isWindows && (
|
||||
<small className="muted">App profiles and ProxiFyre routing</small>
|
||||
)}
|
||||
{!isWindows && activeServer && (
|
||||
<div className="status-text">
|
||||
<strong>
|
||||
{flagFor(activeServer)} {activeServer.tag}
|
||||
@@ -47,29 +52,31 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{!activeServer && (
|
||||
{!isWindows && !activeServer && (
|
||||
<small className="muted">Сервер не выбран</small>
|
||||
)}
|
||||
{traffic && <span className="badge neutral">{traffic}</span>}
|
||||
{!isWindows && traffic && <span className="badge neutral">{traffic}</span>}
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{!isClient && dirty && (
|
||||
{!isClient && !isWindows && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{!isClient && state?.previousTag && (
|
||||
{!isClient && !isWindows && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
{!isWindows && (
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
261
src/web/components/WindowsOverviewPage.jsx
Normal file
261
src/web/components/WindowsOverviewPage.jsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
function targetLabel(target) {
|
||||
if (!target) return 'No proxy target';
|
||||
return `${target.name} - ${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
function routeTitle(status) {
|
||||
const helper = status?.helperStatus;
|
||||
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
|
||||
const singbox = helper?.result?.singbox || helper?.singbox;
|
||||
if (proxifyre === 'Running' && singbox === 'Running') return 'Apps are routed through local sing-box';
|
||||
if (proxifyre === 'Running') return 'Apps are routed through an existing proxy';
|
||||
return 'App routing is stopped';
|
||||
}
|
||||
|
||||
function routeState(status) {
|
||||
const helper = status?.helperStatus;
|
||||
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
|
||||
if (proxifyre === 'Running') return 'running';
|
||||
if (helper?.success === false) return 'error';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
function emptyProfile() {
|
||||
return {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: 'New profile',
|
||||
enabled: true,
|
||||
proxyTargetId: 'local-singbox',
|
||||
protocols: ['TCP', 'UDP'],
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ProfileList({ profiles, selectedId, onSelect }) {
|
||||
return (
|
||||
<div className="win-profile-list">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
className={`win-profile-row ${profile.id === selectedId ? 'active' : ''}`}
|
||||
onClick={() => onSelect(profile.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`win-profile-check ${profile.enabled ? 'on' : ''}`}>on</span>
|
||||
<span>
|
||||
<strong>{profile.name}</strong>
|
||||
<small>{profile.items.length} items - target: {profile.proxyTargetId}</small>
|
||||
</span>
|
||||
<em>{profile.resolvedCount ?? profile.items.length}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDetails({ profile, targets, onChange }) {
|
||||
const [newItem, setNewItem] = useState('');
|
||||
const [newType, setNewType] = useState('process');
|
||||
if (!profile) {
|
||||
return <div className="win-profile-empty">Select or add a profile.</div>;
|
||||
}
|
||||
function patch(patchValue) {
|
||||
onChange({ ...profile, ...patchValue });
|
||||
}
|
||||
function addItem() {
|
||||
const value = newItem.trim();
|
||||
if (!value) return;
|
||||
patch({
|
||||
items: [
|
||||
...profile.items,
|
||||
{ type: newType, value, recursive: newType === 'folder' },
|
||||
],
|
||||
});
|
||||
setNewItem('');
|
||||
}
|
||||
return (
|
||||
<div className="win-detail">
|
||||
<label className="checkbox win-enabled">
|
||||
<input
|
||||
checked={profile.enabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => patch({ enabled: event.target.checked })}
|
||||
/>
|
||||
Enabled profile
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={profile.name}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Proxy target</span>
|
||||
<select
|
||||
className="select"
|
||||
value={profile.proxyTargetId}
|
||||
onChange={(event) => patch({ proxyTargetId: event.target.value })}
|
||||
>
|
||||
{targets.map((target) => (
|
||||
<option key={target.id} value={target.id}>{targetLabel(target)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="win-add-item">
|
||||
<select className="select" value={newType} onChange={(event) => setNewType(event.target.value)}>
|
||||
<option value="process">Process</option>
|
||||
<option value="folder">Folder</option>
|
||||
<option value="exe">EXE file</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
value={newItem}
|
||||
placeholder="Discord, %LOCALAPPDATA%\\vesktop, or C:\\Games\\game.exe"
|
||||
onChange={(event) => setNewItem(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && addItem()}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={addItem} type="button">Add</button>
|
||||
</div>
|
||||
<div className="win-items">
|
||||
{profile.items.map((item, index) => (
|
||||
<div key={`${item.type}-${item.value}-${index}`} className="win-item">
|
||||
<span>{item.value}</span>
|
||||
<small>{item.type}</small>
|
||||
<button
|
||||
className="btn btn-link sm"
|
||||
onClick={() => patch({ items: profile.items.filter((_, i) => i !== index) })}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsOverviewPage({ pushToast }) {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [targets, setTargets] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const data = await api.windows.status();
|
||||
setStatus(data);
|
||||
const nextProfiles = data.profiles || [];
|
||||
setProfiles(nextProfiles);
|
||||
setTargets(data.targets || []);
|
||||
setSelectedId((current) => current || nextProfiles[0]?.id || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((error) => pushToast?.({ kind: 'danger', title: 'Windows status failed', message: error.message }));
|
||||
const timer = setInterval(() => load().catch(() => {}), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const selected = useMemo(
|
||||
() => profiles.find((profile) => profile.id === selectedId) || null,
|
||||
[profiles, selectedId],
|
||||
);
|
||||
|
||||
function replaceProfile(nextProfile) {
|
||||
setProfiles((prev) => prev.map((profile) => profile.id === nextProfile.id ? nextProfile : profile));
|
||||
}
|
||||
|
||||
async function saveProfiles(nextProfiles = profiles) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.windows.profiles.save(nextProfiles);
|
||||
setProfiles(data.summaries || data.profiles || []);
|
||||
pushToast?.({ kind: 'success', title: 'Profiles saved' });
|
||||
} catch (error) {
|
||||
pushToast?.({ kind: 'danger', title: 'Save failed', message: error.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProfiles() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.windows.profiles.save(profiles);
|
||||
await api.windows.profiles.apply();
|
||||
await load();
|
||||
pushToast?.({ kind: 'success', title: 'ProxiFyre updated' });
|
||||
} catch (error) {
|
||||
pushToast?.({ kind: 'danger', title: 'Apply failed', message: error.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addProfile() {
|
||||
const profile = emptyProfile();
|
||||
setProfiles((prev) => [...prev, profile]);
|
||||
setSelectedId(profile.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="windows-page">
|
||||
<section className="windows-status-panel">
|
||||
<div className="windows-status-main">
|
||||
<span className={`windows-status-dot ${routeState(status)}`} />
|
||||
<div>
|
||||
<h1>{routeTitle(status)}</h1>
|
||||
<p>Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="windows-route-line">
|
||||
<span>Selected apps</span><b>-></b><span>ProxiFyre</span><b>-></b><span>Proxy target</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="windows-workspace">
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>Profiles</h2>
|
||||
<small>{profiles.filter((profile) => profile.enabled).length} enabled</small>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={addProfile} type="button">Add profile</button>
|
||||
</div>
|
||||
<ProfileList profiles={profiles} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>{selected?.name || 'Profile'}</h2>
|
||||
<small>{selected ? targetLabel(targets.find((target) => target.id === selected.proxyTargetId)) : 'No selection'}</small>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={applyProfiles} type="button">Apply changes</button>
|
||||
</div>
|
||||
<ProfileDetails profile={selected} targets={targets} onChange={replaceProfile} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel windows-activity">
|
||||
<div className="panel-head">
|
||||
<h2>Recent activity</h2>
|
||||
<button className="btn btn-secondary" disabled={busy} onClick={() => saveProfiles()} type="button">Save only</button>
|
||||
</div>
|
||||
{(status?.activity || []).slice(0, 5).map((entry) => (
|
||||
<div key={entry.id} className="windows-activity-row">
|
||||
<strong>{entry.type}</strong>
|
||||
<span>{entry.message}</span>
|
||||
<small>{entry.ts}</small>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1096,6 +1096,294 @@ code, .mono {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ Windows overview ============ */
|
||||
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.windows-mode .app-main {
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.windows-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.windows-page .panel {
|
||||
background: #101820;
|
||||
border: 1px solid #263442;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.windows-page .panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.windows-page .panel-head h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.windows-status-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #101820;
|
||||
border: 1px solid #263442;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.windows-status-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.windows-status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--subtle);
|
||||
box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12);
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
|
||||
.windows-status-dot.running {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12);
|
||||
}
|
||||
|
||||
.windows-status-dot.stopped {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12);
|
||||
}
|
||||
|
||||
.windows-status-dot.error {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 6px rgba(255, 92, 92, 0.12);
|
||||
}
|
||||
|
||||
.windows-status-panel h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.windows-status-panel p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.windows-route-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.windows-route-line span {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.windows-route-line b {
|
||||
color: var(--subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.windows-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(420px, 1.2fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.win-profile-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.win-profile-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.win-profile-row:hover {
|
||||
border-color: #4c6d88;
|
||||
}
|
||||
|
||||
.win-profile-row.active {
|
||||
border-color: var(--info);
|
||||
background: rgba(142, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.win-profile-row strong,
|
||||
.win-profile-row small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.win-profile-row small,
|
||||
.win-profile-row em {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.win-profile-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #172536;
|
||||
color: var(--subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.win-profile-check.on {
|
||||
background: rgba(109, 255, 157, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.win-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.win-detail label:not(.checkbox) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.win-enabled {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.win-add-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.win-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.win-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 8px 10px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.win-item span {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.win-item small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.win-profile-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.windows-activity {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.windows-activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #253341;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.windows-activity-row strong {
|
||||
color: var(--info);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.windows-activity-row span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.windows-status-panel,
|
||||
.windows-workspace,
|
||||
.windows-activity-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.win-add-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* For drawer rule editor */
|
||||
.field-row {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user