Add the admin panel scaffold

A self-contained template: an Express backend with login, JWT, rate limiting
and SPA serving, and a React 18 + Vite shell with the same tab registry and
warm Material 3 design system the panel uses. No domain logic — routes and tabs
are meant to be hung off it.

node_modules and the frontend build stay out through the existing ignores.
This commit is contained in:
2026-09-10 00:24:18 +03:00
parent a11cfc88de
commit 66ae59e1d0
19 changed files with 4942 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Вкладки
Вкладок пока нет — каркас работает и с пустой папкой (оболочка покажет заглушку).
## Как добавить вкладку
Создай папку `src/tabs/<name>/` с тремя файлами — реестр (`tabs/index.ts`)
подхватит её сам, править его **не нужно**.
```
src/tabs/reports/
metadata.json { "id": "reports", "label": "Отчёты", "icon": "settings", "order": 1 }
index.tsx export default function ReportsPage({ token, showMsg }: PageProps) { … }
reports.css стили только этой вкладки (импортит сам index.tsx)
```
**metadata.json**
| поле | тип | обяз. | смысл |
|---------|--------|-------|----------------------------------------------------|
| `id` | string | да | ключ активной вкладки, должен быть уникальным |
| `label` | string | да | подпись в меню и заголовок раздела |
| `icon` | string | да | имя из `ICONS` в `src/components/icons.tsx` |
| `order` | number | нет | порядок в меню по возрастанию (без него — 0) |
Манифест валидируется в рантайме: опечатка или незарегистрированная иконка
падают с внятной ошибкой при сборке `TABS`, а не «тихо» в UI.
**index.tsx** — дефолтный экспорт компонента `ComponentType<PageProps>`,
где `PageProps = { token, showMsg }` (`src/lib/shared.ts`). Данные вкладка
грузит сама: `apiFetch('GET', '/api/…', token)`.
Новую иконку сначала добавь в `src/components/icons.tsx` и зарегистрируй
в карте `ICONS` — на её имя и ссылается `metadata.json`.
+81
View File
@@ -0,0 +1,81 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — { id, label, icon, order? } (манифест: описание + порядок)
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
// валидируется здесь в рантайме — кривой/неполный манифест падает с внятной
// ошибкой при сборке TABS, а не «тихо» в UI.
import type { ComponentType } from 'react';
import type { PageProps } from '../lib/shared';
import { ICONS } from '../components/icons';
// Сериализуемая часть из metadata.json.
export interface TabMeta {
id: string;
label: string;
icon: string; // имя иконки → ICONS
order?: number; // порядок в меню (по возрастанию; без него — 0)
}
// Собранная вкладка: метадата + резолвнутые Icon и Page.
export interface TabDef extends TabMeta {
Icon: () => JSX.Element;
Page: ComponentType<PageProps>;
}
const metas = import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true });
const pages = import.meta.glob<{ default: ComponentType<PageProps> }>('./*/index.tsx', { eager: true });
// './users/metadata.json' → 'users'
const folderOf = (path: string) => path.split('/')[1];
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
function toMeta(name: string, raw: unknown): TabMeta {
const m = (raw ?? {}) as Record<string, unknown>;
const str = (key: string): string => {
const v = m[key];
if (typeof v !== 'string' || v.trim() === '') {
throw new Error(`tabs/${name}/metadata.json: поле "${key}" должно быть непустой строкой`);
}
return v;
};
if (m.order !== undefined && typeof m.order !== 'number') {
throw new Error(`tabs/${name}/metadata.json: "order" должно быть числом`);
}
return {
id: str('id'),
label: str('label'),
icon: str('icon'),
order: m.order as number | undefined,
};
}
// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка
// показывает заглушку вместо контента.
export const TABS: TabDef[] = Object.entries(metas)
.map(([path, mod]) => {
const name = folderOf(path);
const meta = toMeta(name, mod.default);
const page = pages[`./${name}/index.tsx`]?.default;
if (!page) {
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
}
if (!ICONS[meta.icon]) {
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS (components/icons.tsx)`);
}
return { ...meta, Icon: ICONS[meta.icon], Page: page };
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id);
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
if (dup) {
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
}