Меню згруповане; значки у згорнутій панелі центровані
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

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

Зсув значків мала не розмітка (0.4 px), а смуга прокрутки — вона
забирає ширину справа й тягне колонку вліво. Плюс значок сидів у
коробці w-4 з паддінгом px-3, тобто з нульовою шириною вмісту, і
емодзі різної ширини центрувались випадково. Тепер квадрат h-5 w-5 з
flex-центруванням, смуга прокрутки схована. Зсув рівно 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 15:04:21 +03:00
parent d1c9d450f2
commit 43d1f62b29
2 changed files with 169 additions and 50 deletions

View file

@ -2424,3 +2424,54 @@ button, [role='button'], summary {
список правил рядки з шаблону підписані «із шаблону», кнопок правки немає
міграції 25 на чистій БД, тести сервера з базою — зелені
```
## Меню: групи і центрування
### Групи
Дванадцять пунктів у стовпчик означали читати всі дванадцять, щоб знайти
один. Тепер вони згруповані за питанням, з яким людина відкриває меню:
```
Огляд що зараз відбувається — щодня й багато разів
Мережа що в мене є — коли додають або шукають залізо
Налаштування збору як воно налаштоване — рідко, зазвичай при заведенні
Організація хто має доступ
```
Група, у якій ролі не доступний жоден пункт, зникає разом із
заголовком: заголовок над порожнечею обіцяє розділ, якого немає.
У згорнутому вигляді заголовок замінює риска — текст туди не влазить, а
межа між групами потрібна, інакше значки зливаються в одну стрічку.
Назва групи переїжджає в підказку: `Мережа · Хости`.
### Значки не по центру
Виміряв — розмітка давала зсув 0.4 пікселя, тобто річ була не в ній.
Винних виявилось двоє.
**Смуга прокрутки.** Вона забирає ширину справа й зсуває всю колонку
значків уліво. У згорнутому вигляді це видно одразу, бо є з чим
порівнювати — рівний край панелі. З групами пунктів стало більше, і
прокрутка з випадкової стала звичною. Смугу сховано, прокрутка лишилась
колесом і клавіатурою.
**Значок у вузькій коробці.** Було `w-4 text-center` з накладеним
`md:px-3`: ширина 1rem проти паддінгів 1.5rem дає нульову ширину вмісту,
і гліф центрується випадково — а емодзі різної ширини роблять це
помітним на кожному другому. Тепер це квадрат `h-5 w-5` з власним
центруванням через flex.
Після виправлення всі дванадцять значків і кнопка згортання дають зсув
рівно 0 від центру панелі.
### Мірка, яка збрехала
Проміжні заміри показували ширину 60 px при класі `md:w-52` — виглядало
як зламане правило CSS. Після чесного перезавантаження — 208 px, як і
має бути. Це артефакт гарячої заміни модулів у vite: клас на елементі
вже новий, а перехід ширини лишився від попереднього стану.
Урок той самий, що з невидимою вкладкою: перш ніж шукати винного в коді,
перевірити, що вимірюєш те, що думаєш.

View file

@ -15,19 +15,50 @@ interface NavItem {
badge?: boolean
}
const navItems: NavItem[] = [
{ to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' },
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
{ to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' },
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
interface NavGroup {
title: string
items: NavItem[]
}
/**
* Меню згруповане за питанням, з яким людина його відкриває.
*
* «Що зараз відбувається» щодня й багато разів. «Що в мене є» коли
* додають або шукають залізо. «Як воно налаштоване» рідко, зазвичай
* при заведенні. Плаский список із дванадцяти пунктів змушував щоразу
* читати всі дванадцять, щоб знайти один.
*/
const navGroups: NavGroup[] = [
{
title: 'Огляд',
items: [
{ to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' },
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
{ to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
],
},
{
title: 'Мережа',
items: [
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
],
},
{
title: 'Налаштування збору',
items: [
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
],
},
{
title: 'Організація',
items: [{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' }],
},
]
/**
@ -65,7 +96,12 @@ export function AppShell() {
// лишається поверх щойно відкритої сторінки.
useEffect(() => setNavOpen(false), [location.pathname])
const visible = navItems.filter((i) => session.can(i.perm))
// Група без жодного доступного пункту зникає цілком: заголовок над
// порожнечею обіцяє розділ, якого для цієї ролі не існує.
const groups = navGroups
.map((g) => ({ ...g, items: g.items.filter((i) => 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">
@ -153,48 +189,80 @@ export function AppShell() {
)}
<nav
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col gap-0.5
// Смуга прокрутки забирає ширину справа й зсуває всю колонку
// значків уліво — у згорнутому вигляді це помітно одразу, бо
// порівнювати є з чим: рівний край панелі. Ховаємо смугу,
// прокрутка лишається колесом і клавіатурою.
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 p-2 transition-transform
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]
${collapsed ? 'md:w-[3.75rem]' : 'md:w-52'}
${collapsed ? 'md:w-[3.75rem] md:px-1.5' : 'px-2 md:w-52'}
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
{visible.map((i) => (
<NavLink
key={i.to}
to={i.to}
// Підказка лише у згорнутому вигляді: поруч із видимим
// підписом вона повторювала б його й миготіла на кожному
// проході мишею.
title={collapsed ? i.label : undefined}
className={({ isActive }) =>
`relative flex items-center gap-2.5 rounded py-2.5 text-sm md:py-2 ${
collapsed ? 'md:justify-center md:px-0' : 'px-3'
} ${
isActive
? 'bg-slate-800 font-medium text-slate-100'
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
}`
}
>
<span className={`w-4 shrink-0 text-center opacity-80 ${collapsed ? 'md:px-3' : ''}`}>
{i.icon}
</span>
<span className={`flex-1 truncate ${collapsed ? '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
${collapsed ? 'md:absolute md:right-1 md:top-1 md:px-1' : ''}`}
{groups.map((g, gi) => (
<div key={g.title} className="flex flex-col gap-0.5">
{/* Заголовок групи у згорнутому вигляді замінює риска:
текст туди не влазить, а межа між групами потрібна
без неї значки зливаються в одну стрічку. */}
{collapsed ? (
gi > 0 && <div className="mx-1 my-1.5 hidden border-t border-slate-800 md:block" />
) : (
<div
className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider
text-slate-600 ${gi > 0 ? 'pt-3' : 'pt-1'}`}
>
{alerts.counts.firing}
</span>
{g.title}
</div>
)}
</NavLink>
{g.items.map((i) => (
<NavLink
key={i.to}
to={i.to}
// Підказка лише у згорнутому вигляді: поруч із видимим
// підписом вона повторювала б його й миготіла на кожному
// проході мишею. Група в підказці теж не зайва — саме
// вона зникла разом із заголовком.
title={collapsed ? `${g.title} · ${i.label}` : undefined}
className={({ isActive }) =>
`relative flex items-center rounded py-2.5 text-sm md:py-2 ${
collapsed ? '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 ${collapsed ? '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
${collapsed ? 'md:absolute md:right-0.5 md:top-0.5 md:px-1' : ''}`}
>
{alerts.counts.firing}
</span>
)}
</NavLink>
))}
</div>
))}
{visible.length === 0 && (
{!hasAny && (
<p className="px-3 py-4 text-xs leading-relaxed text-slate-600">
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
</p>
@ -204,14 +272,14 @@ export function AppShell() {
панелі, і шукати її перемикач в іншому кутку екрана
зайвий крок. На телефоні панель і так шухляда. */}
<button
className={`mt-auto hidden shrink-0 items-center gap-2.5 rounded py-2 text-sm
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:px-0' : 'px-3'}`}
${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={`w-4 shrink-0 text-center ${collapsed ? 'md:px-3' : ''}`}>
<span className="flex h-5 w-5 shrink-0 items-center justify-center leading-none">
{collapsed ? '»' : '«'}
</span>
<span className={collapsed ? 'md:hidden' : ''}>Згорнути</span>