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,869 @@
1
+ /**
2
+ * Image Editor Module
3
+ * Opens an interactive editor modal (Cropper.js + DOM API) for a media file.
4
+ * Returns a Promise<boolean> — true when the image was saved, false on cancel.
5
+ */
6
+ import {api} from '../api.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Custom icon definitions
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function registerIcons() {
13
+ const icons = {
14
+ 'rotate-cw': {
15
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
16
+ path: 'M21 2v6h-6M21 8A9 9 0 1 0 19.07 14.4'
17
+ },
18
+ 'rotate-ccw': {
19
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
20
+ path: 'M3 2v6h6M3 8a9 9 0 1 1 1.93 6.4'
21
+ },
22
+ 'flip-h': {
23
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
24
+ paths: ['M12 3v18', 'M4 6l4 6-4 6', 'M20 6l-4 6 4 6']
25
+ },
26
+ 'flip-v': {
27
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
28
+ paths: ['M3 12h18', 'M6 4l6 4 6-4', 'M6 20l6-4 6 4']
29
+ },
30
+ 'lock': {
31
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
32
+ paths: [
33
+ 'M17 11H7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2z',
34
+ 'M12 8a4 4 0 0 0-4 4',
35
+ ],
36
+ path: 'M12 8V5a4 4 0 0 1 4 4v2'
37
+ },
38
+ 'unlock': {
39
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
40
+ paths: [
41
+ 'M17 11H7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2z',
42
+ 'M8 11V7a4 4 0 0 1 8 0',
43
+ ]
44
+ },
45
+ 'crop-icon': {
46
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
47
+ paths: ['M6 2v14a2 2 0 0 0 2 2h14', 'M18 22V8a2 2 0 0 0-2-2H2']
48
+ },
49
+ 'sliders': {
50
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
51
+ paths: ['M4 21v-7', 'M4 10V3', 'M12 21v-9', 'M12 8V3', 'M20 21v-5', 'M20 12V3',
52
+ 'M1 14h6', 'M9 8h6', 'M17 16h6']
53
+ },
54
+ 'droplet': {
55
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
56
+ path: 'M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z'
57
+ },
58
+ 'image-plus': {
59
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
60
+ paths: [
61
+ 'M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7',
62
+ 'M16 5h6', 'M19 2v6', 'M3 15l5-5 4 4 3-3 4 4',
63
+ ]
64
+ },
65
+ 'frame': {
66
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
67
+ paths: ['M7 3H3v4', 'M17 3h4v4', 'M7 21H3v-4', 'M17 21h4v-4']
68
+ },
69
+ 'file-type': {
70
+ viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', strokeWidth: 2,
71
+ paths: [
72
+ 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z',
73
+ 'M14 2v6h6', 'M10 13l-2 4h8l-2-4',
74
+ ]
75
+ },
76
+ };
77
+
78
+ for (const [name, def] of Object.entries(icons)) {
79
+ try {
80
+ I.register(name, def);
81
+ } catch { /* already registered */
82
+ }
83
+ }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // DOM helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function makeIcon(name) {
91
+ const span = document.createElement('span');
92
+ span.setAttribute('data-icon', name);
93
+ return span;
94
+ }
95
+
96
+ function makeBtn(cls, iconName, title) {
97
+ const btn = document.createElement('button');
98
+ btn.className = cls;
99
+ btn.type = 'button';
100
+ btn.title = title;
101
+ btn.appendChild(makeIcon(iconName));
102
+ return btn;
103
+ }
104
+
105
+ function makeSep() {
106
+ const sep = document.createElement('span');
107
+ sep.className = 'image-editor-sep';
108
+ return sep;
109
+ }
110
+
111
+ function clearChildren(el) {
112
+ while (el.firstChild) el.removeChild(el.firstChild);
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Main export
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Open the image editor for a media file.
121
+ *
122
+ * @param {{ name: string, url: string }} file
123
+ * @returns {Promise<boolean>} Resolves true if the image was saved
124
+ */
125
+ export async function openImageEditor(file) {
126
+ registerIcons();
127
+
128
+ let imageInfo;
129
+ try {
130
+ imageInfo = await api.media.info(file.name);
131
+ } catch (err) {
132
+ E.toast(`Cannot open editor: ${err.message}`, {type: 'error'});
133
+ return false;
134
+ }
135
+
136
+ return new Promise((resolve) => {
137
+ // Geometric state
138
+ let cropper = null;
139
+ let cropActive = false;
140
+ let totalRotation = 0;
141
+ let flippedH = false;
142
+ let flippedV = false;
143
+ let aspectLocked = true;
144
+ const aspectRatio = imageInfo.width / imageInfo.height;
145
+ let cropperCanvas = null;
146
+
147
+ // Effects state
148
+ let activePreset = null;
149
+ const adjustments = {brightness: 1, contrast: 1, saturation: 1, blur: 0, sharpen: 0, gamma: 1};
150
+ const watermarkState = {image: null, position: 'bottom-right', opacity: 50, scale: 15};
151
+ const borderState = {width: 0, colour: '#000000', mode: 'solid'};
152
+ const formatState = {type: null, quality: 85};
153
+
154
+ // ----------------------------------------------------------------
155
+ // Build the wrapper
156
+ // ----------------------------------------------------------------
157
+ const wrapper = document.createElement('div');
158
+ wrapper.className = 'image-editor';
159
+
160
+ // --- Toolbar ---
161
+ const toolbar = document.createElement('div');
162
+ toolbar.className = 'image-editor-toolbar';
163
+
164
+ const cropBtn = makeBtn('btn btn-sm btn-ghost editor-toolbar-btn', 'crop-icon', 'Toggle crop');
165
+ const rotateCW = makeBtn('btn btn-sm btn-ghost editor-toolbar-btn', 'rotate-cw', 'Rotate clockwise');
166
+ const rotateCCW = makeBtn('btn btn-sm btn-ghost editor-toolbar-btn', 'rotate-ccw', 'Rotate counter-clockwise');
167
+ const flipHBtn = makeBtn('btn btn-sm btn-ghost editor-toolbar-btn', 'flip-h', 'Flip horizontal');
168
+ const flipVBtn = makeBtn('btn btn-sm btn-ghost editor-toolbar-btn', 'flip-v', 'Flip vertical');
169
+
170
+ toolbar.appendChild(cropBtn);
171
+ toolbar.appendChild(makeSep());
172
+ toolbar.appendChild(rotateCCW);
173
+ toolbar.appendChild(rotateCW);
174
+ toolbar.appendChild(makeSep());
175
+ toolbar.appendChild(flipHBtn);
176
+ toolbar.appendChild(flipVBtn);
177
+
178
+ // --- Canvas ---
179
+ const canvas = document.createElement('div');
180
+ canvas.className = 'image-editor-canvas';
181
+
182
+ const img = document.createElement('img');
183
+ img.src = file.url + '?t=' + Date.now();
184
+ img.alt = file.name;
185
+ img.style.maxWidth = '100%';
186
+ canvas.appendChild(img);
187
+
188
+ // --- Resize bar ---
189
+ const resizeBar = document.createElement('div');
190
+ resizeBar.className = 'image-editor-resize';
191
+
192
+ const wLabel = document.createElement('label');
193
+ wLabel.textContent = 'W';
194
+ const wInput = document.createElement('input');
195
+ wInput.type = 'number';
196
+ wInput.className = 'form-input';
197
+ wInput.value = imageInfo.width;
198
+ wInput.min = 1;
199
+
200
+ const lockBtn = document.createElement('button');
201
+ lockBtn.type = 'button';
202
+ lockBtn.className = 'btn btn-sm btn-ghost editor-toolbar-btn active';
203
+ lockBtn.title = 'Lock aspect ratio';
204
+ lockBtn.appendChild(makeIcon('lock'));
205
+
206
+ const hLabel = document.createElement('label');
207
+ hLabel.textContent = 'H';
208
+ const hInput = document.createElement('input');
209
+ hInput.type = 'number';
210
+ hInput.className = 'form-input';
211
+ hInput.value = imageInfo.height;
212
+ hInput.min = 1;
213
+
214
+ const origLabel = document.createElement('span');
215
+ origLabel.className = 'text-muted';
216
+ origLabel.style.marginLeft = 'auto';
217
+ origLabel.textContent = `Original: ${imageInfo.width}\u00d7${imageInfo.height}`;
218
+
219
+ resizeBar.appendChild(wLabel);
220
+ resizeBar.appendChild(wInput);
221
+ resizeBar.appendChild(lockBtn);
222
+ resizeBar.appendChild(hLabel);
223
+ resizeBar.appendChild(hInput);
224
+ resizeBar.appendChild(origLabel);
225
+
226
+ // ----------------------------------------------------------------
227
+ // Effects panel
228
+ // ----------------------------------------------------------------
229
+ const effectsPanel = document.createElement('div');
230
+ effectsPanel.className = 'image-editor-effects';
231
+
232
+ // Tab bar
233
+ const tabBar = document.createElement('div');
234
+ tabBar.className = 'image-editor-tab-bar';
235
+
236
+ const tabDefs = [
237
+ {id: 'filters', label: 'Filters', icon: 'droplet'},
238
+ {id: 'adjust', label: 'Adjust', icon: 'sliders'},
239
+ {id: 'watermark', label: 'Watermark', icon: 'image-plus'},
240
+ {id: 'border', label: 'Border', icon: 'frame'},
241
+ {id: 'format', label: 'Format', icon: 'file-type'},
242
+ ];
243
+
244
+ const tabPanels = {};
245
+ const tabBtns = {};
246
+
247
+ tabDefs.forEach(({id, label, icon}, i) => {
248
+ const btn = document.createElement('button');
249
+ btn.type = 'button';
250
+ btn.className = 'image-editor-tab-btn' + (i === 0 ? ' active' : '');
251
+ btn.dataset.tab = id;
252
+ btn.appendChild(makeIcon(icon));
253
+ const lbl = document.createElement('span');
254
+ lbl.textContent = label;
255
+ btn.appendChild(lbl);
256
+ btn.addEventListener('click', () => switchTab(id));
257
+ tabBar.appendChild(btn);
258
+ tabBtns[id] = btn;
259
+
260
+ const panel = document.createElement('div');
261
+ panel.className = 'image-editor-tab-panel';
262
+ panel.style.display = i === 0 ? 'block' : 'none';
263
+ tabPanels[id] = panel;
264
+ });
265
+
266
+ function switchTab(id) {
267
+ tabDefs.forEach(({id: tid}) => {
268
+ tabPanels[tid].style.display = tid === id ? 'block' : 'none';
269
+ tabBtns[tid].classList.toggle('active', tid === id);
270
+ });
271
+ }
272
+
273
+ effectsPanel.appendChild(tabBar);
274
+ tabDefs.forEach(({id}) => effectsPanel.appendChild(tabPanels[id]));
275
+
276
+ // ----------------------------------------------------------------
277
+ // Filters tab
278
+ // ----------------------------------------------------------------
279
+ const presetDefs = [
280
+ {id: 'grayscale', label: 'Grayscale', cssPreview: true},
281
+ {id: 'sepia', label: 'Sepia', cssPreview: true},
282
+ {id: 'warm', label: 'Warm', cssPreview: false},
283
+ {id: 'cool', label: 'Cool', cssPreview: false},
284
+ {id: 'enhance', label: 'Enhance', cssPreview: false},
285
+ {id: 'sharpen', label: 'Sharpen', cssPreview: false},
286
+ {id: 'soften', label: 'Soften', cssPreview: false},
287
+ {id: 'invert', label: 'Invert', cssPreview: true},
288
+ ];
289
+
290
+ const presetsGrid = document.createElement('div');
291
+ presetsGrid.className = 'ie-presets-grid';
292
+
293
+ const presetBtns = {};
294
+ presetDefs.forEach(({id, label}) => {
295
+ const btn = document.createElement('button');
296
+ btn.type = 'button';
297
+ btn.className = 'btn btn-sm btn-ghost ie-preset-btn';
298
+ btn.textContent = label;
299
+ btn.addEventListener('click', () => {
300
+ activePreset = activePreset === id ? null : id;
301
+ presetDefs.forEach(({id: pid}) => {
302
+ presetBtns[pid].classList.toggle('active', pid === activePreset);
303
+ });
304
+ applyCssPreview();
305
+ });
306
+ presetBtns[id] = btn;
307
+ presetsGrid.appendChild(btn);
308
+ });
309
+
310
+ const serverNote = document.createElement('p');
311
+ serverNote.className = 'ie-server-note';
312
+ serverNote.textContent = 'Warm, Cool, Enhance, Sharpen, and Soften have no live preview — applied on save only.';
313
+
314
+ tabPanels['filters'].appendChild(presetsGrid);
315
+ tabPanels['filters'].appendChild(serverNote);
316
+
317
+ // ----------------------------------------------------------------
318
+ // Slider helper
319
+ // ----------------------------------------------------------------
320
+ const sliderRefs = {};
321
+
322
+ function makeSlider(label, min, max, step, defaultVal, onChange) {
323
+ const row = document.createElement('div');
324
+ row.className = 'ie-slider-row';
325
+
326
+ const lbl = document.createElement('label');
327
+ lbl.className = 'ie-slider-label';
328
+ lbl.textContent = label;
329
+
330
+ const slider = document.createElement('input');
331
+ slider.type = 'range';
332
+ slider.min = min;
333
+ slider.max = max;
334
+ slider.step = step;
335
+ slider.value = defaultVal;
336
+ slider.className = 'ie-slider';
337
+
338
+ const valDisplay = document.createElement('span');
339
+ valDisplay.className = 'ie-slider-val';
340
+ valDisplay.textContent = defaultVal;
341
+
342
+ slider.addEventListener('input', () => {
343
+ const v = parseFloat(slider.value);
344
+ valDisplay.textContent = step < 1 ? v.toFixed(1) : String(Math.round(v));
345
+ onChange(v);
346
+ });
347
+
348
+ row.appendChild(lbl);
349
+ row.appendChild(slider);
350
+ row.appendChild(valDisplay);
351
+
352
+ return {
353
+ row, slider, valDisplay, defaultVal,
354
+ reset() {
355
+ slider.value = defaultVal;
356
+ valDisplay.textContent = defaultVal;
357
+ },
358
+ };
359
+ }
360
+
361
+ // ----------------------------------------------------------------
362
+ // Adjust tab
363
+ // ----------------------------------------------------------------
364
+ const sliderDefs = [
365
+ {key: 'brightness', label: 'Brightness', min: 0.2, max: 3.0, step: 0.1, def: 1.0},
366
+ {key: 'contrast', label: 'Contrast', min: 0.2, max: 3.0, step: 0.1, def: 1.0},
367
+ {key: 'saturation', label: 'Saturation', min: 0.0, max: 3.0, step: 0.1, def: 1.0},
368
+ {key: 'blur', label: 'Blur', min: 0, max: 10, step: 0.5, def: 0},
369
+ {key: 'sharpen', label: 'Sharpen', min: 0, max: 10, step: 0.5, def: 0},
370
+ {key: 'gamma', label: 'Gamma', min: 0.5, max: 3.0, step: 0.1, def: 1.0},
371
+ ];
372
+
373
+ sliderDefs.forEach(({key, label, min, max, step, def}) => {
374
+ const ref = makeSlider(label, min, max, step, def, (val) => {
375
+ adjustments[key] = val;
376
+ applyCssPreview();
377
+ });
378
+ sliderRefs[key] = ref;
379
+ tabPanels['adjust'].appendChild(ref.row);
380
+ });
381
+
382
+ const resetAdjBtn = document.createElement('button');
383
+ resetAdjBtn.type = 'button';
384
+ resetAdjBtn.className = 'btn btn-sm btn-ghost';
385
+ resetAdjBtn.style.marginTop = '0.5rem';
386
+ resetAdjBtn.textContent = 'Reset adjustments';
387
+ resetAdjBtn.addEventListener('click', () => {
388
+ sliderDefs.forEach(({key, def}) => {
389
+ adjustments[key] = def;
390
+ sliderRefs[key].reset();
391
+ });
392
+ applyCssPreview();
393
+ });
394
+ tabPanels['adjust'].appendChild(resetAdjBtn);
395
+
396
+ // ----------------------------------------------------------------
397
+ // Watermark tab
398
+ // ----------------------------------------------------------------
399
+ const wmChooseBtn = document.createElement('button');
400
+ wmChooseBtn.type = 'button';
401
+ wmChooseBtn.className = 'btn btn-sm btn-secondary';
402
+ wmChooseBtn.textContent = 'Choose watermark image\u2026';
403
+
404
+ const wmPreviewRow = document.createElement('div');
405
+ wmPreviewRow.className = 'ie-wm-preview-row';
406
+ wmPreviewRow.style.display = 'none';
407
+
408
+ const wmPreviewImg = document.createElement('img');
409
+ wmPreviewImg.className = 'ie-wm-preview-img';
410
+
411
+ const wmPreviewName = document.createElement('span');
412
+ wmPreviewName.className = 'ie-wm-preview-name';
413
+
414
+ const wmRemoveBtn = document.createElement('button');
415
+ wmRemoveBtn.type = 'button';
416
+ wmRemoveBtn.className = 'btn btn-sm btn-ghost';
417
+ wmRemoveBtn.textContent = 'Remove';
418
+ wmRemoveBtn.addEventListener('click', () => {
419
+ watermarkState.image = null;
420
+ wmPreviewRow.style.display = 'none';
421
+ wmChooseBtn.style.display = '';
422
+ });
423
+
424
+ wmPreviewRow.appendChild(wmPreviewImg);
425
+ wmPreviewRow.appendChild(wmPreviewName);
426
+ wmPreviewRow.appendChild(wmRemoveBtn);
427
+
428
+ wmChooseBtn.addEventListener('click', async () => {
429
+ const picked = await openMediaPicker();
430
+ if (picked) {
431
+ watermarkState.image = picked.name;
432
+ wmPreviewImg.src = picked.url;
433
+ wmPreviewName.textContent = picked.name;
434
+ wmPreviewRow.style.display = 'flex';
435
+ wmChooseBtn.style.display = 'none';
436
+ }
437
+ });
438
+
439
+ const wmPosRow = document.createElement('div');
440
+ wmPosRow.className = 'ie-slider-row';
441
+ const wmPosLabel = document.createElement('label');
442
+ wmPosLabel.className = 'ie-slider-label';
443
+ wmPosLabel.textContent = 'Position';
444
+ const wmPosSelect = document.createElement('select');
445
+ wmPosSelect.className = 'form-select ie-select';
446
+ [
447
+ 'top-left', 'top-center', 'top-right',
448
+ 'center-left', 'center', 'center-right',
449
+ 'bottom-left', 'bottom-center', 'bottom-right',
450
+ ].forEach(pos => {
451
+ const opt = document.createElement('option');
452
+ opt.value = pos;
453
+ opt.textContent = pos.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
454
+ if (pos === 'bottom-right') opt.selected = true;
455
+ wmPosSelect.appendChild(opt);
456
+ });
457
+ wmPosSelect.addEventListener('change', () => {
458
+ watermarkState.position = wmPosSelect.value;
459
+ });
460
+ wmPosRow.appendChild(wmPosLabel);
461
+ wmPosRow.appendChild(wmPosSelect);
462
+
463
+ const wmOpacityRef = makeSlider('Opacity %', 0, 100, 1, 50, (val) => {
464
+ watermarkState.opacity = val;
465
+ });
466
+ const wmScaleRef = makeSlider('Scale %', 5, 50, 1, 15, (val) => {
467
+ watermarkState.scale = val;
468
+ });
469
+
470
+ tabPanels['watermark'].appendChild(wmChooseBtn);
471
+ tabPanels['watermark'].appendChild(wmPreviewRow);
472
+ tabPanels['watermark'].appendChild(wmPosRow);
473
+ tabPanels['watermark'].appendChild(wmOpacityRef.row);
474
+ tabPanels['watermark'].appendChild(wmScaleRef.row);
475
+
476
+ // ----------------------------------------------------------------
477
+ // Border tab
478
+ // ----------------------------------------------------------------
479
+ const borderWidthRef = makeSlider('Width (px)', 0, 200, 1, 0, (val) => {
480
+ borderState.width = val;
481
+ });
482
+
483
+ const borderColourRow = document.createElement('div');
484
+ borderColourRow.className = 'ie-slider-row';
485
+ const borderColourLabel = document.createElement('label');
486
+ borderColourLabel.className = 'ie-slider-label';
487
+ borderColourLabel.textContent = 'Colour';
488
+ const borderColourInput = document.createElement('input');
489
+ borderColourInput.type = 'color';
490
+ borderColourInput.value = '#000000';
491
+ borderColourInput.className = 'ie-colour-input';
492
+ borderColourInput.addEventListener('input', () => {
493
+ borderState.colour = borderColourInput.value;
494
+ });
495
+ borderColourRow.appendChild(borderColourLabel);
496
+ borderColourRow.appendChild(borderColourInput);
497
+
498
+ const borderModeRow = document.createElement('div');
499
+ borderModeRow.className = 'ie-slider-row';
500
+ const borderModeLabel = document.createElement('label');
501
+ borderModeLabel.className = 'ie-slider-label';
502
+ borderModeLabel.textContent = 'Mode';
503
+ const borderModeSelect = document.createElement('select');
504
+ borderModeSelect.className = 'form-select ie-select';
505
+ [['solid', 'Solid'], ['mirror', 'Mirror'], ['repeat', 'Repeat']].forEach(([val, lbl]) => {
506
+ const opt = document.createElement('option');
507
+ opt.value = val;
508
+ opt.textContent = lbl;
509
+ borderModeSelect.appendChild(opt);
510
+ });
511
+ borderModeSelect.addEventListener('change', () => {
512
+ borderState.mode = borderModeSelect.value;
513
+ });
514
+ borderModeRow.appendChild(borderModeLabel);
515
+ borderModeRow.appendChild(borderModeSelect);
516
+
517
+ tabPanels['border'].appendChild(borderWidthRef.row);
518
+ tabPanels['border'].appendChild(borderColourRow);
519
+ tabPanels['border'].appendChild(borderModeRow);
520
+
521
+ // ----------------------------------------------------------------
522
+ // Format tab
523
+ // ----------------------------------------------------------------
524
+ const fmtRow = document.createElement('div');
525
+ fmtRow.className = 'ie-slider-row';
526
+ const fmtLabel = document.createElement('label');
527
+ fmtLabel.className = 'ie-slider-label';
528
+ fmtLabel.textContent = 'Output format';
529
+ const fmtSelect = document.createElement('select');
530
+ fmtSelect.className = 'form-select ie-select';
531
+ [['', 'Keep original'], ['jpeg', 'JPEG'], ['png', 'PNG'], ['webp', 'WebP']].forEach(([val, lbl]) => {
532
+ const opt = document.createElement('option');
533
+ opt.value = val;
534
+ opt.textContent = lbl;
535
+ fmtSelect.appendChild(opt);
536
+ });
537
+ fmtRow.appendChild(fmtLabel);
538
+ fmtRow.appendChild(fmtSelect);
539
+
540
+ const fmtQualityRef = makeSlider('Quality', 1, 100, 1, 85, (val) => {
541
+ formatState.quality = val;
542
+ });
543
+ fmtQualityRef.row.style.display = 'none';
544
+
545
+ fmtSelect.addEventListener('change', () => {
546
+ formatState.type = fmtSelect.value || null;
547
+ fmtQualityRef.row.style.display =
548
+ (fmtSelect.value === 'jpeg' || fmtSelect.value === 'webp') ? 'flex' : 'none';
549
+ });
550
+
551
+ tabPanels['format'].appendChild(fmtRow);
552
+ tabPanels['format'].appendChild(fmtQualityRef.row);
553
+
554
+ // --- Footer ---
555
+ const footer = document.createElement('div');
556
+ footer.className = 'image-editor-footer';
557
+
558
+ const cancelBtn = document.createElement('button');
559
+ cancelBtn.type = 'button';
560
+ cancelBtn.className = 'btn btn-ghost';
561
+ cancelBtn.textContent = 'Cancel';
562
+
563
+ const footerRight = document.createElement('div');
564
+ footerRight.style.display = 'flex';
565
+ footerRight.style.gap = '0.5rem';
566
+
567
+ const copyBtn = document.createElement('button');
568
+ copyBtn.type = 'button';
569
+ copyBtn.className = 'btn btn-secondary';
570
+ copyBtn.textContent = 'Save as copy';
571
+
572
+ const overwriteBtn = document.createElement('button');
573
+ overwriteBtn.type = 'button';
574
+ overwriteBtn.className = 'btn btn-primary';
575
+ overwriteBtn.textContent = 'Overwrite';
576
+
577
+ footerRight.appendChild(copyBtn);
578
+ footerRight.appendChild(overwriteBtn);
579
+ footer.appendChild(cancelBtn);
580
+ footer.appendChild(footerRight);
581
+
582
+ wrapper.appendChild(toolbar);
583
+ wrapper.appendChild(canvas);
584
+ wrapper.appendChild(resizeBar);
585
+ wrapper.appendChild(effectsPanel);
586
+ wrapper.appendChild(footer);
587
+
588
+ // ----------------------------------------------------------------
589
+ // Modal
590
+ // ----------------------------------------------------------------
591
+ const modal = E.modal({title: `Edit: ${file.name}`, size: 'lg'});
592
+ modal.element.appendChild(wrapper);
593
+ modal.open();
594
+
595
+ // Capture .cropper-canvas BEFORE Cropper fires 'ready' on the img
596
+ img.addEventListener('ready', () => {
597
+ cropperCanvas = canvas.querySelector('.cropper-canvas');
598
+ applyCssPreview();
599
+ }, {once: true});
600
+
601
+ requestAnimationFrame(() => {
602
+ cropper = new Cropper(img, {
603
+ viewMode: 1,
604
+ autoCrop: false,
605
+ zoomable: false,
606
+ movable: true,
607
+ });
608
+ Domma.icons.scan(wrapper);
609
+ });
610
+
611
+ // ----------------------------------------------------------------
612
+ // Toolbar interactions
613
+ // ----------------------------------------------------------------
614
+ cropBtn.addEventListener('click', () => {
615
+ cropActive = !cropActive;
616
+ cropActive ? cropper.crop() : cropper.clear();
617
+ cropBtn.classList.toggle('active', cropActive);
618
+ });
619
+
620
+ rotateCW.addEventListener('click', () => {
621
+ cropper.rotate(90);
622
+ totalRotation = (totalRotation + 90) % 360;
623
+ });
624
+
625
+ rotateCCW.addEventListener('click', () => {
626
+ cropper.rotate(-90);
627
+ totalRotation = (totalRotation - 90 + 360) % 360;
628
+ });
629
+
630
+ flipHBtn.addEventListener('click', () => {
631
+ flippedH = !flippedH;
632
+ cropper.scaleX(flippedH ? -1 : 1);
633
+ flipHBtn.classList.toggle('active', flippedH);
634
+ });
635
+
636
+ flipVBtn.addEventListener('click', () => {
637
+ flippedV = !flippedV;
638
+ cropper.scaleY(flippedV ? -1 : 1);
639
+ flipVBtn.classList.toggle('active', flippedV);
640
+ });
641
+
642
+ // ----------------------------------------------------------------
643
+ // Aspect lock + resize inputs
644
+ // ----------------------------------------------------------------
645
+ lockBtn.addEventListener('click', () => {
646
+ aspectLocked = !aspectLocked;
647
+ lockBtn.classList.toggle('active', aspectLocked);
648
+ clearChildren(lockBtn);
649
+ lockBtn.appendChild(makeIcon(aspectLocked ? 'lock' : 'unlock'));
650
+ Domma.icons.scan(lockBtn);
651
+ });
652
+
653
+ wInput.addEventListener('input', () => {
654
+ if (!aspectLocked) return;
655
+ const w = parseInt(wInput.value, 10);
656
+ if (w > 0) hInput.value = Math.round(w / aspectRatio);
657
+ });
658
+
659
+ hInput.addEventListener('input', () => {
660
+ if (!aspectLocked) return;
661
+ const h = parseInt(hInput.value, 10);
662
+ if (h > 0) wInput.value = Math.round(h * aspectRatio);
663
+ });
664
+
665
+ // ----------------------------------------------------------------
666
+ // CSS filter preview
667
+ // ----------------------------------------------------------------
668
+ function buildCssFilter() {
669
+ const parts = [];
670
+ if (activePreset === 'grayscale') parts.push('grayscale(1)');
671
+ if (activePreset === 'sepia') parts.push('sepia(1)');
672
+ if (activePreset === 'invert') parts.push('invert(1)');
673
+ if (adjustments.brightness !== 1) parts.push(`brightness(${adjustments.brightness})`);
674
+ if (adjustments.contrast !== 1) parts.push(`contrast(${adjustments.contrast})`);
675
+ if (adjustments.saturation !== 1) parts.push(`saturate(${adjustments.saturation})`);
676
+ if (adjustments.blur > 0) parts.push(`blur(${adjustments.blur}px)`);
677
+ return parts.join(' ');
678
+ }
679
+
680
+ function applyCssPreview() {
681
+ if (!cropperCanvas) return;
682
+ cropperCanvas.style.filter = buildCssFilter();
683
+ }
684
+
685
+ // ----------------------------------------------------------------
686
+ // Media picker (watermark image selection)
687
+ // ----------------------------------------------------------------
688
+ async function openMediaPicker() {
689
+ return new Promise(async (resolveMedia) => {
690
+ let mediaList;
691
+ try {
692
+ mediaList = await api.media.list();
693
+ } catch (err) {
694
+ E.toast(`Failed to load media: ${err.message}`, {type: 'error'});
695
+ resolveMedia(null);
696
+ return;
697
+ }
698
+
699
+ const images = mediaList.filter(m => /\.(jpg|jpeg|png|webp|gif)$/i.test(m.name));
700
+
701
+ const pickerContent = document.createElement('div');
702
+ pickerContent.className = 'ie-media-picker';
703
+
704
+ if (images.length === 0) {
705
+ const empty = document.createElement('p');
706
+ empty.className = 'text-muted';
707
+ empty.style.padding = '1rem 0';
708
+ empty.textContent = 'No images in media library.';
709
+ pickerContent.appendChild(empty);
710
+ } else {
711
+ const grid = document.createElement('div');
712
+ grid.className = 'ie-media-picker-grid';
713
+
714
+ images.forEach(imgItem => {
715
+ const thumb = document.createElement('div');
716
+ thumb.className = 'ie-media-thumb';
717
+
718
+ const thumbImg = document.createElement('img');
719
+ thumbImg.src = imgItem.url;
720
+ thumbImg.alt = imgItem.name;
721
+
722
+ const nameSpan = document.createElement('span');
723
+ nameSpan.className = 'ie-media-thumb-name';
724
+ nameSpan.textContent = imgItem.name;
725
+
726
+ thumb.appendChild(thumbImg);
727
+ thumb.appendChild(nameSpan);
728
+
729
+ thumb.addEventListener('click', () => {
730
+ pickerModal.close();
731
+ resolveMedia(imgItem);
732
+ });
733
+
734
+ grid.appendChild(thumb);
735
+ });
736
+
737
+ pickerContent.appendChild(grid);
738
+ }
739
+
740
+ const cancelPickBtn = document.createElement('button');
741
+ cancelPickBtn.type = 'button';
742
+ cancelPickBtn.className = 'btn btn-sm btn-ghost';
743
+ cancelPickBtn.style.marginTop = '0.75rem';
744
+ cancelPickBtn.textContent = 'Cancel';
745
+ cancelPickBtn.addEventListener('click', () => {
746
+ pickerModal.close();
747
+ resolveMedia(null);
748
+ });
749
+ pickerContent.appendChild(cancelPickBtn);
750
+
751
+ const pickerModal = E.modal({title: 'Choose watermark image', size: 'md'});
752
+ pickerModal.element.appendChild(pickerContent);
753
+ pickerModal.open();
754
+ });
755
+ }
756
+
757
+ // ----------------------------------------------------------------
758
+ // Save helpers
759
+ // ----------------------------------------------------------------
760
+ function buildOperations() {
761
+ const ops = {};
762
+
763
+ if (totalRotation) ops.rotate = totalRotation;
764
+ if (flippedV) ops.flip = true;
765
+ if (flippedH) ops.flop = true;
766
+
767
+ if (cropActive) {
768
+ const data = cropper.getData(true);
769
+ if (data.width > 0 && data.height > 0) {
770
+ ops.crop = {left: data.x, top: data.y, width: data.width, height: data.height};
771
+ }
772
+ }
773
+
774
+ const targetW = parseInt(wInput.value, 10) || null;
775
+ const targetH = parseInt(hInput.value, 10) || null;
776
+ if (targetW !== imageInfo.width || targetH !== imageInfo.height) {
777
+ ops.resize = {width: targetW, height: targetH};
778
+ }
779
+
780
+ if (activePreset) ops.preset = activePreset;
781
+
782
+ const adjOut = {};
783
+ if (adjustments.brightness !== 1) adjOut.brightness = adjustments.brightness;
784
+ if (adjustments.contrast !== 1) adjOut.contrast = adjustments.contrast;
785
+ if (adjustments.saturation !== 1) adjOut.saturation = adjustments.saturation;
786
+ if (adjustments.blur > 0) adjOut.blur = adjustments.blur;
787
+ if (adjustments.sharpen > 0) adjOut.sharpen = adjustments.sharpen;
788
+ if (adjustments.gamma !== 1) adjOut.gamma = adjustments.gamma;
789
+ if (Object.keys(adjOut).length > 0) ops.adjustments = adjOut;
790
+
791
+ if (watermarkState.image) {
792
+ ops.watermark = {
793
+ image: watermarkState.image,
794
+ position: watermarkState.position,
795
+ opacity: watermarkState.opacity / 100,
796
+ scale: watermarkState.scale / 100,
797
+ };
798
+ }
799
+
800
+ if (borderState.width > 0) {
801
+ ops.border = {
802
+ width: borderState.width,
803
+ colour: borderState.colour,
804
+ mode: borderState.mode,
805
+ };
806
+ }
807
+
808
+ if (formatState.type) {
809
+ ops.format = {type: formatState.type, quality: formatState.quality};
810
+ }
811
+
812
+ return ops;
813
+ }
814
+
815
+ async function doSave(saveAs) {
816
+ overwriteBtn.disabled = true;
817
+ copyBtn.disabled = true;
818
+ try {
819
+ const operations = buildOperations();
820
+ let outputName = saveAs;
821
+
822
+ // Overwrite + format change: rename file and delete the original
823
+ if (!saveAs && formatState.type) {
824
+ const srcExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
825
+ const newExt = '.' + formatState.type;
826
+ if (srcExt !== newExt) {
827
+ const base = file.name.slice(0, file.name.lastIndexOf('.'));
828
+ outputName = base + newExt;
829
+ operations._deleteOriginal = file.name;
830
+ }
831
+ }
832
+
833
+ await api.media.transform(file.name, {operations, saveAs: outputName || undefined});
834
+ E.toast('Image saved.', {type: 'success'});
835
+ closeEditor(true);
836
+ } catch (err) {
837
+ E.toast(`Save failed: ${err.message}`, {type: 'error'});
838
+ overwriteBtn.disabled = false;
839
+ copyBtn.disabled = false;
840
+ }
841
+ }
842
+
843
+ overwriteBtn.addEventListener('click', async () => {
844
+ if (!await E.confirm(`Overwrite "${file.name}"? This cannot be undone.`)) return;
845
+ await doSave(null);
846
+ });
847
+
848
+ copyBtn.addEventListener('click', async () => {
849
+ const ext = formatState.type ? '.' + formatState.type
850
+ : file.name.substring(file.name.lastIndexOf('.'));
851
+ const base = file.name.slice(0, file.name.lastIndexOf('.'));
852
+ await doSave(`${base}-edited${ext}`);
853
+ });
854
+
855
+ cancelBtn.addEventListener('click', () => closeEditor(false));
856
+
857
+ // ----------------------------------------------------------------
858
+ // Cleanup
859
+ // ----------------------------------------------------------------
860
+ function closeEditor(saved) {
861
+ if (cropper) {
862
+ cropper.destroy();
863
+ cropper = null;
864
+ }
865
+ modal.close();
866
+ resolve(saved);
867
+ }
868
+ });
869
+ }