Netpulse_SasS/web/src/components/AppShell.tsx
byrsapty ae07bd2a79
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s
Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:57:09 +03:00

729 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useSyncExternalStore } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { api } from '../api/client'
import { session } from '../api/session'
import { live } from '../api/ws'
import { useAlerts } from '../hooks/useAlerts'
import type { Permission } from '../types'
import { LogoMark } from './Logo'
import { plural, useWideScreen } from './ui'
interface NavItem {
to: string
label: string
icon: string
/**
* Право, без якого пункт не показується.
*
* Необов'язкове: є сторінки, які належать кожному, хто увійшов
* (власний профіль), і вигадувати для них право означало б брехати
* про модель доступу заради типу.
*/
perm?: Permission
/** Показувати лічильник алертів на цьому пункті. */
badge?: boolean
}
interface NavGroup {
/**
* Стійкий ключ групи — під ним живе згорнутий стан у localStorage.
*
* Окремо від `title` навмисно: підпис міняють заради людини (і в цьому
* файлі його вже міняли), а ключ не міняють ніколи. Якби ключем був
* заголовок, кожне переписування слова тихо скидало б налаштування
* всім, хто його вже зробив, — і виглядало б це як «меню саме
* розгорнулось», без жодної підказки чому.
*/
id: string
title: string
items: NavItem[]
}
/**
* Меню згруповане за ЖИТТЄВИМ ЦИКЛОМ даних, а не за темами.
*
* Порядок груп повторює шлях, яким дані проходять через систему:
*
* Моніторинг те, що вже зібрано й показане людині
* Інвентар з чого збирають
* Конфігурації окремий вид зібраного — текст конфігу
* Збір даних чим і як збирають
* Сповіщення що робити, коли зібране виглядає погано
* Адміністрування хто це все бачить
*
* Це не той самий поділ, що був: «Мережа» змішувала інвентар із
* конфігами, а «Налаштування збору» тримало канали сповіщень — тобто
* те, що збором не є взагалі. Перевірка проста: людина, яка шукає
* «куди приходить повідомлення про аварію», не має заходити в розділ
* про опитування пристроїв.
*
* Три сторінки взагалі не мали пункту й були доступні лише з чужих
* вкладок: правила алертів (/rules), відповідність конфігів
* (/compliance) і профілі збору (/profiles). Сторінка без пункту — це
* сторінка, про існування якої знає лише той, хто її писав.
*/
const navGroups: NavGroup[] = [
{
id: 'monitoring',
title: 'Моніторинг',
items: [
{ to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' },
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
// Трапи в «Моніторингу», а не в «Зборі даних»: це не налаштування
// збору, а те, що прилетіло. Поруч з алертами навмисно — сюди
// йдуть із питання «а що там ще сталося, про що ми не завели
// тригер», і половина відповіді на нього — адреси, за якими хоста
// немає взагалі.
{ to: '/traps', label: 'Трапи', icon: '📥', perm: 'devices:read' },
{ to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' },
// SLA в «Моніторингу», а не в «Адмініструванні»: це підсумок того,
// що показують решта пунктів цієї групи, і питають його ті самі
// люди. Поруч із метриками навмисно — там дивляться, як мережа
// почувається зараз, тут — як вона почувалась за квартал, і другий
// погляд без першого не має сенсу.
{ to: '/sla', label: 'Звіти SLA', icon: '📋', perm: 'devices:read' },
],
},
{
id: 'inventory',
title: 'Інвентар',
items: [
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
],
},
{
id: 'configs',
title: 'Конфігурації',
items: [
// Підписи повторюють вкладки, які ці три сторінки вже малюють
// одна на одній. Різні слова в панелі й на сторінці змусили б
// здогадуватись, що це те саме місце.
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
// Конфіги серверів окремим пунктом, а не вкладкою в «Конфігах»:
// там дивляться архів заліза, а тут вирішують, яка картка є
// машиною якого зонда. Різні предмети — різні місця.
{ to: '/server-files', label: 'Сервери', icon: '🗄', perm: 'ncm:read' },
// Профіль описує, ЯК знімати конфіг із конкретної моделі. Це
// налаштування збору — але збору конфігів, а не метрик, і шукати
// його поруч із шаблонами SNMP немає причин.
{ to: '/profiles', label: 'Профілі', icon: '🧩', perm: 'ncm:read' },
// Дзеркало — це той самий архів, але питання до нього інше: не
// «що зібрано», а «чи є копія за межами цього сервера». Пункт
// видно з ncm:read навмисно: відмову дзеркала має помітити той,
// хто щодня дивиться на конфіги, а не лише власник кабінету.
{ to: '/mirror', label: 'Дзеркало', icon: '🪞', perm: 'ncm:read' },
// Відповідність читає той самий архів, але ставить до нього інше
// питання: не «що там написано», а «чи написано те, що треба».
{ to: '/compliance', label: 'Відповідність', icon: '✔', perm: 'ncm:read' },
// Окремим пунктом, а не вкладкою всередині «Конфігів»: там усе
// читає вже зібране, а тут людина йде на живе залізо. Право теж
// окреме (ncm:exec), тож у більшості ролей пункт просто не
// з'явиться.
{ to: '/commands', label: 'Команди', icon: '⌨', perm: 'ncm:exec' },
],
},
{
id: 'collect',
title: 'Збір даних',
items: [
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
// Черги переїхали сюди з «Огляду». Питання «чи все встигає»
// ставлять до збору, а полагодити його йдуть до зонда — тобто в
// сусідній пункт. В «Огляді» вони стояли поруч із мапою, до якої
// не мають стосунку.
{ to: '/queues', label: 'Черги', icon: '⏳', perm: 'agents:read' },
// Поруч із чергами навмисно: там питання «чи все встигає», тут —
// «чи все вміщається». Обидва про здоров'я збору, і ставлять їх
// ті самі люди в ті самі дні. В «Адмініструванні» сховище стояло
// б поруч із ролями й журналом — тобто там, куди мережевий
// інженер не заходить, а закінчується диск саме в нього.
{ to: '/storage', label: 'Сховище', icon: '💾', perm: 'devices:read' },
],
},
{
id: 'notify',
title: 'Сповіщення',
items: [
// Тригер вирішує, коли виникає алерт; канал — куди про нього
// повідомити. Обидва про сповіщення й обидва досі були заховані:
// тригери — за вкладкою всередині алертів, канали — у «збірі».
//
// У коді й у базі це й далі `alr.rules`: перейменовувати таблицю
// заради підпису — міняти те, що працює, заради того, що видно.
// А от у вікні має стояти слово, яким це називає мережевий
// інженер: тригер він шукає в шаблоні, тригер і має знайти тут.
{ to: '/rules', label: 'Тригери', icon: '⚖', perm: 'alerts:read' },
{ to: '/channels', label: 'Канали', icon: '📨', perm: 'alerts:read' },
// Ескалації йдуть після каналів, бо драбина складається з
// каналів: спершу є куди слати, потім — кому і в якому порядку.
{ to: '/escalations', label: 'Ескалації', icon: '🔺', perm: 'alerts:read' },
],
},
{
id: 'admin',
title: 'Адміністрування',
items: [
// «Користувачі», а не «Команда»: на сторінці керують обліковими
// записами, ролями й доступами. «Команда» — про людей, і шукати
// там налаштування прав нікому не спаде.
{ to: '/team', label: 'Користувачі', icon: '👥', perm: 'users:read' },
// Ролі окремим пунктом одразу після користувачів: там відповідають
// на «хто в системі», тут — на «що кому вільно». Друге питання
// ставлять рідше, але саме до нього приходять із «чому він цього
// не бачить», і шукати відповідь усередині картки людини марно —
// роль спільна для всіх, хто в ній сидить.
{ to: '/roles', label: 'Ролі та права', icon: '🔐', perm: 'users:read' },
// Поруч із «Користувачами», бо це два боки одного питання: там
// видають доступ, тут дивляться, як ним скористались. Право окреме
// (audit:read) і є лише у власника й адміна, тож у решти ролей
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
// нічого не лишилось.
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
// Одразу під журналом аудиту, бо їх плутають, а різниця між ними
// вирішальна: аудит відповідає на «хто що зробив», журнал сервера —
// на «на що лається сама система». Поруч цю різницю видно з двох
// підписів; порізно людина шукала б причину відмови в аудиті й не
// знайшла б її ніколи. Право інше й ширше (settings:write), тому
// пункт бачать не всі, хто бачить аудит.
{ to: '/server-log', label: 'Журнал сервера', icon: '📜', perm: 'settings:write' },
// Тариф останнім в «Адмініструванні», а не окремою групою: питання
// «скільки в мене лишилось слотів» ставлять поруч із питанням
// «кого ще завести», і обидва — тут. Окрема група з одного пункту
// додала б заголовок, який нічого не групує.
{ to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' },
],
},
{
// Окрема група з одного пункту — і це свідомий виняток із правила,
// записаного вище («група з одного пункту додала б заголовок,
// який нічого не групує»).
//
// Причина винятку: усі інші групи — про СИСТЕМУ, а ця про того,
// хто на неї дивиться. Заголовок тут групує саме це — і саме через
// цю різницю в роді профіль нікуди не вписався й лишився без пункту
// взагалі — рівно той випадок, про який написано вище:
// «сторінка, про існування якої знає лише той, хто її писав».
//
// Ціна була не косметична: тут живе прив'язка Telegram, без якої
// кнопки під сповіщеннями відповідають «ваш Telegram не прив'язано».
// За весь час цим не скористався ніхто — ні однієї прив'язки й жодного
// згенерованого коду, включно з власником.
//
// Без perm: профіль є в кожного, хто увійшов.
id: 'account',
title: 'Обліковий запис',
items: [{ to: '/profile', label: 'Мій профіль', icon: '👤' }],
},
]
// ---------------------------------------------------------------------
// Пам'ять налаштувань панелі
// ---------------------------------------------------------------------
/** Панель зведена до рейки зі значків. */
const RAIL_KEY = 'np.nav.collapsed'
/** Перелік ЗГОРНУТИХ груп. */
const GROUPS_KEY = 'np.nav.groups'
/**
* Читання й запис налаштувань, які не валять застосунок.
*
* ПРИЧИНА: у приватному вікні Safari й за політики «блокувати всі
* cookie» виняток кидає САМ доступ до `window.localStorage` — ще до
* `getItem`. Незахищений виклик стоїть в ініціалізаторі стану, тобто
* падає під час першого рендера оболонки: людина дістає білий екран
* замість продукту й не має жодного способу зрозуміти чому.
* НАСЛІДОК: втратити тут налаштування панелі — прийнятна ціна,
* втратити застосунок — ні.
*/
function readSetting(key: string): string | null {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
function writeSetting(key: string, value: string): void {
try {
window.localStorage.setItem(key, value)
} catch {
// Немає де зберегти — панель просто працюватиме до перезавантаження.
}
}
/**
* Зберігаємо ЗГОРНУТІ групи, а не розгорнуті.
*
* ПРИЧИНА: у продукті майже тридцять сторінок і групи додаються далі.
* Якби в пам'яті лежав перелік розгорнутих, кожна нова група приїжджала
* б до всіх наявних користувачів згорнутою — тобто новий розділ побачив
* би лише той, хто здогадався потикати заголовки. НАСЛІДОК: типовий
* стан — «розгорнуто», і в пам'яті лежать лише свідомі відмови.
*
* Зіпсований або чужий вміст ключа — це порожня множина, а не виняток:
* налаштування панелі не варте того, щоб через нього не відкрився
* продукт.
*/
function readCollapsedGroups(): ReadonlySet<string> {
const raw = readSetting(GROUPS_KEY)
if (!raw) return new Set()
try {
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((v): v is string => typeof v === 'string'))
} catch {
return new Set()
}
}
/**
* Група, якій належить адреса.
*
* Збіг за межею сегмента, а не за голим `startsWith`: картка хоста живе
* на `/devices/d-17`, і людина, яка на неї дивиться, лишається в
* «Інвентарі». Гола перевірка префікса при цьому зарахувала б `/map`
* і на адресу `/maps-archive`, якби така з'явилась, — і активною
* підсвічувалась би чужа група.
*/
function groupOfPath(path: string): string | null {
let bestID: string | null = null
let bestLen = -1
for (const g of navGroups) {
for (const i of g.items) {
if ((path === i.to || path.startsWith(i.to + '/')) && i.to.length > bestLen) {
bestID = g.id
bestLen = i.to.length
}
}
}
return bestID
}
/**
* Розгортає групу, у якій опинилась поточна сторінка, — і лише її.
*
* ПРИЧИНА: людина перейшла за посиланням із чату або з картки хоста й
* потрапила в групу, яку колись згорнула. Без цього вона бачить меню, у
* якому поточної сторінки немає взагалі, і не має способу зрозуміти, де
* вона є.
*
* НАСЛІДОК і межа: чіпаємо РІВНО одну групу — ту, у якій сторінка.
* Решта згорнутих лишаються згорнутими, бо їхній стан — свідомий вибір
* під ширину монітора, а не наслідок того, куди сьогодні клацнули.
* Повертаємо ту саму множину, коли міняти нічого: інакше кожен рендер
* давав би новий об'єкт і нескінченне оновлення стану.
*/
function expandGroupOf(collapsed: ReadonlySet<string>, path: string): ReadonlySet<string> {
const id = groupOfPath(path)
if (!id || !collapsed.has(id)) return collapsed
const next = new Set(collapsed)
next.delete(id)
return next
}
/**
* `inert` на згорнутій групі.
*
* ПРИЧИНА: нульова висота ховає пункти від ока, але не від клавіатури.
* Без цього Tab провалюється всередину згорнутої групи, і фокус
* шість разів зникає в нікуди — людина без миші просто не може пройти
* меню. `aria-hidden` прибирає пункти з дерева доступності, `inert` —
* з обходу табом; потрібні обидва, вони про різні речі.
*
* Окремим об'єктом, а не пропсом: типи React 18 атрибута ще не знають,
* браузери знають з 2022 року.
*/
const INERT = { inert: '' } as Record<string, string>
/**
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
*
* Пункт, на який немає права, не показується взагалі. Пункт, який
* завжди веде до 403, гірший за його відсутність: він обіцяє
* можливість, якої немає, і змушує людину гадати, що вона зробила не так.
*/
export function AppShell() {
const [navOpen, setNavOpen] = useState(false)
// Згорнутий стан переживає перезавантаження: людина обирає його раз і
// назавжди — під ширину свого монітора, а не під конкретну сторінку.
const [collapsed, setCollapsed] = useState(() => readSetting(RAIL_KEY) === '1')
useEffect(() => {
writeSetting(RAIL_KEY, collapsed ? '1' : '0')
}, [collapsed])
const location = useLocation()
const me = session.me()
const alerts = useAlerts()
const connection = useSyncExternalStore(live.subscribeState, live.getState)
const wide = useWideScreen()
// Рейка зі значків — режим широкого екрана, і це не примха стилю: на
// телефоні панель уже є шухлядою на всю ширину, і другий «вужчий»
// вигляд усередині неї означав би підписи без заголовків груп.
// Розкладку далі задає саме `rail`, а не `collapsed`: інакше людина,
// яка згорнула панель на моніторі, наступного ранку відкриває меню з
// телефона й не бачить у ньому жодної назви розділу.
const rail = collapsed && wide
// Згорнуті групи — та сама пам'ять, що й у рейки, але окремим ключем:
// це два різні рішення людини, і скидання одного не має чіпати інше.
//
// Початкове значення вже враховує адресу, з якої почали: якби активну
// групу розгортав useEffect, вона встигла б намалюватись згорнутою й
// кожне відкриття сторінки починалося б із анімації — тієї самої, якої
// ніхто не просив.
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(() =>
expandGroupOf(readCollapsedGroups(), location.pathname),
)
// Те саме при переході всередині застосунку. Правка стану просто в
// рендері (а не в ефекті) — той самий випадок: React домальовує другий
// прохід до того, як щось потрапить в DOM, тож стрибка не видно.
const [seenPath, setSeenPath] = useState(location.pathname)
if (seenPath !== location.pathname) {
setSeenPath(location.pathname)
setCollapsedGroups((prev) => expandGroupOf(prev, location.pathname))
}
useEffect(() => {
writeSetting(GROUPS_KEY, JSON.stringify([...collapsedGroups]))
}, [collapsedGroups])
// Одна кнопка — один перемикач: не вгадуємо намір, а міняємо рівно ту
// групу, по заголовку якої натиснули.
const toggleGroup = (id: string) =>
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (!next.delete(id)) next.add(id)
return next
})
const activeGroup = groupOfPath(location.pathname)
// Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам:
// лічильник алертів у шапці має оновлюватись і тоді, коли людина
// дивиться на список пристроїв або взагалі на профіль.
useEffect(() => {
live.start()
return () => live.stop()
}, [])
// Перехід між сторінками закриває мобільну шухляду: інакше вона
// лишається поверх щойно відкритої сторінки.
useEffect(() => setNavOpen(false), [location.pathname])
// Група без жодного доступного пункту зникає цілком: заголовок над
// порожнечею обіцяє розділ, якого для цієї ролі не існує.
const groups = navGroups
// Пункт без perm бачать усі, хто увійшов.
.map((g) => ({ ...g, items: g.items.filter((i) => !i.perm || session.can(i.perm)) }))
.filter((g) => g.items.length > 0)
const hasAny = groups.some((g) => g.items.length > 0)
return (
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 bg-slate-900 px-3 py-2">
<button
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
onClick={() => setNavOpen((v) => !v)}
aria-label="Меню"
>
</button>
<span className="flex items-center gap-2">
<LogoMark className="h-6 w-6 shrink-0 text-slate-100" />
<span className="text-base font-semibold tracking-tight text-slate-100">NetPulse</span>
</span>
<div className="ml-auto flex items-center gap-2 text-xs">
{/* Замерзлий інтерфейс виглядає точно так само, як справний.
Тому стан з'єднання видно з будь-якої сторінки, а не лише
з мапи. */}
<span
className="flex items-center gap-1.5 text-slate-500"
title={
connection === 'online'
? 'Дані оновлюються наживо'
: connection === 'connecting'
? 'Підключення…'
: "Зв'язок із сервером втрачено — дані на екрані застаріли"
}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
connection === 'online'
? 'bg-emerald-400'
: connection === 'connecting'
? 'bg-amber-400'
: 'animate-pulse bg-red-500'
}`}
/>
<span className="hidden sm:inline">
{connection === 'online' ? 'наживо' : connection === 'connecting' ? '…' : 'офлайн'}
</span>
</span>
{alerts.counts.firing > 0 && (
<NavLink
to="/alerts"
className={`flex items-center gap-1.5 rounded border px-2 py-1 ${
alerts.counts.disaster + alerts.counts.high > 0
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
: 'border-amber-800 bg-amber-950/50 text-amber-200'
}`}
title={`${alerts.counts.firing} ${plural(alerts.counts.firing, ['активний алерт', 'активні алерти', 'активних алертів'])}`}
>
🔔 <span className="tabular-nums">{alerts.counts.firing}</span>
</NavLink>
)}
{/*
Вхід у профіль малюється ЗАВЖДИ, а не лише коли є чим
підписати кнопку.
Раніше умова вимагала імені або пошти, а /me віддавало лише
пошту — порожню в облікового запису власника, який заводить
установник і який входить за іменем. Тобто в типовій
інсталяції єдиний шлях у профіль не малювався взагалі, і
сторінка була досяжна лише введенням адреси вручну.
*/}
{me?.userID && (
<div className="flex items-center gap-1">
<NavLink
to="/profile"
className="flex max-w-[10rem] items-center gap-1 truncate rounded border
border-slate-700 px-2 py-1 text-slate-300 hover:bg-slate-800"
title="Мій профіль"
>
<span aria-hidden>👤</span>
<span className="truncate">{me?.username || me?.email || 'Профіль'}</span>
</NavLink>
<button
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
onClick={() => void api.logout()}
title="Вийти"
>
</button>
</div>
)}
</div>
</header>
<div className="relative flex min-h-0 flex-1">
{navOpen && (
<div
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
onClick={() => setNavOpen(false)}
/>
)}
<nav
// Смуга прокрутки забирає ширину справа й зсуває всю колонку
// значків уліво — у згорнутому вигляді це помітно одразу, бо
// порівнювати є з чим: рівний край панелі. Ховаємо смугу,
// прокрутка лишається колесом і клавіатурою.
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col
overflow-y-auto overflow-x-hidden border-r border-slate-800
bg-slate-900 py-2 transition-transform
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden
md:static md:z-0 md:translate-x-0 md:bg-slate-900/50
md:transition-[width]
${rail ? 'md:w-[3.75rem] md:px-1.5' : 'px-2 md:w-52'}
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
{groups.map((g, gi) => {
// У рейці групи не згортаються: там немає заголовка, а
// перемикач без підпису — загадка, а не керування. Значки
// видно всі, і це вже найкоротший можливий вигляд меню.
const open = rail || !collapsedGroups.has(g.id)
const panelID = `nav-group-${g.id}`
// Алерти рахуємо лише за тими пунктами, які їх показують:
// згорнута група не має ховати аварію. Решта груп у
// згорнутому вигляді показує, скільки в ній пунктів — щоб
// ціна розгортання була видна до кліку.
const firing = g.items.some((i) => i.badge) ? alerts.counts.firing : 0
return (
<div key={g.id} className="flex flex-col">
{/* Заголовок групи в рейці замінює риска: текст туди не
влазить, а межа між групами потрібна — без неї значки
зливаються в одну стрічку. */}
{rail ? (
gi > 0 && <div className="mx-1 my-1.5 border-t border-slate-800" />
) : (
<button
type="button"
// Саме <button>, а не <div> зі слухачем: Enter і
// Пробіл на ньому — робота браузера, і жоден наш
// обробник клавіш не розійдеться з тим, як це
// працює в решті системи. Фокус теж не треба
// вигадувати — він приходить разом з елементом.
onClick={() => toggleGroup(g.id)}
aria-expanded={open}
aria-controls={panelID}
// На телефоні заголовок вищий, ніж на моніторі: там у
// нього цілять пальцем, а спільні 31 px — це промах
// через раз. Поруч пункти по 40 px, тож py-3 ще й
// вирівнює список.
//
// Фокус — outline, а не ring: ring малюється тінню,
// а тіні зникають у режимі високої контрастності
// Windows — саме там, де рамку фокуса й шукають.
// Зсув усередину, бо панель ріже все, що вилазить за
// її край (overflow-x-hidden), і зовнішня рамка
// втратила б праву сторону.
className={`flex w-full items-center gap-1.5 rounded px-2 py-3 text-left
text-[10px] font-semibold uppercase tracking-wider
transition-colors hover:bg-slate-800/50
focus-visible:outline-2 focus-visible:-outline-offset-2
focus-visible:outline-slate-400 md:py-1.5
${gi > 0 ? 'mt-2' : ''}
${
activeGroup === g.id
? 'text-slate-400'
: 'text-slate-600 hover:text-slate-500'
}`}
>
{/* Той самий знак повертається, а не підмінюється
іншим: підміна читається як «щось блимнуло»,
поворот — як «це те саме, воно відкрилось».
▸ (U+25B8), а не ▶ (U+25B6): у другого є емодзійне
накреслення, і на Windows системний шрифт малює
його кольоровим прямокутником — у сірому
заголовку це виглядає як помилка. */}
<span
aria-hidden="true"
className={`inline-block text-[10px] leading-none transition-transform
duration-150 motion-reduce:transition-none
${open ? 'rotate-90' : ''}`}
>
</span>
<span className="flex-1 truncate">{g.title}</span>
{!open &&
(firing > 0 ? (
<span
className="rounded bg-red-900/70 px-1.5 text-[10px] font-medium
tabular-nums text-red-200"
title={`${firing} ${plural(firing, ['активний алерт', 'активні алерти', 'активних алертів'])}`}
>
{firing}
</span>
) : (
<span
// Той самий колір, що й у назви групи: число —
// не окрема новина, а частина заголовка, і
// світліше воно перетягувало б увагу на себе.
className="tabular-nums"
title={`${g.items.length} ${plural(g.items.length, ['пункт', 'пункти', 'пунктів'])}`}
>
{g.items.length}
</span>
))}
</button>
)}
{/* Згортання через grid-template-rows, а не через
max-height: висота групи заздалегідь невідома (пункти
ховаються за правами), а вгадане «досить велике»
число робить анімацію тим ривкішою, чим менше в групі
пунктів. */}
<div
id={panelID}
aria-hidden={!open}
{...(open ? {} : INERT)}
className={`grid transition-[grid-template-rows] duration-[180ms] ease-out
motion-reduce:transition-none
${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
>
<div className="flex min-h-0 flex-col gap-0.5 overflow-hidden">
{g.items.map((i) => (
<NavLink
key={i.to}
to={i.to}
// Підказка лише в рейці: поруч із видимим
// підписом вона повторювала б його й миготіла на
// кожному проході мишею. Група в підказці теж не
// зайва — саме вона зникла разом із заголовком.
title={rail ? `${g.title} · ${i.label}` : undefined}
// Страхувальна сітка до `inert` вище: у рушіях,
// старших за 2023 рік, атрибут не робить нічого,
// і там Tab так само провалювався б у нульову
// висоту. Ціна страховки — один атрибут.
tabIndex={open ? undefined : -1}
className={({ isActive }) =>
`relative flex items-center rounded py-2.5 text-sm md:py-2 ${
rail ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3'
} ${
isActive
? 'bg-slate-800 font-medium text-slate-100'
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
}`
}
>
{/* Квадрат зі своїм центруванням, а не текст у вузькій
коробці: значки — емодзі різної ширини, і покластися
на text-align означає зсув на кожному другому. */}
<span
className="flex h-5 w-5 shrink-0 items-center justify-center
text-[15px] leading-none opacity-80"
>
{i.icon}
</span>
<span className={`flex-1 truncate ${rail ? 'md:hidden' : ''}`}>
{i.label}
</span>
{i.badge && alerts.counts.firing > 0 && (
<span
className={`rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200
${rail ? 'md:absolute md:right-0.5 md:top-0.5 md:px-1' : ''}`}
>
{alerts.counts.firing}
</span>
)}
</NavLink>
))}
</div>
</div>
</div>
)
})}
{!hasAny && (
<p className="px-3 py-4 text-xs leading-relaxed text-slate-600">
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
</p>
)}
{/* Кнопка внизу, а не в шапці: згортання стосується самої
панелі, і шукати її перемикач в іншому кутку екрана —
зайвий крок. На телефоні панель і так шухляда. */}
<button
className={`mt-4 hidden shrink-0 items-center rounded py-2 text-sm
text-slate-500 hover:bg-slate-800/60 hover:text-slate-300 md:flex
${collapsed ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3'}`}
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? 'Розгорнути панель' : 'Згорнути панель'}
aria-label={collapsed ? 'Розгорнути панель' : 'Згорнути панель'}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center leading-none">
{collapsed ? '»' : '«'}
</span>
<span className={collapsed ? 'md:hidden' : ''}>Згорнути</span>
</button>
</nav>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet context={alerts} />
</main>
</div>
</div>
)
}