elcrm 1.1.16 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/index.js +4 -2
  2. package/dist/vite/discover-lib.js +3 -3
  3. package/dist/vite/plugin-css-scoped.d.ts +3 -0
  4. package/dist/vite/plugin-css-scoped.js +42 -10
  5. package/package.json +1 -1
  6. package/templates/elcrm-cursor/rules/elcrm-ui.mdc +1 -1
  7. package/templates/elcrm-docs/CLI.elCRM.md +1 -1
  8. package/templates/elcrm-docs/COMPONENTS.elCRM.md +9 -3
  9. package/templates/elcrm-docs/MODAL.elCRM.md +22 -3
  10. package/templates/elcrm-docs/ROUTER.elCRM.md +9 -5
  11. package/templates/orbit/README.md +4 -3
  12. package/templates/orbit/api/package.json +18 -0
  13. package/templates/orbit/api/src/config.ts +49 -0
  14. package/templates/orbit/api/src/index.ts +72 -0
  15. package/templates/orbit/api/src/lib/cron.ts +13 -0
  16. package/templates/orbit/api/src/lib/db.ts +53 -0
  17. package/templates/orbit/api/src/lib/jwt.ts +19 -0
  18. package/templates/orbit/api/src/routes/auth.ts +114 -0
  19. package/templates/orbit/api/src/routes/deploy.ts +16 -0
  20. package/templates/orbit/api/src/routes/index.ts +16 -0
  21. package/templates/orbit/package.json +8 -30
  22. package/templates/orbit/scripts/dev.ts +33 -0
  23. package/templates/orbit/web/.env.example +3 -0
  24. package/templates/orbit/web/package.json +40 -0
  25. package/templates/orbit/{src → web/src}/App.tsx +1 -9
  26. package/templates/orbit/{src → web/src}/component/Nav.tsx +15 -15
  27. package/templates/orbit/web/src/icons.generated.tsx +76 -0
  28. package/templates/orbit/{src → web/src}/main.tsx +10 -7
  29. package/templates/orbit/{src → web/src}/modules/Home/index.tsx +9 -9
  30. package/templates/orbit/web/src/pages/Auth.tsx +71 -0
  31. package/templates/orbit/{src → web/src}/pages/Main.tsx +7 -10
  32. package/templates/orbit/web/src/state.ts +53 -0
  33. package/templates/orbit/{src → web/src}/style/theme-dark.css +5 -0
  34. package/templates/orbit/{src → web/src}/style/theme-light.css +5 -0
  35. package/templates/orbit/{src → web/src}/style/theme.css +15 -0
  36. package/templates/orbit/web/src/vite-env.d.ts +11 -0
  37. package/templates/orbit/web/vite.config.ts +23 -0
  38. package/templates/panel/web/package.json +1 -1
  39. package/templates/panel/web/src/App.tsx +1 -9
  40. package/templates/panel/web/src/main.tsx +1 -14
  41. package/templates/panel/web/src/pages/Main.tsx +1 -9
  42. package/templates/panel/web/src/style/theme-dark.css +10 -0
  43. package/templates/panel/web/src/style/theme-light.css +10 -0
  44. package/templates/panel/web/src/style/theme.css +26 -0
  45. package/templates/orbit/src/lazyRoute.ts +0 -4
  46. package/templates/orbit/src/lib/icon.tsx +0 -48
  47. package/templates/orbit/src/pages/Auth.tsx +0 -45
  48. package/templates/orbit/src/state.ts +0 -28
  49. package/templates/orbit/src/vite-env.d.ts +0 -1
  50. package/templates/orbit/vite.config.ts +0 -14
  51. package/templates/panel/web/src/lazyRoute.ts +0 -10
  52. /package/templates/orbit/{index.html → web/index.html} +0 -0
  53. /package/templates/orbit/{src → web/src}/lib/theme.ts +0 -0
  54. /package/templates/orbit/{src → web/src}/modules/Analytics/index.tsx +0 -0
  55. /package/templates/orbit/{src → web/src}/modules/Catalog/index.tsx +0 -0
  56. /package/templates/orbit/{src → web/src}/modules/Customers/index.tsx +0 -0
  57. /package/templates/orbit/{src → web/src}/modules/Finances/index.tsx +0 -0
  58. /package/templates/orbit/{src → web/src}/modules/Integrations/index.tsx +0 -0
  59. /package/templates/orbit/{src → web/src}/modules/Link/index.tsx +0 -0
  60. /package/templates/orbit/{src → web/src}/modules/Marketing/index.tsx +0 -0
  61. /package/templates/orbit/{src → web/src}/modules/Placeholder.tsx +0 -0
  62. /package/templates/orbit/{src → web/src}/modules/Settings/index.tsx +0 -0
  63. /package/templates/orbit/{src → web/src}/modules/Store/index.tsx +0 -0
  64. /package/templates/orbit/{src → web/src}/router-data.d.ts +0 -0
  65. /package/templates/orbit/{src → web/src}/style/elcrm.css +0 -0
  66. /package/templates/orbit/{tsconfig.json → web/tsconfig.json} +0 -0
package/dist/index.js CHANGED
@@ -3512,7 +3512,7 @@ function copyTemplate(templateName, targetDir, packageName) {
3512
3512
  cpSync(from, to, { recursive: true });
3513
3513
  }
3514
3514
  const name = packageName ?? folderToPackageName(targetDir);
3515
- if (templateName === "panel") {
3515
+ if (templateName === "panel" || templateName === "orbit") {
3516
3516
  finalizePanelNames(targetDir, name);
3517
3517
  return;
3518
3518
  }
@@ -3542,11 +3542,13 @@ function doneHint(template, cdPath) {
3542
3542
  ` : "";
3543
3543
  if (template === "orbit") {
3544
3544
  console.log(`
3545
- \u0413\u043E\u0442\u043E\u0432\u043E (orbit: \u0432\u0438\u0442\u0440\u0438\u043D\u0430 \u043D\u0430 @elcrm/components).
3545
+ \u0413\u043E\u0442\u043E\u0432\u043E (orbit: web + api).
3546
3546
 
3547
3547
  ${prefix} bun run dev
3548
3548
 
3549
3549
  Web \u2192 http://localhost:5173
3550
+ API \u2192 http://localhost:4545
3551
+ \u0412\u0445\u043E\u0434: admin / admin
3550
3552
  `);
3551
3553
  return;
3552
3554
  }
@@ -11,8 +11,8 @@ function assignLetters(names, overrides) {
11
11
  const used = new Set;
12
12
  const letters = {};
13
13
  for (const [name, letter] of Object.entries(overrides)) {
14
- if (!/^[a-z0-9]$/.test(letter)) {
15
- throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-9)`);
14
+ if (!/^[a-z0-9A-Z]$/.test(letter)) {
15
+ throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-9A-Z)`);
16
16
  }
17
17
  if (used.has(letter)) {
18
18
  throw new Error(`[discover-lib] дубль буквы "${letter}" в overrides`);
@@ -25,7 +25,7 @@ function assignLetters(names, overrides) {
25
25
  if (letters[name])
26
26
  continue;
27
27
  const base = name.replace(/[^a-zA-Z]/g, "").toLowerCase();
28
- let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."abcdefghijklmnopqrstuvwxyz0123456789"].find((c) => !used.has(c));
28
+ let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"].find((c) => !used.has(c));
29
29
  if (!letter) {
30
30
  throw new Error(`[discover-lib] нет свободной буквы/цифры для "${name}"`);
31
31
  }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Vite helper: короткие unique className для CSS Modules elCRM.
3
+ * Формат: 3 символа `{prefix}{буква}{класс}`.
3
4
  * @see createCssScoped
4
5
  */
5
6
  export type CssScopedOptions = {
6
7
  /** Первая буква пакета (c = components, b = button, …) */
7
8
  prefix: string;
9
+ /** Свой prefix у компонента, если слотов пакета не хватает */
10
+ prefixOverrides?: Record<string, string>;
8
11
  /** Папка компонента → одна буква */
9
12
  components: Record<string, string>;
10
13
  /** Каталог с папками компонентов (абсолютный путь) */
@@ -15,15 +15,22 @@ function listModuleCss(dir) {
15
15
  }
16
16
  function localClasses(css) {
17
17
  const set = new Set;
18
- for (const m of css.matchAll(/(?:^|[,{\s>+~])\.([a-z][a-z0-9]*)\b/gi)) {
19
- set.add(m[1]);
18
+ for (const m of css.matchAll(/(?:^|[,{\s>+~])\.([a-z0-9])\b/gi)) {
19
+ set.add(m[1].toLowerCase());
20
20
  }
21
21
  return [...set];
22
22
  }
23
+ function assertPrefix(value, label) {
24
+ if (!/^[a-z]$/.test(value)) {
25
+ throw new Error(`[css-scoped] ${label} должен быть одной буквой a-z, получено: "${value}"`);
26
+ }
27
+ }
23
28
  function createCssScoped(options) {
24
29
  const prefix = options.prefix;
25
- if (!/^[a-z]$/.test(prefix)) {
26
- throw new Error(`[css-scoped] prefix должен быть одной буквой a-z, получено: "${prefix}"`);
30
+ assertPrefix(prefix, "prefix");
31
+ const prefixOverrides = options.prefixOverrides ?? {};
32
+ for (const [name, pfx] of Object.entries(prefixOverrides)) {
33
+ assertPrefix(pfx, `prefixOverrides["${name}"]`);
27
34
  }
28
35
  const components = options.components;
29
36
  const libDir = options.libDir;
@@ -37,19 +44,25 @@ function createCssScoped(options) {
37
44
  }
38
45
  return m[1];
39
46
  }
47
+ function prefixFor(component) {
48
+ return prefixOverrides[component] ?? prefix;
49
+ }
40
50
  function generateScopedName(name, filename) {
41
- if (!/^[a-z][a-z0-9]*$/.test(name)) {
42
- throw new Error(`[css-scoped] локальный класс должен быть [a-z][a-z0-9]*: ".${name}" (${filename})`);
51
+ if (!/^[a-z0-9]$/.test(name)) {
52
+ throw new Error(`[css-scoped] локальный класс 1 знак [a-z0-9] (scoped = 3 символа): ".${name}" (${filename})`);
43
53
  }
44
54
  const component = componentFromFilename(filename);
45
55
  const letter = components[component];
46
56
  if (!letter) {
47
57
  throw new Error(`[css-scoped] нет буквы для компонента "${component}". Добавьте в components.`);
48
58
  }
49
- if (!/^[a-z0-9]$/.test(letter)) {
50
- throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9: "${letter}"`);
59
+ if (!/^[a-z0-9A-Z]$/.test(letter)) {
60
+ throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9A-Z: "${letter}"`);
61
+ }
62
+ const scoped = `${prefixFor(component)}${letter}${name}`;
63
+ if (scoped.length !== 3) {
64
+ throw new Error(`[css-scoped] "${scoped}" ≠ 3 символа (${component}.${name})`);
51
65
  }
52
- const scoped = `${prefix}${letter}${name}`;
53
66
  const owner = `${component}.${name}`;
54
67
  const prev = registry.get(scoped);
55
68
  if (prev && prev !== owner) {
@@ -72,6 +85,10 @@ function createCssScoped(options) {
72
85
  if (missingMap.length) {
73
86
  throw new Error(`[css-scoped] нет буквы в components: ${missingMap.join(", ")}`);
74
87
  }
88
+ const unknownOverride = Object.keys(prefixOverrides).filter((n) => !mapped.has(n));
89
+ if (unknownOverride.length) {
90
+ throw new Error(`[css-scoped] prefixOverrides без компонента: ${unknownOverride.join(", ")}`);
91
+ }
75
92
  const letters = Object.values(components);
76
93
  const dupLetter = [
77
94
  ...new Set(letters.filter((l, i) => letters.indexOf(l) !== i))
@@ -79,15 +96,30 @@ function createCssScoped(options) {
79
96
  if (dupLetter.length) {
80
97
  throw new Error(`[css-scoped] дубли букв компонентов: ${dupLetter.join(", ")}`);
81
98
  }
99
+ const namespaces = Object.entries(components).map(([name, letter]) => `${prefixFor(name)}${letter}`);
100
+ const dupNs = [
101
+ ...new Set(namespaces.filter((n, i) => namespaces.indexOf(n) !== i))
102
+ ];
103
+ if (dupNs.length) {
104
+ throw new Error(`[css-scoped] дубли пространства {prefix}{буква}: ${dupNs.join(", ")}`);
105
+ }
82
106
  const files = listModuleCss(libDir);
83
107
  for (const file of files) {
84
108
  const css = readFileSync(file, "utf8");
109
+ const long = [
110
+ ...css.matchAll(/(?:^|[,{\s>+~])\.([a-z][a-z0-9]+)\b/gi)
111
+ ].map((m) => m[1]);
112
+ if (long.length) {
113
+ const component = componentFromFilename(file);
114
+ throw new Error(`[css-scoped] ${component}: класс длиннее 1 знака (${[...new Set(long)].join(", ")}). Сократи или вынеси в prefixOverrides + 1 знак.`);
115
+ }
85
116
  for (const local of localClasses(css)) {
86
117
  generateScopedName(local, file);
87
118
  }
88
119
  }
89
120
  const snap = snapshot();
90
- console.log(`[css-scoped] OK: ${files.length} module.css, ${snap.size} классов (prefix=${prefix})`);
121
+ const extra = Object.entries(prefixOverrides).map(([n, p]) => `${n}=${p}`).join(", ");
122
+ console.log(`[css-scoped] OK: ${files.length} module.css, ${snap.size} классов (prefix=${prefix}${extra ? `; ${extra}` : ""})`);
91
123
  return snap;
92
124
  }
93
125
  function plugin() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elcrm",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "description": "CLI @elcrm/*: doctor --fix (порядок проекта), update, css, docs, cursor, migrate",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@ src/modules/Name/
25
25
  src/style/theme*.css + elcrm.css
26
26
  ```
27
27
 
28
- Orbit: `Layout sidebar={<Sidebar />}` вместо верхней `Header`. Дашборд — `PageShell` + `Stat` / `Card` / `Item`.
28
+ Orbit: `web/` + `api/`. UI: `Layout sidebar={<Sidebar />}` вместо верхней `Header`. Дашборд — `PageShell` + `Stat` / `Card` / `Item`.
29
29
 
30
30
  - Кнопки: `@elcrm/button` (`onSend`). Иконки: `Icons.Line`. Подсказки: `@elcrm/tooltip`.
31
31
  - HTTP: `Api.query("auth/pass", body)`. Роутер: `Router.setRouter` / `mergeRouter`, разные ключи вкладок.
@@ -111,7 +111,7 @@ minifyCssVars({ var: 2 })
111
111
 
112
112
  Короткие имена **уникальны и подряд**: `--aa`, `--ab`, `--ac`… Занятые слоты пропускаются; `--field` не затирает `--field-border`. Чанки JSZip не минифицируются (декремент `--n`). Если слотов нет — сборка падает.
113
113
 
114
- Также: `elcrm/vite/plugin-css-scoped`, `discover-lib`, `concat-css`, `check-tokens`, `postbuild`.
114
+ Также: `elcrm/vite/plugin-css-scoped` (`prefix` + `prefixOverrides`, scoped = 3 знака), `discover-lib`, `concat-css`, `check-tokens`, `postbuild`.
115
115
 
116
116
  ### Прочее
117
117
 
@@ -15,10 +15,11 @@ import {
15
15
  Layout, Header, Footer, Brand, Avatar, AvatarName, AvatarGroup, Badge,
16
16
  Menu, Dropdown, Section, NavSections, TabSections, Breadcrumb, Stack,
17
17
  Card, Stat, ActionGroup, TextGroup, ItemColumn, List, Item, PageHead, PageShell, ChatList, ChatMessages, ChatSplit, Sidebar, Popover,
18
+ Healthmap, PanelInfo,
18
19
  Loading, EmptyState, RadioGroup, PricingGroup, PricingTable,
19
20
  createLazyResolver,
20
21
  } from "@elcrm/components";
21
- import type { PricingPlan } from "@elcrm/components";
22
+ import type { PricingPlan, HealthmapData } from "@elcrm/components";
22
23
  ```
23
24
 
24
25
  ## Роли layout
@@ -34,6 +35,8 @@ import type { PricingPlan } from "@elcrm/components";
34
35
  - **Stack** — ряд/колонка/сетка (`direction="row"|"column"|"grid"`). Не `Block` / `Row` / `Column`. Сетка: `auto-fit` + `--stack-grid-min`, либо `columns={4}` / `columns={["1fr", "100px", "auto"]}`. С `columns` ряды `stretch` (карточки не наезжают); в одну линию — `style={{ alignItems: "center" }}`. Не `height: 100%` у детей сетки.
35
36
  - **Card** — курсор: `--card-pointer` (`auto` / `pointer`). Сетка Stack: `--stack-grid-min`.
36
37
  - **Stat** — метрика: `label` + `value`, `tone` как у Badge, `labelMuted`. Не собирать из `span` в приложении.
38
+ - **Healthmap** — теплокарта дней (`data.days`). Данные грузит приложение; слоты `brandName` / `brandSub` / `unit` / `hint`. Токены `--healthmap-cell`, `--healthmap-gap`, `--healthmap-l0`…`l4`.
39
+ - **PanelInfo** — выезд справа (шапка / body / footer). На узком экране — оверлей. Слоты: `children`, `footer`, `headerExtra`, `close`. Токены `--panel-info-width`, `--panel-info-width-min`, `--panel-info-overlay-max`, `--panel-info-backdrop`.
37
40
  - **ActionGroup** — тулбар: `label` + `items` (узлы) и/или `children`. Кнопки — `@elcrm/button`, не нативный `<button>`.
38
41
  - **TextGroup** — две строки: `title` + `description` (без аватара; с аватаром — AvatarName).
39
42
  - **Item** — строка списка. `as` button/a/li, `active`. `variant="card"` — рамка и muted-фон (идеи / тикеты / inbox). Не дублировать `.desk-row` в приложении.
@@ -42,8 +45,8 @@ import type { PricingPlan } from "@elcrm/components";
42
45
  ## Меню
43
46
 
44
47
  - **Menu** — inline-навигация в шапке. `items`: `value` + `label` + `badge` (`0` не показываем, иначе `Badge` size s). `variant="line"` — вкладки с чертой снизу (не ломать pill селекторами страницы).
45
- - **Dropdown** — выпадающее меню по `items`/`groups` (portal).
46
- - **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`.
48
+ - **Dropdown** — выпадающее меню по `items`/`groups` (portal). `placement="auto"|"top"|"bottom"` — куда открывать панель (футер Sidebar — `top`).
49
+ - **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`. `placement` как у Dropdown: позиция по **реальной** высоте панели, не по `maxHeight` 420.
47
50
 
48
51
  ## Avatar
49
52
 
@@ -59,6 +62,9 @@ import type { PricingPlan } from "@elcrm/components";
59
62
  ## Можно
60
63
 
61
64
  - `--layout-height` — высота каркаса `Layout` при `fixed` (по умолчанию `100dvh`).
65
+ - `--layout-padding` / `--layout-margin` / `--layout-max-width` — отступы и макс. ширина корня Layout (`0` / `none` = на весь вьюпорт).
66
+ - `--header-border` — рамка Header (shorthand: все стороны и толщина). Только снизу: `none` + линия через `--header-shadow`.
67
+ - `--header-nav-justify` — выравнивание `nav` в шапке (`flex-start` / `center` / `flex-end`).
62
68
  - Узкие токены (`--chat-*`, `--sidebar-*`, `--item-column-*`) по умолчанию = `--shell-*`. Переопределяй только отличие.
63
69
  - Задать `--shell-color` / `--shell-padding` / `--shell-radius` один раз — большинство компонентов подхватят.
64
70
  - `--popup-shadow` общий с `@elcrm/form`.
@@ -18,7 +18,8 @@ const { Open, Icon } = Modal.Create(EditModal, "edit-modal");
18
18
  Open({ id: 123 });
19
19
  ```
20
20
 
21
- Файлы модулей: `modules/<Type>/modal/<Name>.tsx` + `Modal.Open("Type", "Name", params)` или `import_modules` у Init.
21
+ Файлы модулей: `modules/<Type>/modal/<Name>.tsx` + `Modal.Open("Type", "Name", params)`.
22
+ Лоадер — тот же `glob` в `Router.createRouter` (не второй glob и не `importModules`). Свой лоадер — `import_modules` у `Init`.
22
23
 
23
24
  ## Состав
24
25
 
@@ -30,8 +31,26 @@ Open({ id: 123 });
30
31
  | `Modal.Close(name?)` | одно или все |
31
32
  | `Modal.Shake` | тряска |
32
33
 
33
- `size`: `a` auto · `s` 400 · `m` 500 · `l` 800 · `x` 1000 · `f` full.
34
- `escape` закрывает верхнее; клик по оверлею при `shake` (default true) трясёт, не закрывает; крестик закрывает всегда.
34
+ `size`: `a` auto · `s` 400 · `m` 500 · `l` 800 · `x` 1000 · `f` full · `p` page (весь экран поверх страницы).
35
+ `escape` закрывает верхнее; клик по оверлею при `shake` (default true) трясёт, не закрывает; крестик закрывает всегда. Для `size="p"` обычно `shake={false}`.
36
+
37
+ ## Раскрытие из элемента
38
+
39
+ `Create` возвращает `Open`, `Icon` и **`Trigger`**. По клику снимается `getBoundingClientRect`, окно анимируется FLIP из этой позиции (и обратно при закрытии).
40
+
41
+ ```tsx
42
+ const Card = Modal.Create(CardView, "card");
43
+
44
+ // Обёртка (предпочтительно)
45
+ <Card.Trigger params={{ id: 1 }}>
46
+ <Item as="li" variant="card">…</Item>
47
+ </Card.Trigger>
48
+
49
+ // Или вручную
50
+ Card.Open({ id: 1 }, { origin: event.currentTarget });
51
+ ```
52
+
53
+ `prefers-reduced-motion: reduce` — без анимации.
35
54
 
36
55
  ## Нельзя
37
56
 
@@ -13,22 +13,26 @@ Router.createRouter({
13
13
  glob: import.meta.glob("./{pages,modules}/**/*.{tsx,ts}", { eager: false }),
14
14
  });
15
15
 
16
- <LoadRoute variant="page" defaultName="Main" resolveLazyPage={resolvePage}>
17
- <span aria-hidden />
18
- </LoadRoute>
16
+ <LoadRoute variant="page" />
19
17
  ```
20
18
 
19
+ `page` → `pages/{Name}.tsx`. `module` → `modules/{Name}/index.tsx`.
20
+ Тот же glob: `Modal.Open("Home", "Demo")` → `modules/Home/modal/Demo.tsx`.
21
+
21
22
  ## Можно
22
23
 
23
24
  - Состояние: `useRouter`, `setRouter`, `mergeRouter`, `getRouter`, `resetRouter`, `matchesRouter`.
24
- - Типизация: `declare module "@elcrm/router" { interface RouterDataMap { page: "Auth"|"Main"; module: … } }`.
25
+ - Типизация (не обязательна для работы): `declare module "@elcrm/router" { interface RouterDataMap { page: "Auth"|"Main"; module: … } }`.
25
26
  - Секции модуля: отдельный ключ (`section` / `listTab` / `accountTab`) — **не один ключ на все вкладки**.
26
- - `createLazyResolver` + `import(\`./sections/${key}.tsx\`)` (`export default`).
27
+ - `createLazyResolver` + `import(\`./sections/${key}.tsx\`)` — только вкладки внутри модуля (`TabSections`), не страницы.
27
28
  - Persist sessionStorage (crypto), `urlTemplate` по желанию.
28
29
  - `GuardRoute`, `RouteLoader`, `RouterOutletStack`.
30
+ - `paths` / `resolveLazyPage` — если папки не `pages/` и `modules/`.
29
31
 
30
32
  ## Нельзя
31
33
 
32
34
  - `react-router` / `window.location` как основной роутер приложения.
35
+ - Второй `import.meta.glob` + `importModules` для модалок, если glob уже в `createRouter`.
36
+ - `resolveLazyPage` и заглушка `<span aria-hidden />` «чтобы завелось».
33
37
  - Смешивать ключи вкладок разных модулей (после смены таба откроется чужая lazy-секция).
34
38
  - Нормализовать регистр сегментов самостоятельно, если URL-слой включён — библиотека не нижний регистр.
@@ -1,12 +1,13 @@
1
1
  # Orbit
2
2
 
3
- Шаблон витрины на `@elcrm/components`: `Layout` + `Sidebar` + дашборд Home.
3
+ Витрина: `web/` (React + Vite) и `api/` (Bun + SQLite), как panel.
4
4
 
5
5
  ```bash
6
6
  elcrm create my-shop --template orbit
7
7
  bun run dev
8
8
  ```
9
9
 
10
- Web: http://localhost:5173
10
+ - Web: http://localhost:5173
11
+ - API: http://localhost:4545 (`/v1/...`)
11
12
 
12
- Демо-вход: любой логин/пароль (локальный store, API не обязателен).
13
+ Демо-вход: `admin` / `admin`
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "orbit-api",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "module": "src/index.ts",
7
+ "scripts": {
8
+ "dev": "bun run --watch src/index.ts",
9
+ "start": "bun run src/index.ts"
10
+ },
11
+ "dependencies": {
12
+ "@elcrm/server": "^0.6.2",
13
+ "@elcrm/deploy": "^0.0.10"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest"
17
+ }
18
+ }
@@ -0,0 +1,49 @@
1
+ /** Конфиг panel.api */
2
+
3
+ const isProd = process.env.NODE_ENV === "production";
4
+
5
+ /** Не 3535 — часто занят icons.api / другими сервисами elCRM */
6
+ export const PORT = Number(process.env.PORT ?? 4545);
7
+
8
+ function requireSecret(envName: string, devFallback: string): string {
9
+ const v = process.env[envName]?.trim();
10
+ if (v) return v;
11
+ if (isProd) {
12
+ console.error(`❌ ${envName} обязателен в production`);
13
+ process.exit(1);
14
+ }
15
+ console.warn(`⚠️ ${envName} не задан — dev-секрет (только локально)`);
16
+ return devFallback;
17
+ }
18
+
19
+ /** Секрет AES для ssid (@elcrm/jwt) */
20
+ export const TOKEN = requireSecret("TOKEN", "panel-dev-secret-change-me");
21
+
22
+ /** SQLite по умолчанию */
23
+ export const DB_PATH =
24
+ process.env.SQLITE_PATH?.trim() ||
25
+ process.env.DB_BASE?.trim() ||
26
+ "./data/panel.db";
27
+
28
+ /** Секрет hot-upload (@elcrm/deploy), опционально */
29
+ export const UPD = process.env.UPD?.trim() || "";
30
+
31
+ export function getCorsOrigin(): string | string[] {
32
+ const raw = process.env.CORS_ORIGINS?.trim();
33
+ if (!raw || raw === "*") {
34
+ if (!isProd) {
35
+ return [
36
+ "http://localhost:5173",
37
+ "http://localhost:3000",
38
+ "http://127.0.0.1:5173",
39
+ "http://127.0.0.1:3000",
40
+ ];
41
+ }
42
+ return raw === "*" ? ["http://localhost:5173"] : [];
43
+ }
44
+ const list = raw
45
+ .split(",")
46
+ .map((s) => s.trim())
47
+ .filter(Boolean);
48
+ return list.length === 1 ? list[0]! : list;
49
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * orbit.api — createRouter + createServer; JWT/CORS/cron из конфига.
3
+ */
4
+ import { createServer, cors as serverCors } from "@elcrm/server";
5
+ import { DB_PATH, getCorsOrigin, PORT, TOKEN } from "./config";
6
+ import { initDatabase } from "./lib/db";
7
+ import { jwt } from "./lib/jwt";
8
+ import "./lib/cron";
9
+ import { registerRoutes } from "./routes/index";
10
+
11
+ async function start() {
12
+ // TOKEN в env — createJWT/createServer видят один секрет
13
+ if (!process.env.TOKEN?.trim()) {
14
+ process.env.TOKEN = TOKEN;
15
+ }
16
+
17
+ await initDatabase();
18
+ registerRoutes();
19
+
20
+ try {
21
+ createServer({
22
+ port: PORT,
23
+ prefix: "/v1",
24
+ // alias + password hash — явный jwt; иначе хватило бы omit + TOKEN
25
+ jwt,
26
+ // auth в exclude: pass без ssid; me/profile — middleware requireUser (user уже из createServer)
27
+ jwtAuth: { bearer: false },
28
+ securityHeaders: true,
29
+ cron: true,
30
+ cors: serverCors({
31
+ origin: getCorsOrigin(),
32
+ credentials: true,
33
+ headers: [
34
+ "Content-Type",
35
+ "Authorization",
36
+ "ssid",
37
+ "user-agent",
38
+ "X-Request-Id",
39
+ ],
40
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
41
+ }),
42
+ onError: (err, ctx) => {
43
+ console.error(
44
+ "[orbit.api]",
45
+ ctx.requestId,
46
+ ctx.path.join("/"),
47
+ err instanceof Error ? err.message : err,
48
+ );
49
+ },
50
+ });
51
+ } catch (e) {
52
+ const msg = e instanceof Error ? e.message : String(e);
53
+ if (msg.includes("EADDRINUSE") || msg.includes("port")) {
54
+ console.error(
55
+ `❌ Порт ${PORT} занят. Освободите его или задайте другой:\n` +
56
+ ` PORT=4546 bun run dev\n` +
57
+ ` lsof -iTCP:${PORT} -sTCP:LISTEN`,
58
+ );
59
+ }
60
+ throw e;
61
+ }
62
+
63
+ console.log(`orbit.api: http://localhost:${PORT}`);
64
+ console.log(` path-RPC: /v1/{router}/{method}`);
65
+ console.log(` db: ${DB_PATH}`);
66
+ console.log(` demo: admin / admin`);
67
+ }
68
+
69
+ start().catch((e) => {
70
+ console.error(e instanceof Error ? e.message : e);
71
+ process.exit(1);
72
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Минимальный cron — heartbeat. createServer({ cron: true }) / CRON_AUTORUN=1
3
+ * подхватит cron.run() сам.
4
+ */
5
+ import { cron } from "@elcrm/server/cron";
6
+
7
+ cron.use(
8
+ () => {
9
+ console.log(`[cron] heartbeat ${new Date().toISOString()}`);
10
+ },
11
+ "0 */10 * * * *",
12
+ "heartbeat",
13
+ );
@@ -0,0 +1,53 @@
1
+ /**
2
+ * SQLite через @elcrm/server/db + схема users и демо-админ.
3
+ * Каталог для файла БД создаёт сам openSqlite (ensureDir) — node:fs не нужен.
4
+ */
5
+ import { db, initDB } from "@elcrm/server/db";
6
+ import { DB_PATH } from "../config";
7
+ import { jwt } from "./jwt";
8
+
9
+ let ready: Promise<typeof db> | null = null;
10
+
11
+ export async function initDatabase() {
12
+ if (!ready) {
13
+ ready = (async () => {
14
+ await initDB({ type: "sqlite", database: DB_PATH });
15
+ const ok = await db.ping();
16
+ if (!ok) throw new Error(`SQLite недоступна: ${DB_PATH}`);
17
+ await migrate();
18
+ console.log(`✅ DB (sqlite): ${DB_PATH}`);
19
+ return db;
20
+ })();
21
+ }
22
+ return ready;
23
+ }
24
+
25
+ async function migrate() {
26
+ await db.run(`
27
+ CREATE TABLE IF NOT EXISTS users (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ login TEXT NOT NULL UNIQUE,
30
+ password TEXT NOT NULL,
31
+ active INTEGER NOT NULL DEFAULT 1,
32
+ admin INTEGER NOT NULL DEFAULT 0,
33
+ display_name TEXT DEFAULT '',
34
+ email TEXT DEFAULT '',
35
+ avatar TEXT DEFAULT ''
36
+ )
37
+ `);
38
+
39
+ const count = await db.get<{ n: number }>(
40
+ "SELECT count(*) AS n FROM users",
41
+ );
42
+ if (!count?.n) {
43
+ const hash = jwt.password("admin");
44
+ await db.run(
45
+ `INSERT INTO users (login, password, active, admin, display_name, email)
46
+ VALUES (?, ?, 1, 1, ?, ?)`,
47
+ ["admin", hash, "Administrator", "admin@localhost"],
48
+ );
49
+ console.log("✅ seed: admin / admin");
50
+ }
51
+ }
52
+
53
+ export { db };
@@ -0,0 +1,19 @@
1
+ import { createJWT } from "@elcrm/server/jwt";
2
+ import { TOKEN } from "../config";
3
+
4
+ export const jwt = createJWT({
5
+ secret: TOKEN,
6
+ alias: [
7
+ ["i", "id"],
8
+ ["a", "admin"],
9
+ ],
10
+ validateAgent: true,
11
+ excludeRoutes: ["auth", "health", "version"],
12
+ });
13
+
14
+ export type JwtUser = {
15
+ id: number;
16
+ admin?: number;
17
+ agent?: string;
18
+ [key: string]: unknown;
19
+ };