mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 16:24:25 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,698 @@
|
||||
/*
|
||||
Copyright (c) 2026 Ivan Vasilev
|
||||
This source code is licensed under the MIT license found in the
|
||||
LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
:root {
|
||||
--primary: #6a45b8;
|
||||
--on-primary: #ffffff;
|
||||
--primary-container: #ebddff;
|
||||
--on-primary-container: #5a2da8;
|
||||
|
||||
--surface: #faf6f0;
|
||||
--surface-bright: #fdfaf6;
|
||||
--surface-container: #f0e8dd;
|
||||
--surface-hi: #ece2d3;
|
||||
--on-surface: #3a2e22;
|
||||
|
||||
--outline: #d8cdbe;
|
||||
--outline-variant: #ece2d3;
|
||||
|
||||
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
|
||||
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
||||
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
||||
|
||||
--danger-border: #e6c4bc;
|
||||
--danger-text: #b34a3a;
|
||||
|
||||
/* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */
|
||||
--label: #5a4a3a;
|
||||
|
||||
/* state layers / тени — зависят от темы */
|
||||
--state-primary: rgba(106, 69, 184, .10);
|
||||
--state-row: rgba(106, 69, 184, .035);
|
||||
--state-neutral: rgba(58, 46, 34, .08);
|
||||
--scrim: rgba(58, 46, 34, .45);
|
||||
--shadow: rgba(58, 46, 34, .15);
|
||||
|
||||
--font: system-ui, -apple-system, 'Inter', sans-serif;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
|
||||
--r-pill: 100px;
|
||||
--r-card: 24px;
|
||||
--r-table: 16px;
|
||||
--r-logo: 9px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--primary: #cbb3ff;
|
||||
--on-primary: #381b6c;
|
||||
--primary-container: #52389e;
|
||||
--on-primary-container: #ebddff;
|
||||
|
||||
--surface: #1a1611;
|
||||
--surface-bright: #2b251d;
|
||||
--surface-container: #251f18;
|
||||
--surface-hi: #302820;
|
||||
--on-surface: #ede2d4;
|
||||
|
||||
--outline: #4d4334;
|
||||
--outline-variant: #342c20;
|
||||
|
||||
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
|
||||
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
||||
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
||||
|
||||
--danger-border: #6e4540;
|
||||
--danger-text: #ffb4ab;
|
||||
|
||||
--label: #b6a994;
|
||||
|
||||
--state-primary: rgba(203, 179, 255, .14);
|
||||
--state-row: rgba(203, 179, 255, .05);
|
||||
--state-neutral: rgba(237, 226, 212, .08);
|
||||
--scrim: rgba(0, 0, 0, .55);
|
||||
--shadow: rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; }
|
||||
|
||||
.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
.login-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
}
|
||||
.login-card {
|
||||
width: 352px;
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-card);
|
||||
border: 1px solid var(--outline-variant);
|
||||
padding: 36px 32px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.login-sub {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-error {
|
||||
font-size: 13px;
|
||||
color: var(--danger-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px solid var(--outline);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-bright);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.field:focus { border-color: var(--primary); }
|
||||
.field::placeholder { color: var(--neutral-dot); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--r-pill);
|
||||
border: none;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn:hover { opacity: 0.88; }
|
||||
.btn:active { opacity: 0.74; }
|
||||
|
||||
.btn--primary { background: var(--primary); color: var(--on-primary); }
|
||||
.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; }
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--outline);
|
||||
color: var(--primary);
|
||||
}
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--danger-border);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
.btn--full { width: 100%; }
|
||||
.btn--sq { width: 44px; padding: 0; flex-shrink: 0; }
|
||||
|
||||
/* icon-only circular button */
|
||||
.btn-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
transition: background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { background: var(--state-primary); }
|
||||
.btn-icon--danger { color: var(--danger-text); }
|
||||
.btn-icon--danger:hover { background: rgba(179,74,58,.10); }
|
||||
.btn-icon--neutral { color: var(--neutral-text); }
|
||||
.btn-icon--neutral:hover { background: var(--state-neutral); }
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 250;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--outline);
|
||||
background: var(--surface-bright);
|
||||
color: var(--on-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
/* Перезапуск AWG — pill-кнопка с подписью в правом верхнем углу, слева от темы */
|
||||
.awg-restart {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 64px;
|
||||
z-index: 250;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
gap: 6px;
|
||||
border-radius: 100px;
|
||||
border: 1.5px solid var(--outline);
|
||||
background: var(--surface-bright);
|
||||
color: var(--on-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.awg-restart:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
|
||||
.awg-restart:disabled { opacity: 0.5; cursor: default; }
|
||||
.awg-restart.spinning svg { animation: awg-spin 0.8s linear infinite; }
|
||||
@keyframes awg-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Мобильная кнопка перезапуска (в сервер-баре) — на десктопе скрыта */
|
||||
.awg-restart-mobile { display: none; }
|
||||
.awg-restart-mobile.spinning svg { animation: awg-spin 0.8s linear infinite; }
|
||||
|
||||
.top-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 56px;
|
||||
padding: 0 8px 0 4px;
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.hamburger-btn {
|
||||
width: 48px; height: 48px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--on-surface);
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hamburger-btn:active { background: var(--state-neutral); }
|
||||
.top-bar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.drawer-scrim {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--scrim);
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
.drawer-scrim--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-container);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 12px;
|
||||
gap: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 0 8px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.drawer-divider {
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--outline);
|
||||
margin: 0 -12px 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--label);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-item--active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; }
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip--online { background: var(--success-bg); color: var(--success-text); }
|
||||
.chip--online .chip-dot { background: var(--success-dot); }
|
||||
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||
.chip--offline .chip-dot { background: var(--neutral-dot); }
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 32px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.server-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.server-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px 8px 10px;
|
||||
border-radius: var(--r-card);
|
||||
border: 1.5px solid var(--outline-variant);
|
||||
background: var(--surface-bright);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.server-card--active { border-color: var(--primary-container); }
|
||||
.server-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.server-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.server-card-ip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--neutral-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.server-card-peers {
|
||||
font-size: 12px;
|
||||
color: var(--neutral-text);
|
||||
background: var(--surface-container);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Переключатель сервера — скрыт на десктопе, показывается на мобильном */
|
||||
.server-toggle {
|
||||
display: none;
|
||||
position: relative;
|
||||
width: 36px; height: 20px;
|
||||
background: var(--neutral-bg);
|
||||
border-radius: var(--r-pill);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.server-toggle--on { background: var(--success-bg); }
|
||||
.server-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 3px; left: 3px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--neutral-dot);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.server-toggle--on .server-toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
background: var(--success-dot);
|
||||
}
|
||||
|
||||
/* Чип онлайн/офлайн на карточке сервера — виден на десктопе */
|
||||
.server-online-chip { display: inline-flex; }
|
||||
|
||||
/* Tooltip */
|
||||
.tip-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.tip-wrap::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--on-surface);
|
||||
color: var(--surface-bright);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tip-wrap:hover::after { opacity: 1; }
|
||||
|
||||
.server-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1.5px dashed var(--outline);
|
||||
background: transparent;
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
color: var(--neutral-dot);
|
||||
cursor: not-allowed;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar .field {
|
||||
width: 224px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* На десктопе кнопки участвуют в flex toolbar'а напрямую */
|
||||
.toolbar-btns { display: contents; }
|
||||
|
||||
.table-card {
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-table);
|
||||
border: 1px solid var(--outline-variant);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: var(--surface-container);
|
||||
color: var(--label);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
thead th:first-child { padding-left: 20px; }
|
||||
thead th:last-child { padding-right: 20px; }
|
||||
|
||||
tbody tr { border-top: 0.5px solid var(--outline-variant); }
|
||||
tbody tr:hover { background: var(--state-row); }
|
||||
|
||||
tbody td {
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody td:first-child { padding-left: 20px; }
|
||||
tbody td:last-child { padding-right: 20px; }
|
||||
|
||||
.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; }
|
||||
.td-mono { font-family: var(--mono); font-size: 12px; }
|
||||
.td-actions { width: 96px; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 2px; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--neutral-text);
|
||||
padding: 48px 0 !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Карточки/метрики вкладки «Пользователи» → tabs/users/users.css */
|
||||
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: scale(.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.qr-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--scrim);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 300;
|
||||
animation: backdrop-in 0.18s ease;
|
||||
}
|
||||
.qr-card {
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-card);
|
||||
border: 1px solid var(--outline-variant);
|
||||
padding: 28px 28px 24px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
width: 320px;
|
||||
animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1);
|
||||
}
|
||||
.qr-name { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.qr-img {
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, box-shadow 0.35s ease;
|
||||
box-shadow: 0 0 0 0px transparent;
|
||||
width: 280px; height: 280px;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
.qr-img:hover { opacity: 0.88; }
|
||||
.qr-img:active {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 3px var(--primary-container), 0 0 0 5.5px var(--primary);
|
||||
transition: opacity 0.1s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.qr-hint { font-size: 12px; color: var(--neutral-text); text-align: center; }
|
||||
.qr-actions { display: flex; gap: 8px; width: 100%; }
|
||||
.qr-actions .btn { flex: 1; }
|
||||
|
||||
/* Открытый API-ключ → tabs/apikeys/apikeys.css */
|
||||
|
||||
.snack {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--on-surface);
|
||||
color: var(--surface-bright);
|
||||
font-size: 13px;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--r-pill);
|
||||
z-index: 500;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 16px var(--shadow);
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
font-size: 11px;
|
||||
color: var(--neutral-dot);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Top bar */
|
||||
.top-bar { display: flex; }
|
||||
.page-title { display: none; }
|
||||
|
||||
/* Drawer scrim */
|
||||
.drawer-scrim { display: block; }
|
||||
|
||||
/* Запретить горизонтальный скролл страницы */
|
||||
body, .app { overflow-x: hidden; }
|
||||
|
||||
/* Sidebar → модальный drawer */
|
||||
.layout {
|
||||
min-height: 0; /* flex: 1 на .app справится сам */
|
||||
overflow: visible;
|
||||
}
|
||||
/* main — не скролл-контейнер, пусть скроллит body */
|
||||
.main { overflow: visible; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 280px;
|
||||
z-index: 400;
|
||||
border-radius: 0 var(--r-card) var(--r-card) 0;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1);
|
||||
box-shadow: 4px 0 24px var(--shadow);
|
||||
padding-top: 36px;
|
||||
}
|
||||
.sidebar--open { transform: translateX(0); }
|
||||
|
||||
/* Main: одна колонка, поля 16px */
|
||||
.main {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Server bar → вертикально */
|
||||
.server-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.server-card {
|
||||
width: 100%;
|
||||
border-width: 2px;
|
||||
border-color: var(--primary-container);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.server-toggle { display: flex; }
|
||||
.server-online-chip { display: none; }
|
||||
|
||||
/* Кнопка «Добавить сервер» — полная ширина */
|
||||
.tip-wrap { width: 100%; }
|
||||
.server-add { width: 100%; justify-content: center; }
|
||||
|
||||
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */
|
||||
.awg-restart { display: none; }
|
||||
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
|
||||
|
||||
/* Toolbar → вертикально */
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar .field { width: 100%; }
|
||||
.toolbar-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-btns .btn { flex: 1; }
|
||||
|
||||
/* Таблица скрывается; карточки показываются в tabs/users/users.css */
|
||||
.table-card { display: none; }
|
||||
|
||||
/* QR модалка — по ширине экрана */
|
||||
.qr-card {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 360px;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
.qr-img { width: 100%; height: auto; aspect-ratio: 1; }
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||
import ServerBar from './components/ServerBar';
|
||||
import { TABS } from './tabs';
|
||||
|
||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||
// из реестра TABS — App про конкретные вкладки ничего не знает.
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
|
||||
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
|
||||
const touchStartX = useRef(0);
|
||||
const touchCurrentX = useRef(0);
|
||||
|
||||
const showMsg = useCallback((text: string) => {
|
||||
setMsg(text);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}, []);
|
||||
|
||||
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
|
||||
// вкладок (юзеры, ключи) грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 });
|
||||
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
|
||||
const tok = auth.token as string;
|
||||
await startSession(tok);
|
||||
localStorage.setItem(TOKEN_KEY, tok);
|
||||
setToken(tok);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
||||
showMsg('Неверный логин или пароль');
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 429) {
|
||||
showMsg(e.response.data?.error ?? 'Слишком много попыток');
|
||||
} else {
|
||||
setStatusText('offline:Недоступен');
|
||||
}
|
||||
}
|
||||
}, [loginUser, loginPass, startSession, showMsg]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await axios.post('/logout', null, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch { /* токен уже мог истечь */ }
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setStatusText('');
|
||||
setServerInfo(null);
|
||||
setDrawerOpen(false);
|
||||
}, [token]);
|
||||
|
||||
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
|
||||
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
|
||||
const restartAwg = useCallback(async () => {
|
||||
if (restarting) return;
|
||||
if (!confirm('Перезапустить AWG? Соединения клиентов кратковременно прервутся.')) return;
|
||||
setRestarting(true);
|
||||
try {
|
||||
await apiFetch('POST', '/awg/restart', token);
|
||||
showMsg('AWG перезапущен');
|
||||
} catch {
|
||||
showMsg('Не удалось перезапустить AWG');
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [restarting, token, showMsg]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// Восстановить сессию из сохранённого токена
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY);
|
||||
if (saved) {
|
||||
startSession(saved).catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
});
|
||||
}
|
||||
}, [startSession]);
|
||||
|
||||
// Применять и сохранять тему
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
// Блокировать прокрутку фона когда drawer открыт
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = drawerOpen ? 'hidden' : '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [drawerOpen]);
|
||||
|
||||
// Закрывать drawer по Escape
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); };
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [drawerOpen]);
|
||||
|
||||
const isOnline = statusText.startsWith('online:');
|
||||
const statusLabel = statusText.replace(/^(online|offline):/, '');
|
||||
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
|
||||
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
|
||||
const chrome = tab.chrome ?? {};
|
||||
const showServerBar = chrome.serverBar !== false;
|
||||
const showRestart = chrome.restart !== false;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<button
|
||||
className="theme-toggle"
|
||||
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
|
||||
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
|
||||
>
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
|
||||
{token && showRestart && (
|
||||
<button
|
||||
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
||||
aria-label="Перезапустить AWG"
|
||||
title="Перезапустить AWG-интерфейс"
|
||||
onClick={restartAwg}
|
||||
disabled={restarting}
|
||||
>
|
||||
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!token ? (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<span className="logo-name">Forgetting</span>
|
||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Логин"
|
||||
value={loginUser}
|
||||
onChange={e => setLoginUser(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={loginPass}
|
||||
onChange={e => setLoginPass(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && login()}
|
||||
/>
|
||||
<button className="btn btn--primary btn--full" onClick={login}>
|
||||
Войти
|
||||
</button>
|
||||
{statusText.startsWith('offline:') && (
|
||||
<p className="login-error">{statusLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Top bar — видим только на мобильном */}
|
||||
<header className="top-bar">
|
||||
<button
|
||||
className="hamburger-btn"
|
||||
aria-label="Открыть меню"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<IcoMenu />
|
||||
</button>
|
||||
<span className="top-bar-title">{tab.label}</span>
|
||||
</header>
|
||||
|
||||
{/* Затемнение под drawer */}
|
||||
<div
|
||||
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="layout">
|
||||
<aside
|
||||
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="sidebar-logo">
|
||||
<span className="logo-name">Forgetting</span>
|
||||
<span className="sidebar-version">Alpha 0.1.3.1</span>
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{TABS.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<button className="btn btn--danger" onClick={logout}>
|
||||
<IcoLogout /> Выйти
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<h2 className="page-title">{tab.label}</h2>
|
||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
||||
{showServerBar && (
|
||||
<ServerBar
|
||||
serverInfo={serverInfo}
|
||||
serverOnline={isOnline}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showAddServer={chrome.addServer !== false}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
)}
|
||||
<tab.Page token={token} showMsg={showMsg} />
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
import { type ServerInfo } from '../lib/shared';
|
||||
import { IcoPlus, IcoRefresh } from './icons';
|
||||
|
||||
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
|
||||
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает)
|
||||
// сервер, к которому относится содержимое вкладки (пиры / API-ключи).
|
||||
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
|
||||
// станет несколько — карточки станут кликабельными, активная = выбранная.
|
||||
export default function ServerBar({
|
||||
serverInfo, serverOnline, onRestartAwg, restarting,
|
||||
showAddServer = true, showRestart = true,
|
||||
}: {
|
||||
serverInfo: ServerInfo | null;
|
||||
serverOnline: boolean;
|
||||
onRestartAwg: () => void;
|
||||
restarting: boolean;
|
||||
showAddServer?: boolean;
|
||||
showRestart?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="server-bar">
|
||||
{serverInfo && (
|
||||
<div className="server-card server-card--active">
|
||||
{/* toggle — виден на мобильном вместо чипа */}
|
||||
<div
|
||||
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="server-toggle-thumb" />
|
||||
</div>
|
||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
||||
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<div className="server-card-info">
|
||||
<span className="server-card-name">{serverInfo.name}</span>
|
||||
<span className="server-card-ip">{serverInfo.ip}</span>
|
||||
</div>
|
||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||
</div>
|
||||
)}
|
||||
{showAddServer && (
|
||||
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
|
||||
<button className="server-add" disabled>
|
||||
<IcoPlus /> Добавить сервер
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
||||
{showRestart && (
|
||||
<button
|
||||
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
|
||||
onClick={onRestartAwg}
|
||||
disabled={restarting}
|
||||
>
|
||||
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
|
||||
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
|
||||
|
||||
export const IcoMenu = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoUsers = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoKey = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5"/>
|
||||
<path d="M10.7 12.3 21 2"/><path d="M16 7l3 3"/><path d="M18 5l2 2"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoPlus = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoRefresh = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoQR = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<path d="M14 14h1v1h-1zM18 14h1v1h-1zM14 18h1v1h-1zM18 18h1v1h-1zM16 16h1v1h-1z"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoTrash = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoLogout = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoGlobe = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoCopy = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoSun = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoMoon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
|
||||
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
users: IcoUsers,
|
||||
key: IcoKey,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// Общие типы, константы и утилиты для всех вкладок панели.
|
||||
import axios from 'axios';
|
||||
export interface User {
|
||||
name: string;
|
||||
ip: string;
|
||||
pub_key: string;
|
||||
vpn_key: string;
|
||||
online: boolean;
|
||||
lastHandshake: number;
|
||||
rx?: number;
|
||||
tx?: number;
|
||||
}
|
||||
export interface ServerInfo {
|
||||
name: string;
|
||||
ip: string;
|
||||
peers: number;
|
||||
}
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
label: string;
|
||||
prefix: string;
|
||||
server_id: number;
|
||||
created_at: number;
|
||||
last_used: number | null;
|
||||
}
|
||||
export interface PageProps {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
}
|
||||
export const TOKEN_KEY = 'awg_token';
|
||||
export const THEME_KEY = 'awg_theme';
|
||||
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
||||
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function vpnKeyToConf(vpnKey: string): Promise<string> {
|
||||
const b64 = vpnKey.replace('vpn://', '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||
const stream = new Blob([raw.slice(4)]).stream().pipeThrough(new DecompressionStream('deflate'));
|
||||
const json = await new Response(stream).text();
|
||||
const data = JSON.parse(json);
|
||||
const awg = data.containers[0].awg;
|
||||
try {
|
||||
const lc = JSON.parse(awg.last_config || '{}');
|
||||
return (lc.config || '')
|
||||
.replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1')
|
||||
.replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1');
|
||||
} catch {
|
||||
return (awg.last_config || '')
|
||||
.replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1')
|
||||
.replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1');
|
||||
}
|
||||
}
|
||||
export function downloadFile(filename: string, text: string) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
export async function copyText(text: string) {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
export function bytes(n: number | undefined): string {
|
||||
if (!n) return '—';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
|
||||
return (n / 1_073_741_824).toFixed(2) + ' GB';
|
||||
}
|
||||
export function timeAgo(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const sec = Math.floor(Date.now() / 1000) - ts;
|
||||
if (sec < 60) return sec + ' сек';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
|
||||
return Math.floor(sec / 3600) + ' ч';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
|
||||
|
||||
/* Открытый API-ключ (показывается один раз при создании) */
|
||||
.apikey-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.apikey-count { font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-empty {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.apikey-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.apikey-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.apikey-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.apikey-card-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.apikey-card-prefix {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--neutral-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.apikey-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.apikey-cards-list { display: flex; }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||
import './apikeys.css';
|
||||
|
||||
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
||||
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
|
||||
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
|
||||
// серверов — ключ будет привязываться к выбранному в сервер-баре.
|
||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/ui/apikeys', token);
|
||||
setKeys(data.keys ?? []);
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки ключей');
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
await load();
|
||||
}, [label, token, load, showMsg]);
|
||||
|
||||
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
||||
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
||||
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
|
||||
showMsg('Ключ удалён: ' + lbl);
|
||||
await load();
|
||||
}, [token, load, showMsg]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
|
||||
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Метка ключа (напр. ci-bot)"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createKey()}
|
||||
/>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={createKey}>
|
||||
<IcoPlus /> Создать ключ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
|
||||
<div className="apikey-cards-list">
|
||||
<p className="apikey-count">{keys.length} ключей</p>
|
||||
{keys.length === 0 ? (
|
||||
<p className="apikey-empty">Нет ключей</p>
|
||||
) : keys.map((k, i) => (
|
||||
<div className="apikey-card" key={k.id}>
|
||||
<div className="apikey-card-header">
|
||||
<div className="apikey-card-title">
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{created && (
|
||||
<div className="qr-backdrop" onClick={() => setCreated(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">Ключ «{created.label}» создан</p>
|
||||
<p className="qr-hint">
|
||||
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
|
||||
</p>
|
||||
<code className="apikey-value">{created.key}</code>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
await copyText(created.key);
|
||||
showMsg('Ключ скопирован');
|
||||
}}><IcoCopy /> Скопировать</button>
|
||||
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "apikeys",
|
||||
"label": "API-ключи",
|
||||
"icon": "key",
|
||||
"order": 2
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
|
||||
// index.tsx — компонент + логика
|
||||
// <name>.css — стили вкладки (импортит сам компонент)
|
||||
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок)
|
||||
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
|
||||
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
|
||||
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
|
||||
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
|
||||
// валидируется здесь в рантайме — кривой/неполный манифест падает с внятной
|
||||
// ошибкой при сборке TABS, а не «тихо» в UI.
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PageProps } from '../lib/shared';
|
||||
import { ICONS } from '../components/icons';
|
||||
|
||||
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
||||
export interface TabChrome {
|
||||
serverBar?: boolean;
|
||||
addServer?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
// Сериализуемая часть из metadata.json.
|
||||
export interface TabMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // имя иконки → ICONS
|
||||
order?: number; // порядок в меню (по возрастанию; без него — 0)
|
||||
chrome?: TabChrome;
|
||||
}
|
||||
|
||||
// Собранная вкладка: метадата + резолвнутые Icon и Page.
|
||||
export interface TabDef extends TabMeta {
|
||||
Icon: () => JSX.Element;
|
||||
Page: ComponentType<PageProps>;
|
||||
}
|
||||
|
||||
const metas = import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true });
|
||||
const pages = import.meta.glob<{ default: ComponentType<PageProps> }>('./*/index.tsx', { eager: true });
|
||||
|
||||
// './users/metadata.json' → 'users'
|
||||
const folderOf = (path: string) => path.split('/')[1];
|
||||
|
||||
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
|
||||
function toMeta(name: string, raw: unknown): TabMeta {
|
||||
const m = (raw ?? {}) as Record<string, unknown>;
|
||||
const str = (key: string): string => {
|
||||
const v = m[key];
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
throw new Error(`tabs/${name}/metadata.json: поле "${key}" должно быть непустой строкой`);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
if (m.order !== undefined && typeof m.order !== 'number') {
|
||||
throw new Error(`tabs/${name}/metadata.json: "order" должно быть числом`);
|
||||
}
|
||||
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
|
||||
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
|
||||
}
|
||||
return {
|
||||
id: str('id'),
|
||||
label: str('label'),
|
||||
icon: str('icon'),
|
||||
order: m.order as number | undefined,
|
||||
chrome: m.chrome as TabChrome | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const TABS: TabDef[] = Object.entries(metas)
|
||||
.map(([path, mod]) => {
|
||||
const name = folderOf(path);
|
||||
const meta = toMeta(name, mod.default);
|
||||
const page = pages[`./${name}/index.tsx`]?.default;
|
||||
if (!page) {
|
||||
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
|
||||
}
|
||||
if (!ICONS[meta.icon]) {
|
||||
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS (components/icons.tsx)`);
|
||||
}
|
||||
return { ...meta, Icon: ICONS[meta.icon], Page: page };
|
||||
})
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
|
||||
const ids = TABS.map(t => t.id);
|
||||
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
if (dup) {
|
||||
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
|
||||
type User, type PageProps,
|
||||
} from '../../lib/shared';
|
||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||
import './users.css';
|
||||
|
||||
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
||||
// + модалка QR. Сервер-бар сверху рендерит App. Свой стейт и поллинг — здесь.
|
||||
export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
|
||||
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadStats = useCallback(async (tok: string) => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/api/users/stats', tok);
|
||||
setUsers(prev => prev.map(u => {
|
||||
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u;
|
||||
}));
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async (tok: string) => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/api/users', tok);
|
||||
setUsers(data.users ?? []);
|
||||
await loadStats(tok);
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки');
|
||||
}
|
||||
}, [loadStats, showMsg]);
|
||||
|
||||
const createUser = useCallback(async () => {
|
||||
if (!newName) return;
|
||||
const u = await apiFetch('POST', '/api/users', token, { name: newName });
|
||||
if (u.error) { showMsg(u.error); return; }
|
||||
setNewName('');
|
||||
showMsg('Создан: ' + newName);
|
||||
await loadUsers(token);
|
||||
}, [newName, token, loadUsers, showMsg]);
|
||||
|
||||
const deleteUser = useCallback(async (name: string) => {
|
||||
if (!confirm('Удалить ' + name + '?')) return;
|
||||
await apiFetch('DELETE', '/api/users/' + name, token);
|
||||
showMsg('Удалён: ' + name);
|
||||
await loadUsers(token);
|
||||
}, [token, loadUsers, showMsg]);
|
||||
|
||||
const showQR = useCallback(async (name: string) => {
|
||||
try {
|
||||
const u = await apiFetch('POST', '/api/users/' + name, token);
|
||||
const vpnKey = u.vpn_key ?? '';
|
||||
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
|
||||
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
|
||||
const dataUrl = await QRCode.toDataURL(vpnKey, {
|
||||
width: 560,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'L',
|
||||
});
|
||||
setQrModal({ name, dataUrl, vpnKey });
|
||||
} catch {
|
||||
showMsg('Не удалось сгенерировать QR-код');
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
// Загрузить пользователей и запустить поллинг статистики, пока вкладка открыта.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadUsers(token);
|
||||
statsRef.current = setInterval(() => loadStats(token), 60_000);
|
||||
return () => { if (statsRef.current) clearInterval(statsRef.current); };
|
||||
}, [token, loadUsers, loadStats]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Имя пользователя"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createUser()}
|
||||
/>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={createUser}>
|
||||
<IcoPlus /> Создать
|
||||
</button>
|
||||
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
|
||||
<IcoRefresh /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Статус</th>
|
||||
<th>Имя</th>
|
||||
<th>IP</th>
|
||||
<th>↓ rx</th>
|
||||
<th>↑ tx</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
|
||||
) : users.map((u, i) => (
|
||||
<tr key={u.name}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>
|
||||
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{u.online
|
||||
? 'онлайн'
|
||||
: u.lastHandshake
|
||||
? timeAgo(u.lastHandshake) + ' назад'
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{u.name}</td>
|
||||
<td className="td-mono">{u.ip}</td>
|
||||
<td>{bytes(u.rx)}</td>
|
||||
<td>{bytes(u.tx)}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="QR-код vpn://"
|
||||
onClick={() => showQR(u.name)}
|
||||
><IcoQR /></button>
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить"
|
||||
onClick={() => deleteUser(u.name)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="user-cards-list">
|
||||
<p className="users-count">{users.length} пользователей</p>
|
||||
{users.length === 0 ? (
|
||||
<p className="empty-cards">Нет пользователей</p>
|
||||
) : users.map((u, i) => (
|
||||
<div className="user-card" key={u.name}>
|
||||
<div className="user-card-header">
|
||||
<div className="user-card-title">
|
||||
<span className="user-card-num">#{i + 1}</span>
|
||||
<span className="user-card-name">{u.name}</span>
|
||||
</div>
|
||||
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{u.online
|
||||
? 'онлайн'
|
||||
: u.lastHandshake
|
||||
? timeAgo(u.lastHandshake) + ' назад'
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="user-card-ip">
|
||||
<IcoGlobe />
|
||||
<span className="user-card-ip-text">{u.ip}</span>
|
||||
</div>
|
||||
<div className="user-card-metrics">
|
||||
<div className="metric-block">
|
||||
<span className="metric-label">↓ rx</span>
|
||||
<span className="metric-value">{bytes(u.rx)}</span>
|
||||
</div>
|
||||
<div className="metric-block">
|
||||
<span className="metric-label">↑ tx</span>
|
||||
<span className="metric-value">{bytes(u.tx)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-card-actions">
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
aria-label="QR-код"
|
||||
onClick={() => showQR(u.name)}
|
||||
><IcoQR /> QR</button>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить"
|
||||
onClick={() => deleteUser(u.name)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{qrModal && (
|
||||
<div className="qr-backdrop" onClick={() => setQrModal(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">{qrModal.name}</p>
|
||||
<img
|
||||
className="qr-img"
|
||||
src={qrModal.dataUrl}
|
||||
alt="QR"
|
||||
title="Нажми чтобы скопировать vpn:// ключ"
|
||||
onClick={async () => { await copyText(qrModal.vpnKey); showMsg('Ключ скопирован'); }}
|
||||
/>
|
||||
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--tonal" onClick={async () => {
|
||||
try {
|
||||
const conf = await vpnKeyToConf(qrModal.vpnKey);
|
||||
const safe = qrModal.name.replace(/[\/\\:*?"<>|]+/g, '_').trim() || 'awg';
|
||||
downloadFile(safe + '.conf', conf);
|
||||
showMsg('Конфиг скачан');
|
||||
} catch { showMsg('Не удалось декодировать ключ'); }
|
||||
}}>Скачать .conf</button>
|
||||
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "users",
|
||||
"label": "Пользователи",
|
||||
"icon": "users",
|
||||
"order": 1
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
|
||||
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
|
||||
|
||||
.user-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.users-count { font-size: 12px; color: var(--neutral-text); }
|
||||
.empty-cards {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.user-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.user-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.user-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-card-ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.user-card-ip-text { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
.user-card-metrics { display: flex; gap: 8px; }
|
||||
.metric-block {
|
||||
flex: 1;
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--r-table);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.metric-label { font-size: 12px; color: var(--neutral-text); }
|
||||
.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
.user-card-actions { display: flex; gap: 8px; }
|
||||
.user-card-actions .btn--outline { flex: 1; }
|
||||
|
||||
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
||||
@media (max-width: 640px) {
|
||||
.user-cards-list { display: flex; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user