Files
ma7engine/core/browser/tabs/index.ts
T
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

151 lines
8.0 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.
// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.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<any>;
}
export interface TabGroup {
category: string;
tabs: TabDef[];
}
export interface BuiltTabs {
TABS: TabDef[];
ALL_TAB_PERMISSIONS: string[];
visibleTabsByCategory: (permissions: string[]) => TabGroup[];
}
type MetaModules = Record<string, { default: unknown }>;
type PageModules = Record<string, { default: ComponentType<any> }>;
type IconMap = Record<string, () => 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<string, unknown>;
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 };
}