@dyrected/core 2.16.0 → 2.17.0

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.
package/dist/server.js CHANGED
@@ -13774,6 +13774,537 @@ function getSwaggerHtml(specUrl) {
13774
13774
  `;
13775
13775
  }
13776
13776
 
13777
+ // src/types/navigation.ts
13778
+ var CURRENT_NAV_PREFERENCES_VERSION = 1;
13779
+ var DEFAULT_USER_NAV_PREFERENCES = {
13780
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
13781
+ pinned: [],
13782
+ hidden: [],
13783
+ groupOrder: [],
13784
+ itemOrder: {},
13785
+ groups: [],
13786
+ items: []
13787
+ };
13788
+
13789
+ // src/utils/navigation-reconciler.ts
13790
+ var normalizeKey = (val) => (val || "").toLowerCase().replace(/[^a-z0-9]/g, "-");
13791
+ function reconcileNavigation(baseTree, prefs, schemas, options) {
13792
+ if (!baseTree) {
13793
+ return {
13794
+ groups: [],
13795
+ ungrouped: [],
13796
+ pinnedItems: []
13797
+ };
13798
+ }
13799
+ const includeHidden = options?.includeHidden ?? false;
13800
+ const effectivePrefs = prefs || DEFAULT_USER_NAV_PREFERENCES;
13801
+ const hiddenSet = new Set(effectivePrefs.hidden || []);
13802
+ const knownCollectionSlugs = new Set((schemas?.collections || []).map((c) => c.slug));
13803
+ const knownGlobalSlugs = new Set((schemas?.globals || []).map((g) => g.slug));
13804
+ const groupsList = [];
13805
+ const findGroup = (query) => {
13806
+ const qNorm = normalizeKey(query);
13807
+ return groupsList.find(
13808
+ (g) => g.id === query || g.slug === query || g.name === query || normalizeKey(g.id) === qNorm || normalizeKey(g.slug) === qNorm || normalizeKey(g.name) === qNorm
13809
+ );
13810
+ };
13811
+ for (const g of baseTree.groups || []) {
13812
+ groupsList.push({
13813
+ ...g,
13814
+ items: (g.items || []).map((item) => ({ ...item }))
13815
+ });
13816
+ }
13817
+ for (const ug of effectivePrefs.groups || []) {
13818
+ const existing = findGroup(ug.slug || ug.name);
13819
+ if (!existing) {
13820
+ groupsList.push({
13821
+ id: ug.slug ? `group_${ug.slug}` : `group_${normalizeKey(ug.name)}`,
13822
+ name: ug.name,
13823
+ slug: ug.slug || normalizeKey(ug.name),
13824
+ icon: ug.icon || "Folder",
13825
+ defaultExpanded: ug.defaultExpanded ?? true,
13826
+ order: ug.order ?? 100,
13827
+ items: []
13828
+ });
13829
+ }
13830
+ }
13831
+ for (const itemOpt of effectivePrefs.items || []) {
13832
+ const targetGroup = typeof itemOpt.group === "object" ? itemOpt.group?.name : itemOpt.group;
13833
+ const groupKey = targetGroup || "Custom";
13834
+ let group = findGroup(groupKey);
13835
+ if (!group) {
13836
+ group = {
13837
+ id: `group_${normalizeKey(groupKey)}`,
13838
+ name: groupKey,
13839
+ slug: normalizeKey(groupKey),
13840
+ icon: "Folder",
13841
+ defaultExpanded: true,
13842
+ order: 99,
13843
+ items: []
13844
+ };
13845
+ groupsList.push(group);
13846
+ }
13847
+ const itemId = itemOpt.slug ? `workspace_${itemOpt.slug}` : itemOpt.collection ? `collection_${itemOpt.collection}` : `custom_${Math.random()}`;
13848
+ for (const otherGroup of groupsList) {
13849
+ if (otherGroup !== group) {
13850
+ otherGroup.items = otherGroup.items.filter((i) => i.slug !== itemOpt.slug && i.id !== itemId);
13851
+ }
13852
+ }
13853
+ const existingIndex = group.items.findIndex((i) => i.id === itemId || i.slug === itemOpt.slug);
13854
+ const existingItem = existingIndex >= 0 ? group.items[existingIndex] : void 0;
13855
+ const col = schemas?.collections?.find((c) => c.slug === itemOpt.collection);
13856
+ const colViews = col?.views || [];
13857
+ const baseViews = existingItem?.views || [];
13858
+ const userViews = itemOpt.views || [];
13859
+ const userViewSlugs = new Set(userViews.map((v) => v.slug));
13860
+ let mergedViews = [
13861
+ ...baseViews.filter((v) => !userViewSlugs.has(v.slug)),
13862
+ ...userViews
13863
+ ];
13864
+ if (mergedViews.length === 0 && itemOpt.collection) {
13865
+ if (colViews.length > 0) {
13866
+ mergedViews = [...colViews];
13867
+ } else {
13868
+ mergedViews = [
13869
+ {
13870
+ slug: "default",
13871
+ label: col?.label || col?.labels?.plural || itemOpt.label || "All Records",
13872
+ layout: "table",
13873
+ collection: itemOpt.collection
13874
+ }
13875
+ ];
13876
+ }
13877
+ }
13878
+ mergedViews = mergedViews.map((v) => ({
13879
+ ...v,
13880
+ collection: v.collection || itemOpt.collection || existingItem?.collection
13881
+ }));
13882
+ const compiledItem = {
13883
+ id: itemId,
13884
+ type: itemOpt.slug ? "workspace" : itemOpt.collection ? "collection" : itemOpt.global ? "global" : "workspace",
13885
+ slug: itemOpt.slug || itemOpt.collection || itemOpt.global || "item",
13886
+ label: itemOpt.label || itemOpt.slug || "Item",
13887
+ icon: itemOpt.icon || existingItem?.icon || col?.admin?.icon || "Briefcase",
13888
+ group: group.name,
13889
+ order: itemOpt.order ?? existingItem?.order ?? 100,
13890
+ views: mergedViews,
13891
+ collection: itemOpt.collection || existingItem?.collection,
13892
+ global: itemOpt.global || existingItem?.global,
13893
+ badge: itemOpt.badge || existingItem?.badge,
13894
+ access: itemOpt.access || existingItem?.access
13895
+ };
13896
+ if (existingIndex >= 0) {
13897
+ group.items[existingIndex] = compiledItem;
13898
+ } else {
13899
+ group.items.push(compiledItem);
13900
+ }
13901
+ }
13902
+ const allAvailableItems = [];
13903
+ const reconciledGroups = [];
13904
+ for (const group of groupsList) {
13905
+ const isGroupHidden = hiddenSet.has(group.id) || !!group.slug && hiddenSet.has(group.slug) || hiddenSet.has(group.name);
13906
+ if (!includeHidden && isGroupHidden) {
13907
+ continue;
13908
+ }
13909
+ const visibleItems = [];
13910
+ for (const item of group.items) {
13911
+ const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
13912
+ if (!includeHidden && isItemHidden) {
13913
+ continue;
13914
+ }
13915
+ const allViews = item.views || [];
13916
+ const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
13917
+ const compoundKey = `${item.slug}_${v.slug}`;
13918
+ return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
13919
+ });
13920
+ let isTombstone = false;
13921
+ if (schemas && schemas.collections && schemas.collections.length > 0) {
13922
+ if (item.collection && !knownCollectionSlugs.has(item.collection)) {
13923
+ isTombstone = true;
13924
+ }
13925
+ }
13926
+ if (schemas && schemas.globals && schemas.globals.length > 0) {
13927
+ if (item.global && !knownGlobalSlugs.has(item.global)) {
13928
+ isTombstone = true;
13929
+ }
13930
+ }
13931
+ const processedItem = {
13932
+ ...item,
13933
+ views: visibleViews,
13934
+ ...isTombstone ? { isTombstone: true } : {}
13935
+ };
13936
+ visibleItems.push(processedItem);
13937
+ allAvailableItems.push(processedItem);
13938
+ }
13939
+ const groupKey = group.slug || group.name;
13940
+ const itemOrderList = effectivePrefs.itemOrder?.[group.id] || effectivePrefs.itemOrder?.[groupKey];
13941
+ if (itemOrderList && itemOrderList.length > 0) {
13942
+ visibleItems.sort((a, b) => {
13943
+ const aIdx = itemOrderList.indexOf(a.id) >= 0 ? itemOrderList.indexOf(a.id) : a.slug ? itemOrderList.indexOf(a.slug) : -1;
13944
+ const bIdx = itemOrderList.indexOf(b.id) >= 0 ? itemOrderList.indexOf(b.id) : b.slug ? itemOrderList.indexOf(b.slug) : -1;
13945
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
13946
+ if (aIdx >= 0) return -1;
13947
+ if (bIdx >= 0) return 1;
13948
+ return (a.order ?? 100) - (b.order ?? 100);
13949
+ });
13950
+ } else {
13951
+ visibleItems.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
13952
+ }
13953
+ reconciledGroups.push({
13954
+ ...group,
13955
+ items: visibleItems
13956
+ });
13957
+ }
13958
+ if (effectivePrefs.groupOrder && effectivePrefs.groupOrder.length > 0) {
13959
+ reconciledGroups.sort((a, b) => {
13960
+ const aIdx = effectivePrefs.groupOrder.indexOf(a.id) >= 0 ? effectivePrefs.groupOrder.indexOf(a.id) : a.slug ? effectivePrefs.groupOrder.indexOf(a.slug) : effectivePrefs.groupOrder.indexOf(a.name);
13961
+ const bIdx = effectivePrefs.groupOrder.indexOf(b.id) >= 0 ? effectivePrefs.groupOrder.indexOf(b.id) : b.slug ? effectivePrefs.groupOrder.indexOf(b.slug) : effectivePrefs.groupOrder.indexOf(b.name);
13962
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
13963
+ if (aIdx >= 0) return -1;
13964
+ if (bIdx >= 0) return 1;
13965
+ return (a.order ?? 100) - (b.order ?? 100);
13966
+ });
13967
+ } else {
13968
+ reconciledGroups.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
13969
+ }
13970
+ const reconciledUngrouped = (baseTree.ungrouped || []).filter((item) => {
13971
+ const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
13972
+ return includeHidden || !isItemHidden;
13973
+ }).map((item) => {
13974
+ const allViews = item.views || [];
13975
+ const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
13976
+ const compoundKey = `${item.slug}_${v.slug}`;
13977
+ return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
13978
+ });
13979
+ const processed = { ...item, views: visibleViews };
13980
+ allAvailableItems.push(processed);
13981
+ return processed;
13982
+ });
13983
+ const pinnedItems = [];
13984
+ for (const pin of effectivePrefs.pinned || []) {
13985
+ const match = allAvailableItems.find((i) => i.slug === pin.slug || i.id === pin.slug);
13986
+ if (match) {
13987
+ pinnedItems.push({
13988
+ ...match,
13989
+ id: `pin_${match.id}`,
13990
+ label: pin.label || match.label,
13991
+ icon: pin.icon || match.icon
13992
+ });
13993
+ } else {
13994
+ pinnedItems.push({
13995
+ id: `pin_${pin.slug}`,
13996
+ type: pin.type,
13997
+ slug: pin.slug,
13998
+ label: pin.label || pin.slug,
13999
+ icon: pin.icon || "Star",
14000
+ order: 0,
14001
+ views: []
14002
+ });
14003
+ }
14004
+ }
14005
+ return {
14006
+ groups: reconciledGroups,
14007
+ ungrouped: reconciledUngrouped,
14008
+ pinnedItems
14009
+ };
14010
+ }
14011
+
14012
+ // src/utils/navigation.ts
14013
+ var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
14014
+ "collections",
14015
+ "globals",
14016
+ "setup",
14017
+ "api",
14018
+ "login",
14019
+ "logout",
14020
+ "settings",
14021
+ "admin",
14022
+ "preferences",
14023
+ "auth"
14024
+ ]);
14025
+ function assertValidNavigationSlugs(navigation) {
14026
+ for (const item of navigation) {
14027
+ if (item.slug) {
14028
+ const normalizedSlug = item.slug.toLowerCase().trim().replace(/^\//, "");
14029
+ if (RESERVED_NAVIGATION_SLUGS.has(normalizedSlug)) {
14030
+ throw new Error(
14031
+ `[Dyrected Config] The slug '${item.slug}' in navigation item '${item.label || item.slug}' is a reserved system path and cannot be used as an operational workspace slug.`
14032
+ );
14033
+ }
14034
+ }
14035
+ }
14036
+ }
14037
+ function resolveGroupMeta(groupProp, defaultGroupName = "Collections") {
14038
+ if (!groupProp) {
14039
+ return { name: defaultGroupName };
14040
+ }
14041
+ if (typeof groupProp === "string") {
14042
+ return { name: groupProp };
14043
+ }
14044
+ return { name: groupProp.name, meta: groupProp };
14045
+ }
14046
+ function compileNavigation(config) {
14047
+ const collections = config.collections || [];
14048
+ const globals = config.globals || [];
14049
+ const explicitNav = config.admin?.navigation || [];
14050
+ assertValidNavigationSlugs(explicitNav);
14051
+ const collectionsBySlug = /* @__PURE__ */ new Map();
14052
+ for (const col of collections) {
14053
+ collectionsBySlug.set(col.slug, col);
14054
+ }
14055
+ const globalsBySlug = /* @__PURE__ */ new Map();
14056
+ for (const gl of globals) {
14057
+ globalsBySlug.set(gl.slug, gl);
14058
+ }
14059
+ const mentionedCollections = /* @__PURE__ */ new Set();
14060
+ const mentionedGlobals = /* @__PURE__ */ new Set();
14061
+ const injectedViewsByCollection = /* @__PURE__ */ new Map();
14062
+ for (const item of explicitNav) {
14063
+ if (item.addToCollection) {
14064
+ const existing = injectedViewsByCollection.get(item.addToCollection) || [];
14065
+ if (item.views && item.views.length > 0) {
14066
+ injectedViewsByCollection.set(item.addToCollection, [...existing, ...item.views]);
14067
+ }
14068
+ }
14069
+ if (item.collection) mentionedCollections.add(item.collection);
14070
+ if (item.global) mentionedGlobals.add(item.global);
14071
+ }
14072
+ const groupMetadataMap = /* @__PURE__ */ new Map();
14073
+ const itemsByGroup = /* @__PURE__ */ new Map();
14074
+ const ungroupedItems = [];
14075
+ const registerItem = (groupName, item) => {
14076
+ if (!groupName) {
14077
+ ungroupedItems.push(item);
14078
+ } else {
14079
+ const list = itemsByGroup.get(groupName) || [];
14080
+ list.push(item);
14081
+ itemsByGroup.set(groupName, list);
14082
+ }
14083
+ };
14084
+ for (const item of explicitNav) {
14085
+ if (item.addToCollection && !item.slug && !item.collection && !item.global && !item.dashboard && !item.href) {
14086
+ continue;
14087
+ }
14088
+ const { name: groupName, meta: groupMeta } = resolveGroupMeta(item.group, void 0);
14089
+ if (groupName && groupMeta) {
14090
+ groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
14091
+ }
14092
+ if (item.dashboard) {
14093
+ registerItem(groupName, {
14094
+ id: "dashboard",
14095
+ type: "dashboard",
14096
+ slug: "dashboard",
14097
+ label: item.label || "Dashboard",
14098
+ icon: item.icon || "LayoutDashboard",
14099
+ group: groupName,
14100
+ order: item.order ?? 0,
14101
+ views: [],
14102
+ access: item.access
14103
+ });
14104
+ continue;
14105
+ }
14106
+ if (item.collection) {
14107
+ const col = collectionsBySlug.get(item.collection);
14108
+ const combinedViews = [
14109
+ ...col?.views || [],
14110
+ ...injectedViewsByCollection.get(item.collection) || [],
14111
+ ...item.views || []
14112
+ ];
14113
+ registerItem(groupName, {
14114
+ id: item.slug || `collection_${item.collection}`,
14115
+ type: item.slug ? "workspace" : "collection",
14116
+ slug: item.slug || item.collection,
14117
+ collection: item.collection,
14118
+ label: item.label || col?.labels?.plural || item.collection,
14119
+ icon: item.icon || col?.admin?.icon || "Folder",
14120
+ group: groupName,
14121
+ order: item.order ?? col?.admin?.order ?? 100,
14122
+ views: combinedViews,
14123
+ badge: item.badge,
14124
+ access: item.access || col?.access?.read
14125
+ });
14126
+ continue;
14127
+ }
14128
+ if (item.global) {
14129
+ const gl = globalsBySlug.get(item.global);
14130
+ registerItem(groupName, {
14131
+ id: item.slug || `global_${item.global}`,
14132
+ type: "global",
14133
+ slug: item.slug || item.global,
14134
+ global: item.global,
14135
+ label: item.label || gl?.label || item.global,
14136
+ icon: item.icon || "Settings",
14137
+ group: groupName,
14138
+ order: item.order ?? 100,
14139
+ views: [],
14140
+ badge: item.badge,
14141
+ access: item.access
14142
+ });
14143
+ continue;
14144
+ }
14145
+ if (item.href) {
14146
+ registerItem(groupName, {
14147
+ id: `link_${item.slug || item.href}`,
14148
+ type: "link",
14149
+ slug: item.slug || "link",
14150
+ label: item.label || "Link",
14151
+ href: item.href,
14152
+ icon: item.icon || "ExternalLink",
14153
+ group: groupName,
14154
+ order: item.order ?? 100,
14155
+ views: [],
14156
+ badge: item.badge,
14157
+ access: item.access
14158
+ });
14159
+ continue;
14160
+ }
14161
+ const workspaceSlug = item.slug || "workspace";
14162
+ registerItem(groupName, {
14163
+ id: `workspace_${workspaceSlug}`,
14164
+ type: "workspace",
14165
+ slug: workspaceSlug,
14166
+ label: item.label || workspaceSlug,
14167
+ icon: item.icon || "Briefcase",
14168
+ group: groupName,
14169
+ order: item.order ?? 100,
14170
+ views: item.views || [],
14171
+ badge: item.badge,
14172
+ access: item.access
14173
+ });
14174
+ }
14175
+ for (const col of collections) {
14176
+ if (mentionedCollections.has(col.slug)) continue;
14177
+ if (col.admin?.hidden) continue;
14178
+ const groupProp = col.admin?.group;
14179
+ const { name: groupName, meta: groupMeta } = resolveGroupMeta(groupProp, "Collections");
14180
+ if (groupMeta) {
14181
+ groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
14182
+ }
14183
+ const combinedViews = [
14184
+ ...col.views || [],
14185
+ ...injectedViewsByCollection.get(col.slug) || []
14186
+ ];
14187
+ registerItem(groupName, {
14188
+ id: `collection_${col.slug}`,
14189
+ type: "collection",
14190
+ slug: col.slug,
14191
+ collection: col.slug,
14192
+ label: col.labels?.plural || col.slug,
14193
+ icon: col.admin?.icon || "Folder",
14194
+ group: groupName,
14195
+ order: col?.admin?.order ?? 100,
14196
+ views: combinedViews,
14197
+ access: col.access?.read
14198
+ });
14199
+ }
14200
+ for (const gl of globals) {
14201
+ if (mentionedGlobals.has(gl.slug)) continue;
14202
+ const groupName = "Configuration";
14203
+ registerItem(groupName, {
14204
+ id: `global_${gl.slug}`,
14205
+ type: "global",
14206
+ slug: gl.slug,
14207
+ global: gl.slug,
14208
+ label: gl.label || gl.slug,
14209
+ icon: "Settings",
14210
+ group: groupName,
14211
+ order: 100,
14212
+ views: []
14213
+ });
14214
+ }
14215
+ const sortAndSpliceItems = (items) => {
14216
+ const sorted = [...items].sort((a, b) => a.order - b.order);
14217
+ const explicitMap = /* @__PURE__ */ new Map();
14218
+ for (const opt of explicitNav) {
14219
+ if (opt.slug) explicitMap.set(opt.slug, opt);
14220
+ if (opt.collection) explicitMap.set(opt.collection, opt);
14221
+ if (opt.global) explicitMap.set(opt.global, opt);
14222
+ }
14223
+ for (const opt of explicitNav) {
14224
+ const itemKey = opt.slug || opt.collection || opt.global;
14225
+ if (!itemKey) continue;
14226
+ if (opt.after) {
14227
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14228
+ const targetIdx = sorted.findIndex((i) => i.slug === opt.after);
14229
+ if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
14230
+ const [moved] = sorted.splice(itemIdx, 1);
14231
+ const newTargetIdx = sorted.findIndex((i) => i.slug === opt.after);
14232
+ sorted.splice(newTargetIdx + 1, 0, moved);
14233
+ }
14234
+ } else if (opt.before) {
14235
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14236
+ const targetIdx = sorted.findIndex((i) => i.slug === opt.before);
14237
+ if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
14238
+ const [moved] = sorted.splice(itemIdx, 1);
14239
+ const newTargetIdx = sorted.findIndex((i) => i.slug === opt.before);
14240
+ sorted.splice(newTargetIdx, 0, moved);
14241
+ }
14242
+ } else if (opt.position === "first") {
14243
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14244
+ if (itemIdx > 0) {
14245
+ const [moved] = sorted.splice(itemIdx, 1);
14246
+ sorted.unshift(moved);
14247
+ }
14248
+ } else if (opt.position === "last") {
14249
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14250
+ if (itemIdx !== -1 && itemIdx < sorted.length - 1) {
14251
+ const [moved] = sorted.splice(itemIdx, 1);
14252
+ sorted.push(moved);
14253
+ }
14254
+ }
14255
+ }
14256
+ return sorted;
14257
+ };
14258
+ const compiledGroups = [];
14259
+ for (const [groupName, rawItems] of itemsByGroup.entries()) {
14260
+ const meta = groupMetadataMap.get(groupName);
14261
+ const sortedItems = sortAndSpliceItems(rawItems);
14262
+ compiledGroups.push({
14263
+ id: `group_${groupName.toLowerCase().replace(/\s+/g, "_")}`,
14264
+ name: groupName,
14265
+ slug: meta?.slug || groupName.toLowerCase().replace(/\s+/g, "-"),
14266
+ icon: meta?.icon,
14267
+ defaultExpanded: meta?.defaultExpanded ?? true,
14268
+ order: meta?.order ?? 100,
14269
+ items: sortedItems
14270
+ });
14271
+ }
14272
+ compiledGroups.sort((a, b) => a.order - b.order);
14273
+ return {
14274
+ groups: compiledGroups,
14275
+ ungrouped: sortAndSpliceItems(ungroupedItems)
14276
+ };
14277
+ }
14278
+ function pruneNavigationForUser(tree, user) {
14279
+ const userRoles = /* @__PURE__ */ new Set();
14280
+ if (user?.role) userRoles.add(user.role);
14281
+ if (Array.isArray(user?.roles)) {
14282
+ for (const r of user.roles) userRoles.add(r);
14283
+ }
14284
+ const isAllowed = (item) => {
14285
+ if (!item.access) return true;
14286
+ if (Array.isArray(item.access)) {
14287
+ if (item.access.length === 0) return true;
14288
+ return item.access.some((role) => userRoles.has(role));
14289
+ }
14290
+ return true;
14291
+ };
14292
+ const prunedGroups = [];
14293
+ for (const group of tree.groups) {
14294
+ const visibleItems = group.items.filter(isAllowed);
14295
+ if (visibleItems.length > 0) {
14296
+ prunedGroups.push({
14297
+ ...group,
14298
+ items: visibleItems
14299
+ });
14300
+ }
14301
+ }
14302
+ return {
14303
+ groups: prunedGroups,
14304
+ ungrouped: tree.ungrouped.filter(isAllowed)
14305
+ };
14306
+ }
14307
+
13777
14308
  // src/router.ts
13778
14309
  init_access_control();
13779
14310
  import { HTTPException as HTTPException5 } from "hono/http-exception";
@@ -14273,6 +14804,7 @@ function registerRoutes(app, config) {
14273
14804
  const user = c.get("user");
14274
14805
  const key = c.req.param("key");
14275
14806
  const scope = c.req.query("scope");
14807
+ const roleParam = c.req.query("role");
14276
14808
  if (!db) return c.json({ message: "Database not configured" }, 500);
14277
14809
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
14278
14810
  const getGlobalPreference = async () => {
@@ -14282,27 +14814,61 @@ function registerRoutes(app, config) {
14282
14814
  });
14283
14815
  return globalDoc ? globalDoc.value : null;
14284
14816
  };
14285
- if (scope === "global" || !user?.collection || !user.sub) {
14817
+ const getRolePreference = async (r) => {
14818
+ const roleDoc = await db.findOne({
14819
+ collection: "__role_preferences",
14820
+ id: `${r}_${key}`
14821
+ });
14822
+ return roleDoc ? roleDoc.value : null;
14823
+ };
14824
+ const getUserPreferences = async () => {
14825
+ if (!user?.collection || !user.sub) return null;
14826
+ const doc = await db.findOne({ collection: user.collection, id: user.sub });
14827
+ if (!doc) return null;
14828
+ return typeof doc.__preferences === "object" && doc.__preferences !== null ? doc.__preferences : {};
14829
+ };
14830
+ if (scope === "global") {
14286
14831
  const globalValue2 = await getGlobalPreference();
14287
14832
  return c.json({ key, value: globalValue2 });
14288
14833
  }
14289
- const doc = await db.findOne({ collection: user.collection, id: user.sub });
14290
- if (!doc) {
14291
- const globalValue2 = await getGlobalPreference();
14292
- return c.json({ key, value: globalValue2 });
14834
+ if (scope === "role") {
14835
+ if (roleParam) {
14836
+ const val = await getRolePreference(roleParam);
14837
+ return c.json({ key, value: val, role: roleParam });
14838
+ }
14839
+ const userRoles2 = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
14840
+ for (const r of userRoles2) {
14841
+ const val = await getRolePreference(r);
14842
+ if (val !== null && val !== void 0) {
14843
+ return c.json({ key, value: val, role: r });
14844
+ }
14845
+ }
14846
+ return c.json({ key, value: null });
14293
14847
  }
14294
- const preferences = typeof doc.__preferences === "object" && doc.__preferences !== null ? doc.__preferences : {};
14295
- if (key in preferences) {
14296
- return c.json({ key, value: preferences[key] ?? null });
14848
+ if (scope === "personal") {
14849
+ const prefs2 = await getUserPreferences();
14850
+ return c.json({ key, value: prefs2 && key in prefs2 ? prefs2[key] ?? null : null });
14851
+ }
14852
+ const prefs = await getUserPreferences();
14853
+ if (prefs && key in prefs && prefs[key] !== null && prefs[key] !== void 0) {
14854
+ return c.json({ key, value: prefs[key] });
14855
+ }
14856
+ const userRoles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
14857
+ for (const r of userRoles) {
14858
+ const val = await getRolePreference(r);
14859
+ if (val !== null && val !== void 0) {
14860
+ return c.json({ key, value: val });
14861
+ }
14297
14862
  }
14298
14863
  const globalValue = await getGlobalPreference();
14299
- return c.json({ key, value: globalValue });
14864
+ return c.json({ key, value: globalValue ?? null });
14300
14865
  });
14301
14866
  app.put("/api/preferences/:key", requireAuth(config), async (c) => {
14302
14867
  const db = config.db;
14303
14868
  const user = c.get("user");
14304
14869
  const key = c.req.param("key");
14305
- const scope = c.req.query("scope");
14870
+ const scope = c.req.query("scope") || "personal";
14871
+ const roleParam = c.req.query("role");
14306
14872
  if (!db) return c.json({ message: "Database not configured" }, 500);
14307
14873
  if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
14308
14874
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
@@ -14335,7 +14901,47 @@ function registerRoutes(app, config) {
14335
14901
  data: { id: key, value: body.value }
14336
14902
  });
14337
14903
  }
14338
- return c.json({ key, value: body.value });
14904
+ return c.json({ key, value: body.value, scope: "global" });
14905
+ }
14906
+ if (scope === "role") {
14907
+ const userCol = config.collections?.find((col) => col.slug === user?.collection);
14908
+ const isAdminUser = isUserAdmin(user, userCol);
14909
+ if (!isAdminUser) {
14910
+ return c.json(
14911
+ {
14912
+ error: true,
14913
+ message: "Only administrators can save role preferences."
14914
+ },
14915
+ 403
14916
+ );
14917
+ }
14918
+ if (!roleParam) {
14919
+ return c.json(
14920
+ {
14921
+ error: true,
14922
+ message: "Role query parameter is required for role-scoped preferences."
14923
+ },
14924
+ 400
14925
+ );
14926
+ }
14927
+ const roleDocId = `${roleParam}_${key}`;
14928
+ const existing = await db.findOne({
14929
+ collection: "__role_preferences",
14930
+ id: roleDocId
14931
+ });
14932
+ if (existing) {
14933
+ await db.update({
14934
+ collection: "__role_preferences",
14935
+ id: roleDocId,
14936
+ data: { value: body.value, role: roleParam, key }
14937
+ });
14938
+ } else {
14939
+ await db.create({
14940
+ collection: "__role_preferences",
14941
+ data: { id: roleDocId, role: roleParam, key, value: body.value }
14942
+ });
14943
+ }
14944
+ return c.json({ key, value: body.value, role: roleParam, scope: "role" });
14339
14945
  }
14340
14946
  const doc = await db.findOne({ collection: user.collection, id: user.sub });
14341
14947
  if (!doc) return c.json({ error: true, message: "User not found." }, 404);
@@ -14346,13 +14952,14 @@ function registerRoutes(app, config) {
14346
14952
  id: user.sub,
14347
14953
  data: { __preferences: nextPreferences }
14348
14954
  });
14349
- return c.json({ key, value: body.value });
14955
+ return c.json({ key, value: body.value, scope: "personal" });
14350
14956
  });
14351
14957
  app.delete("/api/preferences/:key", requireAuth(config), async (c) => {
14352
14958
  const db = config.db;
14353
14959
  const user = c.get("user");
14354
14960
  const key = c.req.param("key");
14355
- const scope = c.req.query("scope");
14961
+ const scope = c.req.query("scope") || "personal";
14962
+ const roleParam = c.req.query("role");
14356
14963
  if (!db) return c.json({ message: "Database not configured" }, 500);
14357
14964
  if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
14358
14965
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
@@ -14371,6 +14978,30 @@ function registerRoutes(app, config) {
14371
14978
  await db.delete({ collection: "__global_preferences", id: key });
14372
14979
  return c.json({ success: true });
14373
14980
  }
14981
+ if (scope === "role") {
14982
+ const userCol = config.collections?.find((col) => col.slug === user?.collection);
14983
+ const isAdminUser = isUserAdmin(user, userCol);
14984
+ if (!isAdminUser) {
14985
+ return c.json(
14986
+ {
14987
+ error: true,
14988
+ message: "Only administrators can delete role preferences."
14989
+ },
14990
+ 403
14991
+ );
14992
+ }
14993
+ if (!roleParam) {
14994
+ return c.json(
14995
+ {
14996
+ error: true,
14997
+ message: "Role query parameter is required for role-scoped preferences."
14998
+ },
14999
+ 400
15000
+ );
15001
+ }
15002
+ await db.delete({ collection: "__role_preferences", id: `${roleParam}_${key}` });
15003
+ return c.json({ success: true });
15004
+ }
14374
15005
  const doc = await db.findOne({ collection: user.collection, id: user.sub });
14375
15006
  if (!doc) return c.json({ error: true, message: "User not found." }, 404);
14376
15007
  const preferences = typeof doc.__preferences === "object" && doc.__preferences !== null ? { ...doc.__preferences } : {};
@@ -14382,6 +15013,198 @@ function registerRoutes(app, config) {
14382
15013
  });
14383
15014
  return c.json({ success: true });
14384
15015
  });
15016
+ app.get("/api/admin/navigation", optionalAuth(config), async (c) => {
15017
+ const user = c.get("user");
15018
+ const compiled = compileNavigation(config);
15019
+ const pruned = pruneNavigationForUser(compiled, user);
15020
+ return c.json(pruned);
15021
+ });
15022
+ app.on(["GET", "POST"], "/api/admin/navigation/badges", optionalAuth(config), async (c) => {
15023
+ const db = config.db;
15024
+ if (!db) return c.json({ badges: {} });
15025
+ const user = c.get("user");
15026
+ const compiled = compileNavigation(config);
15027
+ let userNavPrefs = null;
15028
+ const prefsQuery = c.req.query("prefs");
15029
+ if (prefsQuery) {
15030
+ try {
15031
+ userNavPrefs = JSON.parse(decodeURIComponent(prefsQuery));
15032
+ } catch {
15033
+ }
15034
+ }
15035
+ if (!userNavPrefs && c.req.method === "POST") {
15036
+ try {
15037
+ const body = await c.req.json();
15038
+ if (body?.preferences) userNavPrefs = body.preferences;
15039
+ } catch {
15040
+ }
15041
+ }
15042
+ if (!userNavPrefs && user?.collection && user?.sub) {
15043
+ try {
15044
+ const userDoc = await db.findOne({ collection: user.collection, id: user.sub });
15045
+ if (userDoc?.__preferences && typeof userDoc.__preferences === "object") {
15046
+ userNavPrefs = userDoc.__preferences["admin:navigation"];
15047
+ }
15048
+ } catch {
15049
+ }
15050
+ }
15051
+ if (!userNavPrefs) {
15052
+ try {
15053
+ const globalDoc = await db.findOne({
15054
+ collection: "__global_preferences",
15055
+ id: "admin:navigation"
15056
+ });
15057
+ if (globalDoc?.value) {
15058
+ userNavPrefs = globalDoc.value;
15059
+ }
15060
+ } catch {
15061
+ }
15062
+ }
15063
+ let allItems;
15064
+ if (userNavPrefs) {
15065
+ const reconciled = reconcileNavigation(compiled, userNavPrefs, {
15066
+ collections: config.collections,
15067
+ globals: config.globals
15068
+ });
15069
+ allItems = [
15070
+ ...reconciled.ungrouped || [],
15071
+ ...reconciled.pinnedItems || [],
15072
+ ...(reconciled.groups || []).flatMap((g) => g.items || [])
15073
+ ];
15074
+ } else {
15075
+ const pruned = pruneNavigationForUser(compiled, user);
15076
+ allItems = [
15077
+ ...pruned.ungrouped,
15078
+ ...pruned.groups.flatMap((g) => g.items)
15079
+ ];
15080
+ }
15081
+ const badges = {};
15082
+ const requestsByCollection = /* @__PURE__ */ new Map();
15083
+ let reqCounter = 0;
15084
+ for (const item of allItems) {
15085
+ if (item.badge) {
15086
+ const itemBadge = item.badge;
15087
+ const badgeKey = item.slug || item.collection || item.global || item.id;
15088
+ if (typeof itemBadge === "string") {
15089
+ badges[badgeKey] = { count: itemBadge, variant: "default" };
15090
+ } else {
15091
+ const badgeConfig = itemBadge;
15092
+ const variant = badgeConfig.variant ?? "default";
15093
+ if (badgeConfig.text) {
15094
+ badges[badgeKey] = { count: badgeConfig.text, variant };
15095
+ } else {
15096
+ let targetCol;
15097
+ let targetWhere;
15098
+ if (badgeConfig.aggregate) {
15099
+ targetCol = badgeConfig.aggregate.collection;
15100
+ targetWhere = badgeConfig.aggregate.where;
15101
+ } else if (badgeConfig.count) {
15102
+ targetCol = item.collection || item.views?.[0]?.collection;
15103
+ const primaryView = item.views?.[0];
15104
+ if (primaryView?.filter) {
15105
+ targetWhere = primaryView.filter;
15106
+ }
15107
+ }
15108
+ if (targetCol) {
15109
+ const reqId = `agg_${reqCounter++}`;
15110
+ const list = requestsByCollection.get(targetCol) || [];
15111
+ list.push({
15112
+ id: reqId,
15113
+ where: targetWhere,
15114
+ onResult: (count2) => {
15115
+ badges[badgeKey] = { count: count2, variant };
15116
+ }
15117
+ });
15118
+ requestsByCollection.set(targetCol, list);
15119
+ }
15120
+ }
15121
+ }
15122
+ }
15123
+ if (item.views && item.views.length > 0) {
15124
+ for (const view of item.views) {
15125
+ if (!view.badge) continue;
15126
+ const viewBadge = view.badge;
15127
+ const viewKey = `${item.slug}:${view.slug}`;
15128
+ const altKey = `${item.slug}_${view.slug}`;
15129
+ const plainKey = view.slug;
15130
+ const setBadgeResult = (res) => {
15131
+ badges[viewKey] = res;
15132
+ badges[altKey] = res;
15133
+ if (!badges[plainKey]) {
15134
+ badges[plainKey] = res;
15135
+ }
15136
+ if (view.collection) {
15137
+ badges[`${view.collection}:${view.slug}`] = res;
15138
+ badges[`${view.collection}_${view.slug}`] = res;
15139
+ }
15140
+ };
15141
+ if (typeof viewBadge === "string") {
15142
+ setBadgeResult({ count: viewBadge, variant: "default" });
15143
+ continue;
15144
+ }
15145
+ const badgeConfig = viewBadge;
15146
+ const variant = badgeConfig.variant ?? "default";
15147
+ if (badgeConfig.text) {
15148
+ setBadgeResult({ count: badgeConfig.text, variant });
15149
+ continue;
15150
+ }
15151
+ const targetCol = badgeConfig.aggregate?.collection || view.collection || item.collection || (item.type === "collection" ? item.slug : void 0);
15152
+ const targetWhere = badgeConfig.aggregate?.where || (typeof view.filter === "object" ? view.filter : void 0);
15153
+ if (!targetCol) continue;
15154
+ const reqId = `agg_${reqCounter++}`;
15155
+ const list = requestsByCollection.get(targetCol) || [];
15156
+ list.push({
15157
+ id: reqId,
15158
+ where: targetWhere,
15159
+ onResult: (count2) => {
15160
+ setBadgeResult({ count: count2, variant });
15161
+ }
15162
+ });
15163
+ requestsByCollection.set(targetCol, list);
15164
+ }
15165
+ }
15166
+ }
15167
+ const collectionTasks = Array.from(requestsByCollection.entries()).map(async ([col, reqs]) => {
15168
+ if (typeof db.aggregate === "function") {
15169
+ try {
15170
+ const aggregates = {};
15171
+ for (const req of reqs) {
15172
+ aggregates[req.id] = { count: "*", where: req.where };
15173
+ }
15174
+ const agg = await db.aggregate({
15175
+ collection: col,
15176
+ aggregates
15177
+ });
15178
+ for (const req of reqs) {
15179
+ const val = agg?.[req.id];
15180
+ req.onResult(typeof val === "number" ? val : 0);
15181
+ }
15182
+ } catch {
15183
+ for (const req of reqs) {
15184
+ req.onResult(0);
15185
+ }
15186
+ }
15187
+ } else {
15188
+ await Promise.allSettled(
15189
+ reqs.map(async (req) => {
15190
+ try {
15191
+ const res = await db.find({
15192
+ collection: col,
15193
+ where: req.where,
15194
+ limit: 1
15195
+ });
15196
+ const total = res?.totalDocs ?? res?.total ?? 0;
15197
+ req.onResult(total);
15198
+ } catch {
15199
+ req.onResult(0);
15200
+ }
15201
+ })
15202
+ );
15203
+ }
15204
+ });
15205
+ await Promise.allSettled(collectionTasks);
15206
+ return c.json({ badges });
15207
+ });
14385
15208
  app.get("/api/media/folders", async (c) => {
14386
15209
  const folderController = new MediaFolderController("media");
14387
15210
  return folderController.list(c);
@@ -14861,7 +15684,33 @@ function normalizeConfig(config) {
14861
15684
  }
14862
15685
  ];
14863
15686
  }
15687
+ if (!existingFieldNames.has("__preferences")) {
15688
+ fields = [
15689
+ ...fields,
15690
+ {
15691
+ name: "__preferences",
15692
+ type: "json",
15693
+ label: "Preferences",
15694
+ promoted: true,
15695
+ admin: {
15696
+ hidden: true,
15697
+ readOnly: true
15698
+ }
15699
+ }
15700
+ ];
15701
+ }
14864
15702
  fields = fields.map((field) => {
15703
+ if (field.name === "__preferences") {
15704
+ return {
15705
+ ...field,
15706
+ promoted: true,
15707
+ admin: {
15708
+ ...field.admin || {},
15709
+ hidden: true,
15710
+ readOnly: true
15711
+ }
15712
+ };
15713
+ }
14865
15714
  if (field.name === "email") {
14866
15715
  return {
14867
15716
  ...field,