Add Mac client mode and simplify local proxy UI
This commit is contained in:
@@ -6,6 +6,7 @@ import { Topbar } from './components/Topbar.jsx';
|
||||
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 { ServersPage } from './components/ServersPage.jsx';
|
||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||
import { LogsPage } from './components/LogsPage.jsx';
|
||||
@@ -87,6 +88,12 @@ function App() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
@@ -352,6 +359,7 @@ function App() {
|
||||
() => servers.find((s) => s.tag === state?.selectedTag) || null,
|
||||
[servers, state?.selectedTag],
|
||||
);
|
||||
const isClientMode = state?.mode === 'client';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
@@ -380,24 +388,42 @@ function App() {
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
|
||||
<div className="app-body">
|
||||
<Sidebar active={page} onChange={navigate} badges={sidebarBadges} />
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<main className="app-main">
|
||||
{page === 'overview' && (
|
||||
<OverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
busy={busy}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onNav={navigate}
|
||||
onBypassToggle={toggleBypass}
|
||||
onFlushDirectCache={flushDirectCache}
|
||||
/>
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
busy={busy}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
/>
|
||||
) : (
|
||||
<OverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
busy={busy}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onNav={navigate}
|
||||
onBypassToggle={toggleBypass}
|
||||
onFlushDirectCache={flushDirectCache}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && (
|
||||
{page === 'servers' && !isClientMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
@@ -413,7 +439,7 @@ function App() {
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && (
|
||||
{page === 'routing' && !isClientMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
@@ -431,8 +457,8 @@ function App() {
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && (
|
||||
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && !isClientMode && (
|
||||
<SettingsPage
|
||||
state={state}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
@@ -489,13 +515,15 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
/>
|
||||
{!isClientMode && (
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||
|
||||
256
src/web/components/ClientOverviewPage.jsx
Normal file
256
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
|
||||
function CopyField({ label, value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="copy-field">
|
||||
<div>
|
||||
<small className="muted">{label}</small>
|
||||
<div className="text-mono">{value}</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary sm" onClick={copy}>
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHero({ state, status, activeServer }) {
|
||||
const cfg = {
|
||||
running: {
|
||||
title: 'Локальный proxy работает',
|
||||
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
|
||||
badge: 'Готов',
|
||||
kind: 'success',
|
||||
},
|
||||
applying: {
|
||||
title: 'Применяем сервер',
|
||||
hint: 'sing-box перезапускается',
|
||||
badge: 'Применяем',
|
||||
kind: 'warning',
|
||||
},
|
||||
error: {
|
||||
title: 'Нужна проверка',
|
||||
hint: 'Откройте логи и config',
|
||||
badge: 'Ошибка',
|
||||
kind: 'danger',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Proxy остановлен',
|
||||
hint: 'Конфиг есть, sing-box не запущен',
|
||||
badge: 'Остановлен',
|
||||
kind: 'warning',
|
||||
},
|
||||
no_config: {
|
||||
title: 'Proxy ещё не настроен',
|
||||
hint: 'Загрузите подписку и выберите сервер',
|
||||
badge: 'Не настроен',
|
||||
kind: 'neutral',
|
||||
},
|
||||
}[status] || {};
|
||||
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||
: 'нет данных';
|
||||
|
||||
return (
|
||||
<section className="client-hero">
|
||||
<div className="client-hero-main">
|
||||
<span className={`badge ${cfg.kind}`}>{cfg.badge}</span>
|
||||
<h1>{cfg.title}</h1>
|
||||
<p>{cfg.hint}</p>
|
||||
</div>
|
||||
<div className="client-hero-meta">
|
||||
<div>
|
||||
<small className="muted">Активный сервер</small>
|
||||
<strong>{activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Трафик</small>
|
||||
<strong>{traffic}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Применено</small>
|
||||
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSetup({
|
||||
state,
|
||||
servers,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
}) {
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const canApply = selected && selected !== state?.selectedTag;
|
||||
|
||||
return (
|
||||
<div className="card client-setup">
|
||||
<div className="card-header">
|
||||
<h2>Подключение</h2>
|
||||
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">URL подписки или VLESS-ссылка</label>
|
||||
<div className="subscription-input">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://… или vless://…"
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">VPN-сервер</label>
|
||||
<div className="subscription-input">
|
||||
<select
|
||||
className="select"
|
||||
value={selected}
|
||||
disabled={!servers.length}
|
||||
onChange={(e) => setPendingTag(e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.tag} value={server.tag}>
|
||||
{flagFor(server)} {server.tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
<small className="field-hint">
|
||||
В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyCard({ state }) {
|
||||
const port = state?.proxyPort || 8080;
|
||||
const urls = useMemo(() => ({
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
socks: `socks5://127.0.0.1:${port}`,
|
||||
}), [port]);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Локальный proxy</h2>
|
||||
<span className="badge info">127.0.0.1:{port}</span>
|
||||
</div>
|
||||
<div className="copy-stack">
|
||||
<CopyField label="HTTP / HTTPS" value={urls.http} />
|
||||
<CopyField label="SOCKS5" value={urls.socks} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientFlow({ state, activeServer }) {
|
||||
const steps = [
|
||||
{ label: 'Mac', value: 'приложения' },
|
||||
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
||||
{ label: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' },
|
||||
{ label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Цепочка подключения</h2></div>
|
||||
<div className="client-flow">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.label}>
|
||||
<div className="flow-node">
|
||||
<small>{step.label}</small>
|
||||
<strong>{step.value}</strong>
|
||||
</div>
|
||||
{index < steps.length - 1 && <span className="flow-arrow">→</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientActions({ state, busy, onRestart, onStop }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Управление</h2></div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
|
||||
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
status,
|
||||
activeServer,
|
||||
busy,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
}) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<ClientHero state={state} status={status} activeServer={activeServer} />
|
||||
<ClientSetup
|
||||
state={state}
|
||||
servers={servers}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
busy={busy}
|
||||
onFetchSubscription={onFetchSubscription}
|
||||
onApply={onApply}
|
||||
/>
|
||||
<div className="grid-2">
|
||||
<ProxyCard state={state} />
|
||||
<ClientActions
|
||||
state={state}
|
||||
busy={busy}
|
||||
onRestart={onRestart}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
<ClientFlow state={state} activeServer={activeServer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -741,18 +741,16 @@ function RuleSetsCard({ pushToast }) {
|
||||
}
|
||||
|
||||
function PortsCard({ state }) {
|
||||
const isClient = state?.mode === 'client';
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Порты и маршруты</h2></div>
|
||||
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
|
||||
<div className="row"><span className="key">Mixed proxy (http+socks5)</span><span className="val text-mono">{state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
|
||||
<div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>
|
||||
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
|
||||
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
|
||||
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginTop: 10 }}>
|
||||
Эти параметры задаются в config.js на сервере.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ const NAV = [
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {} }) {
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
{NAV.map((item) => {
|
||||
{items.map((item) => {
|
||||
const badge = badges[item.id];
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -25,11 +25,13 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||
: null;
|
||||
|
||||
const isClient = state?.mode === 'client';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<span className="logo-dot" />
|
||||
VPN Gateway
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
@@ -52,10 +54,10 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{dirty && (
|
||||
{!isClient && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{state?.previousTag && (
|
||||
{!isClient && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
@@ -66,7 +68,7 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Restart
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -116,6 +116,9 @@ code, .mono {
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
|
||||
min-height: 0;
|
||||
}
|
||||
.app-body.client-mode {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: var(--space-6);
|
||||
@@ -129,6 +132,7 @@ code, .mono {
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-body { grid-template-columns: 1fr; }
|
||||
.app-body.client-mode { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.app-main { padding: var(--space-4); }
|
||||
}
|
||||
@@ -821,6 +825,124 @@ code, .mono {
|
||||
}
|
||||
.subscription-input .input { flex: 1; }
|
||||
|
||||
/* ============ Client overview ============ */
|
||||
|
||||
.client-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
|
||||
gap: var(--space-4);
|
||||
align-items: stretch;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.client-mode .app-main {
|
||||
max-width: 1120px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.client-hero-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.client-hero-main h1 {
|
||||
font-size: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.client-hero-main p {
|
||||
color: var(--muted);
|
||||
}
|
||||
.client-hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.client-hero-meta {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-content: center;
|
||||
}
|
||||
.client-hero-meta > div {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.client-hero-meta strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.copy-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.client-setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.copy-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.copy-field .text-mono {
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.client-flow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
.flow-node {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
.flow-node strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.flow-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.client-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.client-flow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.flow-arrow {
|
||||
justify-content: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.copy-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* For drawer rule editor */
|
||||
.field-row {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user