domma-cms 0.1.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-GB">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Domma CMS - Admin</title>
7
+
8
+ <!-- DommaJS CSS -->
9
+ <link rel="stylesheet" href="/dist/domma/domma.css">
10
+ <link rel="stylesheet" href="/dist/domma/grid.css">
11
+ <link rel="stylesheet" href="/dist/domma/elements.css">
12
+ <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
13
+
14
+ <!-- Cropper.js - image editor -->
15
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1/dist/cropper.min.css">
16
+
17
+ <!-- Admin CSS -->
18
+ <link rel="stylesheet" href="/admin/css/admin.css">
19
+ </head>
20
+ <body class="dm-cloaked dm-theme-charcoal-dark dashboard-layout">
21
+
22
+ <!-- Top Bar -->
23
+ <header id="admin-topbar">
24
+ <div class="topbar-brand">
25
+ <span data-icon="layout"></span>
26
+ <span class="topbar-brand-text">Domma CMS</span>
27
+ </div>
28
+ <div class="topbar-user" id="topbar-user">
29
+ <!-- Populated after auth: name + role badge -->
30
+ </div>
31
+ <div class="topbar-actions" id="topbar-actions">
32
+ <!-- Populated after auth: Settings link + Sign out -->
33
+ </div>
34
+ </header>
35
+
36
+ <div class="dashboard-wrapper">
37
+ <!-- Sidebar -->
38
+ <aside id="admin-sidebar" class="dashboard-sidebar"></aside>
39
+
40
+ <!-- Main content area -->
41
+ <main class="dashboard-main">
42
+ <div id="view-container" class="view-container"></div>
43
+ </main>
44
+ </div>
45
+
46
+ <!-- DOMPurify - must load before DommaJS -->
47
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
48
+
49
+ <!-- DommaJS -->
50
+ <script src="/dist/domma/domma.min.js"></script>
51
+
52
+ <!-- Initialise DommaJS before ES module loads -->
53
+ <script>
54
+ if (window.Domma && typeof window.Domma.init === 'function') {
55
+ window.Domma.init();
56
+ window.Domma.icons.scan();
57
+ }
58
+ </script>
59
+
60
+ <!-- DommaJS Syntax Highlighter (adds Domma.syntax) -->
61
+ <script src="/dist/domma/domma-syntax.min.js"></script>
62
+
63
+ <!-- Cropper.js - image editor -->
64
+ <script src="https://cdn.jsdelivr.net/npm/cropperjs@1/dist/cropper.min.js"></script>
65
+
66
+ <!-- Marked - Markdown parser for page editor preview -->
67
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
68
+
69
+ <!-- Admin App (ES module entry) -->
70
+ <script type="module" src="/admin/js/app.js"></script>
71
+ </body>
72
+ </html>
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Admin API Client
3
+ * Authenticated requests using Bearer tokens with automatic refresh on 401.
4
+ * All existing api.* namespaces are preserved — views need no changes.
5
+ */
6
+
7
+ const BASE = '/api';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Token helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function getToken() { return S.get('auth_token'); }
14
+ function getRefreshToken() { return S.get('auth_refresh_token'); }
15
+ function setToken(t) { S.set('auth_token', t); }
16
+ function clearAuth() {
17
+ S.remove('auth_token');
18
+ S.remove('auth_refresh_token');
19
+ S.remove('auth_user');
20
+ }
21
+
22
+ /**
23
+ * Attempt to refresh the access token using the stored refresh token.
24
+ * Clears auth and redirects to login if refresh fails.
25
+ *
26
+ * @returns {Promise<string>} New access token
27
+ */
28
+ async function refreshAccessToken() {
29
+ const refreshToken = getRefreshToken();
30
+ if (!refreshToken) throw new Error('No refresh token');
31
+
32
+ const res = await fetch(`${BASE}/auth/refresh`, {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ refreshToken })
36
+ });
37
+
38
+ if (!res.ok) {
39
+ clearAuth();
40
+ R.navigate('/login');
41
+ throw new Error('Token refresh failed');
42
+ }
43
+
44
+ const { token } = await res.json();
45
+ setToken(token);
46
+ return token;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Core request function
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Make an authenticated API request.
55
+ * Automatically retries once after refreshing the access token on 401.
56
+ *
57
+ * @param {string} endpoint
58
+ * @param {RequestInit} options
59
+ * @returns {Promise<any>}
60
+ */
61
+ async function apiRequest(endpoint, options = {}) {
62
+ let token = getToken();
63
+
64
+ const buildHeaders = (t) => ({
65
+ ...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
66
+ ...options.headers,
67
+ ...(t ? { Authorization: `Bearer ${t}` } : {})
68
+ });
69
+
70
+ let res = await fetch(`${BASE}${endpoint}`, { ...options, headers: buildHeaders(token) });
71
+
72
+ // On 401, try refreshing once
73
+ if (res.status === 401 && getRefreshToken()) {
74
+ try {
75
+ token = await refreshAccessToken();
76
+ res = await fetch(`${BASE}${endpoint}`, { ...options, headers: buildHeaders(token) });
77
+ } catch {
78
+ return; // Already redirected to login
79
+ }
80
+ }
81
+
82
+ if (!res.ok) {
83
+ const body = await res.json().catch(() => ({ error: 'Request failed' }));
84
+ throw new Error(body.error || body.message || `HTTP ${res.status}`);
85
+ }
86
+
87
+ // 204 No Content
88
+ if (res.status === 204) return null;
89
+
90
+ return res.json();
91
+ }
92
+
93
+ /**
94
+ * Upload a file with Bearer token (multipart — no Content-Type override).
95
+ *
96
+ * @param {string} endpoint
97
+ * @param {FormData} formData
98
+ * @returns {Promise<any>}
99
+ */
100
+ async function uploadRequest(endpoint, formData) {
101
+ const token = getToken();
102
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
103
+
104
+ const res = await fetch(`${BASE}${endpoint}`, { method: 'POST', headers, body: formData });
105
+ if (!res.ok) {
106
+ const body = await res.json().catch(() => ({ error: 'Upload failed' }));
107
+ throw new Error(body.error || body.message || `HTTP ${res.status}`);
108
+ }
109
+ return res.json();
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Domain API namespaces
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export const api = {
117
+ // Auth
118
+ auth: {
119
+ setupStatus: () => apiRequest('/auth/setup-status', { method: 'GET' }),
120
+ setup: (data) => apiRequest('/auth/setup', { method: 'POST', body: JSON.stringify(data) }),
121
+ login: (data) => apiRequest('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
122
+ me: () => apiRequest('/auth/me', { method: 'GET' }),
123
+ refresh: (refreshToken) => apiRequest('/auth/refresh', { method: 'POST', body: JSON.stringify({ refreshToken }) })
124
+ },
125
+
126
+ // Pages
127
+ pages: {
128
+ list: () => apiRequest('/pages', { method: 'GET' }),
129
+ get: (urlPath) => apiRequest(`/pages${urlPath}`, { method: 'GET' }),
130
+ create: (payload) => apiRequest('/pages', { method: 'POST', body: JSON.stringify(payload) }),
131
+ update: (urlPath, payload) => apiRequest(`/pages${urlPath}`, { method: 'PUT', body: JSON.stringify(payload) }),
132
+ delete: (urlPath) => apiRequest(`/pages${urlPath}`, {method: 'DELETE'}),
133
+ preview: (markdown) => apiRequest('/pages/preview', {method: 'POST', body: JSON.stringify({markdown})})
134
+ },
135
+
136
+ // Settings
137
+ settings: {
138
+ get: () => apiRequest('/settings', { method: 'GET' }),
139
+ save: (data) => apiRequest('/settings', { method: 'PUT', body: JSON.stringify(data) })
140
+ },
141
+
142
+ // Navigation
143
+ navigation: {
144
+ get: () => apiRequest('/navigation', { method: 'GET' }),
145
+ save: (data) => apiRequest('/navigation', { method: 'PUT', body: JSON.stringify(data) })
146
+ },
147
+
148
+ // Layouts (presets)
149
+ layouts: {
150
+ get: () => apiRequest('/layouts', { method: 'GET' }),
151
+ save: (data) => apiRequest('/layouts', { method: 'PUT', body: JSON.stringify(data) })
152
+ },
153
+
154
+ // Media
155
+ media: {
156
+ list: () => apiRequest('/media', { method: 'GET' }),
157
+ upload: (formData) => uploadRequest('/media', formData),
158
+ delete: (name) => apiRequest(`/media/${encodeURIComponent(name)}`, {method: 'DELETE'}),
159
+ rename: (name, newName) => apiRequest(`/media/${encodeURIComponent(name)}`, {
160
+ method: 'PATCH',
161
+ body: JSON.stringify({newName})
162
+ }),
163
+ info: (name) => apiRequest(`/media/${encodeURIComponent(name)}/info`, {method: 'GET'}),
164
+ transform: (name, body) => apiRequest(`/media/${encodeURIComponent(name)}/transform`, {
165
+ method: 'POST',
166
+ body: JSON.stringify(body)
167
+ }),
168
+ },
169
+
170
+ // Users
171
+ users: {
172
+ list: () => apiRequest('/users', { method: 'GET' }),
173
+ get: (id) => apiRequest(`/users/${id}`, { method: 'GET' }),
174
+ create: (data) => apiRequest('/users', { method: 'POST', body: JSON.stringify(data) }),
175
+ update: (id, data) => apiRequest(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
176
+ delete: (id) => apiRequest(`/users/${id}`, { method: 'DELETE' })
177
+ },
178
+
179
+ // Plugins
180
+ plugins: {
181
+ list: () => apiRequest('/plugins', { method: 'GET' }),
182
+ update: (name, data) => apiRequest(`/plugins/${name}`, { method: 'PUT', body: JSON.stringify(data) }),
183
+ adminConfig: () => apiRequest('/plugins/admin-config', { method: 'GET' })
184
+ }
185
+ };
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Auth state helpers (used by app.js)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export function isAuthenticated() {
192
+ return !!getToken();
193
+ }
194
+
195
+ export function getUser() {
196
+ return S.get('auth_user');
197
+ }
198
+
199
+ export function setAuthData({ token, refreshToken, user }) {
200
+ if (token) setToken(token);
201
+ if (refreshToken) S.set('auth_refresh_token', refreshToken);
202
+ if (user) S.set('auth_user', user);
203
+ }
204
+
205
+ export function logout() {
206
+ clearAuth();
207
+ R.navigate('/login');
208
+ }
209
+
210
+ export {apiRequest};
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Domma CMS Admin - SPA Entry Point
3
+ * Mirrors the domma-board candidate/app.js pattern.
4
+ * Adds JWT auth guard, role-aware sidebar, and dynamic plugin loading.
5
+ */
6
+ import {getSidebarConfig} from './config/sidebar-config.js';
7
+ import {views as coreViews} from './views/index.js';
8
+ import {api, getUser, isAuthenticated, logout} from './api.js';
9
+
10
+ $(() => {
11
+ // Fetch admin theme preference from server config; fall back to charcoal-dark
12
+ (async () => {
13
+ try {
14
+ const site = await api.settings.get();
15
+ Domma.theme.init({ theme: site.adminTheme || 'charcoal-dark', persist: true });
16
+ } catch {
17
+ Domma.theme.init({ theme: 'charcoal-dark', persist: true });
18
+ }
19
+ })();
20
+
21
+ // -------------------------------------------------------------------------
22
+ // Auth guard — redirect to login unless already on /login
23
+ // -------------------------------------------------------------------------
24
+ R.use(async (to, from, next) => {
25
+ if (to.path === '/login') return next();
26
+
27
+ if (!isAuthenticated()) {
28
+ R.navigate('/login');
29
+ return;
30
+ }
31
+
32
+ next();
33
+ });
34
+
35
+ // -------------------------------------------------------------------------
36
+ // Sidebar (role-aware — re-built after login lands on dashboard)
37
+ // -------------------------------------------------------------------------
38
+ let sidebar = null;
39
+
40
+ async function fetchSidebarCounts() {
41
+ if (!isAuthenticated()) return {};
42
+ try {
43
+ const [pages, media, users, plugins] = await Promise.all([
44
+ api.pages.list().catch(() => []),
45
+ api.media.list().catch(() => []),
46
+ api.users.list().catch(() => []),
47
+ api.plugins.list().catch(() => [])
48
+ ]);
49
+ return {
50
+ pages: pages.length,
51
+ media: media.length,
52
+ users: users.length,
53
+ plugins: plugins.filter(p => p.enabled).length || plugins.length
54
+ };
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ async function buildSidebar(role) {
61
+ if (sidebar) {
62
+ $('#admin-sidebar').empty();
63
+ }
64
+ const counts = await fetchSidebarCounts();
65
+ sidebar = Domma.elements.sidebar('#admin-sidebar', {
66
+ header: { title: 'CMS Admin', icon: 'layout' },
67
+ items: getSidebarConfig(role, counts, pluginSidebarItems),
68
+ collapsible: true,
69
+ collapseAt: 992,
70
+ push: true,
71
+ contentSelector: '.dashboard-main',
72
+ top: '60px'
73
+ });
74
+ attachSidebarTooltips();
75
+ attachCollapsibleHeadings();
76
+ }
77
+
78
+ function attachSidebarTooltips() {
79
+ $('#admin-sidebar .sidebar-link').each(function () {
80
+ const label = $(this).find('.sidebar-text').text().trim();
81
+ if (label) $(this).attr('data-tooltip', label);
82
+ });
83
+ E.tooltip('#admin-sidebar [data-tooltip]', { placement: 'right' });
84
+ }
85
+
86
+ function attachCollapsibleHeadings() {
87
+ $('#admin-sidebar .sidebar-heading').each(function () {
88
+ const $heading = $(this);
89
+ $heading.addClass('sidebar-heading--collapsible');
90
+ $heading.append('<span class="sidebar-heading-toggle"><span data-icon="chevron-down"></span></span>');
91
+
92
+ $heading.on('click', function () {
93
+ const collapsing = !$heading.hasClass('is-collapsed');
94
+ $heading.toggleClass('is-collapsed', collapsing);
95
+
96
+ let $el = $heading.next();
97
+ while ($el.length && !$el.hasClass('sidebar-heading') && !$el.hasClass('sidebar-divider')) {
98
+ $el.toggle(!collapsing);
99
+ $el = $el.next();
100
+ }
101
+ });
102
+ });
103
+
104
+ Domma.icons.scan('#admin-sidebar');
105
+ }
106
+
107
+ // Build with no role initially (login page has no sidebar)
108
+ buildSidebar(null);
109
+
110
+ // Keep sidebar active item in sync with route
111
+ M.subscribe('router:afterChange', ({ to }) => {
112
+ if (sidebar && to.path !== '/login') {
113
+ sidebar.setActive('#' + to.path);
114
+ }
115
+ });
116
+
117
+ // -------------------------------------------------------------------------
118
+ // Router — core routes + login
119
+ // -------------------------------------------------------------------------
120
+ const coreRoutes = [
121
+ { path: '/', view: 'dashboard', title: 'Dashboard - Domma CMS' },
122
+ { path: '/pages', view: 'pages', title: 'Pages - Domma CMS' },
123
+ { path: '/pages/new', view: 'pageEditor', title: 'New Page - Domma CMS' },
124
+ { path: '/pages/edit/*', view: 'pageEditor', title: 'Edit Page - Domma CMS' },
125
+ { path: '/media', view: 'media', title: 'Media - Domma CMS' },
126
+ { path: '/navigation', view: 'navigation', title: 'Navigation - Domma CMS' },
127
+ { path: '/layouts', view: 'layouts', title: 'Layouts - Domma CMS' },
128
+ { path: '/settings', view: 'settings', title: 'Settings - Domma CMS' },
129
+ { path: '/users', view: 'users', title: 'Users - Domma CMS' },
130
+ { path: '/users/new', view: 'userEditor', title: 'New User - Domma CMS' },
131
+ { path: '/users/edit/:id', view: 'userEditor', title: 'Edit User - Domma CMS' },
132
+ {path: '/plugins', view: 'plugins', title: 'Plugins - Domma CMS'},
133
+ {path: '/documentation', view: 'documentation', title: 'Usage - Domma CMS'},
134
+ {path: '/tutorials', view: 'tutorials', title: 'Tutorials - Domma CMS'},
135
+ { path: '/login', view: 'login', title: 'Sign in - Domma CMS',
136
+ onEnter: () => {
137
+ $('#admin-sidebar').hide();
138
+ $('#admin-topbar').hide();
139
+ }
140
+ }
141
+ ];
142
+
143
+ // After login, re-show chrome and rebuild sidebar with correct role
144
+ M.subscribe('router:afterChange', ({ to, from }) => {
145
+ if (to.path !== '/login') {
146
+ $('#admin-sidebar').show();
147
+ $('#admin-topbar').show();
148
+ if (from?.path === '/login') {
149
+ const user = getUser();
150
+ if (user) buildSidebar(user.role);
151
+ }
152
+ injectUserMenu();
153
+ }
154
+ });
155
+
156
+ // Reveal primary/danger buttons after each view renders
157
+ M.subscribe('router:afterChange', () => {
158
+ setTimeout(() => {
159
+ if ($('.btn-primary, .btn-danger').length) {
160
+ Domma.effects.reveal('.btn-primary, .btn-danger', {animation: 'fade', stagger: 40, duration: 300});
161
+ }
162
+ }, 50);
163
+ });
164
+
165
+ // Collapsible cards — event delegation handles all views including plugins
166
+ $('#view-container').on('click', '.card-collapsible .card-header', function (e) {
167
+ if ($(e.target).closest('button, a').length) return;
168
+ $(this).closest('.card').toggleClass('card-collapsed');
169
+ });
170
+
171
+ // -------------------------------------------------------------------------
172
+ // Dynamic plugin routes (loaded before R.init)
173
+ // -------------------------------------------------------------------------
174
+ const allViews = { ...coreViews };
175
+ const allRoutes = [...coreRoutes];
176
+ let pluginSidebarItems = [];
177
+
178
+ async function loadPlugins() {
179
+ if (!isAuthenticated()) return;
180
+ try {
181
+ const pluginConfig = await api.plugins.adminConfig();
182
+ pluginSidebarItems = pluginConfig.sidebar || [];
183
+ if (pluginConfig.routes?.length) allRoutes.push(...pluginConfig.routes);
184
+ for (const [viewName, viewDef] of Object.entries(pluginConfig.views || {})) {
185
+ try {
186
+ const mod = await import(`/plugins/${viewDef.entry}`);
187
+ allViews[viewName] = mod[viewDef.exportName];
188
+ } catch { /* plugin view load failure is non-fatal */ }
189
+ }
190
+ } catch { /* plugins endpoint unreachable — continue without plugins */ }
191
+ }
192
+
193
+ // -------------------------------------------------------------------------
194
+ // Inject user info + sign out into topbar
195
+ // -------------------------------------------------------------------------
196
+ function injectUserMenu() {
197
+ const user = getUser();
198
+ if (!user) return;
199
+ if ($('#topbar-user-name').length) return; // already injected
200
+
201
+ const roleLabelMap = {
202
+ admin: 'Admin', manager: 'Manager', editor: 'Editor', subscriber: 'Subscriber'
203
+ };
204
+ const roleLabel = roleLabelMap[user.role] || user.role;
205
+
206
+ $('#topbar-user').html(`
207
+ <span id="topbar-user-name" class="topbar-user-name">${escapeHtml(user.name)}</span>
208
+ <span class="topbar-role-badge topbar-role-badge--${escapeHtml(user.role)}">${roleLabel}</span>
209
+ `);
210
+
211
+ $('#topbar-actions').html(`
212
+ <a href="#/settings" class="topbar-action-link" data-tooltip="Settings" data-tooltip-placement="bottom">
213
+ <span data-icon="settings"></span>
214
+ <span>Settings</span>
215
+ </a>
216
+ <a href="#" id="topbar-logout-btn" class="topbar-action-link topbar-signout" data-tooltip="Sign out" data-tooltip-placement="bottom">
217
+ <span data-icon="log-out"></span>
218
+ <span>Sign out</span>
219
+ </a>
220
+ `);
221
+
222
+ $('#topbar-logout-btn').on('click', (e) => { e.preventDefault(); logout(); });
223
+ Domma.icons.scan('#admin-topbar');
224
+ E.tooltip('#topbar-actions [data-tooltip]', { placement: 'bottom' });
225
+ }
226
+
227
+ function escapeHtml(str) {
228
+ return String(str)
229
+ .replace(/&/g, '&amp;')
230
+ .replace(/</g, '&lt;')
231
+ .replace(/>/g, '&gt;');
232
+ }
233
+
234
+ // -------------------------------------------------------------------------
235
+ // Boot sequence
236
+ // -------------------------------------------------------------------------
237
+ (async () => {
238
+ await loadPlugins();
239
+
240
+ const user = getUser();
241
+ if (user) {
242
+ await buildSidebar(user.role);
243
+ injectUserMenu();
244
+ }
245
+
246
+ R.init({
247
+ container: '#view-container',
248
+ routes: allRoutes,
249
+ views: allViews,
250
+ default: '/',
251
+ transitions: { enter: 'fadeIn', leave: 'fadeOut', duration: 150 }
252
+ });
253
+
254
+ // Patch Domma router to support /* wildcard routes (multi-segment catch-all)
255
+ const origExtract = R._extractParams.bind(R);
256
+ R._extractParams = function(routePath, actualPath) {
257
+ if (routePath.endsWith('/*')) {
258
+ const prefix = routePath.slice(0, -2);
259
+ return actualPath.startsWith(prefix + '/') ? {} : null;
260
+ }
261
+ return origExtract(routePath, actualPath);
262
+ };
263
+ // Only re-trigger if the current URL needs the wildcard patch (e.g. /pages/edit/about)
264
+ const currentPath = (window.location.hash || '#/').slice(1) || '/';
265
+ const needsWildcard = allRoutes
266
+ .filter(r => r.path.endsWith('/*'))
267
+ .some(r => currentPath.startsWith(r.path.slice(0, -2) + '/'));
268
+ if (needsWildcard) R._handleRouteChange();
269
+ })();
270
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Admin Sidebar Configuration
3
+ * Role-aware — certain sections are only visible to privileged roles.
4
+ *
5
+ * @param {string|null} role - Current user role: 'admin' | 'manager' | 'editor' | null
6
+ * @param {object} counts - Optional badge counts: { pages, media, users }
7
+ * @param pluginItems
8
+ * @returns {object[]}
9
+ */
10
+ export function getSidebarConfig(role, counts = {}, pluginItems = []) {
11
+ const isAdmin = role === 'admin';
12
+ const isManager = role === 'manager';
13
+ const canStructure = isAdmin || isManager;
14
+
15
+ const badge = (n) => (n != null && n > 0) ? String(n) : null;
16
+
17
+ const items = [];
18
+
19
+ items.push(
20
+ {heading: 'Overview'},
21
+ {id: 'dashboard', text: 'Dashboard', icon: 'home', url: '#/', section: '#/'},
22
+ {divider: true}
23
+ );
24
+
25
+ if (canStructure) {
26
+ items.push(
27
+ {heading: 'Structure'},
28
+ {id: 'navigation', text: 'Navigation', icon: 'menu', url: '#/navigation', section: '#/navigation'},
29
+ {id: 'layouts', text: 'Layouts', icon: 'layout', url: '#/layouts', section: '#/layouts'},
30
+ {divider: true}
31
+ )
32
+ ;
33
+ }
34
+
35
+ items.push(
36
+ {heading: 'Content'},
37
+ {id: 'pages', text: 'Pages', icon: 'file-text', url: '#/pages', section: '#/pages', badge: badge(counts.pages)},
38
+ {id: 'media', text: 'Media', icon: 'image', url: '#/media', section: '#/media', badge: badge(counts.media)}
39
+ );
40
+
41
+ items.push(
42
+ {divider: true},
43
+ {heading: 'Configuration'}
44
+ );
45
+
46
+ if (canStructure) {
47
+ items.push(
48
+ {id: 'users', text: 'Users', icon: 'users', url: '#/users', section: '#/users', badge: badge(counts.users)},
49
+ {id: 'settings', text: 'Site Settings', icon: 'settings', url: '#/settings', section: '#/settings'}
50
+ );
51
+ }
52
+
53
+ items.push(
54
+ {divider: true},
55
+ {heading: 'Plugins'}
56
+ );
57
+
58
+ if (isAdmin) {
59
+ items.push(
60
+ {
61
+ id: 'plugins',
62
+ text: 'Plugins',
63
+ icon: 'package',
64
+ url: '#/plugins',
65
+ section: '#/plugins',
66
+ badge: badge(counts.plugins)
67
+ }
68
+ );
69
+ items.push(...pluginItems);
70
+ }
71
+
72
+ items.push(
73
+ {divider: true},
74
+ {heading: 'View Site'}
75
+ );
76
+
77
+ items.push(
78
+ {id: 'view-site', text: 'View Site', icon: 'external-link', url: '/', section: '/'}
79
+ );
80
+
81
+ items.push(
82
+ {divider: true},
83
+ {heading: 'Documentation'}
84
+ );
85
+
86
+ items.push(
87
+ {
88
+ id: 'documentation',
89
+ text: 'Usage',
90
+ icon: 'book',
91
+ url: '#/documentation',
92
+ section: '#/documentation',
93
+ badge: badge(counts.documents)
94
+ },
95
+ {
96
+ id: 'tutorials',
97
+ text: 'Tutorials',
98
+ icon: 'document',
99
+ url: '#/tutorials',
100
+ section: '#/documentation',
101
+ badge: badge(counts.tutorials)
102
+ }
103
+ );
104
+ // items.push(...pluginItems);
105
+
106
+ return items;
107
+ }