viur-ui 0.0.1

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 (114) hide show
  1. package/README.md +79 -0
  2. package/index.d.ts +351 -0
  3. package/index.js +46 -0
  4. package/package.json +84 -0
  5. package/src/components/Browser/Browser.vue +496 -0
  6. package/src/components/Browser/TreeNav.vue +408 -0
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
  8. package/src/components/Browser/ViurBrowser.vue +271 -0
  9. package/src/components/Browser/dragSession.ts +134 -0
  10. package/src/components/Browser/index.ts +14 -0
  11. package/src/components/Browser/types.ts +30 -0
  12. package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
  13. package/src/components/DataTable/DataTable.vue +3155 -0
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
  15. package/src/components/DataTable/ViurDataTable.vue +331 -0
  16. package/src/components/DataTable/columns.ts +400 -0
  17. package/src/components/DataTable/index.ts +60 -0
  18. package/src/components/DataTable/rows.ts +446 -0
  19. package/src/components/DataTable/types.ts +267 -0
  20. package/src/components/DataTable/useColumnLayout.ts +294 -0
  21. package/src/components/DataTable/useGridNavigation.ts +193 -0
  22. package/src/components/DataTable/useVirtualRows.ts +126 -0
  23. package/src/components/DataTable/viurColumns.ts +330 -0
  24. package/src/forms/ViForm.d.vue.ts +106 -0
  25. package/src/forms/ViForm.vue +592 -0
  26. package/src/forms/ViFormCategory.vue +57 -0
  27. package/src/forms/bones/Bone.vue +1023 -0
  28. package/src/forms/bones/BoneActions.vue +127 -0
  29. package/src/forms/bones/BoneLabel.vue +122 -0
  30. package/src/forms/bones/BoneMultipleList.vue +91 -0
  31. package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
  32. package/src/forms/bones/BoneNestedWrapper.vue +155 -0
  33. package/src/forms/bones/BoneSingleEntry.vue +51 -0
  34. package/src/forms/bones/FilterBone.vue +694 -0
  35. package/src/forms/bones/actionbar/defaultBar.vue +98 -0
  36. package/src/forms/bones/actionbar/fileBar.vue +165 -0
  37. package/src/forms/bones/actionbar/relationalBar.vue +125 -0
  38. package/src/forms/bones/actions/ActionDialog.vue +70 -0
  39. package/src/forms/bones/actions/Debug.vue +106 -0
  40. package/src/forms/bones/actions/Delete.vue +13 -0
  41. package/src/forms/bones/actions/DescribeImage.vue +201 -0
  42. package/src/forms/bones/actions/Download.vue +27 -0
  43. package/src/forms/bones/actions/Placeholder.vue +14 -0
  44. package/src/forms/bones/actions/Translation.vue +238 -0
  45. package/src/forms/bones/actions/index.js +280 -0
  46. package/src/forms/bones/bone.d.ts +45 -0
  47. package/src/forms/bones/country_information.json +779 -0
  48. package/src/forms/bones/index.js +225 -0
  49. package/src/forms/bones/uploadAdapter.js +46 -0
  50. package/src/forms/bones/utils.js +284 -0
  51. package/src/forms/bones/view/base_item.vue +35 -0
  52. package/src/forms/bones/view/boneLogic.js +202 -0
  53. package/src/forms/bones/view/index.js +5 -0
  54. package/src/forms/bones/widgets/booleanBone.vue +83 -0
  55. package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
  56. package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
  57. package/src/forms/bones/widgets/codeBone.vue +214 -0
  58. package/src/forms/bones/widgets/colorBone.vue +75 -0
  59. package/src/forms/bones/widgets/dateBone.vue +114 -0
  60. package/src/forms/bones/widgets/emailBone.vue +100 -0
  61. package/src/forms/bones/widgets/fileBone.vue +273 -0
  62. package/src/forms/bones/widgets/jsonBone.vue +63 -0
  63. package/src/forms/bones/widgets/keyBone.vue +71 -0
  64. package/src/forms/bones/widgets/numericBone.vue +125 -0
  65. package/src/forms/bones/widgets/passwordBone.vue +215 -0
  66. package/src/forms/bones/widgets/phoneBone.vue +173 -0
  67. package/src/forms/bones/widgets/rawBone.vue +94 -0
  68. package/src/forms/bones/widgets/recordBone.vue +64 -0
  69. package/src/forms/bones/widgets/relationalBone.vue +164 -0
  70. package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
  71. package/src/forms/bones/widgets/selectBone.vue +122 -0
  72. package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
  73. package/src/forms/bones/widgets/spatialBone.vue +111 -0
  74. package/src/forms/bones/widgets/stringBone.vue +139 -0
  75. package/src/forms/bones/widgets/textBone.vue +641 -0
  76. package/src/forms/install.d.ts +4 -0
  77. package/src/forms/install.js +43 -0
  78. package/src/forms/layouts/LayloutList2Columns.vue +57 -0
  79. package/src/forms/layouts/LayoutCategory.vue +38 -0
  80. package/src/forms/layouts/LayoutList.vue +31 -0
  81. package/src/forms/utils.js +601 -0
  82. package/src/public/assets/icons/fontawesome-manifest.json +8 -0
  83. package/src/public/assets/icons/regular/file.svg +1 -0
  84. package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
  85. package/src/public/assets/icons/solid/bug.svg +1 -0
  86. package/src/public/assets/icons/solid/check.svg +1 -0
  87. package/src/public/assets/icons/solid/circle-info.svg +1 -0
  88. package/src/public/assets/icons/solid/compress.svg +1 -0
  89. package/src/public/assets/icons/solid/download.svg +1 -0
  90. package/src/public/assets/icons/solid/expand.svg +1 -0
  91. package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
  92. package/src/public/assets/icons/solid/plus.svg +1 -0
  93. package/src/public/assets/icons/solid/question.svg +1 -0
  94. package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
  95. package/src/public/assets/icons/solid/upload.svg +1 -0
  96. package/src/public/assets/icons/solid/xmark.svg +1 -0
  97. package/src/request/cache.js +183 -0
  98. package/src/request/configStore.js +516 -0
  99. package/src/request/envelope.js +211 -0
  100. package/src/request/index.js +35 -0
  101. package/src/request/messageStore.js +201 -0
  102. package/src/request/request.js +752 -0
  103. package/src/request/skel.js +90 -0
  104. package/src/theme/colorStore.js +168 -0
  105. package/src/translations/de.js +178 -0
  106. package/src/translations/en.js +177 -0
  107. package/src/user/LoginScreen.d.vue.ts +54 -0
  108. package/src/user/LoginScreen.vue +627 -0
  109. package/src/user/google.js +75 -0
  110. package/src/user/index.js +3 -0
  111. package/src/user/store.d.ts +59 -0
  112. package/src/user/store.js +183 -0
  113. package/src/webawesomeCompat.css +72 -0
  114. package/src/webawesomeConfig.js +61 -0
@@ -0,0 +1,183 @@
1
+ const MAX_CACHE_ENTRIES = 1000
2
+ const DEFAULT_TTL = 60 * 60 * 24 // 24h in seconds
3
+ const DB_NAME = "viur-request-cache"
4
+ const DB_VERSION = 1
5
+ const STORE_NAME = "requests"
6
+
7
+ let _db = null
8
+
9
+ async function _openDb() {
10
+ if (_db) return _db
11
+ return new Promise((resolve, reject) => {
12
+ const req = indexedDB.open(DB_NAME, DB_VERSION)
13
+ req.onupgradeneeded = (e) => {
14
+ const db = e.target.result
15
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
16
+ db.createObjectStore(STORE_NAME, { keyPath: "cacheKey" })
17
+ }
18
+ }
19
+ req.onsuccess = (e) => {
20
+ _db = e.target.result
21
+ resolve(_db)
22
+ }
23
+ req.onerror = () => reject(req.error)
24
+ })
25
+ }
26
+
27
+ async function _dbGet(cacheKey) {
28
+ const db = await _openDb()
29
+ return new Promise((resolve, reject) => {
30
+ const tx = db.transaction(STORE_NAME, "readonly")
31
+ const req = tx.objectStore(STORE_NAME).get(cacheKey)
32
+ req.onsuccess = () => resolve(req.result ?? null)
33
+ req.onerror = () => reject(req.error)
34
+ })
35
+ }
36
+
37
+ async function _dbPut(entry) {
38
+ const db = await _openDb()
39
+ return new Promise((resolve, reject) => {
40
+ const tx = db.transaction(STORE_NAME, "readwrite")
41
+ tx.objectStore(STORE_NAME).put(entry)
42
+ tx.oncomplete = resolve
43
+ tx.onerror = () => reject(tx.error)
44
+ })
45
+ }
46
+
47
+ async function _dbDelete(cacheKey) {
48
+ const db = await _openDb()
49
+ return new Promise((resolve, reject) => {
50
+ const tx = db.transaction(STORE_NAME, "readwrite")
51
+ tx.objectStore(STORE_NAME).delete(cacheKey)
52
+ tx.oncomplete = resolve
53
+ tx.onerror = () => reject(tx.error)
54
+ })
55
+ }
56
+
57
+ async function _dbGetAll() {
58
+ const db = await _openDb()
59
+ return new Promise((resolve, reject) => {
60
+ const tx = db.transaction(STORE_NAME, "readonly")
61
+ const req = tx.objectStore(STORE_NAME).getAll()
62
+ req.onsuccess = () => resolve(req.result)
63
+ req.onerror = () => reject(req.error)
64
+ })
65
+ }
66
+
67
+ async function _dbClear() {
68
+ const db = await _openDb()
69
+ return new Promise((resolve, reject) => {
70
+ const tx = db.transaction(STORE_NAME, "readwrite")
71
+ tx.objectStore(STORE_NAME).clear()
72
+ tx.oncomplete = resolve
73
+ tx.onerror = () => reject(tx.error)
74
+ })
75
+ }
76
+
77
+ async function _trimCache() {
78
+ const all = await _dbGetAll()
79
+ if (all.length <= MAX_CACHE_ENTRIES) return
80
+ all.sort((a, b) => a.date - b.date)
81
+ const toDelete = all.slice(0, all.length - MAX_CACHE_ENTRIES)
82
+ for (const entry of toDelete) {
83
+ await _dbDelete(entry.cacheKey)
84
+ }
85
+ }
86
+
87
+ // keyToCache maps entity keys (e.g. viur db keys) to cache keys for invalidation
88
+ // In-memory only — rebuilt lazily from IndexedDB after page reload
89
+ const _keyToCache = new Map()
90
+ let _keyMappingsLoaded = false
91
+
92
+ function _registerKeyMapping(entityKey, cacheKey) {
93
+ if (!_keyToCache.has(entityKey)) _keyToCache.set(entityKey, new Set())
94
+ _keyToCache.get(entityKey).add(cacheKey)
95
+ }
96
+
97
+ async function _ensureKeyMappings() {
98
+ if (_keyMappingsLoaded) return
99
+ const all = await _dbGetAll()
100
+ for (const entry of all) {
101
+ for (const key of entry.entityKeys ?? []) {
102
+ _registerKeyMapping(key, entry.cacheKey)
103
+ }
104
+ }
105
+ _keyMappingsLoaded = true
106
+ }
107
+
108
+ /**
109
+ * Get cached response for a URL.
110
+ * Returns null if not cached or expired.
111
+ * @param {string} url
112
+ * @param {number} ttl - TTL in seconds (0 = no expiry)
113
+ */
114
+ if (typeof window !== "undefined" && window.location.hostname === "localhost") {
115
+ clearCache().catch(() => {})
116
+ }
117
+
118
+ export async function getCached(url, ttl = DEFAULT_TTL) {
119
+ const entry = await _dbGet(url)
120
+ if (!entry) return null
121
+ if (ttl > 0 && Date.now() - entry.date > ttl * 1000) {
122
+ await _dbDelete(url)
123
+ return null
124
+ }
125
+ return entry.data
126
+ }
127
+
128
+ /**
129
+ * Store a response in cache.
130
+ * @param {string} url
131
+ * @param {any} data
132
+ * @param {string[]} [entityKeys] - viur entity keys contained in the response (for invalidation)
133
+ */
134
+ export async function setCached(url, data, entityKeys = []) {
135
+ await _dbPut({ cacheKey: url, data, date: Date.now(), entityKeys })
136
+ for (const key of entityKeys) {
137
+ _registerKeyMapping(key, url)
138
+ }
139
+ await _trimCache()
140
+ }
141
+
142
+ /**
143
+ * Invalidate cache entries associated with an entity key.
144
+ * Called after edit/delete/add.
145
+ * @param {string|string[]} entityKeys
146
+ */
147
+ export async function invalidateByKey(entityKeys) {
148
+ await _ensureKeyMappings()
149
+ const keys = Array.isArray(entityKeys) ? entityKeys : [entityKeys]
150
+ for (const entityKey of keys) {
151
+ const cacheKeys = _keyToCache.get(entityKey)
152
+ if (!cacheKeys) continue
153
+ for (const cacheKey of cacheKeys) {
154
+ await _dbDelete(cacheKey)
155
+ }
156
+ _keyToCache.delete(entityKey)
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Clear cached entries.
162
+ * @param {string} [prefix] - If given, only entries whose cache key starts with this prefix are removed.
163
+ * If omitted, all entries are cleared.
164
+ */
165
+ export async function clearCache(prefix) {
166
+ if (prefix === undefined) {
167
+ _keyToCache.clear()
168
+ _keyMappingsLoaded = false
169
+ await _dbClear()
170
+ return
171
+ }
172
+ _keyMappingsLoaded = false
173
+ const all = await _dbGetAll()
174
+ for (const entry of all) {
175
+ if (entry.cacheKey.startsWith(prefix)) {
176
+ await _dbDelete(entry.cacheKey)
177
+ for (const [entityKey, cacheKeys] of _keyToCache) {
178
+ cacheKeys.delete(entry.cacheKey)
179
+ if (cacheKeys.size === 0) _keyToCache.delete(entityKey)
180
+ }
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,516 @@
1
+ import { defineStore } from "pinia"
2
+ import { reactive, computed, markRaw } from "vue"
3
+
4
+ // --------------------------------------------------------------------------- //
5
+ // Config store — `/vi/config` //
6
+ // --------------------------------------------------------------------------- //
7
+ //
8
+ // ViUR core (>= 3.9) exposes `/vi/config`, which serves two audiences with one
9
+ // endpoint:
10
+ //
11
+ // • ANONYMOUS: the public `configuration` map — the `conf.admin.*` settings
12
+ // (dotted keys, e.g. "admin.name"), the Google OAuth client id under
13
+ // "admin.user.google.clientID" (only when configured), the current
14
+ // "admin.language" and the available "admin.languages". The login screen
15
+ // needs this BEFORE it renders, e.g. to decide whether Google sign-in is
16
+ // offered at all.
17
+ //
18
+ // • ADMIN / ROOT: the same `configuration` PLUS `version` (core version as a
19
+ // 4-tuple) and `modules` — the admin info of every routable module, keyed
20
+ // by its vi path ("user", "file", "shop.order", …). This is everything the
21
+ // admin needs to build its navigation and module views. The server only
22
+ // adds these when the session user carries the "admin" or "root" right, so
23
+ // after a login the call is only worth repeating for such a user — see
24
+ // `fetchConfig({ user })`.
25
+
26
+ // --------------------------------------------------------------------------- //
27
+ // Module tree — navigation tree + lookup (part of the config store) //
28
+ // --------------------------------------------------------------------------- //
29
+ //
30
+ // Input (both held by the store):
31
+ // • `modules`: `{ [modulePath]: adminInfo }` — every routable module keyed by
32
+ // its dotted vi path ("user", "shop.order"). adminInfo carries `name`,
33
+ // `handler`, `icon`, `display`, `sortIndex`, `moduleGroup`, `views`, … (see
34
+ // viur-core `Module.adminInfo`).
35
+ // • `groups`: `configuration["admin.module.groups"]` (so rendert viur-core
36
+ // `conf.admin.module_groups`) —
37
+ // `{ [groupKey]: { name, icon, sortIndex, moduleGroup? } }`. A group may
38
+ // itself name a parent group via `moduleGroup` (nested groups).
39
+ //
40
+ // Requirements (derived from the old admin's db.js, not copied):
41
+ // 1. Modules are placed below the group named in their `moduleGroup`; when
42
+ // that group does not exist they stay at top level.
43
+ // 2. Groups nest via their own `moduleGroup`; a group without any visible
44
+ // child is hidden.
45
+ // 3. `views` of a module become child nodes that INHERIT the module's info
46
+ // (`{ ...module, ...view }`, `context` merged) and are addressed by their
47
+ // index. Views may nest again.
48
+ // 4. Access: "root" sees everything; otherwise a module is accessible with
49
+ // `<module>-add|edit|delete` (plus `-view` when `showWithViewAccess`), a
50
+ // grouped view with `<module>-<group>-…`. The flags are checked for the
51
+ // full path ("shop.order") and the base module name ("order").
52
+ // 5. Ordering: explicit numeric `sortIndex` first (ascending), then the rest
53
+ // alphabetically by name. Groups and modules are sorted together.
54
+ // 6. `display`: "visible" (default) | "hidden" (registered but not shown; a
55
+ // relational bone may still use it) | "group" (shown, not clickable, only
56
+ // expands its views).
57
+ // 7. Two consumers: the NAVIGATION needs the tree (`buildModuleTree`), the
58
+ // rest of the app needs to LOOK UP a module/view by name
59
+ // (`flattenModuleTree` + `findModule`), e.g. from a route.
60
+ //
61
+ // The input objects are never mutated; every node is a fresh object.
62
+
63
+ const MUTATING_RIGHTS = ["add", "edit", "delete"]
64
+ const VIEW_RIGHTS = [...MUTATING_RIGHTS, "view"]
65
+
66
+ // one collator for all name comparisons (much cheaper than localeCompare with
67
+ // options per call)
68
+ const collator = new Intl.Collator(undefined, { sensitivity: "base" })
69
+
70
+ /** Access flags as a Set for O(1) checks; accepts an array or a Set. */
71
+ function accessSet(access) {
72
+ if (access instanceof Set) return access
73
+ return new Set(Array.isArray(access) ? access : [])
74
+ }
75
+
76
+ /** Dotted module path — accepts the "/" form the old admin used in routes. */
77
+ export function normalizeModulePath(path) {
78
+ return typeof path === "string" ? path.replaceAll("/", ".") : path
79
+ }
80
+
81
+ /** Last segment of a dotted module path: "shop.order" → "order". */
82
+ export function baseModuleName(path) {
83
+ return normalizeModulePath(path).split(".").at(-1)
84
+ }
85
+
86
+ /**
87
+ * Coarse handler family for picking a UI component:
88
+ * "list" | "tree" | "hierarchy" (tree.node) | "singleton" | null
89
+ */
90
+ export function handlerType(handler) {
91
+ if (typeof handler !== "string" || !handler) return null
92
+ if (handler === "tree.node" || handler.startsWith("tree.node.")) return "hierarchy"
93
+ for (const type of ["list", "tree", "singleton"]) {
94
+ if (handler === type || handler.startsWith(`${type}.`)) return type
95
+ }
96
+ return null
97
+ }
98
+
99
+ /**
100
+ * Whether an access list grants a module (or one of its grouped views).
101
+ * @param {string[]|Set<string>} access user access flags
102
+ * @param {string} module dotted module path
103
+ * @param {{ group?: string|null, showWithViewAccess?: boolean }} [options]
104
+ */
105
+ export function hasModuleAccess(access, module, { group = null, showWithViewAccess = false } = {}) {
106
+ const flags = accessSet(access)
107
+ if (flags.has("root")) return true
108
+ const rights = showWithViewAccess ? VIEW_RIGHTS : MUTATING_RIGHTS
109
+ const path = normalizeModulePath(module)
110
+ const base = baseModuleName(path)
111
+ const names = base === path ? [path] : [path, base]
112
+ for (const name of names) {
113
+ const prefix = group ? `${name}-${group}` : name
114
+ for (const right of rights) {
115
+ if (flags.has(`${prefix}-${right}`)) return true
116
+ }
117
+ }
118
+ return false
119
+ }
120
+
121
+ function sortNodes(nodes) {
122
+ const indexed = (n) => typeof n.sortIndex === "number" && Number.isFinite(n.sortIndex) && n.sortIndex !== 0
123
+ return nodes.sort((a, b) => {
124
+ const ai = indexed(a)
125
+ const bi = indexed(b)
126
+ if (ai && bi && a.sortIndex !== b.sortIndex) return a.sortIndex - b.sortIndex
127
+ if (ai !== bi) return ai ? -1 : 1
128
+ return collator.compare(String(a.name ?? ""), String(b.name ?? ""))
129
+ })
130
+ }
131
+
132
+ function mergeViewInfo(parentInfo, viewInfo) {
133
+ // a view inherits the module's info, but not its own children, position or
134
+ // display mode
135
+ const { views: _views, display: _display, sortIndex: _sortIndex, ...inherited } = parentInfo
136
+ const merged = { ...inherited, ...viewInfo }
137
+ if (parentInfo.context || viewInfo.context) {
138
+ merged.context = { ...(parentInfo.context ?? {}), ...(viewInfo.context ?? {}) }
139
+ }
140
+ return merged
141
+ }
142
+
143
+ function finalizeVisibility(node) {
144
+ for (const child of node.children) finalizeVisibility(child)
145
+ if (node.display === "hidden") {
146
+ node.visible = false
147
+ return
148
+ }
149
+ const childVisible = node.children.some((c) => c.visible)
150
+ if (node.type === "group") {
151
+ node.visible = childVisible
152
+ } else {
153
+ node.visible = node.hasAccess || childVisible
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build the navigation tree.
159
+ *
160
+ * @param {{ modules?: object, groups?: object, access?: string[] }} input
161
+ * @returns {ModuleNode[]} sorted top-level nodes
162
+ */
163
+ export function buildModuleTree({ modules = {}, groups = {}, access = [] } = {}) {
164
+ const flags = accessSet(access)
165
+ const isRoot = flags.has("root")
166
+ const groupNodes = {}
167
+ for (const [key, info] of Object.entries(groups ?? {})) {
168
+ groupNodes[key] = {
169
+ type: "group",
170
+ key: `group:${key}`,
171
+ groupKey: key,
172
+ module: null,
173
+ baseModule: null,
174
+ name: info?.name ?? key,
175
+ icon: info?.icon ?? null,
176
+ handler: null,
177
+ handlerType: null,
178
+ display: info?.display ?? "visible",
179
+ sortIndex: info?.sortIndex,
180
+ hasAccess: false,
181
+ visible: true,
182
+ parent: null,
183
+ info: info ?? {},
184
+ children: [],
185
+ }
186
+ }
187
+
188
+ const roots = []
189
+
190
+ // groups: nest below their parent group, else top level
191
+ for (const node of Object.values(groupNodes)) {
192
+ const parentKey = node.info.moduleGroup
193
+ const parent = parentKey && parentKey !== node.groupKey ? groupNodes[parentKey] : null
194
+ if (parent) {
195
+ node.parent = parent.key
196
+ parent.children.push(node)
197
+ } else {
198
+ roots.push(node)
199
+ }
200
+ }
201
+
202
+ function makeViews(parentNode, views, depth) {
203
+ const out = []
204
+ views.forEach((viewInfo, index) => {
205
+ const info = mergeViewInfo(parentNode.info, viewInfo ?? {})
206
+ const node = makeModuleNode(parentNode.module, info, {
207
+ type: "view",
208
+ key: `${parentNode.key}#${index}`,
209
+ view: index,
210
+ parent: parentNode.key,
211
+ })
212
+ if (Array.isArray(info.views) && info.views.length && depth < 10) {
213
+ node.children = makeViews(node, info.views, depth + 1)
214
+ }
215
+ out.push(node)
216
+ })
217
+ return out // sorted once for the whole tree by sortAll()
218
+ }
219
+
220
+ function makeModuleNode(module, info, extra) {
221
+ const showWithViewAccess = !!info.showWithViewAccess
222
+ const group = info.group ?? null
223
+ const baseModule = baseModuleName(module)
224
+ const node = {
225
+ type: "module",
226
+ key: module,
227
+ groupKey: null,
228
+ module,
229
+ baseModule,
230
+ name: info.name ?? baseModule,
231
+ icon: info.icon ?? null,
232
+ handler: info.handler ?? null,
233
+ handlerType: handlerType(info.handler),
234
+ group,
235
+ display: info.display ?? "visible",
236
+ sortIndex: info.sortIndex,
237
+ hasAccess: false,
238
+ visible: true,
239
+ view: null,
240
+ parent: null,
241
+ info,
242
+ children: [],
243
+ ...extra,
244
+ }
245
+ // a "group" display never gets its own access — it only expands its views
246
+ node.hasAccess =
247
+ node.display !== "group" &&
248
+ !!node.handler &&
249
+ (isRoot || hasModuleAccess(flags, module, { group, showWithViewAccess }))
250
+ return node
251
+ }
252
+
253
+ for (const [path, rawInfo] of Object.entries(modules ?? {})) {
254
+ const module = normalizeModulePath(path)
255
+ const info = rawInfo ?? {}
256
+ const node = makeModuleNode(module, info, {})
257
+ if (Array.isArray(info.views) && info.views.length) {
258
+ node.children = makeViews(node, info.views, 0)
259
+ }
260
+ const parent = info.moduleGroup ? groupNodes[info.moduleGroup] : null
261
+ if (parent) {
262
+ node.parent = parent.key
263
+ parent.children.push(node)
264
+ } else {
265
+ roots.push(node)
266
+ }
267
+ }
268
+
269
+ const sortAll = (nodes) => {
270
+ sortNodes(nodes)
271
+ for (const n of nodes) sortAll(n.children)
272
+ return nodes
273
+ }
274
+ sortAll(roots)
275
+ for (const node of roots) finalizeVisibility(node)
276
+ return roots
277
+ }
278
+
279
+ /**
280
+ * Flatten a tree into a lookup `{ [key]: node }`. Keys are the dotted module
281
+ * path for modules ("shop.order"), `<module>#<index>` for views
282
+ * ("shop.order#1", nested "shop.order#1#0") and `group:<key>` for groups.
283
+ */
284
+ export function flattenModuleTree(tree) {
285
+ const out = {}
286
+ const walk = (nodes) => {
287
+ for (const node of nodes) {
288
+ out[node.key] = node
289
+ if (node.children.length) walk(node.children)
290
+ }
291
+ }
292
+ walk(tree ?? [])
293
+ return out
294
+ }
295
+
296
+ /**
297
+ * Look up a module or one of its views.
298
+ *
299
+ * @param {object} lookup result of `flattenModuleTree`
300
+ * @param {string} module dotted (or slashed) module path
301
+ * @param {number|string|null} [view] view index, or a string matching the
302
+ * view's `group` or `name`
303
+ * @returns {ModuleNode|null}
304
+ */
305
+ export function findModule(lookup, module, view = null) {
306
+ if (!lookup || !module) return null
307
+ const node = lookup[normalizeModulePath(module)] ?? null
308
+ if (view === null || view === undefined || view === "") return node
309
+ if (!node) return null
310
+ const index = typeof view === "number" ? view : /^\d+$/.test(String(view)) ? Number(view) : null
311
+ if (index !== null) return lookup[`${node.key}#${index}`] ?? null
312
+ return node.children.find((c) => c.type === "view" && (c.group === view || c.name === view)) ?? null
313
+ }
314
+
315
+ /** Only the visible nodes (recursively) — what a navigation renders. */
316
+ export function visibleModuleTree(tree) {
317
+ return (tree ?? []).filter((n) => n.visible).map((n) => ({ ...n, children: visibleModuleTree(n.children) }))
318
+ }
319
+
320
+ // --------------------------------------------------------------------------- //
321
+ // Store //
322
+ // --------------------------------------------------------------------------- //
323
+
324
+ const ADMIN_RIGHTS = ["admin", "root"]
325
+
326
+ /** True when a user skel carries a right that unlocks the module part. */
327
+ export function canAccessAdminConfig(user) {
328
+ const access = Array.isArray(user?.access) ? user.access : []
329
+ return ADMIN_RIGHTS.some((flag) => access.includes(flag))
330
+ }
331
+
332
+ function configUrl(renderer) {
333
+ const base = import.meta.env.VITE_API_URL ?? window.location.origin
334
+ return `${base}/${renderer}/config`
335
+ }
336
+
337
+ export const useConfigStore = defineStore("viurConfig", () => {
338
+ const state = reactive({
339
+ // public settings: `conf.admin.*` with dotted keys, language(s), google client id
340
+ configuration: {},
341
+ // admin-only: `{ [modulePath]: adminInfo }`, null until loaded with admin rights
342
+ modules: null,
343
+ // admin-only: core version `[major, minor, patch, extra]`, null until loaded
344
+ version: null,
345
+ // "unknown" | "loading" | "public" | "admin" | "error"
346
+ status: "unknown",
347
+ error: null,
348
+ // renderer segment of the endpoint; ViUR serves the config via the vi renderer
349
+ renderer: "vi",
350
+ // access flags of the session user, used to gate the module tree
351
+ access: [],
352
+ })
353
+
354
+ // Set view of `state.access`, rebuilt only when the flags change
355
+ const accessFlags = computed(() => new Set(state.access))
356
+
357
+ let _pending = null
358
+
359
+ // ── getters ────────────────────────────────────────────────────────────────
360
+ const isLoaded = computed(() => state.status === "public" || state.status === "admin")
361
+ /** The module part (admin/root) has been loaded. */
362
+ const isAdminLoaded = computed(() => state.status === "admin")
363
+ const googleClientId = computed(() => state.configuration["admin.user.google.clientID"] ?? null)
364
+ const hasGoogleLogin = computed(() => !!googleClientId.value)
365
+ const language = computed(() => state.configuration["admin.language"] ?? null)
366
+ const languages = computed(() => state.configuration["admin.languages"] ?? [])
367
+ const moduleNames = computed(() => Object.keys(state.modules ?? {}))
368
+ /**
369
+ * Gruppen der Modul-Navigation. viur-core rendert `conf.admin.module_groups`
370
+ * als PUNKT-Schlüssel `admin.module.groups` (Singular). Der Plural bleibt als
371
+ * Rückfallebene stehen, falls ein Projekt/eine Version ihn so ausliefert.
372
+ */
373
+ const groups = computed(
374
+ () => state.configuration["admin.module.groups"] ?? state.configuration["admin.modules.groups"] ?? {}
375
+ )
376
+
377
+ // ── module tree (navigation) & lookup ─────────────────────────────────────
378
+ /** Full tree incl. hidden/inaccessible nodes (see buildModuleTree above). */
379
+ const moduleTree = computed(() =>
380
+ buildModuleTree({ modules: state.modules ?? {}, groups: groups.value, access: accessFlags.value })
381
+ )
382
+ /** Tree pruned to what the navigation should render. */
383
+ const navigationTree = computed(() => visibleModuleTree(moduleTree.value))
384
+ /** `{ [key]: node }` for lookups; keys: "shop.order", "shop.order#1", "group:shop". */
385
+ const moduleLookup = computed(() => flattenModuleTree(moduleTree.value))
386
+
387
+ /** Resolve a module (or one of its views) node; view = index | group | name. */
388
+ function moduleNode(name, view = null) {
389
+ return findModule(moduleLookup.value, name, view)
390
+ }
391
+
392
+ /** Set the access flags used for the tree (usually `user.access`). */
393
+ function setAccess(access) {
394
+ state.access = Array.isArray(access) ? [...access] : []
395
+ }
396
+
397
+ /** Read a setting; accepts the dotted key ("admin.name") or the bare part
398
+ * ("name", "user.google.clientID") — the `admin.` prefix is optional. */
399
+ function get(key, fallback = undefined) {
400
+ if (key in state.configuration) return state.configuration[key]
401
+ const prefixed = `admin.${key}`
402
+ return prefixed in state.configuration ? state.configuration[prefixed] : fallback
403
+ }
404
+
405
+ /** Admin info of one module ("user", "shop.order"); null when unknown or
406
+ * when the module part has not been loaded. */
407
+ function module(name) {
408
+ return state.modules?.[name] ?? null
409
+ }
410
+
411
+ function configure({ renderer } = {}) {
412
+ if (renderer) state.renderer = renderer
413
+ }
414
+
415
+ // ── fetching ───────────────────────────────────────────────────────────────
416
+ /**
417
+ * Load `/vi/config`.
418
+ *
419
+ * Without arguments the request is always made — call it once BEFORE
420
+ * rendering the login screen (anonymous → public settings).
421
+ *
422
+ * Pass `{ user }` (the UserSkel values) after a login: the module part is
423
+ * only served for admin/root, so for any other user the request is skipped
424
+ * and the public settings already loaded are kept. If the user has the
425
+ * rights, the config is re-fetched and `modules`/`version` filled.
426
+ *
427
+ * Concurrent calls share one request. Resolves with the store state; a
428
+ * failed request sets `status: "error"` and rejects.
429
+ *
430
+ * @param {{ user?: object|null, force?: boolean }} [options]
431
+ * @returns {Promise<object>} the reactive state
432
+ */
433
+ async function fetchConfig({ user = undefined, force = false } = {}) {
434
+ if (user !== undefined) {
435
+ setAccess(user?.access)
436
+ if (!canAccessAdminConfig(user)) return state
437
+ if (isAdminLoaded.value && !force) return state
438
+ }
439
+ if (_pending) return _pending
440
+ _pending = _fetch().finally(() => {
441
+ _pending = null
442
+ })
443
+ return _pending
444
+ }
445
+
446
+ async function _fetch() {
447
+ state.status = "loading"
448
+ state.error = null
449
+ try {
450
+ const response = await fetch(configUrl(state.renderer), {
451
+ credentials: "include",
452
+ headers: { Accept: "application/json" },
453
+ })
454
+ if (!response.ok) {
455
+ throw new Error(`config request failed: ${response.status} ${response.statusText}`)
456
+ }
457
+ const body = await response.json()
458
+ // markRaw: the config is read-only data. Wrapping thousands of module
459
+ // infos in deep reactive proxies would make every read in the tree
460
+ // builder go through a proxy trap and track thousands of dependencies.
461
+ // Replacing the whole object still triggers the computeds.
462
+ state.configuration = markRaw(body?.configuration ?? {})
463
+ if (body?.modules) {
464
+ state.modules = markRaw(body.modules)
465
+ state.version = Array.isArray(body.version) ? body.version : null
466
+ state.status = "admin"
467
+ } else {
468
+ // an anonymous answer never carries modules — drop stale admin data
469
+ // (e.g. after a logout)
470
+ state.modules = null
471
+ state.version = null
472
+ state.status = "public"
473
+ }
474
+ return state
475
+ } catch (error) {
476
+ state.status = "error"
477
+ state.error = error
478
+ throw error
479
+ }
480
+ }
481
+
482
+ function reset() {
483
+ state.configuration = {}
484
+ state.modules = null
485
+ state.version = null
486
+ state.status = "unknown"
487
+ state.error = null
488
+ state.access = []
489
+ _pending = null
490
+ }
491
+
492
+ return {
493
+ state,
494
+ // getters
495
+ isLoaded,
496
+ isAdminLoaded,
497
+ googleClientId,
498
+ hasGoogleLogin,
499
+ language,
500
+ languages,
501
+ moduleNames,
502
+ groups,
503
+ moduleTree,
504
+ navigationTree,
505
+ moduleLookup,
506
+ get,
507
+ module,
508
+ moduleNode,
509
+ setAccess,
510
+ // config
511
+ configure,
512
+ // actions
513
+ fetchConfig,
514
+ reset,
515
+ }
516
+ })