ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
+301
@@ -0,0 +1,301 @@
|
||||
// Оболочка панели: логин, тема, drawer/сайдбар, снэкбар и переключение вкладок.
|
||||
// Про конкретные вкладки не знает ничего — их приносит реестр из конфига.
|
||||
//
|
||||
// Всё продуктовое вынесено в PanelConfig: имя и версия, ключи localStorage,
|
||||
// адреса эндпоинтов, способ загрузки сессии и слоты для своего хрома. Панель
|
||||
// без ролей (сессия отдаёт permissions: ['*']) и панель с правами — один и тот
|
||||
// же код, отличаются только конфигом.
|
||||
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import './styles.css';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from '../icons';
|
||||
import { setUnauthorizedHandler } from '../http';
|
||||
import { EMPTY_SESSION, type Session } from '../auth';
|
||||
import type { BuiltTabs, TabDef } from '../tabs';
|
||||
|
||||
// Контекст, который оболочка отдаёт слотам — чтобы свой хром мог ходить в сеть
|
||||
// и показывать сообщения теми же средствами, что и вкладки.
|
||||
export interface SlotContext {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
session: Session;
|
||||
tab: TabDef | null;
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
// Реестр вкладок: результат buildTabs(...) из приложения. Передаётся сюда,
|
||||
// а не импортируется, потому что import.meta.glob обязан остаться в
|
||||
// приложении — см. комментарий в tabs/index.ts.
|
||||
tabs: BuiltTabs;
|
||||
branding: { name: string; version?: string };
|
||||
// Ключи localStorage. Разводятся по панелям, чтобы две панели на одном
|
||||
// домене не затирали друг другу токен и тему.
|
||||
storageKeys?: { token?: string; theme?: string };
|
||||
endpoints?: { login?: string; logout?: string };
|
||||
// Как узнать, жив ли токен, и получить роль с правами. По умолчанию —
|
||||
// GET /api/session. Панель без ролей отдаёт отсюда permissions: ['*'].
|
||||
loadSession?: (token: string) => Promise<Session>;
|
||||
// Свой хром вокруг вкладки. Слот получает активную вкладку в ctx.tab и
|
||||
// может вернуть null там, где рисоваться не должен.
|
||||
slots?: {
|
||||
aboveTab?: (ctx: SlotContext) => ReactNode;
|
||||
headerExtra?: (ctx: SlotContext) => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Panel({ config }: { config: PanelConfig }) {
|
||||
const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = config.tabs;
|
||||
const TOKEN_KEY = config.storageKeys?.token ?? 'admin_token';
|
||||
const THEME_KEY = config.storageKeys?.theme ?? 'admin_theme';
|
||||
const LOGIN_URL = config.endpoints?.login ?? '/login';
|
||||
const LOGOUT_URL = config.endpoints?.logout ?? '/logout';
|
||||
const APP_NAME = config.branding.name;
|
||||
const APP_VERSION = config.branding.version;
|
||||
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [session, setSession] = useState<Session>(EMPTY_SESSION);
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// Проверяет, жив ли токен, и заодно тянет роль/права — по ним фильтруется
|
||||
// меню. Данные конкретных вкладок грузят сами вкладки.
|
||||
const loadSession = config.loadSession;
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
const data = loadSession
|
||||
? await loadSession(tok)
|
||||
: (await axios.get<Session>('/api/session', { headers: { Authorization: `Bearer ${tok}` } })).data;
|
||||
setSession(data);
|
||||
return data;
|
||||
}, [loadSession]);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const { data: auth } = await axios.post(LOGIN_URL, { user: loginUser, pass: loginPass });
|
||||
const tok = auth.token as string;
|
||||
await startSession(tok);
|
||||
localStorage.setItem(TOKEN_KEY, tok);
|
||||
setLoginError('');
|
||||
setToken(tok);
|
||||
} catch (e) {
|
||||
// Все три причины отказа пишем под форму, а не в снэкбар: тот живёт
|
||||
// 3 секунды и уезжает, а человек в этот момент смотрит на поля и
|
||||
// перенабирает пароль. Особенно важно для 429 — там верный пароль
|
||||
// тоже не пустит, и без текста это выглядит как «пароль не тот».
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
||||
setLoginError('Неверный логин или пароль');
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 429) {
|
||||
setLoginError(e.response.data?.error ?? 'Слишком много попыток');
|
||||
} else {
|
||||
setLoginError('Сервер недоступен');
|
||||
}
|
||||
}
|
||||
}, [loginUser, loginPass, startSession, showMsg, LOGIN_URL, TOKEN_KEY]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await axios.post(LOGOUT_URL, null, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch { /* токен уже мог истечь */ }
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setSession(EMPTY_SESSION);
|
||||
setActiveTab('');
|
||||
setDrawerOpen(false);
|
||||
}, [token, LOGOUT_URL, TOKEN_KEY]);
|
||||
|
||||
// Любой 401 из apiFetch вкладки означает, что токен протух или отозван —
|
||||
// разлогиниваем сразу, а не показываем ошибку в снэкбаре и ждём, пока
|
||||
// пользователь сам догадается перезайти.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setSession(EMPTY_SESSION);
|
||||
setActiveTab('');
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [TOKEN_KEY]);
|
||||
|
||||
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, TOKEN_KEY]);
|
||||
|
||||
// Применять и сохранять тему
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme, THEME_KEY]);
|
||||
|
||||
// Блокировать прокрутку фона когда 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 groups = visibleTabsByCategory(session.permissions);
|
||||
const visible = groups.flatMap(g => g.tabs);
|
||||
const tab = visible.find(t => t.id === activeTab) ?? visible[0] ?? null;
|
||||
|
||||
// Слот рисуется, если он задан в конфиге. Погасить его на конкретной вкладке
|
||||
// решает сам слот: активная вкладка лежит в ctx.tab.
|
||||
const slotCtx: SlotContext = { token, showMsg, session, tab };
|
||||
const slot = (name: 'aboveTab' | 'headerExtra') => config.slots?.[name]?.(slotCtx) ?? null;
|
||||
|
||||
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 && slot('headerExtra')}
|
||||
|
||||
{!token ? (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<span className="logo-name">{APP_NAME}</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>
|
||||
{loginError && <p className="login-error">{loginError}</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 ?? APP_NAME}</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">{APP_NAME}</span>
|
||||
{APP_VERSION && <span className="sidebar-version">{APP_VERSION}</span>}
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{groups.map(g => (
|
||||
<div key={g.category} className="nav-group">
|
||||
{/* Вкладки без category собраны в одну безымянную группу —
|
||||
заголовка у неё нет, меню выглядит плоским списком. */}
|
||||
{g.category && <div className="nav-category">{g.category}</div>}
|
||||
{g.tabs.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`nav-item${t.id === tab?.id ? ' nav-item--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
|
||||
>
|
||||
<t.Icon /> {t.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<button className="btn btn--danger" onClick={logout}>
|
||||
<IcoLogout /> Выйти
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<h2 className="page-title">{tab?.label ?? APP_NAME}</h2>
|
||||
{slot('aboveTab')}
|
||||
{tab
|
||||
? <tab.Page token={token} showMsg={showMsg}
|
||||
username={session.user ?? ''} userId={session.userId ?? ''}
|
||||
role={session.role} permissions={session.permissions}
|
||||
availablePermissions={ALL_TAB_PERMISSIONS}
|
||||
onNavigate={setActiveTab} />
|
||||
: <p className="placeholder">
|
||||
{TABS.length === 0
|
||||
? <>Вкладок пока нет — добавь папку в <code>src/tabs/</code></>
|
||||
: 'Ни одна вкладка не доступна твоим правам'}
|
||||
</p>}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
/*
|
||||
Дизайн-система панели: тёплая кремовая база + прохладный фиолетовый акцент
|
||||
в ролях Material 3. Здесь только общий язык — стили конкретной вкладки живут
|
||||
в её папке (tabs/<name>/<name>.css) и импортятся её же компонентом.
|
||||
|
||||
ПУБЛИЧНЫЙ КОНТРАКТ. На эти имена классов опирается код вкладок во всех
|
||||
панелях-потребителях, поэтому переименование любого из них — ломающее
|
||||
изменение, а не рефакторинг:
|
||||
|
||||
кнопки .btn .btn--primary .btn--tonal .btn--outline .btn--danger
|
||||
.btn--full .btn--sq .btn-icon .btn-icon--danger .btn-icon--neutral
|
||||
формы .field
|
||||
таблица .table-card .td-mono .td-num .td-actions
|
||||
прочее .chip (+--online/--offline/--error) .chip-dot
|
||||
.toolbar .toolbar-btns .actions .empty
|
||||
модалка .modal-backdrop .modal-card .modal-title .modal-actions .modal-hint
|
||||
|
||||
Всё остальное (.app, .sidebar, .drawer-*, .top-bar, .nav-*, .login-*, .snack,
|
||||
.main, .page-title, .placeholder) принадлежит оболочке — вкладки этих имён
|
||||
не используют.
|
||||
|
||||
Живая витрина всех этих классов — вкладка «Витрина» в frontend/ (шаблон панели).
|
||||
|
||||
Палитра задаётся токенами на :root и переопределяется на [data-theme="dark"];
|
||||
цвета в правилах — только через var(--token), литералов в компонентах нет.
|
||||
*/
|
||||
*, *::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:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.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); }
|
||||
|
||||
.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;
|
||||
}
|
||||
.sidebar-version {
|
||||
font-size: 11px;
|
||||
color: var(--neutral-dot);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.drawer-divider {
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--outline);
|
||||
margin: 0 -12px 8px;
|
||||
}
|
||||
|
||||
.nav-group + .nav-group { margin-top: 8px; }
|
||||
.nav-category {
|
||||
padding: 12px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neutral-dot);
|
||||
}
|
||||
|
||||
.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); }
|
||||
.chip--error { background: var(--error-bg); color: var(--error-text); }
|
||||
.chip--error .chip-dot { background: var(--error-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);
|
||||
}
|
||||
|
||||
/* Заглушка на месте контента — когда вкладок ещё нет */
|
||||
.placeholder {
|
||||
color: var(--neutral-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.placeholder code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--surface-container);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Тултип без задержки: нативный title браузер показывает через ~500 мс */
|
||||
.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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Модалка — общий каркас (backdrop + карточка). Содержимое даёт вкладка. */
|
||||
@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); }
|
||||
}
|
||||
|
||||
.modal-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;
|
||||
}
|
||||
.modal-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);
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.modal-hint { font-size: 12px; color: var(--neutral-text); text-align: center; }
|
||||
.modal-actions { display: flex; gap: 8px; width: 100%; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Таблицы на мобильном заменяются карточками — их даёт сама вкладка */
|
||||
.table-card { display: none; }
|
||||
|
||||
/* Модалка — по ширине экрана */
|
||||
.modal-card {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: 360px;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user