// Реестр вкладок. Каждая вкладка — самодостаточная папка 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 }; }