commit 53c438535edcaa3ca6d5a19c6d1d3c98fd16ece2 Author: Ivan Vasilev Date: Wed Aug 26 20:39:37 2026 +0300 ma7engine: ядро, шаблон панели, скаффолдер и доктор Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz diff --git a/auth/index.ts b/auth/index.ts new file mode 100644 index 0000000..0364008 --- /dev/null +++ b/auth/index.ts @@ -0,0 +1,40 @@ +// Слой сессии и прав. Опционален: панель без учёток и ролей (плоский список +// вкладок, доступных всем) обходится BasePageProps и в этот модуль не заходит. + +// "*" в permissions — владелец, доступ ко всему в обход остальных проверок. +export const OWNER_PERMISSION = '*'; + +export function hasPermission(permissions: string[], perm: string): boolean { + return permissions.includes(OWNER_PERMISSION) || permissions.includes(perm); +} + +// Что оболочка узнаёт о вошедшем. Отдаётся эндпоинтом сессии, адрес и форма +// ответа которого задаются в конфиге панели (loadSession). +export interface Session { + user: string | null; + userId: string | null; + role: string; + permissions: string[]; +} + +export const EMPTY_SESSION: Session = { user: null, userId: null, role: '', permissions: [] }; + +// Пропсы вкладки в панели без прав: вкладке нужен только токен и снэкбар. +export interface BasePageProps { + token: string; + showMsg: (text: string) => void; +} + +// Пропсы вкладки в панели с сессией и правами. +export interface SessionPageProps extends BasePageProps { + username: string; + userId: string; + role: string; + permissions: string[]; + // Все permission-строки, встречающиеся у вкладок — нужно только тем вкладкам, + // которые сами выдают права; остальные это поле игнорируют. + availablePermissions: string[]; + // Переключить активную вкладку по её id из metadata.json. Роутера в панели + // нет — активная вкладка это состояние оболочки. + onNavigate: (tabId: string) => void; +} diff --git a/http/index.ts b/http/index.ts new file mode 100644 index 0000000..bd9bf0c --- /dev/null +++ b/http/index.ts @@ -0,0 +1,33 @@ +// Единственная точка выхода вкладок в сеть. Вкладка не создаёт свой axios и не +// собирает заголовок авторизации руками — иначе обработка протухшего токена +// разъезжается по вкладкам. +import axios from 'axios'; + +// Что делать, когда сервер ответил 401. Ставит оболочка при старте: токен +// протух или отозван — надо разлогинить, а не показывать ошибку в снэкбаре. +// Само ядро про localStorage не знает — ключ хранения принадлежит приложению. +let onUnauthorized: (() => void) | null = null; + +export function setUnauthorizedHandler(fn: (() => void) | null) { + onUnauthorized = fn; +} + +export async function apiFetch(method: string, path: string, token: string, body?: object): Promise { + try { + const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body }); + return r.data; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 401) { + onUnauthorized?.(); + } + throw e; + } +} + +// Бэкенды панелей отдают ошибку по-разному: одни как {error}, другие как +// {message}. Вкладки читают через этот хелпер, а не разбирают форму ответа +// в каждом catch. +export function errText(e: unknown, fallback = 'Сервер недоступен'): string { + const data = (e as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + return data?.error ?? data?.message ?? fallback; +} diff --git a/icons/index.tsx b/icons/index.tsx new file mode 100644 index 0000000..a19b08e --- /dev/null +++ b/icons/index.tsx @@ -0,0 +1,79 @@ +// Иконки ядра — только те, что уже совпадали побайтово в панелях-потребителях. +// Продуктовые иконки живут в приложении; карта ICONS собирается там же: +// export const ICONS = { ...coreIcons, ...myIcons }; +// Все иконки — инлайновый SVG на currentColor, без внешних зависимостей. + +export const IcoMenu = () => ( + + + + + +); +export const IcoLogout = () => ( + + + + + +); +export const IcoSun = () => ( + + + + +); +export const IcoMoon = () => ( + + + +); +export const IcoPlus = () => ( + + + +); +export const IcoTrash = () => ( + + + + + +); +export const IcoRefresh = () => ( + + + + +); +export const IcoCopy = () => ( + + + + +); +export const IcoGlobe = () => ( + + + + + +); +export const IcoKey = () => ( + + + + +); + +// Карта иконок ВКЛАДОК — по ней реестр резолвит поле "icon" из metadata.json. +// Сюда попадают только иконки, осмысленные как иконка раздела; хром оболочки +// (menu/logout/sun/moon) оболочка импортирует именованными экспортами и через +// карту никогда не ищет. +// +// Приложение расширяет карту своими и при совпадении ключа перекрывает: +// export const ICONS = { ...coreIcons, ...myIcons }; +export const coreIcons: Record JSX.Element> = { + key: IcoKey, + globe: IcoGlobe, +}; diff --git a/shell/index.tsx b/shell/index.tsx new file mode 100644 index 0000000..ec514c5 --- /dev/null +++ b/shell/index.tsx @@ -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; + // Свой хром вокруг вкладки. Слот получает активную вкладку в 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(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('/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 ( +
+ + + {token && slot('headerExtra')} + + {!token ? ( +
+
+ {APP_NAME} +

Войдите, чтобы продолжить

+ setLoginUser(e.target.value)} + autoFocus + /> + setLoginPass(e.target.value)} + onKeyDown={e => e.key === 'Enter' && login()} + /> + + {loginError &&

{loginError}

} +
+
+ ) : ( + <> + {/* Top bar — видим только на мобильном */} +
+ + {tab?.label ?? APP_NAME} +
+ + {/* Затемнение под drawer */} +
setDrawerOpen(false)} + /> + +
+ + +
+

{tab?.label ?? APP_NAME}

+ {slot('aboveTab')} + {tab + ? + :

+ {TABS.length === 0 + ? <>Вкладок пока нет — добавь папку в src/tabs/ + : 'Ни одна вкладка не доступна твоим правам'} +

} +
+
+ + )} + + {msg &&
{msg}
} +
+ ); +} diff --git a/shell/styles.css b/shell/styles.css new file mode 100644 index 0000000..b959335 --- /dev/null +++ b/shell/styles.css @@ -0,0 +1,583 @@ +/* +Дизайн-система панели: тёплая кремовая база + прохладный фиолетовый акцент +в ролях Material 3. Здесь только общий язык — стили конкретной вкладки живут +в её папке (tabs//.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; + } +} diff --git a/tabs/index.ts b/tabs/index.ts new file mode 100644 index 0000000..1ba919f --- /dev/null +++ b/tabs/index.ts @@ -0,0 +1,150 @@ +// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs//: +// index.tsx — компонент + логика +// .css — стили вкладки (импортит сам компонент) +// metadata.json — манифест: { id, label, icon, category?, permission?, order?, devOnly? } +// +// Сам поиск папок (import.meta.glob) остаётся в приложении и передаётся сюда +// аргументами — это compile-time фича Vite, её паттерн резолвится относительно +// файла, где она написана. Уехав в ядро, она сканировала бы ядро, а не +// src/tabs/ приложения. +// +// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое +// валидируется здесь в рантайме — кривой манифест падает с внятной ошибкой при +// сборке TABS, а не «тихо» в UI. +import type { ComponentType } from 'react'; +import { OWNER_PERMISSION } from '../auth'; + +// Сериализуемая часть из metadata.json. Обязательны только id, label и icon: +// панель без ролей и без группировки меню — базовый случай, а не урезанный. +export interface TabMeta { + id: string; + label: string; + icon: string; // имя иконки → карта ICONS + // Группа в меню. Без неё вкладка попадает в единственную безымянную группу, + // и сайдбар рисует плоский список. + category?: string; + // Право, нужное для доступа. Без него вкладка видна любому залогиненному. + permission?: string; + order?: number; // порядок в меню (по возрастанию; без него — 0) + // Вкладка, которой не должно быть в проде: её роут на бэкенде там всё равно + // отдаёт 404 независимо от прав, и вкладка вела бы в тупик. + devOnly?: boolean; +} + +// Собранная вкладка: метадата + резолвнутые Icon и Page. +export interface TabDef extends TabMeta { + Icon: () => JSX.Element; + Page: ComponentType; +} + +export interface TabGroup { + category: string; + tabs: TabDef[]; +} + +export interface BuiltTabs { + TABS: TabDef[]; + ALL_TAB_PERMISSIONS: string[]; + visibleTabsByCategory: (permissions: string[]) => TabGroup[]; +} + +type MetaModules = Record; +type PageModules = Record }>; +type IconMap = Record JSX.Element>; + +// './users/metadata.json' → 'users' +const folderOf = (path: string) => path.split('/')[1]; + +const REQUIRED_STRINGS = ['id', 'label', 'icon'] as const; +const OPTIONAL_STRINGS = ['category', 'permission'] as const; + +// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка). +function toMeta(name: string, raw: unknown): TabMeta { + const m = (raw ?? {}) as Record; + const fail = (what: string): never => { + throw new Error(`tabs/${name}/metadata.json: ${what}`); + }; + const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== ''; + + for (const key of REQUIRED_STRINGS) { + if (!nonEmpty(m[key])) fail(`поле "${key}" должно быть непустой строкой`); + } + for (const key of OPTIONAL_STRINGS) { + if (m[key] !== undefined && !nonEmpty(m[key])) fail(`"${key}" должно быть непустой строкой`); + } + if (m.order !== undefined && typeof m.order !== 'number') fail('"order" должно быть числом'); + if (m.devOnly !== undefined && typeof m.devOnly !== 'boolean') fail('"devOnly" должно быть булевым'); + + return { + id: m.id as string, + label: m.label as string, + icon: m.icon as string, + category: m.category as string | undefined, + permission: m.permission as string | undefined, + order: m.order as number | undefined, + devOnly: m.devOnly as boolean | undefined, + }; +} + +// Собирает реестр вкладок из результатов import.meta.glob приложения: +// +// export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs( +// import.meta.glob('./*/metadata.json', { eager: true }), +// import.meta.glob('./*/index.tsx', { eager: true }), +// ICONS, +// ); +// +// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка +// показывает заглушку вместо контента. +export function buildTabs(metas: MetaModules, pages: PageModules, icons: IconMap): BuiltTabs { + 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`); + } + return { ...meta, Icon: icons[meta.icon], Page: page }; + }) + // import.meta.env.PROD — режим сборки этого фронта (vite build), а не + // окружения бэкенда, к которому он ходит. + .filter(t => !t.devOnly || !import.meta.env.PROD) + .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 вкладок должны быть уникальны`); + } + + // Полный список прав, встречающихся у вкладок — по нему строится форма + // выдачи прав в панелях, где такая вкладка есть: появилась вкладка с новым + // permission — появился и чекбокс, без ручной синхронизации списков. + const ALL_TAB_PERMISSIONS = Array.from( + new Set(TABS.map(t => t.permission).filter((p): p is string => !!p)), + ); + + // Вкладки, доступные набору прав, сгруппированные по категориям в порядке + // первого появления (сами вкладки уже отсортированы по order). Вкладки без + // category собираются в одну безымянную группу — сайдбар рисует её без + // заголовка, то есть плоским списком. + function visibleTabsByCategory(permissions: string[]): TabGroup[] { + const owner = permissions.includes(OWNER_PERMISSION); + const visible = TABS.filter(t => !t.permission || owner || permissions.includes(t.permission)); + const groups: TabGroup[] = []; + for (const tab of visible) { + const category = tab.category ?? ''; + let group = groups.find(g => g.category === category); + if (!group) { group = { category, tabs: [] }; groups.push(group); } + group.tabs.push(tab); + } + return groups; + } + + return { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory }; +} diff --git a/utils/index.ts b/utils/index.ts new file mode 100644 index 0000000..47b3aad --- /dev/null +++ b/utils/index.ts @@ -0,0 +1,52 @@ +// Форматирование и работа с буфером/файлами. Чистые функции без зависимостей — +// то, что во всех панелях уже совпадало побайтово. + +// ISO-дата → «2026-08-01 12:34». Без секунд и без часового пояса — +// формат для таблиц, где важна разница между записями, а не точность до сек. +export function fmtDateTime(iso: string | null | undefined): string { + if (!iso) return '—'; + return iso.replace('T', ' ').slice(0, 16); +} + +// unix-секунды → «5 мин» / «3 ч» (без суффикса «назад» — его добавляет вызывающий) +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) + ' ч'; +} + +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 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); +} + +// navigator.clipboard недоступен вне https/localhost — отсюда фолбэк на execCommand. +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); + } +}