menu.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. import { fetcher } from 'app/lib/fetcher';
  2. import { UNA_URL } from 'app/config';
  3. import { appSetting } from './settings';
  4. import { strToObj } from './object';
  5. import { getURI } from './url';
  6. /** UNA / settings menu item: `name`, `title`, `link`, `icon`, `type`, nested `items` / `subitems`, visibility flags… */
  7. export type MenuItem = { [key: string]: any };
  8. /** Menu items as an array or a name-keyed object. */
  9. type MenuItemList = MenuItem[] | Record<string, MenuItem> | null | undefined;
  10. export type DropdownSection = { header: MenuItem | null; items: MenuItem[] };
  11. const getNameFromSetting = (setting: unknown): string | null => {
  12. if (typeof setting === 'string') {
  13. return setting;
  14. } else if (setting && typeof setting === 'object' && (setting as MenuItem).name) {
  15. return (setting as MenuItem).name;
  16. }
  17. return null;
  18. };
  19. export async function getDataForMenu(menu: { object?: unknown; params?: unknown } | null | undefined, callback?: (data: any) => any): Promise<any> {
  20. const data = await fetcher(
  21. '/api.php?r=system/get_menu/TemplServices&params[]={"object":"' + menu?.object + '","params":' + JSON.stringify(menu?.params) + '}'
  22. )
  23. callback?.(data.data)
  24. return data.data
  25. }
  26. export async function runMenuItemCallback(oItem: MenuItem | null | undefined): Promise<boolean> {
  27. const requestUrl = oItem?.data?.request_url
  28. if (!requestUrl) return false
  29. await fetcher('/api.php?r=' + requestUrl)
  30. return true
  31. }
  32. /** App settings use `description`; UNA Studio stores the same copy as `info`. */
  33. export function resolveMenuItemDescription(item: MenuItem | null | undefined): string {
  34. const candidates = [item?.description, item?.info];
  35. for (const value of candidates) {
  36. if (typeof value === 'string') {
  37. const trimmed = value.trim();
  38. if (trimmed) return trimmed;
  39. }
  40. }
  41. return '';
  42. }
  43. /**
  44. * UNA nests children on the parent as `subitems` once Studio sets parent_id
  45. * (`getMenuItemsHierarchy` / `_getMenuItemAPI`). Custom settings menus nest
  46. * the same way as `items` (navbar More, launcher, etc.). The API field may be
  47. * an array or a name-keyed object — accept both.
  48. */
  49. function asMenuItemList(items: MenuItemList): MenuItem[] {
  50. if (!items) return [];
  51. if (Array.isArray(items)) return items;
  52. if (typeof items === 'object') return Object.values(items);
  53. return [];
  54. }
  55. export function getMenuItemChildren(item: MenuItem | null | undefined): MenuItem[] {
  56. const fromSub = asMenuItemList(item?.subitems).filter(Boolean);
  57. if (fromSub.length) return fromSub;
  58. return asMenuItemList(item?.items).filter(Boolean);
  59. }
  60. function getMenuItemChildrenKey(item: MenuItem | null | undefined): 'subitems' | 'items' | null {
  61. if (asMenuItemList(item?.subitems).length) return 'subitems';
  62. if (asMenuItemList(item?.items).length) return 'items';
  63. return null;
  64. }
  65. export function isMenuItemNavigable(item: MenuItem | null | undefined): boolean {
  66. const link = typeof item?.link === 'string' ? item.link.trim() : '';
  67. if (!link) return false;
  68. if (link.startsWith('javascript:')) return false;
  69. return true;
  70. }
  71. /** Prefix relative UNA menu links so dropdown rows navigate in-app. */
  72. export function normalizeMenuItemHref(link: unknown): string {
  73. if (typeof link !== 'string') return '';
  74. const trimmed = link.trim();
  75. if (!trimmed || trimmed.startsWith('javascript:')) return '';
  76. if (trimmed[0] === '/' || trimmed.includes('://')) return trimmed;
  77. return '/' + trimmed;
  78. }
  79. /**
  80. * Flatten a menu tree for DropdownMenu: parent items with children become
  81. * `group_header` rows (section columns), and nested `subitems` / `items`
  82. * follow as regular rows. Empty parents are omitted. Already-flat lists
  83. * with `group_header` rows pass through unchanged.
  84. */
  85. export function flattenMenuItemsForDropdown(items: MenuItemList): MenuItem[] {
  86. const out: MenuItem[] = [];
  87. const visit = (list: MenuItemList) => {
  88. asMenuItemList(list).forEach((item) => {
  89. if (!item || item.name === 'more-auto') return;
  90. if (item.type === 'group_header' || item.type === 'separator') {
  91. out.push(item);
  92. return;
  93. }
  94. const children = getMenuItemChildren(item);
  95. if (children.length) {
  96. const start = out.length;
  97. visit(children);
  98. if (out.length === start) return;
  99. const title = item.title || item.name;
  100. if (title) {
  101. out.splice(start, 0, {
  102. type: 'group_header',
  103. id: `header-${item.id ?? item.name ?? start}`,
  104. name: item.name,
  105. title,
  106. first: start === 0,
  107. });
  108. }
  109. return;
  110. }
  111. if (!isMenuItemNavigable(item)) return;
  112. out.push(item);
  113. });
  114. };
  115. visit(items);
  116. return out;
  117. }
  118. /** Turn a flat dropdown list (`group_header` + items) into column sections. */
  119. export function groupDropdownItems(items: MenuItemList): DropdownSection[] {
  120. const sections: DropdownSection[] = [];
  121. let current: DropdownSection | null = null;
  122. const startSection = (header: MenuItem | null = null): DropdownSection => {
  123. const section: DropdownSection = { header, items: [] };
  124. current = section;
  125. sections.push(section);
  126. return section;
  127. };
  128. asMenuItemList(items).forEach((item) => {
  129. if (item?.type === 'group_header') {
  130. startSection(item);
  131. return;
  132. }
  133. const target = current ?? startSection(null);
  134. target.items.push(item);
  135. });
  136. return sections.filter((section) => section.header || section.items.length);
  137. }
  138. function menuLeafKeys(item: MenuItem | null | undefined): string[] {
  139. if (!item || item.type === 'group_header' || item.type === 'separator') return [];
  140. const keys: string[] = [];
  141. if (item.name) keys.push(`name:${item.name}`);
  142. const uri = getURI(normalizeMenuItemHref(item.link || '')) || '';
  143. if (uri) keys.push(`uri:${uri}`);
  144. return keys;
  145. }
  146. function menuSectionKey(header: MenuItem | null | undefined): string {
  147. const raw = String(header?.name || header?.title || '').toLowerCase().trim();
  148. return raw.replace(/-\d+$/, '');
  149. }
  150. function withNormalizedLink(item: MenuItem): MenuItem {
  151. if (!item?.link || item.type === 'group_header' || item.type === 'separator') return item;
  152. const link = normalizeMenuItemHref(item.link);
  153. return link && link !== item.link ? { ...item, link } : item;
  154. }
  155. function overlayMenuLeaf(base: MenuItem, overlay: MenuItem | undefined): MenuItem {
  156. if (!overlay) return withNormalizedLink(base);
  157. const description = resolveMenuItemDescription(overlay) || resolveMenuItemDescription(base);
  158. return withNormalizedLink({
  159. ...base,
  160. icon: overlay.icon || base.icon,
  161. animated: overlay.animated ?? base.animated,
  162. ...(description ? { description } : {}),
  163. });
  164. }
  165. /**
  166. * Navbar More is a static tree; Apps (`sys_homepage`) is API-driven.
  167. * Prefer the launcher order/inventory, overlay local copy/icons, and append
  168. * local-only rows (Documentation, Groups, …) into the matching section.
  169. */
  170. export function mergeNavbarMoreWithLauncher(localItems: MenuItemList, launcherItems: MenuItemList): MenuItem[] {
  171. const localFlat = flattenMenuItemsForDropdown(localItems);
  172. const remoteFlat = flattenMenuItemsForDropdown(launcherItems);
  173. if (!remoteFlat.length) return localFlat;
  174. if (!localFlat.length) return remoteFlat;
  175. const overlayByKey = new Map<string, MenuItem>();
  176. localFlat.forEach((item) => {
  177. menuLeafKeys(item).forEach((key) => overlayByKey.set(key, item));
  178. });
  179. const used = new Set<string>();
  180. const markUsed = (item: MenuItem) => {
  181. menuLeafKeys(item).forEach((key) => used.add(key));
  182. };
  183. const isUsed = (item: MenuItem) => menuLeafKeys(item).some((key) => used.has(key));
  184. const overlaySections = groupDropdownItems(localFlat);
  185. const primarySections = groupDropdownItems(remoteFlat);
  186. const out: MenuItem[] = [];
  187. primarySections.forEach((section) => {
  188. if (section.header) {
  189. out.push({ ...section.header, first: out.length === 0 });
  190. }
  191. section.items.forEach((item) => {
  192. const overlay = menuLeafKeys(item).map((key) => overlayByKey.get(key)).find(Boolean);
  193. markUsed(overlay || item);
  194. out.push(overlayMenuLeaf(item, overlay));
  195. });
  196. const sectionKey = menuSectionKey(section.header);
  197. if (!sectionKey) return;
  198. overlaySections.forEach((overlaySection) => {
  199. if (menuSectionKey(overlaySection.header) !== sectionKey) return;
  200. overlaySection.items.forEach((item) => {
  201. if (isUsed(item)) return;
  202. markUsed(item);
  203. out.push(withNormalizedLink(item));
  204. });
  205. });
  206. });
  207. overlaySections.forEach((section) => {
  208. const remaining = section.items.filter((item) => !isUsed(item));
  209. if (!remaining.length) return;
  210. if (section.header) {
  211. out.push({ ...section.header, first: out.length === 0 });
  212. }
  213. remaining.forEach((item) => {
  214. markUsed(item);
  215. out.push(withNormalizedLink(item));
  216. });
  217. });
  218. return out;
  219. }
  220. const DROPDOWN_SECTION_MIN = 240; // w-60
  221. const DROPDOWN_SECTION_MAX = 320; // w-80
  222. const DROPDOWN_SECTION_GAP = 4; // gap-1
  223. const DROPDOWN_SECTION_STACK_BELOW = 640; // sm
  224. const DROPDOWN_POPUP_GUTTER = 32;
  225. // Theme shell uses `p-1.5` (6px × 2). Must match `theme.dropdown.cnt` — too
  226. // small and the last column wraps while the popup still reserves its width.
  227. const DROPDOWN_POPUP_PAD = 12;
  228. /**
  229. * How many section columns fit, and how wide they should be.
  230. * Columns stay between w-60 and w-80; the shell hugs that row (does not
  231. * reserve width for wrapped sections).
  232. */
  233. export type DropdownSectionsLayout = {
  234. stacked: boolean;
  235. cols: number;
  236. colWidth: number | null;
  237. contentWidth: number | null;
  238. popupWidth: number | null;
  239. minWidth: number;
  240. maxWidth: number;
  241. gap: number;
  242. };
  243. type LayoutOptions = { minWidth?: number; maxWidth?: number; gap?: number; stackBelow?: number; pad?: number };
  244. export function getDropdownSectionsLayout(sectionCount: number, windowWidth: number, options: LayoutOptions = {}): DropdownSectionsLayout {
  245. const minWidth = options.minWidth ?? DROPDOWN_SECTION_MIN;
  246. const maxWidth = options.maxWidth ?? DROPDOWN_SECTION_MAX;
  247. const gap = options.gap ?? DROPDOWN_SECTION_GAP;
  248. const stackBelow = options.stackBelow ?? DROPDOWN_SECTION_STACK_BELOW;
  249. const pad = options.pad ?? DROPDOWN_POPUP_PAD;
  250. const count = Math.max(1, sectionCount || 1);
  251. if (!windowWidth || windowWidth < stackBelow) {
  252. return {
  253. stacked: true,
  254. cols: 1,
  255. colWidth: null,
  256. contentWidth: null,
  257. popupWidth: null,
  258. minWidth,
  259. maxWidth,
  260. gap,
  261. };
  262. }
  263. const available = Math.max(
  264. minWidth,
  265. windowWidth - DROPDOWN_POPUP_GUTTER - pad
  266. );
  267. const maxCols = Math.max(1, Math.floor((available + gap) / (minWidth + gap)));
  268. const cols = Math.min(count, maxCols);
  269. const colWidth = Math.min(
  270. maxWidth,
  271. Math.max(minWidth, Math.floor((available - gap * (cols - 1)) / cols))
  272. );
  273. const contentWidth = cols * colWidth + gap * (cols - 1);
  274. return {
  275. stacked: false,
  276. cols,
  277. colWidth,
  278. contentWidth,
  279. popupWidth: contentWidth + pad,
  280. minWidth,
  281. maxWidth,
  282. gap,
  283. };
  284. }
  285. const DROPDOWN_GRID_COLS = 3;
  286. const DROPDOWN_GRID_CELL = 108;
  287. const DROPDOWN_GRID_GAP = 4;
  288. /** Drop section headers/separators so a dropdown can render as a tile grid. */
  289. export function dropdownLeafItems(items: MenuItemList): MenuItem[] {
  290. return asMenuItemList(items).filter(
  291. (item) => item && item.type !== 'group_header' && item.type !== 'separator'
  292. );
  293. }
  294. /** Compact waffle (Apps launcher): 3 equal tiles, shell hugs the grid. */
  295. export function getDropdownGridLayout(options: { cols?: number; cellWidth?: number; gap?: number; pad?: number } = {}): { cols: number; cellWidth: number; gap: number; contentWidth: number; popupWidth: number } {
  296. const cols = options.cols ?? DROPDOWN_GRID_COLS;
  297. const cellWidth = options.cellWidth ?? DROPDOWN_GRID_CELL;
  298. const gap = options.gap ?? DROPDOWN_GRID_GAP;
  299. const pad = options.pad ?? DROPDOWN_POPUP_PAD;
  300. const contentWidth = cols * cellWidth + Math.max(0, cols - 1) * gap;
  301. return {
  302. cols,
  303. cellWidth,
  304. gap,
  305. contentWidth,
  306. popupWidth: contentWidth + pad,
  307. };
  308. }
  309. const KEBAB_ICON_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
  310. // Font Awesome style prefixes UNA puts before the name (`far file-word`).
  311. const FA_STYLE_TOKENS = new Set(['fa', 'fas', 'far', 'fab', 'fal', 'fad', 'fat']);
  312. /** Map a UNA / Font Awesome icon string to an icon-set name (PascalCase); returns input if unknown. */
  313. export function findIconFromRemote<T extends string | null | undefined>(s: T): T | string {
  314. if (!s || /^[A-Z][^\s]*$/.test(s))
  315. return s;
  316. const data = appSetting('menu_items', 'iconset');
  317. const words = s.replace(/\bcol-\S*\b/g, '').trim().split(/\s+/)
  318. .filter((word) => !FA_STYLE_TOKENS.has(word));
  319. for (let word of words) {
  320. const name = data[word] || data[word.replace(/^fa-/, '')];
  321. if (name) {
  322. return name;
  323. }
  324. }
  325. // UNA also sends Lucide names in kebab case (`log-in`); icon sets are keyed by PascalCase.
  326. const kebab = words.map((word) => word.replace(/^fa-/, '')).find((word) => KEBAB_ICON_NAME.test(word));
  327. if (kebab) {
  328. return kebab.replace(/(^|-)([a-z0-9])/g, (_: string, _dash: string, ch: string) => ch.toUpperCase());
  329. }
  330. return s;
  331. }
  332. export function menuItemsByNameNew(name: string, menu: { items?: MenuItemList; config?: unknown } | null | undefined, currentUser: any, url = ''): MenuItem[] {
  333. return menuItemsByName(name, menu?.items, currentUser, url, menu?.config)
  334. }
  335. export function menuItemsByName(name: string, items: any, currentUser: any, url = '', config: unknown = null): MenuItem[] {
  336. if (!items)
  337. return [];
  338. const menuSettings = getMenuSettings(name, config);//appSetting('menu_items', name)
  339. let menuSettingNames: (string | null)[] = []
  340. if (menuSettings) {
  341. if (menuSettings.items) {
  342. if (typeof menuSettings.items[0] === 'string') {
  343. items = items.filter((item: MenuItem) => (item.hideInTop || (!!item.name && menuSettings.items.includes(item.name)) || (!!item.link && menuSettings.items.includes(getURI(item.link) as string))));
  344. }
  345. else {
  346. menuSettingNames = menuSettings.items.map(getNameFromSetting);
  347. items = items.filter((item: MenuItem) => (item.hideInTop || (!!item.name && menuSettingNames.includes(item.name)) || (!!item.link && menuSettingNames.includes(getURI(item.link) as string))));
  348. menuSettingNames = [];
  349. items = items.map((item1: MenuItem) => {
  350. const item2 = menuSettings.items.find((item2: MenuItem) => item2.name === item1.name);
  351. return item2 ? { ...item1, ...item2 } : item1;
  352. });
  353. }
  354. }
  355. else {
  356. if (menuSettings.items) {
  357. menuSettingNames = menuSettings.map(getNameFromSetting);
  358. items = items.filter((item: MenuItem) => (item.hideInTop || (!!item.name && menuSettingNames.includes(item.name)) || (!!item.link && menuSettingNames.includes(getURI(item.link) as string))));
  359. }
  360. }
  361. if (menuSettingNames) {
  362. items.forEach((item: MenuItem) => {
  363. if (menuSettingNames.includes(item.name)) {
  364. let matchedSettings = menuSettings.filter((item2: MenuItem) => item.name === item2.name);
  365. if (matchedSettings.length > 0) {
  366. item.settings = matchedSettings[0].settings;
  367. }
  368. }
  369. });
  370. }
  371. return menuItemsFilter(items, currentUser);
  372. }
  373. if (!items)
  374. items = [{ link: url, title: '' }];
  375. return menuItemsFilter(items, currentUser);
  376. }
  377. /** Apply visibility flags (logged / nonlogged / nonoperator / membership_level) and fill placeholder links. */
  378. export function menuItemsFilter(items: any, currentUser: any): any {
  379. if (!items)
  380. return items;
  381. items = asMenuItemList(items);
  382. if (!currentUser) {
  383. items = items.filter((item: MenuItem) => item.nonlogged !== false && item.nonoperator !== false);
  384. }
  385. else {
  386. items = items.filter((item: MenuItem) => item.logged !== false);
  387. if (!currentUser.operator) {
  388. items = items.filter((item: MenuItem) => item.nonoperator !== false);
  389. }
  390. items = items.filter((item: MenuItem) => {
  391. return !item.membership_level || (item.membership_level & Math.pow(2, currentUser.membership)) == Math.pow(2, currentUser.membership)
  392. });
  393. }
  394. items = items.map((item: MenuItem) => {
  395. const description = resolveMenuItemDescription(item);
  396. let next = description && item.description !== description
  397. ? { ...item, description }
  398. : item;
  399. if (next.link === '{studio}') {
  400. next = { ...next, link: UNA_URL + '/studio/launcher.php' };
  401. } else if (next.link === '{profile}') {
  402. next = { ...next, link: currentUser?.url };
  403. } else if (next?.link && next?.link?.includes("{profile_url_postfix}")) { // SHOULD BE IMPROVED by url_postfix
  404. next = { ...next, link: next.link.replace("{profile_url_postfix}", currentUser?.url.replace('/view-persons-profile/', '')) };
  405. }
  406. const childrenKey = getMenuItemChildrenKey(next);
  407. if (childrenKey) {
  408. next = { ...next, [childrenKey]: menuItemsFilter(getMenuItemChildren(next), currentUser) };
  409. }
  410. return next;
  411. });
  412. return items;
  413. }
  414. export function getMenuSettings(object: string, config?: unknown, menu?: { title?: string; icon?: string }): any {
  415. if (!appSetting('layout', 'user_remote_config'))
  416. return appSetting('menu_items', object);
  417. let a: any = {}
  418. if (appSetting('layout', 'user_remote_config') && config)
  419. a = strToObj(config as string);
  420. if (a && !a.name && menu?.title)
  421. a.name = menu.title;
  422. if (a && !a.icon && menu?.icon)
  423. a.icon = menu.icon;
  424. return a
  425. }