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,254 @@
1
+ /**
2
+ * Login / First-time Setup View
3
+ *
4
+ * Flow:
5
+ * - Already logged in → redirect to dashboard
6
+ * - needsSetup = true → show setup form → onboarding wizard (site → theme → done)
7
+ * - needsSetup = false → show login form → redirect to dashboard
8
+ */
9
+ import { api, setAuthData, isAuthenticated } from '../api.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Blueprints
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const setupBlueprint = {
16
+ name: {
17
+ type: 'string',
18
+ required: true,
19
+ minLength: 2,
20
+ label: 'Full Name',
21
+ formConfig: { placeholder: 'Your name', autocomplete: 'name' }
22
+ },
23
+ email: {
24
+ type: 'email',
25
+ required: true,
26
+ label: 'Email Address',
27
+ formConfig: { placeholder: 'admin@example.com', autocomplete: 'email' }
28
+ },
29
+ password: {
30
+ type: 'password',
31
+ required: true,
32
+ minLength: 8,
33
+ label: 'Password',
34
+ formConfig: { placeholder: '••••••••', autocomplete: 'new-password', tooltip: 'Minimum 8 characters' }
35
+ }
36
+ };
37
+
38
+ const loginBlueprint = {
39
+ email: {
40
+ type: 'email',
41
+ required: true,
42
+ label: 'Email Address',
43
+ formConfig: { placeholder: 'you@example.com', autocomplete: 'email' }
44
+ },
45
+ password: {
46
+ type: 'password',
47
+ required: true,
48
+ label: 'Password',
49
+ formConfig: { placeholder: '••••••••', autocomplete: 'current-password' }
50
+ }
51
+ };
52
+
53
+ const THEMES = [
54
+ { id: 'charcoal-dark', label: 'Charcoal', dark: true, primary: '#5b8cff', bg: '#1a1d23' },
55
+ { id: 'charcoal-light', label: 'Charcoal', dark: false, primary: '#4a7aff', bg: '#f4f5f7' },
56
+ { id: 'ocean-dark', label: 'Ocean', dark: true, primary: '#00b4d8', bg: '#0d1b2a' },
57
+ { id: 'ocean-light', label: 'Ocean', dark: false, primary: '#0096c7', bg: '#e8f4f8' },
58
+ { id: 'forest-dark', label: 'Forest', dark: true, primary: '#52b788', bg: '#1a231e' },
59
+ { id: 'forest-light', label: 'Forest', dark: false, primary: '#40916c', bg: '#f0f7f4' },
60
+ { id: 'sunset-dark', label: 'Sunset', dark: true, primary: '#ff6b6b', bg: '#1f1a1a' },
61
+ { id: 'sunset-light', label: 'Sunset', dark: false, primary: '#e05555', bg: '#fff0f0' },
62
+ { id: 'royal-dark', label: 'Royal', dark: true, primary: '#9b59b6', bg: '#1a1525' },
63
+ { id: 'royal-light', label: 'Royal', dark: false, primary: '#8e44ad', bg: '#f5f0fa' },
64
+ { id: 'lemon-dark', label: 'Lemon', dark: true, primary: '#f1c40f', bg: '#1a1a10' },
65
+ { id: 'lemon-light', label: 'Lemon', dark: false, primary: '#d4ac0d', bg: '#fffef0' },
66
+ { id: 'silver-dark', label: 'Silver', dark: true, primary: '#95a5a6', bg: '#1c1e20' },
67
+ { id: 'silver-light', label: 'Silver', dark: false, primary: '#7f8c8d', bg: '#f2f3f4' },
68
+ { id: 'grayve', label: 'Grayve', dark: true, primary: '#aaaaaa', bg: '#111111' },
69
+ ];
70
+
71
+ export const loginView = {
72
+ templateUrl: '/admin/js/templates/login.html',
73
+
74
+ async onMount($container) {
75
+ if (isAuthenticated()) {
76
+ R.navigate('/');
77
+ return;
78
+ }
79
+
80
+ let needsSetup = false;
81
+ try {
82
+ const status = await api.auth.setupStatus();
83
+ needsSetup = status.needsSetup;
84
+ } catch {
85
+ E.toast('Could not reach the server.', { type: 'error' });
86
+ }
87
+
88
+ if (needsSetup) {
89
+ showStep($container, 'setup');
90
+ bindSetup($container);
91
+ } else {
92
+ showStep($container, 'login');
93
+ bindLogin($container);
94
+ }
95
+
96
+ Domma.icons.scan();
97
+ }
98
+ };
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Panel switching
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const PANELS = ['setup', 'onboarding-site', 'onboarding-theme', 'onboarding-done', 'login'];
105
+
106
+ function showStep($container, name) {
107
+ PANELS.forEach(p => $container.find(`#${p}-panel`).hide());
108
+ $container.find(`#${name}-panel`).show();
109
+ Domma.icons.scan();
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Setup step
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function bindSetup($container) {
117
+ F.render('#setup-form-container', setupBlueprint, {}, {
118
+ layout: 'stacked',
119
+ submitText: 'Create admin account',
120
+ onSubmit: async (data) => {
121
+ try {
122
+ const result = await api.auth.setup(data);
123
+ setAuthData(result);
124
+ showStep($container, 'onboarding-site');
125
+ bindOnboardingSite($container);
126
+ } catch (err) {
127
+ E.toast(err.message || 'Setup failed. Please try again.', { type: 'error' });
128
+ return false;
129
+ }
130
+ }
131
+ });
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Onboarding Step 2 — Site Identity
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function bindOnboardingSite($container) {
139
+ $container.find('#ob-site-skip').on('click', (e) => {
140
+ e.preventDefault();
141
+ showStep($container, 'onboarding-theme');
142
+ buildThemeGrid($container);
143
+ bindOnboardingTheme($container);
144
+ });
145
+
146
+ $container.find('#ob-site-btn').on('click', async () => {
147
+ $container.find('#ob-site-error').hide();
148
+ const title = $container.find('#ob-title').val().trim();
149
+ const tagline = $container.find('#ob-tagline').val().trim();
150
+
151
+ const $btn = $container.find('#ob-site-btn').prop('disabled', true).text('Saving…');
152
+ try {
153
+ // Fetch current settings then merge title + tagline
154
+ const current = await api.settings.get();
155
+ await api.settings.save({
156
+ ...current,
157
+ title: title || current.title,
158
+ tagline: tagline || current.tagline,
159
+ seo: {
160
+ ...(current.seo || {}),
161
+ defaultTitle: title || (current.seo && current.seo.defaultTitle)
162
+ }
163
+ });
164
+
165
+ // Also update nav brand text
166
+ const nav = await api.navigation.get();
167
+ await api.navigation.save({
168
+ ...nav,
169
+ brand: { ...(nav.brand || {}), text: title || nav.brand.text }
170
+ });
171
+
172
+ showStep($container, 'onboarding-theme');
173
+ buildThemeGrid($container);
174
+ bindOnboardingTheme($container);
175
+ } catch (err) {
176
+ showError($container, 'ob-site-error', err.message || 'Could not save site details.');
177
+ } finally {
178
+ $btn.prop('disabled', false).text('Continue');
179
+ }
180
+ });
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Onboarding Step 3 — Theme Picker
185
+ // ---------------------------------------------------------------------------
186
+
187
+ function buildThemeGrid($container) {
188
+ const $grid = $container.find('#theme-grid').empty();
189
+ THEMES.forEach(t => {
190
+ const $card = $(`
191
+ <div class="theme-swatch" data-theme="${t.id}" title="${t.id}">
192
+ <div class="theme-swatch-preview" style="background:${t.bg}">
193
+ <div class="theme-swatch-accent" style="background:${t.primary}"></div>
194
+ </div>
195
+ <div class="theme-swatch-label">
196
+ <span>${t.label}</span>
197
+ <span class="theme-swatch-mode">${t.dark ? 'Dark' : 'Light'}</span>
198
+ </div>
199
+ </div>
200
+ `);
201
+ $card.on('click', () => {
202
+ $container.find('.theme-swatch').removeClass('selected');
203
+ $card.addClass('selected');
204
+ });
205
+ $grid.append($card);
206
+ });
207
+
208
+ // Default selection
209
+ $container.find('[data-theme="charcoal-dark"]').addClass('selected');
210
+ }
211
+
212
+ function bindOnboardingTheme($container) {
213
+ $container.find('#ob-theme-skip').on('click', (e) => {
214
+ e.preventDefault();
215
+ showStep($container, 'onboarding-done');
216
+ });
217
+
218
+ $container.find('#ob-theme-btn').on('click', async () => {
219
+ $container.find('#ob-theme-error').hide();
220
+ const selected = $container.find('.theme-swatch.selected').attr('data-theme') || 'charcoal-dark';
221
+
222
+ const $btn = $container.find('#ob-theme-btn').prop('disabled', true).text('Applying…');
223
+ try {
224
+ const current = await api.settings.get();
225
+ await api.settings.save({ ...current, theme: selected });
226
+ showStep($container, 'onboarding-done');
227
+ } catch (err) {
228
+ showError($container, 'ob-theme-error', err.message || 'Could not save theme.');
229
+ } finally {
230
+ $btn.prop('disabled', false).text('Apply theme');
231
+ }
232
+ });
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Login step
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function bindLogin($container) {
240
+ F.render('#login-form-container', loginBlueprint, {}, {
241
+ layout: 'stacked',
242
+ submitText: 'Sign in',
243
+ onSubmit: async (data) => {
244
+ try {
245
+ const result = await api.auth.login(data);
246
+ setAuthData(result);
247
+ R.navigate('/');
248
+ } catch (err) {
249
+ E.toast(err.message || 'Invalid credentials. Please try again.', { type: 'error' });
250
+ return false;
251
+ }
252
+ }
253
+ });
254
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Media View
3
+ */
4
+ import {api} from '../api.js';
5
+ import {openImageEditor} from '../lib/image-editor.js';
6
+
7
+ export const mediaView = {
8
+ templateUrl: '/admin/js/templates/media.html',
9
+
10
+ async onMount($container) {
11
+ const renderGrid = (files) => {
12
+ const grid = $container.find('#media-grid').empty().get(0);
13
+ if (!files.length) {
14
+ const empty = document.createElement('p');
15
+ empty.className = 'text-muted';
16
+ empty.textContent = 'No media files yet. Upload one above.';
17
+ grid.appendChild(empty);
18
+ return;
19
+ }
20
+ files.forEach(file => {
21
+ const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(file.name);
22
+ const isEditable = /\.(png|jpe?g|gif|webp|tiff?)$/i.test(file.name);
23
+
24
+ const card = document.createElement('div');
25
+ card.className = 'media-card';
26
+
27
+ // Preview
28
+ const preview = document.createElement('div');
29
+ preview.className = 'media-preview';
30
+ if (isImage) {
31
+ const img = document.createElement('img');
32
+ img.src = file.url + '?t=' + Date.now();
33
+ img.alt = file.name;
34
+ img.className = 'media-thumb';
35
+ preview.appendChild(img);
36
+ } else {
37
+ const thumb = document.createElement('div');
38
+ thumb.className = 'media-thumb media-thumb--file';
39
+ const icon = document.createElement('span');
40
+ icon.setAttribute('data-icon', 'file');
41
+ thumb.appendChild(icon);
42
+ preview.appendChild(thumb);
43
+ }
44
+ card.appendChild(preview);
45
+
46
+ // Info
47
+ const info = document.createElement('div');
48
+ info.className = 'media-info';
49
+
50
+ const nameSpan = document.createElement('span');
51
+ nameSpan.className = 'media-name';
52
+ nameSpan.title = 'Double-click to rename';
53
+ nameSpan.textContent = file.name;
54
+
55
+ const sizeSpan = document.createElement('span');
56
+ sizeSpan.className = 'media-size';
57
+ sizeSpan.textContent = formatBytes(file.size);
58
+ info.appendChild(nameSpan);
59
+ info.appendChild(sizeSpan);
60
+ card.appendChild(info);
61
+
62
+ // Inline rename: double-click the filename to edit
63
+ nameSpan.addEventListener('dblclick', () => startRename(nameSpan, file, renderGrid));
64
+
65
+ // Actions
66
+ const actions = document.createElement('div');
67
+ actions.className = 'media-actions';
68
+
69
+ const copyBtn = document.createElement('button');
70
+ copyBtn.className = 'btn btn-sm btn-ghost';
71
+ const copyIcon = document.createElement('span');
72
+ copyIcon.setAttribute('data-icon', 'copy');
73
+ copyBtn.appendChild(copyIcon);
74
+ copyBtn.addEventListener('click', () => {
75
+ navigator.clipboard.writeText(window.location.origin + file.url)
76
+ .then(() => E.toast('URL copied.', {type: 'success'}))
77
+ .catch(() => E.toast('Copy failed.', {type: 'error'}));
78
+ });
79
+
80
+ const renameBtn = document.createElement('button');
81
+ renameBtn.className = 'btn btn-sm btn-ghost';
82
+ const renameIcon = document.createElement('span');
83
+ renameIcon.setAttribute('data-icon', 'type');
84
+ renameBtn.appendChild(renameIcon);
85
+ renameBtn.addEventListener('click', () => startRename(nameSpan, file, renderGrid));
86
+
87
+ let editBtn = null;
88
+ if (isEditable) {
89
+ editBtn = document.createElement('button');
90
+ editBtn.className = 'btn btn-sm btn-ghost';
91
+ const editIcon = document.createElement('span');
92
+ editIcon.setAttribute('data-icon', 'edit');
93
+ editBtn.appendChild(editIcon);
94
+ editBtn.addEventListener('click', async () => {
95
+ const saved = await openImageEditor(file);
96
+ if (saved) renderGrid(await api.media.list().catch(() => []));
97
+ });
98
+ }
99
+
100
+ const deleteBtn = document.createElement('button');
101
+ deleteBtn.className = 'btn btn-sm btn-danger';
102
+ const deleteIcon = document.createElement('span');
103
+ deleteIcon.setAttribute('data-icon', 'trash');
104
+ deleteBtn.appendChild(deleteIcon);
105
+ deleteBtn.addEventListener('click', async () => {
106
+ if (!await E.confirm(`Delete "${file.name}"?`)) return;
107
+ try {
108
+ await api.media.delete(file.name);
109
+ E.toast('File deleted.', {type: 'success'});
110
+ renderGrid(await api.media.list().catch(() => []));
111
+ } catch {
112
+ E.toast('Delete failed.', {type: 'error'});
113
+ }
114
+ });
115
+
116
+ actions.appendChild(copyBtn);
117
+ actions.appendChild(renameBtn);
118
+ if (editBtn) actions.appendChild(editBtn);
119
+ actions.appendChild(deleteBtn);
120
+ card.appendChild(actions);
121
+ grid.appendChild(card);
122
+
123
+ // Tooltips must be registered after the element is in the DOM
124
+ E.tooltip(copyBtn, {content: 'Copy URL', position: 'top'});
125
+ E.tooltip(renameBtn, {content: 'Rename', position: 'top'});
126
+ if (editBtn) E.tooltip(editBtn, {content: 'Edit image', position: 'top'});
127
+ E.tooltip(deleteBtn, {content: 'Delete file', position: 'top'});
128
+ });
129
+
130
+ Domma.icons.scan();
131
+ Domma.effects.reveal('.media-card', { animation: 'fade', stagger: 40, duration: 350 });
132
+ };
133
+
134
+ const initialFiles = await api.media.list().catch(() => []);
135
+ renderGrid(initialFiles);
136
+
137
+ Domma.effects.ripple('#upload-btn');
138
+ $container.find('#upload-btn').on('click', () => $container.find('#file-input').get(0).click());
139
+
140
+ $container.find('#file-input').on('change', async function () {
141
+ if (!this.files.length) return;
142
+ const formData = new FormData();
143
+ for (const file of this.files) formData.append('file', file);
144
+
145
+ const btn = $container.find('#upload-btn').get(0);
146
+ const textNode = btn.lastChild;
147
+ btn.disabled = true;
148
+ textNode.textContent = ' Uploading…';
149
+
150
+ try {
151
+ const resp = await fetch('/api/media', {
152
+ method: 'POST',
153
+ body: formData,
154
+ headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
155
+ });
156
+ if (!resp.ok) throw new Error('Upload failed');
157
+ E.toast('File uploaded.', { type: 'success' });
158
+ renderGrid(await api.media.list().catch(() => []));
159
+ } catch {
160
+ E.toast('Upload failed.', { type: 'error' });
161
+ } finally {
162
+ btn.disabled = false;
163
+ textNode.textContent = ' Upload';
164
+ this.value = '';
165
+ }
166
+ });
167
+ }
168
+ };
169
+
170
+ /**
171
+ * Replace a filename span with an inline input for renaming.
172
+ * Confirms with Enter, cancels with Escape or blur.
173
+ *
174
+ * @param {HTMLElement} nameSpan
175
+ * @param {{ name: string, url: string }} file
176
+ * @param {Function} renderGrid
177
+ */
178
+ function startRename(nameSpan, file, renderGrid) {
179
+ if (nameSpan.querySelector('input')) return; // already editing
180
+
181
+ const original = file.name;
182
+ const dotIndex = original.lastIndexOf('.');
183
+ const baseName = dotIndex > 0 ? original.slice(0, dotIndex) : original;
184
+
185
+ const input = document.createElement('input');
186
+ input.type = 'text';
187
+ input.className = 'media-rename-input';
188
+ input.value = original;
189
+ input.title = '';
190
+
191
+ nameSpan.textContent = '';
192
+ nameSpan.appendChild(input);
193
+
194
+ input.focus();
195
+ input.setSelectionRange(0, baseName.length);
196
+
197
+ async function confirm() {
198
+ const newName = input.value.trim();
199
+ if (!newName || newName === original) return cancel();
200
+ input.disabled = true;
201
+ try {
202
+ await api.media.rename(original, newName);
203
+ E.toast('File renamed.', {type: 'success'});
204
+ renderGrid(await api.media.list().catch(() => []));
205
+ } catch (err) {
206
+ E.toast(err.message || 'Rename failed.', {type: 'error'});
207
+ cancel();
208
+ }
209
+ }
210
+
211
+ function cancel() {
212
+ nameSpan.textContent = original;
213
+ nameSpan.title = 'Double-click to rename';
214
+ }
215
+
216
+ input.addEventListener('keydown', (e) => {
217
+ if (e.key === 'Enter') {
218
+ e.preventDefault();
219
+ confirm();
220
+ }
221
+ if (e.key === 'Escape') {
222
+ e.preventDefault();
223
+ cancel();
224
+ }
225
+ });
226
+
227
+ input.addEventListener('blur', () => {
228
+ // Short delay so Enter keydown fires before blur
229
+ setTimeout(() => {
230
+ if (nameSpan.contains(input)) cancel();
231
+ }, 150);
232
+ });
233
+ }
234
+
235
+ function formatBytes(bytes) {
236
+ if (!bytes) return '0 B';
237
+ const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
238
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
239
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
240
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Navigation View
3
+ */
4
+ import { api } from '../api.js';
5
+
6
+ let _nextId = 1;
7
+
8
+ const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
9
+
10
+ function flattenItems(items) {
11
+ const flat = [];
12
+ (items || []).forEach(item => {
13
+ const id = _nextId++;
14
+ flat.push({ _id: id, text: item.text || '', url: item.url || '', icon: item.icon || '', parentId: null });
15
+ (item.items || item.children || []).forEach(child => {
16
+ flat.push({ _id: _nextId++, text: child.text || '', url: child.url || '', icon: child.icon || '', parentId: id });
17
+ });
18
+ });
19
+ return flat;
20
+ }
21
+
22
+ function nestItems(flat) {
23
+ return flat
24
+ .filter(i => i.parentId === null)
25
+ .map(parent => {
26
+ const children = flat
27
+ .filter(i => i.parentId === parent._id)
28
+ .map(c => ({ text: c.text, url: c.url, ...(c.icon && { icon: c.icon }) }));
29
+ const obj = { text: parent.text, url: parent.url, ...(parent.icon && { icon: parent.icon }) };
30
+ if (children.length) obj.items = children;
31
+ return obj;
32
+ });
33
+ }
34
+
35
+ // Returns items sorted so children appear immediately after their parent
36
+ function getOrderedItems(flat) {
37
+ const result = [];
38
+ flat.filter(i => i.parentId === null).forEach(parent => {
39
+ result.push(parent);
40
+ flat.filter(i => i.parentId === parent._id).forEach(child => result.push(child));
41
+ });
42
+ return result;
43
+ }
44
+
45
+ export const navigationView = {
46
+ templateUrl: '/admin/js/templates/navigation.html',
47
+
48
+ async onMount($container) {
49
+ let nav = await api.navigation.get().catch(() => ({ brand: {}, items: [] }));
50
+ let flatItems = flattenItems(nav.items);
51
+
52
+ const syncFromDom = () => {
53
+ $container.find('.nav-item-row').each(function () {
54
+ const id = parseInt($(this).data('id'), 10);
55
+ const item = flatItems.find(i => i._id === id);
56
+ if (!item) return;
57
+ item.text = $(this).find('.item-text').val();
58
+ item.url = $(this).find('.item-url').val();
59
+ item.icon = $(this).find('.item-icon').val();
60
+ const parentVal = $(this).find('.item-parent').val();
61
+ item.parentId = parentVal ? parseInt(parentVal, 10) : null;
62
+ });
63
+ // Promote children whose parent is now itself a child
64
+ const childIds = new Set(flatItems.filter(i => i.parentId !== null).map(i => i._id));
65
+ flatItems.forEach(i => {
66
+ if (i.parentId !== null && childIds.has(i.parentId)) i.parentId = null;
67
+ });
68
+ };
69
+
70
+ const renderItems = () => {
71
+ const $list = $container.find('#nav-items-list').empty();
72
+ const topLevel = flatItems.filter(i => i.parentId === null);
73
+
74
+ getOrderedItems(flatItems).forEach(item => {
75
+ const isChild = item.parentId !== null;
76
+ const parentOptions = `<option value="">— top-level —</option>` +
77
+ topLevel
78
+ .filter(t => t._id !== item._id)
79
+ .map(t => `<option value="${t._id}"${t._id === item.parentId ? ' selected' : ''}>${esc(t.text) || '(untitled)'}</option>`)
80
+ .join('');
81
+
82
+ $list.append(`
83
+ <div class="nav-item-row${isChild ? ' nav-item-row--child' : ''}" data-id="${item._id}">
84
+ <span class="nav-col-indent">${isChild ? '↳' : ''}</span>
85
+ <input type="text" class="form-input item-text nav-col-main" value="${esc(item.text)}" placeholder="Label">
86
+ <input type="text" class="form-input item-url nav-col-main" value="${esc(item.url)}" placeholder="/url">
87
+ <input type="text" class="form-input item-icon nav-col-icon" value="${esc(item.icon)}" placeholder="icon">
88
+ <select class="form-select item-parent nav-col-parent">${parentOptions}</select>
89
+ <span class="nav-col-action">
90
+ <button class="btn btn-sm btn-danger btn-remove-item" data-id="${item._id}" data-tooltip="Remove"><span data-icon="trash"></span></button>
91
+ </span>
92
+ </div>
93
+ `);
94
+ });
95
+ Domma.icons.scan('#nav-items-list');
96
+ document.querySelectorAll('#nav-items-list [data-tooltip]').forEach(el => {
97
+ E.tooltip(el, {content: el.getAttribute('data-tooltip'), position: 'top'});
98
+ });
99
+ };
100
+
101
+ $container.find('#field-brand-text').val(nav.brand?.text || '');
102
+ $container.find('#field-brand-url').val(nav.brand?.url || '/');
103
+ $container.find('#field-nav-variant').val(nav.variant || 'dark');
104
+ renderItems();
105
+
106
+ $container.find('#add-nav-item').on('click', () => {
107
+ syncFromDom();
108
+ flatItems.push({ _id: _nextId++, text: '', url: '', icon: '', parentId: null });
109
+ renderItems();
110
+ });
111
+
112
+ $container.off('click', '.btn-remove-item').on('click', '.btn-remove-item', function () {
113
+ syncFromDom();
114
+ const id = parseInt($(this).data('id'), 10);
115
+ flatItems = flatItems.filter(i => i._id !== id && i.parentId !== id);
116
+ renderItems();
117
+ });
118
+
119
+ // Re-render when a parent changes so other dropdowns update
120
+ $container.off('change', '.item-parent').on('change', '.item-parent', function () {
121
+ syncFromDom();
122
+ renderItems();
123
+ });
124
+
125
+ $container.find('#save-nav-btn').on('click', async () => {
126
+ syncFromDom();
127
+ const nested = nestItems(
128
+ flatItems.map(i => ({ ...i, text: i.text.trim(), url: i.url.trim(), icon: i.icon.trim() }))
129
+ ).filter(i => i.text || i.url);
130
+
131
+ const data = {
132
+ brand: {
133
+ text: $container.find('#field-brand-text').val().trim(),
134
+ url: $container.find('#field-brand-url').val().trim() || '/'
135
+ },
136
+ items: nested,
137
+ variant: $container.find('#field-nav-variant').val(),
138
+ position: nav.position || 'sticky'
139
+ };
140
+
141
+ try {
142
+ await api.navigation.save(data);
143
+ nav = data;
144
+ flatItems = flattenItems(nav.items);
145
+ renderItems();
146
+ E.toast('Navigation saved.', { type: 'success' });
147
+ } catch {
148
+ E.toast('Failed to save navigation.', { type: 'error' });
149
+ }
150
+ });
151
+ }
152
+ };