@aphexcms/cms-core 2.0.10 → 2.0.11

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 (281) hide show
  1. package/dist/api/api-keys.d.ts +4 -0
  2. package/dist/api/api-keys.d.ts.map +1 -1
  3. package/dist/api/assets.d.ts.map +1 -1
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +1 -0
  7. package/dist/api/roles.d.ts +22 -0
  8. package/dist/api/roles.d.ts.map +1 -0
  9. package/dist/api/roles.js +26 -0
  10. package/dist/api/schemas/api-keys.d.ts +50 -4
  11. package/dist/api/schemas/api-keys.d.ts.map +1 -1
  12. package/dist/api/schemas/api-keys.js +22 -3
  13. package/dist/api/schemas/organizations.d.ts +4 -22
  14. package/dist/api/schemas/organizations.d.ts.map +1 -1
  15. package/dist/api/schemas/organizations.js +13 -2
  16. package/dist/api/schemas/roles.d.ts +77 -0
  17. package/dist/api/schemas/roles.d.ts.map +1 -0
  18. package/dist/api/schemas/roles.js +42 -0
  19. package/dist/app-augment.d.ts +32 -0
  20. package/dist/app-augment.d.ts.map +1 -0
  21. package/dist/app-augment.js +18 -0
  22. package/dist/auth/auth-hooks.d.ts +2 -1
  23. package/dist/auth/auth-hooks.d.ts.map +1 -1
  24. package/dist/auth/auth-hooks.js +22 -2
  25. package/dist/client/index.d.ts +2 -0
  26. package/dist/client/index.d.ts.map +1 -1
  27. package/dist/client/index.js +4 -0
  28. package/dist/components/AdminApp.svelte +300 -140
  29. package/dist/components/AdminApp.svelte.d.ts +8 -0
  30. package/dist/components/AdminApp.svelte.d.ts.map +1 -1
  31. package/dist/components/admin/DocumentEditor.svelte +633 -350
  32. package/dist/components/admin/DocumentEditor.svelte.d.ts +2 -0
  33. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
  34. package/dist/components/admin/DocumentVersionPanel.svelte +46 -20
  35. package/dist/components/admin/DocumentVersionPanel.svelte.d.ts.map +1 -1
  36. package/dist/components/admin/MediaBrowser.svelte +70 -30
  37. package/dist/components/admin/MediaBrowser.svelte.d.ts.map +1 -1
  38. package/dist/components/admin/ObjectModal.svelte +70 -95
  39. package/dist/components/admin/ObjectModal.svelte.d.ts +1 -2
  40. package/dist/components/admin/ObjectModal.svelte.d.ts.map +1 -1
  41. package/dist/components/admin/PermissionsDebug.svelte +119 -0
  42. package/dist/components/admin/PermissionsDebug.svelte.d.ts +4 -0
  43. package/dist/components/admin/PermissionsDebug.svelte.d.ts.map +1 -0
  44. package/dist/components/admin/SchemaField.svelte +3 -1
  45. package/dist/components/admin/SchemaField.svelte.d.ts.map +1 -1
  46. package/dist/components/admin/confirm-dialog/ConfirmDialogHost.svelte +8 -10
  47. package/dist/components/admin/confirm-dialog/ConfirmDialogHost.svelte.d.ts.map +1 -1
  48. package/dist/components/admin/fields/ArrayField.svelte +104 -108
  49. package/dist/components/admin/fields/ArrayField.svelte.d.ts.map +1 -1
  50. package/dist/components/admin/fields/FileField.svelte +22 -17
  51. package/dist/components/admin/fields/FileField.svelte.d.ts.map +1 -1
  52. package/dist/components/admin/fields/ImageField.svelte +21 -15
  53. package/dist/components/admin/fields/ImageField.svelte.d.ts.map +1 -1
  54. package/dist/components/admin/fields/ReferenceField.svelte +142 -112
  55. package/dist/components/admin/fields/ReferenceField.svelte.d.ts.map +1 -1
  56. package/dist/components/admin/fields/TextareaField.svelte +4 -3
  57. package/dist/components/admin/fields/TextareaField.svelte.d.ts +2 -2
  58. package/dist/components/admin/fields/TextareaField.svelte.d.ts.map +1 -1
  59. package/dist/components/layout/Sidebar.svelte +21 -11
  60. package/dist/components/layout/Sidebar.svelte.d.ts.map +1 -1
  61. package/dist/components/layout/sidebar/NavMain.svelte +1 -0
  62. package/dist/db/interfaces/document.d.ts.map +1 -1
  63. package/dist/db/interfaces/index.d.ts +3 -1
  64. package/dist/db/interfaces/index.d.ts.map +1 -1
  65. package/dist/db/interfaces/organization.d.ts +1 -1
  66. package/dist/db/interfaces/organization.d.ts.map +1 -1
  67. package/dist/db/interfaces/role.d.ts +25 -0
  68. package/dist/db/interfaces/role.d.ts.map +1 -0
  69. package/dist/db/interfaces/role.js +1 -0
  70. package/dist/engine.d.ts.map +1 -1
  71. package/dist/engine.js +5 -0
  72. package/dist/field-access.d.ts +30 -0
  73. package/dist/field-access.d.ts.map +1 -0
  74. package/dist/field-access.js +79 -0
  75. package/dist/hooks.d.ts +2 -0
  76. package/dist/hooks.d.ts.map +1 -1
  77. package/dist/hooks.js +19 -13
  78. package/dist/lib/api/api-keys.d.ts +4 -0
  79. package/dist/lib/api/api-keys.d.ts.map +1 -1
  80. package/dist/lib/api/api-keys.js.map +1 -1
  81. package/dist/lib/api/assets.d.ts.map +1 -1
  82. package/dist/lib/api/assets.js.map +1 -1
  83. package/dist/lib/api/index.d.ts +1 -0
  84. package/dist/lib/api/index.d.ts.map +1 -1
  85. package/dist/lib/api/index.js +1 -0
  86. package/dist/lib/api/index.js.map +1 -1
  87. package/dist/lib/api/roles.d.ts +22 -0
  88. package/dist/lib/api/roles.d.ts.map +1 -0
  89. package/dist/lib/api/roles.js +27 -0
  90. package/dist/lib/api/roles.js.map +1 -0
  91. package/dist/lib/api/schemas/api-keys.d.ts +50 -4
  92. package/dist/lib/api/schemas/api-keys.d.ts.map +1 -1
  93. package/dist/lib/api/schemas/api-keys.js +22 -3
  94. package/dist/lib/api/schemas/api-keys.js.map +1 -1
  95. package/dist/lib/api/schemas/organizations.d.ts +4 -22
  96. package/dist/lib/api/schemas/organizations.d.ts.map +1 -1
  97. package/dist/lib/api/schemas/organizations.js +13 -2
  98. package/dist/lib/api/schemas/organizations.js.map +1 -1
  99. package/dist/lib/api/schemas/roles.d.ts +77 -0
  100. package/dist/lib/api/schemas/roles.d.ts.map +1 -0
  101. package/dist/lib/api/schemas/roles.js +43 -0
  102. package/dist/lib/api/schemas/roles.js.map +1 -0
  103. package/dist/lib/app-augment.d.ts +32 -0
  104. package/dist/lib/app-augment.d.ts.map +1 -0
  105. package/dist/lib/app-augment.js +19 -0
  106. package/dist/lib/app-augment.js.map +1 -0
  107. package/dist/lib/auth/auth-hooks.d.ts +2 -1
  108. package/dist/lib/auth/auth-hooks.d.ts.map +1 -1
  109. package/dist/lib/auth/auth-hooks.js +22 -2
  110. package/dist/lib/auth/auth-hooks.js.map +1 -1
  111. package/dist/lib/client/index.d.ts +2 -0
  112. package/dist/lib/client/index.d.ts.map +1 -1
  113. package/dist/lib/client/index.js +4 -0
  114. package/dist/lib/client/index.js.map +1 -1
  115. package/dist/lib/db/interfaces/document.d.ts.map +1 -1
  116. package/dist/lib/db/interfaces/index.d.ts +3 -1
  117. package/dist/lib/db/interfaces/index.d.ts.map +1 -1
  118. package/dist/lib/db/interfaces/organization.d.ts +1 -1
  119. package/dist/lib/db/interfaces/organization.d.ts.map +1 -1
  120. package/dist/lib/db/interfaces/role.d.ts +25 -0
  121. package/dist/lib/db/interfaces/role.d.ts.map +1 -0
  122. package/dist/lib/db/interfaces/role.js +2 -0
  123. package/dist/lib/db/interfaces/role.js.map +1 -0
  124. package/dist/lib/engine.d.ts.map +1 -1
  125. package/dist/lib/engine.js +5 -0
  126. package/dist/lib/engine.js.map +1 -1
  127. package/dist/lib/field-access.d.ts +30 -0
  128. package/dist/lib/field-access.d.ts.map +1 -0
  129. package/dist/lib/field-access.js +80 -0
  130. package/dist/lib/field-access.js.map +1 -0
  131. package/dist/lib/hooks.d.ts +2 -0
  132. package/dist/lib/hooks.d.ts.map +1 -1
  133. package/dist/lib/hooks.js +19 -13
  134. package/dist/lib/hooks.js.map +1 -1
  135. package/dist/lib/local-api/collection-api.d.ts +2 -0
  136. package/dist/lib/local-api/collection-api.d.ts.map +1 -1
  137. package/dist/lib/local-api/collection-api.js +98 -30
  138. package/dist/lib/local-api/collection-api.js.map +1 -1
  139. package/dist/lib/local-api/index.d.ts.map +1 -1
  140. package/dist/lib/local-api/index.js.map +1 -1
  141. package/dist/lib/local-api/permissions.d.ts +26 -26
  142. package/dist/lib/local-api/permissions.d.ts.map +1 -1
  143. package/dist/lib/local-api/permissions.js +122 -80
  144. package/dist/lib/local-api/permissions.js.map +1 -1
  145. package/dist/lib/permissions-context.svelte.d.ts +44 -0
  146. package/dist/lib/permissions-context.svelte.d.ts.map +1 -0
  147. package/dist/lib/permissions-context.svelte.js +80 -0
  148. package/dist/lib/permissions-context.svelte.js.map +1 -0
  149. package/dist/lib/routes/assets.d.ts.map +1 -1
  150. package/dist/lib/routes/assets.js.map +1 -1
  151. package/dist/lib/routes/document-versions.d.ts.map +1 -1
  152. package/dist/lib/routes/document-versions.js.map +1 -1
  153. package/dist/lib/routes/organizations-by-id.d.ts.map +1 -1
  154. package/dist/lib/routes/organizations-by-id.js +6 -3
  155. package/dist/lib/routes/organizations-by-id.js.map +1 -1
  156. package/dist/lib/routes/organizations-invitations.d.ts.map +1 -1
  157. package/dist/lib/routes/organizations-invitations.js +17 -7
  158. package/dist/lib/routes/organizations-invitations.js.map +1 -1
  159. package/dist/lib/routes/organizations-members.d.ts.map +1 -1
  160. package/dist/lib/routes/organizations-members.js +15 -7
  161. package/dist/lib/routes/organizations-members.js.map +1 -1
  162. package/dist/lib/routes/organizations.d.ts.map +1 -1
  163. package/dist/lib/routes/organizations.js +3 -0
  164. package/dist/lib/routes/organizations.js.map +1 -1
  165. package/dist/lib/routes/roles-by-name.d.ts +4 -0
  166. package/dist/lib/routes/roles-by-name.d.ts.map +1 -0
  167. package/dist/lib/routes/roles-by-name.js +112 -0
  168. package/dist/lib/routes/roles-by-name.js.map +1 -0
  169. package/dist/lib/routes/roles.d.ts +4 -0
  170. package/dist/lib/routes/roles.d.ts.map +1 -0
  171. package/dist/lib/routes/roles.js +91 -0
  172. package/dist/lib/routes/roles.js.map +1 -0
  173. package/dist/lib/routes-exports.d.ts +2 -0
  174. package/dist/lib/routes-exports.d.ts.map +1 -1
  175. package/dist/lib/routes-exports.js +3 -0
  176. package/dist/lib/routes-exports.js.map +1 -1
  177. package/dist/lib/save-state-context.svelte.d.ts +8 -0
  178. package/dist/lib/save-state-context.svelte.d.ts.map +1 -0
  179. package/dist/lib/save-state-context.svelte.js +9 -0
  180. package/dist/lib/save-state-context.svelte.js.map +1 -0
  181. package/dist/lib/schema-utils/validator.d.ts.map +1 -1
  182. package/dist/lib/schema-utils/validator.js +14 -0
  183. package/dist/lib/schema-utils/validator.js.map +1 -1
  184. package/dist/lib/server/index.d.ts +1 -0
  185. package/dist/lib/server/index.d.ts.map +1 -1
  186. package/dist/lib/server/index.js +1 -0
  187. package/dist/lib/server/index.js.map +1 -1
  188. package/dist/lib/services/index.d.ts +1 -0
  189. package/dist/lib/services/index.d.ts.map +1 -1
  190. package/dist/lib/services/index.js +1 -0
  191. package/dist/lib/services/index.js.map +1 -1
  192. package/dist/lib/services/roles-service.d.ts +35 -0
  193. package/dist/lib/services/roles-service.d.ts.map +1 -0
  194. package/dist/lib/services/roles-service.js +91 -0
  195. package/dist/lib/services/roles-service.js.map +1 -0
  196. package/dist/lib/services/version-service.d.ts.map +1 -1
  197. package/dist/lib/services/version-service.js.map +1 -1
  198. package/dist/lib/types/auth.d.ts +43 -9
  199. package/dist/lib/types/auth.d.ts.map +1 -1
  200. package/dist/lib/types/auth.js +31 -26
  201. package/dist/lib/types/auth.js.map +1 -1
  202. package/dist/lib/types/capabilities.d.ts +104 -0
  203. package/dist/lib/types/capabilities.d.ts.map +1 -0
  204. package/dist/lib/types/capabilities.js +217 -0
  205. package/dist/lib/types/capabilities.js.map +1 -0
  206. package/dist/lib/types/index.d.ts +1 -0
  207. package/dist/lib/types/index.d.ts.map +1 -1
  208. package/dist/lib/types/index.js +1 -0
  209. package/dist/lib/types/index.js.map +1 -1
  210. package/dist/lib/types/organization.d.ts +13 -4
  211. package/dist/lib/types/organization.d.ts.map +1 -1
  212. package/dist/lib/types/schemas.d.ts +71 -0
  213. package/dist/lib/types/schemas.d.ts.map +1 -1
  214. package/dist/lib/types/sidebar.d.ts +1 -1
  215. package/dist/lib/types/sidebar.d.ts.map +1 -1
  216. package/dist/lib/utils/mime-detect.d.ts.map +1 -1
  217. package/dist/lib/utils/mime-detect.js +28 -6
  218. package/dist/lib/utils/mime-detect.js.map +1 -1
  219. package/dist/local-api/collection-api.d.ts +2 -0
  220. package/dist/local-api/collection-api.d.ts.map +1 -1
  221. package/dist/local-api/collection-api.js +98 -30
  222. package/dist/local-api/index.d.ts.map +1 -1
  223. package/dist/local-api/permissions.d.ts +26 -26
  224. package/dist/local-api/permissions.d.ts.map +1 -1
  225. package/dist/local-api/permissions.js +122 -80
  226. package/dist/permissions-context.svelte.d.ts +44 -0
  227. package/dist/permissions-context.svelte.d.ts.map +1 -0
  228. package/dist/permissions-context.svelte.js +79 -0
  229. package/dist/plugins/README.md +20 -20
  230. package/dist/routes/assets.d.ts.map +1 -1
  231. package/dist/routes/document-versions.d.ts.map +1 -1
  232. package/dist/routes/organizations-by-id.d.ts.map +1 -1
  233. package/dist/routes/organizations-by-id.js +6 -3
  234. package/dist/routes/organizations-invitations.d.ts.map +1 -1
  235. package/dist/routes/organizations-invitations.js +17 -7
  236. package/dist/routes/organizations-members.d.ts.map +1 -1
  237. package/dist/routes/organizations-members.js +15 -7
  238. package/dist/routes/organizations.d.ts.map +1 -1
  239. package/dist/routes/organizations.js +3 -0
  240. package/dist/routes/roles-by-name.d.ts +4 -0
  241. package/dist/routes/roles-by-name.d.ts.map +1 -0
  242. package/dist/routes/roles-by-name.js +111 -0
  243. package/dist/routes/roles.d.ts +4 -0
  244. package/dist/routes/roles.d.ts.map +1 -0
  245. package/dist/routes/roles.js +90 -0
  246. package/dist/routes-exports.d.ts +2 -0
  247. package/dist/routes-exports.d.ts.map +1 -1
  248. package/dist/routes-exports.js +3 -0
  249. package/dist/save-state-context.svelte.d.ts +8 -0
  250. package/dist/save-state-context.svelte.d.ts.map +1 -0
  251. package/dist/save-state-context.svelte.js +8 -0
  252. package/dist/schema-utils/validator.d.ts.map +1 -1
  253. package/dist/schema-utils/validator.js +14 -0
  254. package/dist/server/index.d.ts +1 -0
  255. package/dist/server/index.d.ts.map +1 -1
  256. package/dist/server/index.js +1 -0
  257. package/dist/services/index.d.ts +1 -0
  258. package/dist/services/index.d.ts.map +1 -1
  259. package/dist/services/index.js +1 -0
  260. package/dist/services/roles-service.d.ts +35 -0
  261. package/dist/services/roles-service.d.ts.map +1 -0
  262. package/dist/services/roles-service.js +90 -0
  263. package/dist/services/version-service.d.ts.map +1 -1
  264. package/dist/types/auth.d.ts +43 -9
  265. package/dist/types/auth.d.ts.map +1 -1
  266. package/dist/types/auth.js +31 -26
  267. package/dist/types/capabilities.d.ts +104 -0
  268. package/dist/types/capabilities.d.ts.map +1 -0
  269. package/dist/types/capabilities.js +216 -0
  270. package/dist/types/index.d.ts +1 -0
  271. package/dist/types/index.d.ts.map +1 -1
  272. package/dist/types/index.js +1 -0
  273. package/dist/types/organization.d.ts +13 -4
  274. package/dist/types/organization.d.ts.map +1 -1
  275. package/dist/types/schemas.d.ts +71 -0
  276. package/dist/types/schemas.d.ts.map +1 -1
  277. package/dist/types/sidebar.d.ts +1 -1
  278. package/dist/types/sidebar.d.ts.map +1 -1
  279. package/dist/utils/mime-detect.d.ts.map +1 -1
  280. package/dist/utils/mime-detect.js +28 -6
  281. package/package.json +6 -2
@@ -36,6 +36,7 @@
36
36
  import { pluralize } from '../utils/pluralize';
37
37
  import { toast } from 'svelte-sonner';
38
38
  import { resolve } from '$app/paths';
39
+ import { setPermissionsContext } from '../permissions-context.svelte';
39
40
 
40
41
  interface Props {
41
42
  schemas: SchemaType[];
@@ -44,6 +45,14 @@
44
45
  title?: string;
45
46
  graphqlSettings?: { endpoint: string; enableGraphiQL: boolean } | null;
46
47
  isReadOnly?: boolean;
48
+ /**
49
+ * Capabilities resolved for the current session. Used for per-action UI
50
+ * gating. When absent, all actions are shown and the server remains the
51
+ * enforcement surface.
52
+ */
53
+ capabilities?: string[];
54
+ /** Effective organization role name, for role-list style checks. */
55
+ rbacRole?: string | null;
47
56
  activeTab?: { value: 'structure' | 'vision' | 'media' };
48
57
  handleTabChange: (value: string) => void;
49
58
  userPreferences?: UserSessionPreferences | null;
@@ -56,27 +65,68 @@
56
65
  title = 'Aphex CMS',
57
66
  graphqlSettings = null,
58
67
  isReadOnly = false,
68
+ capabilities = [],
69
+ rbacRole = null,
59
70
  activeTab = { value: 'structure' } as { value: 'structure' | 'vision' | 'media' },
60
71
  handleTabChange = () => {},
61
72
  userPreferences = null
62
73
  }: Props = $props();
63
74
 
75
+ // Publish capabilities to every descendant (DocumentEditor, fields, etc)
76
+ // via Svelte context. Using a getter closure so prop reactivity propagates:
77
+ // if the parent swaps the capabilities array (e.g. role change mid-session)
78
+ // every `perms.can()` call in the subtree picks it up on next read.
79
+ const perms = setPermissionsContext(
80
+ () => capabilities,
81
+ () => rbacRole
82
+ );
83
+
64
84
  // Keep a state of the organization id
65
85
  let currentOrgId = $state<string | null>(page.url.searchParams.get('orgId'));
66
86
 
67
- // Merge document types with schema icons (schemas have icons, server data doesn't)
87
+ // Merge document types with schema icons (schemas have icons, server data doesn't),
88
+ // then filter out any schemas the caller can't read. Mirrors the server check:
89
+ // an `access.read` list on a schema is an allowlist of role names (built-in
90
+ // or custom); absence of a list means "whoever can read documents at all".
68
91
  const documentTypes = $derived(
69
- documentTypesFromServer.map((docType) => {
70
- const schema = schemas.find((s) => s.name === docType.name);
71
- return {
72
- ...docType,
73
- icon: schema?.icon
74
- };
75
- })
92
+ documentTypesFromServer
93
+ .map((docType) => {
94
+ const schema = schemas.find((s) => s.name === docType.name);
95
+ return {
96
+ ...docType,
97
+ icon: schema?.icon,
98
+ group: schema?.group,
99
+ access: schema?.access
100
+ };
101
+ })
102
+ .filter((docType) => {
103
+ const readList = docType.access?.read;
104
+ if (!readList) return true; // no list = open to any reader
105
+ // Functions (policy-based access) can't be evaluated on the client
106
+ // without a target doc — show them and let the server enforce.
107
+ if (typeof readList === 'function') return true;
108
+ const role = perms.role;
109
+ return role !== null && readList.includes(role);
110
+ })
76
111
  );
77
112
 
78
113
  const hasDocumentTypes = $derived(documentTypes.length > 0);
79
114
 
115
+ // Bucket document types by their `group` property. Ungrouped types sit in a
116
+ // leading null bucket; named groups follow in first-seen order.
117
+ const groupedDocumentTypes = $derived.by(() => {
118
+ const buckets = new Map<string | null, typeof documentTypes>();
119
+ buckets.set(null, []);
120
+ for (const dt of documentTypes) {
121
+ const key = dt.group ?? null;
122
+ if (!buckets.has(key)) buckets.set(key, []);
123
+ buckets.get(key)!.push(dt);
124
+ }
125
+ return Array.from(buckets.entries())
126
+ .filter(([, items]) => items.length > 0)
127
+ .map(([name, items]) => ({ name, items }));
128
+ });
129
+
80
130
  // Client-side routing state
81
131
  let currentView = $state<'dashboard' | 'documents' | 'editor'>('dashboard');
82
132
  let selectedDocumentType = $state<string | null>(null);
@@ -109,7 +159,11 @@
109
159
  // Version history panel state
110
160
  let showVersionPanel = $state(false);
111
161
  let versionPanelDocId = $state<string | null>(null);
112
- let versionPreviewData = $state<{ versionNumber: number; data: Record<string, any>; eventType: string } | null>(null);
162
+ let versionPreviewData = $state<{
163
+ versionNumber: number;
164
+ data: Record<string, any>;
165
+ eventType: string;
166
+ } | null>(null);
113
167
 
114
168
  // Documents list sorting state
115
169
  let sortDropdownOpen = $state(false);
@@ -171,14 +225,15 @@
171
225
  let activeEditorIndex = $state<number>(0);
172
226
 
173
227
  // Calculate how many editors can be shown expanded based on available space
174
- const MIN_EDITOR_WIDTH = 600; // Minimum width for ANY expanded editor
228
+ const MIN_EDITOR_WIDTH = 700; // Minimum width for ANY expanded editor
175
229
  const COLLAPSED_WIDTH = 60; // Width of collapsed panels
176
230
  const TYPES_EXPANDED = 350;
177
231
  const DOCS_EXPANDED = 350;
178
232
 
179
233
  let layoutConfig = $derived.by(() => {
180
234
  const start = performance.now();
181
- const totalEditors = (currentView === 'editor' ? 1 : 0) + editorStack.length + (showVersionPanel ? 1 : 0);
235
+ const totalEditors =
236
+ (currentView === 'editor' ? 1 : 0) + editorStack.length + (showVersionPanel ? 1 : 0);
182
237
 
183
238
  if (totalEditors === 0) {
184
239
  return {
@@ -433,10 +488,19 @@
433
488
  cmsLogger.debug('[URL Effect]', 'Branch: CREATE');
434
489
  currentView = 'editor';
435
490
  mobileView = 'editor';
436
- selectedDocumentType = docType;
437
491
  isCreatingDocument = true;
438
492
  editingDocumentId = null;
439
493
  editorStack = [];
494
+ if (selectedDocumentType !== docType) {
495
+ docCurrentPage = 1;
496
+ selectedDocumentType = docType;
497
+ fetchDocuments(docType);
498
+ }
499
+ // Note: no fallback refetch when docsList is empty — empty is a valid
500
+ // steady state (type has no records). fetchDocuments reassigns the
501
+ // list (new identity), which retriggers this effect; any "refetch if
502
+ // empty" guard loops forever. If you need a manual refresh, use a
503
+ // button, not an effect.
440
504
  } else if (docId) {
441
505
  cmsLogger.debug('[URL Effect]', 'Branch: EDIT (docId)');
442
506
  currentView = 'editor';
@@ -478,8 +542,8 @@
478
542
  }
479
543
 
480
544
  if (docType) {
481
- selectedDocumentType = docType;
482
- if (documentsList.length === 0 || selectedDocumentType !== docType) {
545
+ if (selectedDocumentType !== docType) {
546
+ selectedDocumentType = docType;
483
547
  fetchDocuments(docType);
484
548
  }
485
549
  } else {
@@ -523,6 +587,9 @@
523
587
  });
524
588
 
525
589
  async function navigateToDocumentType(docType: string) {
590
+ if (activeTab.value !== 'structure') {
591
+ handleTabChange('structure');
592
+ }
526
593
  const params = new SvelteURLSearchParams(page.url.searchParams);
527
594
  params.set('docType', docType);
528
595
  params.delete('docId');
@@ -627,7 +694,19 @@
627
694
  return;
628
695
  }
629
696
 
630
- // On desktop, add to editor stack
697
+ // On desktop, add to editor stack — unless the document is already open somewhere
698
+ if (editingDocumentId === documentId) {
699
+ // Already open as the primary editor — just focus it
700
+ activeEditorIndex = 0;
701
+ return;
702
+ }
703
+ const existingIndex = editorStack.findIndex((item) => item.documentId === documentId);
704
+ if (existingIndex !== -1) {
705
+ // Already open in the stack — focus that editor (stack index 0 = activeEditorIndex 1)
706
+ activeEditorIndex = existingIndex + 1;
707
+ return;
708
+ }
709
+
631
710
  const newStack = [...editorStack, { documentId, documentType, isCreating: false }];
632
711
 
633
712
  // Build stack param string: type1:id1,type2:id2,...
@@ -818,8 +897,10 @@
818
897
  </button>
819
898
  <span class="text-muted-foreground mx-2">/</span>
820
899
  <span class="text-sm font-medium">
821
- {pluralize(documentTypes.find((t) => t.name === selectedDocumentType)?.title ||
822
- selectedDocumentType)}
900
+ {pluralize(
901
+ documentTypes.find((t) => t.name === selectedDocumentType)?.title ||
902
+ selectedDocumentType
903
+ )}
823
904
  </span>
824
905
  {:else if mobileView === 'editor'}
825
906
  <Button
@@ -877,7 +958,7 @@
877
958
  {:else}
878
959
  <!-- Types Panel -->
879
960
  <div
880
- class="border-r transition-all duration-200 {windowWidth < 620
961
+ class="border-rule border-r transition-all duration-200 {windowWidth < 620
881
962
  ? typesPanel === 'hidden'
882
963
  ? 'hidden'
883
964
  : 'h-full w-screen'
@@ -888,12 +969,12 @@
888
969
  {#if typesPanel === 'w-[60px]'}
889
970
  <button
890
971
  onclick={() => setActiveEditor(-1)}
891
- class="hover:bg-muted/30 flex h-full w-full flex-col transition-colors"
972
+ class="hover:bg-muted/30 flex h-full w-full cursor-pointer flex-col transition-colors"
892
973
  title="Click to expand content types"
893
974
  >
894
975
  <div class="flex flex-1 items-start justify-center p-2 pt-8 text-left">
895
976
  <div
896
- class="text-foreground rotate-90 transform text-sm font-medium whitespace-nowrap"
977
+ class="text-foreground -mt-2 text-sm font-medium whitespace-nowrap [writing-mode:vertical-rl]"
897
978
  >
898
979
  Content
899
980
  </div>
@@ -903,46 +984,55 @@
903
984
  <div class="h-full overflow-y-auto p-3">
904
985
  {#if hasDocumentTypes}
905
986
  <h2
906
- class="text-muted-foreground mb-2 hidden px-2 text-sm font-medium sm:block"
987
+ class="text-muted-foreground border-rule mt-2 mb-3 hidden px-2 pb-3 text-sm font-medium sm:block sm:border-b"
907
988
  >
908
989
  Content
909
990
  </h2>
910
- {#each documentTypes as docType, index (index)}
911
- <button
912
- onclick={() => navigateToDocumentType(docType.name)}
913
- class="hover:bg-muted/50 group flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-2.5 text-left transition-colors {selectedDocumentType ===
914
- docType.name
915
- ? 'bg-muted/50'
916
- : ''}"
917
- title={docType.description || ''}
918
- >
919
- <div class="flex items-center gap-2">
920
- <div
921
- class="text-muted-foreground flex h-5 w-5 items-center justify-center"
922
- >
923
- {#if docType.icon}
924
- {@const Icon = docType.icon}
925
- <Icon class="h-4 w-4" />
926
- {:else}
927
- <FileText class="h-4 w-4" />
928
- {/if}
929
- </div>
930
- <span class="text-sm">{pluralize(docType.title)}</span>
991
+ {#each groupedDocumentTypes as bucket (bucket.name ?? '__ungrouped__')}
992
+ {#if bucket.name}
993
+ <div
994
+ class="text-muted-foreground mt-3 mb-1 px-2 text-xs font-semibold tracking-wide uppercase first:mt-0"
995
+ >
996
+ {bucket.name}
931
997
  </div>
932
- <svg
933
- class="text-muted-foreground h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
934
- fill="none"
935
- viewBox="0 0 24 24"
936
- stroke="currentColor"
998
+ {/if}
999
+ {#each bucket.items as docType (docType.name)}
1000
+ <button
1001
+ onclick={() => navigateToDocumentType(docType.name)}
1002
+ class="hover:bg-muted/50 group flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-2.5 text-left transition-colors {selectedDocumentType ===
1003
+ docType.name
1004
+ ? 'bg-muted/50'
1005
+ : ''}"
1006
+ title={docType.description || ''}
937
1007
  >
938
- <path
939
- stroke-linecap="round"
940
- stroke-linejoin="round"
941
- stroke-width="2"
942
- d="M9 5l7 7-7 7"
943
- />
944
- </svg>
945
- </button>
1008
+ <div class="flex items-center gap-2">
1009
+ <div
1010
+ class="text-muted-foreground flex h-5 w-5 items-center justify-center"
1011
+ >
1012
+ {#if docType.icon}
1013
+ {@const Icon = docType.icon}
1014
+ <Icon class="h-4 w-4" />
1015
+ {:else}
1016
+ <FileText class="h-4 w-4" />
1017
+ {/if}
1018
+ </div>
1019
+ <span class="text-sm">{pluralize(docType.title)}</span>
1020
+ </div>
1021
+ <svg
1022
+ class="text-muted-foreground h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
1023
+ fill="none"
1024
+ viewBox="0 0 24 24"
1025
+ stroke="currentColor"
1026
+ >
1027
+ <path
1028
+ stroke-linecap="round"
1029
+ stroke-linejoin="round"
1030
+ stroke-width="2"
1031
+ d="M9 5l7 7-7 7"
1032
+ />
1033
+ </svg>
1034
+ </button>
1035
+ {/each}
946
1036
  {/each}
947
1037
  {:else}
948
1038
  <div class="p-6 text-center">
@@ -969,7 +1059,7 @@
969
1059
  <!-- Documents Panel -->
970
1060
  {#if selectedDocumentType}
971
1061
  <div
972
- class="flex h-full flex-col overflow-hidden border-r transition-all duration-200
1062
+ class="border-rule flex h-full flex-col overflow-hidden border-r transition-all duration-200
973
1063
  {!documentsPanelState.visible ? 'hidden' : ''}
974
1064
  {windowWidth < 620 ? (documentsPanelState.visible ? 'w-screen' : 'hidden') : ''}
975
1065
  {windowWidth >= 620 && documentsPanelState.width === 'full' ? 'w-full' : ''}
@@ -981,13 +1071,17 @@
981
1071
  {#if documentsPanelState.width === 'compact'}
982
1072
  <button
983
1073
  onclick={() => setActiveEditor(-2)}
984
- class="hover:bg-muted/30 flex h-full w-full flex-col transition-colors"
1074
+ class="hover:bg-muted/30 flex h-full w-full cursor-pointer flex-col transition-colors"
985
1075
  title="Click to expand documents list"
986
1076
  >
987
1077
  <div class="flex flex-1 items-start justify-center p-2 pt-8 text-left">
988
- <div class="mt-4 text-foreground rotate-90 transform text-md font-medium whitespace-nowrap">
989
- {pluralize(documentTypes.find((t) => t.name === selectedDocumentType)?.title ||
990
- selectedDocumentType)}
1078
+ <div
1079
+ class="text-foreground -mt-2 text-sm font-medium whitespace-nowrap [writing-mode:vertical-rl]"
1080
+ >
1081
+ {pluralize(
1082
+ documentTypes.find((t) => t.name === selectedDocumentType)?.title ||
1083
+ selectedDocumentType
1084
+ )}
991
1085
  </div>
992
1086
  </div>
993
1087
  </button>
@@ -1019,7 +1113,7 @@
1019
1113
  </div>
1020
1114
  </div>
1021
1115
  <div class="flex items-center gap-1">
1022
- {#if !isReadOnly}
1116
+ {#if perms.can('document.create')}
1023
1117
  <Button
1024
1118
  size="sm"
1025
1119
  variant="ghost"
@@ -1051,7 +1145,7 @@
1051
1145
  {...props}
1052
1146
  size="sm"
1053
1147
  variant="ghost"
1054
- class="h-8 w-8 p-0"
1148
+ class="h-8 w-8 cursor-pointer p-0"
1055
1149
  title="Sort documents"
1056
1150
  >
1057
1151
  <Ellipsis class="h-4 w-4" />
@@ -1164,7 +1258,7 @@
1164
1258
  {@const isActive = editingDocumentId === doc.id}
1165
1259
  <button
1166
1260
  onclick={() => navigateToEditDocument(doc.id, selectedDocumentType!)}
1167
- class="hover:bg-muted/50 border-border group flex w-full items-center justify-between border-b p-3 text-left transition-colors {isActive
1261
+ class="hover:bg-muted/50 border-border group flex w-full cursor-pointer items-center justify-between border-b p-3 text-left transition-colors {isActive
1168
1262
  ? 'bg-muted/50'
1169
1263
  : ''}"
1170
1264
  >
@@ -1190,13 +1284,37 @@
1190
1284
  </p>
1191
1285
  {:else if doc.slug}
1192
1286
  <p class="text-muted-foreground text-xs">/{doc.slug}</p>
1193
- {:else if doc.status}
1194
- <p class="text-muted-foreground text-xs">{doc.status}</p>
1195
1287
  {/if}
1196
1288
  </div>
1197
1289
  </div>
1198
- <div class="text-muted-foreground text-xs">
1199
- {doc.updatedAt?.toLocaleDateString() || ''}
1290
+ <div class="flex items-center gap-2">
1291
+ <span class="text-muted-foreground text-xs">
1292
+ {doc.updatedAt?.toLocaleDateString() || ''}
1293
+ </span>
1294
+ <div class="flex items-center gap-1">
1295
+ {#if doc.status === 'published'}
1296
+ {#if doc.hasChanges}
1297
+ <span
1298
+ class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
1299
+ title="Unpublished changes"
1300
+ ></span>
1301
+ {/if}
1302
+ <span
1303
+ class="h-1.5 w-1.5 shrink-0 rounded-full bg-green-500"
1304
+ title="Published"
1305
+ ></span>
1306
+ {:else if doc.status === 'unpublished'}
1307
+ <span
1308
+ class="bg-muted-foreground/60 h-1.5 w-1.5 shrink-0 rounded-full"
1309
+ title="Unpublished"
1310
+ ></span>
1311
+ {:else}
1312
+ <span
1313
+ class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
1314
+ title="Draft"
1315
+ ></span>
1316
+ {/if}
1317
+ </div>
1200
1318
  </div>
1201
1319
  </button>
1202
1320
  {/each}
@@ -1289,75 +1407,100 @@
1289
1407
 
1290
1408
  <!-- Primary Editor Panel -->
1291
1409
  {#if primaryEditorState.visible}
1292
- {#if primaryEditorState.expanded}
1293
- <div
1294
- class="transition-all duration-200 {windowWidth < 620
1295
- ? 'w-screen'
1296
- : 'flex-1'} h-full overflow-y-auto"
1297
- style={windowWidth >= 620 ? 'min-width: 0;' : ''}
1298
- >
1299
- <DocumentEditor
1300
- {schemas}
1301
- documentType={selectedDocumentType!}
1302
- documentId={editingDocumentId}
1303
- isCreating={isCreatingDocument}
1304
- onBack={navigateBack}
1305
- onOpenReference={handleOpenReference}
1306
- onOpenVersionHistory={handleOpenVersionHistory}
1307
- externalVersionPreview={versionPreviewData}
1308
- onSaved={async (docId) => {
1309
- if (selectedDocumentType) {
1310
- await fetchDocuments(selectedDocumentType);
1311
- }
1312
- // For first-time creation, just update URL without changing props
1313
- // This prevents DocumentEditor from reloading and keeps focus
1314
- if (isCreatingDocument) {
1315
- const params = new SvelteURLSearchParams(page.url.searchParams);
1316
- params.set('docId', docId);
1317
- if (selectedDocumentType) params.set('docType', selectedDocumentType);
1318
- params.delete('action');
1319
- replaceState(`/admin?${params.toString()}`, { ...page.state });
1320
-
1321
- // Update local state immediately to prevent creating duplicate documents
1322
- isCreatingDocument = false;
1323
- editingDocumentId = docId;
1324
- } else {
1325
- // For subsequent saves, use normal navigation
1326
- navigateToEditDocument(docId, selectedDocumentType!);
1327
- }
1328
- }}
1329
- onAutoSaved={handleAutoSave}
1330
- onPublished={async (_) => {
1331
- if (selectedDocumentType) {
1332
- await fetchDocuments(selectedDocumentType);
1333
- }
1334
- }}
1335
- onDeleted={async () => {
1336
- if (selectedDocumentType) {
1337
- await fetchDocuments(selectedDocumentType);
1338
- const params = new SvelteURLSearchParams(page.url.searchParams);
1339
- params.set('docType', selectedDocumentType);
1340
- params.delete('docId');
1341
- params.delete('action');
1342
- await goto(`/admin?${params.toString()}`, { replaceState: false });
1343
- } else {
1344
- const orgId = page.url.searchParams.get('orgId');
1345
- const url = orgId ? `/admin?orgId=${orgId}` : '/admin';
1346
- await goto(url, { replaceState: false });
1347
- }
1348
- }}
1349
- {isReadOnly}
1350
- />
1351
- </div>
1352
- {:else}
1410
+ <div
1411
+ class="transition-all duration-200 {windowWidth < 620
1412
+ ? 'w-screen'
1413
+ : 'flex-1'} h-full overflow-x-hidden overflow-y-auto {primaryEditorState.expanded
1414
+ ? ''
1415
+ : 'hidden'}"
1416
+ style={windowWidth >= 620 ? 'min-width: 0;' : ''}
1417
+ >
1418
+ <DocumentEditor
1419
+ {schemas}
1420
+ documentType={selectedDocumentType!}
1421
+ documentId={editingDocumentId}
1422
+ isCreating={isCreatingDocument}
1423
+ onBack={navigateBack}
1424
+ onOpenReference={handleOpenReference}
1425
+ onOpenVersionHistory={handleOpenVersionHistory}
1426
+ externalVersionPreview={versionPanelDocId === editingDocumentId
1427
+ ? versionPreviewData
1428
+ : null}
1429
+ onSaved={async (docId) => {
1430
+ if (selectedDocumentType) {
1431
+ await fetchDocuments(selectedDocumentType);
1432
+ }
1433
+ // For first-time creation, update URL and local state.
1434
+ // Use goto with replaceState:true so page.url stays in sync —
1435
+ // raw replaceState() from $app/navigation updates the URL bar
1436
+ // but leaves page.url.searchParams stale, which breaks any
1437
+ // subsequent navigation that reads from it (e.g. opening a
1438
+ // stacked reference editor) because the old ?action=create
1439
+ // gets carried forward.
1440
+ if (isCreatingDocument) {
1441
+ // Update local state FIRST so the URL Effect's EDIT branch
1442
+ // sees consistent values when goto fires it.
1443
+ isCreatingDocument = false;
1444
+ editingDocumentId = docId;
1445
+
1446
+ const params = new SvelteURLSearchParams(page.url.searchParams);
1447
+ params.set('docId', docId);
1448
+ if (selectedDocumentType) params.set('docType', selectedDocumentType);
1449
+ params.delete('action');
1450
+ await goto(`/admin?${params.toString()}`, {
1451
+ replaceState: true,
1452
+ keepFocus: true,
1453
+ noScroll: true
1454
+ });
1455
+ } else {
1456
+ // For subsequent saves, use normal navigation
1457
+ navigateToEditDocument(docId, selectedDocumentType!);
1458
+ }
1459
+ }}
1460
+ onAutoSaved={handleAutoSave}
1461
+ onPublished={async (_) => {
1462
+ if (selectedDocumentType) {
1463
+ await fetchDocuments(selectedDocumentType);
1464
+ }
1465
+ }}
1466
+ onUnpublished={async (_) => {
1467
+ if (selectedDocumentType) {
1468
+ await fetchDocuments(selectedDocumentType);
1469
+ }
1470
+ }}
1471
+ onRestored={async (_) => {
1472
+ if (selectedDocumentType) {
1473
+ await fetchDocuments(selectedDocumentType);
1474
+ }
1475
+ }}
1476
+ onDeleted={async () => {
1477
+ if (selectedDocumentType) {
1478
+ await fetchDocuments(selectedDocumentType);
1479
+ const params = new SvelteURLSearchParams(page.url.searchParams);
1480
+ params.set('docType', selectedDocumentType);
1481
+ params.delete('docId');
1482
+ params.delete('action');
1483
+ await goto(`/admin?${params.toString()}`, { replaceState: false });
1484
+ } else {
1485
+ const orgId = page.url.searchParams.get('orgId');
1486
+ const url = orgId ? `/admin?orgId=${orgId}` : '/admin';
1487
+ await goto(url, { replaceState: false });
1488
+ }
1489
+ }}
1490
+ {isReadOnly}
1491
+ />
1492
+ </div>
1493
+ {#if !primaryEditorState.expanded}
1353
1494
  <!-- Collapsed Primary Editor Strip -->
1354
1495
  <button
1355
1496
  onclick={() => setActiveEditor(0)}
1356
- class="hover:bg-muted/50 flex h-full w-[60px] flex-col border-l transition-colors"
1497
+ class="border-rule hover:bg-muted/50 flex h-full w-[60px] cursor-pointer flex-col border-l transition-colors"
1357
1498
  title="Click to expand {selectedDocumentType}"
1358
1499
  >
1359
- <div class="mt-7 flex flex-1 items-start justify-center p-2 pt-8 text-left">
1360
- <div class="text-foreground rotate-90 transform text-sm font-medium">
1500
+ <div class="flex flex-1 items-start justify-center p-2 pt-8 text-left">
1501
+ <div
1502
+ class="text-foreground -mt-2 text-sm font-medium whitespace-nowrap [writing-mode:vertical-rl]"
1503
+ >
1361
1504
  {selectedDocumentType
1362
1505
  ? selectedDocumentType.charAt(0).toUpperCase() +
1363
1506
  selectedDocumentType.slice(1)
@@ -1375,7 +1518,7 @@
1375
1518
 
1376
1519
  {#if isExpanded}
1377
1520
  <div
1378
- class="h-full flex-1 overflow-y-auto border-l transition-all duration-200"
1521
+ class="border-rule h-full flex-1 overflow-x-hidden overflow-y-auto border-l transition-all duration-200"
1379
1522
  style="min-width: 0;"
1380
1523
  >
1381
1524
  <DocumentEditor
@@ -1386,9 +1529,26 @@
1386
1529
  onBack={() => handleCloseStackedEditor(index)}
1387
1530
  onOpenReference={handleOpenReference}
1388
1531
  onOpenVersionHistory={handleOpenVersionHistory}
1532
+ externalVersionPreview={versionPanelDocId === stackedEditor.documentId
1533
+ ? versionPreviewData
1534
+ : null}
1389
1535
  onSaved={async () => {}}
1390
1536
  onAutoSaved={() => {}}
1391
- onPublished={async () => {}}
1537
+ onPublished={async () => {
1538
+ if (selectedDocumentType) {
1539
+ await fetchDocuments(selectedDocumentType);
1540
+ }
1541
+ }}
1542
+ onUnpublished={async () => {
1543
+ if (selectedDocumentType) {
1544
+ await fetchDocuments(selectedDocumentType);
1545
+ }
1546
+ }}
1547
+ onRestored={async () => {
1548
+ if (selectedDocumentType) {
1549
+ await fetchDocuments(selectedDocumentType);
1550
+ }
1551
+ }}
1392
1552
  onDeleted={async () => {
1393
1553
  handleCloseStackedEditor(index);
1394
1554
  }}
@@ -1399,14 +1559,12 @@
1399
1559
  <!-- Collapsed Stacked Editor Strip -->
1400
1560
  <button
1401
1561
  onclick={() => setActiveEditor(editorIndex)}
1402
- class="hover:bg-muted/50 flex h-full w-[60px] flex-col border-l transition-colors"
1562
+ class="border-rule hover:bg-muted/50 flex h-full w-[60px] cursor-pointer flex-col border-l transition-colors"
1403
1563
  title="Click to expand {stackedEditor.documentType}"
1404
1564
  >
1405
- <div
1406
- class="-mt-2 flex h-full flex-1 items-start justify-center p-2 pt-8 text-left"
1407
- >
1565
+ <div class="flex h-full flex-1 items-start justify-center p-2 pt-8 text-left">
1408
1566
  <div
1409
- class="text-foreground rotate-90 transform text-sm font-medium whitespace-nowrap"
1567
+ class="text-foreground text-sm font-medium whitespace-nowrap [writing-mode:vertical-rl]"
1410
1568
  >
1411
1569
  {stackedEditor.documentType.charAt(0).toUpperCase() +
1412
1570
  stackedEditor.documentType.slice(1)}
@@ -1420,12 +1578,14 @@
1420
1578
  <!-- Version History Panel -->
1421
1579
  {#if showVersionPanel && versionPanelDocId}
1422
1580
  <div
1423
- class="h-full w-[280px] min-w-[250px] overflow-y-auto border-l transition-all duration-200"
1581
+ class="border-rule h-full w-[280px] min-w-[250px] overflow-y-auto border-l transition-all duration-200"
1424
1582
  >
1425
1583
  <DocumentVersionPanel
1426
1584
  documentId={versionPanelDocId}
1427
1585
  onClose={handleCloseVersionPanel}
1428
- onPreviewVersion={(v) => { versionPreviewData = v; }}
1586
+ onPreviewVersion={(v) => {
1587
+ versionPreviewData = v;
1588
+ }}
1429
1589
  onRestored={async () => {
1430
1590
  versionPreviewData = null;
1431
1591
  if (selectedDocumentType) {
@@ -16,6 +16,14 @@ interface Props {
16
16
  enableGraphiQL: boolean;
17
17
  } | null;
18
18
  isReadOnly?: boolean;
19
+ /**
20
+ * Capabilities resolved for the current session. Used for per-action UI
21
+ * gating. When absent, all actions are shown and the server remains the
22
+ * enforcement surface.
23
+ */
24
+ capabilities?: string[];
25
+ /** Effective organization role name, for role-list style checks. */
26
+ rbacRole?: string | null;
19
27
  activeTab?: {
20
28
  value: 'structure' | 'vision' | 'media';
21
29
  };