domma-cms 0.2.1 → 0.3.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 (70) hide show
  1. package/admin/css/admin.css +1 -1200
  2. package/admin/js/api.js +1 -242
  3. package/admin/js/app.js +5 -279
  4. package/admin/js/config/sidebar-config.js +1 -115
  5. package/admin/js/lib/card.js +1 -63
  6. package/admin/js/lib/image-editor.js +1 -869
  7. package/admin/js/lib/markdown-toolbar.js +46 -421
  8. package/admin/js/templates/layouts.html +44 -7
  9. package/admin/js/templates/page-editor.html +9 -0
  10. package/admin/js/templates/settings.html +18 -1
  11. package/admin/js/templates/users.html +29 -4
  12. package/admin/js/views/collection-editor.js +3 -487
  13. package/admin/js/views/collection-entries.js +1 -484
  14. package/admin/js/views/collections.js +1 -153
  15. package/admin/js/views/dashboard.js +1 -56
  16. package/admin/js/views/documentation.js +1 -12
  17. package/admin/js/views/index.js +1 -39
  18. package/admin/js/views/layouts.js +9 -42
  19. package/admin/js/views/login.js +7 -251
  20. package/admin/js/views/media.js +1 -240
  21. package/admin/js/views/navigation.js +14 -212
  22. package/admin/js/views/page-editor.js +53 -661
  23. package/admin/js/views/pages.js +5 -72
  24. package/admin/js/views/plugins.js +13 -90
  25. package/admin/js/views/settings.js +1 -199
  26. package/admin/js/views/tutorials.js +1 -12
  27. package/admin/js/views/user-editor.js +1 -88
  28. package/admin/js/views/users.js +7 -76
  29. package/config/auth.json +1 -17
  30. package/config/navigation.json +15 -0
  31. package/config/site.json +5 -4
  32. package/package.json +1 -1
  33. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  34. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  35. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  36. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  37. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  38. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  39. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  40. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  41. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  42. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  43. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  44. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  45. package/plugins/example-analytics/stats.json +16 -12
  46. package/plugins/form-builder/admin/templates/form-editor.html +158 -130
  47. package/plugins/form-builder/admin/views/form-editor.js +3 -1
  48. package/plugins/form-builder/data/forms/contact-details.json +71 -35
  49. package/plugins/form-builder/data/forms/feedback.json +130 -0
  50. package/plugins/form-builder/data/submissions/feedback.json +1 -0
  51. package/plugins/form-builder/public/form-logic-engine.js +1 -568
  52. package/public/css/site.css +1 -302
  53. package/public/js/btt.js +1 -90
  54. package/public/js/cookie-consent.js +1 -61
  55. package/public/js/site.js +1 -204
  56. package/scripts/setup.js +4 -4
  57. package/server/middleware/auth.js +44 -21
  58. package/server/routes/api/auth.js +38 -8
  59. package/server/routes/api/collections.js +18 -5
  60. package/server/routes/api/layouts.js +18 -4
  61. package/server/routes/api/media.js +2 -3
  62. package/server/routes/api/navigation.js +2 -3
  63. package/server/routes/api/pages.js +3 -3
  64. package/server/routes/api/settings.js +2 -3
  65. package/server/routes/api/users.js +4 -6
  66. package/server/routes/public.js +3 -3
  67. package/server/server.js +8 -0
  68. package/server/services/markdown.js +102 -3
  69. package/server/services/userTypes.js +167 -0
  70. package/plugins/form-builder/email.js +0 -103
@@ -1,240 +1 @@
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
- }
1
+ import{api as r}from"../api.js";import{openImageEditor as D}from"../lib/image-editor.js";export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(n){const o=a=>{const d=n.find("#media-grid").empty().get(0);if(!a.length){const e=document.createElement("p");e.className="text-muted",e.textContent="No media files yet. Upload one above.",d.appendChild(e);return}a.forEach(e=>{const t=/\.(png|jpe?g|gif|webp|svg)$/i.test(e.name),y=/\.(png|jpe?g|gif|webp|tiff?)$/i.test(e.name),s=document.createElement("div");s.className="media-card";const i=document.createElement("div");if(i.className="media-preview",t){const c=document.createElement("img");c.src=e.url+"?t="+Date.now(),c.alt=e.name,c.className="media-thumb",i.appendChild(c)}else{const c=document.createElement("div");c.className="media-thumb media-thumb--file";const v=document.createElement("span");v.setAttribute("data-icon","file"),c.appendChild(v),i.appendChild(c)}s.appendChild(i);const p=document.createElement("div");p.className="media-info";const m=document.createElement("span");m.className="media-name",m.title="Double-click to rename",m.textContent=e.name;const C=document.createElement("span");C.className="media-size",C.textContent=B(e.size),p.appendChild(m),p.appendChild(C),s.appendChild(p),m.addEventListener("dblclick",()=>x(m,e,o));const u=document.createElement("div");u.className="media-actions";const f=document.createElement("button");f.className="btn btn-sm btn-ghost";const w=document.createElement("span");w.setAttribute("data-icon","copy"),f.appendChild(w),f.addEventListener("click",()=>{navigator.clipboard.writeText(window.location.origin+e.url).then(()=>E.toast("URL copied.",{type:"success"})).catch(()=>E.toast("Copy failed.",{type:"error"}))});const b=document.createElement("button");b.className="btn btn-sm btn-ghost";const N=document.createElement("span");N.setAttribute("data-icon","type"),b.appendChild(N),b.addEventListener("click",()=>x(m,e,o));let l=null;if(y){l=document.createElement("button"),l.className="btn btn-sm btn-ghost";const c=document.createElement("span");c.setAttribute("data-icon","edit"),l.appendChild(c),l.addEventListener("click",async()=>{await D(e)&&o(await r.media.list().catch(()=>[]))})}const g=document.createElement("button");g.className="btn btn-sm btn-danger";const k=document.createElement("span");k.setAttribute("data-icon","trash"),g.appendChild(k),g.addEventListener("click",async()=>{if(await E.confirm(`Delete "${e.name}"?`))try{await r.media.delete(e.name),E.toast("File deleted.",{type:"success"}),o(await r.media.list().catch(()=>[]))}catch{E.toast("Delete failed.",{type:"error"})}}),u.appendChild(f),u.appendChild(b),l&&u.appendChild(l),u.appendChild(g),s.appendChild(u),d.appendChild(s),E.tooltip(f,{content:"Copy URL",position:"top"}),E.tooltip(b,{content:"Rename",position:"top"}),l&&E.tooltip(l,{content:"Edit image",position:"top"}),E.tooltip(g,{content:"Delete file",position:"top"})}),Domma.icons.scan(),Domma.effects.reveal(".media-card",{animation:"fade",stagger:40,duration:350})},h=await r.media.list().catch(()=>[]);o(h),Domma.effects.ripple("#upload-btn"),n.find("#upload-btn").on("click",()=>n.find("#file-input").get(0).click()),n.find("#file-input").on("change",async function(){if(!this.files.length)return;const a=new FormData;for(const t of this.files)a.append("file",t);const d=n.find("#upload-btn").get(0),e=d.lastChild;d.disabled=!0,e.textContent=" Uploading\u2026";try{if(!(await fetch("/api/media",{method:"POST",body:a,headers:{Authorization:"Bearer "+(S.get("auth_token")||"")}})).ok)throw new Error("Upload failed");E.toast("File uploaded.",{type:"success"}),o(await r.media.list().catch(()=>[]))}catch{E.toast("Upload failed.",{type:"error"})}finally{d.disabled=!1,e.textContent=" Upload",this.value=""}})}};function x(n,o,h){if(n.querySelector("input"))return;const a=o.name,d=a.lastIndexOf("."),e=d>0?a.slice(0,d):a,t=document.createElement("input");t.type="text",t.className="media-rename-input",t.value=a,t.title="",n.textContent="",n.appendChild(t),t.focus(),t.setSelectionRange(0,e.length);async function y(){const i=t.value.trim();if(!i||i===a)return s();t.disabled=!0;try{await r.media.rename(a,i),E.toast("File renamed.",{type:"success"}),h(await r.media.list().catch(()=>[]))}catch(p){E.toast(p.message||"Rename failed.",{type:"error"}),s()}}function s(){n.textContent=a,n.title="Double-click to rename"}t.addEventListener("keydown",i=>{i.key==="Enter"&&(i.preventDefault(),y()),i.key==="Escape"&&(i.preventDefault(),s())}),t.addEventListener("blur",()=>{setTimeout(()=>{n.contains(t)&&s()},150)})}function B(n){if(!n)return"0 B";const o=1024,h=["B","KB","MB","GB"],a=Math.floor(Math.log(n)/Math.log(o));return`${(n/Math.pow(o,a)).toFixed(1)} ${h[a]}`}
@@ -1,218 +1,20 @@
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, site] = await Promise.all([
50
- api.navigation.get().catch(() => ({brand: {}, items: []})),
51
- api.settings.get().catch(() => ({}))
52
- ]);
53
- let flatItems = flattenItems(nav.items);
54
- let footerLinks = (site.footer?.links || []).map(l => ({text: l.text || '', url: l.url || ''}));
55
-
56
- const syncFromDom = () => {
57
- $container.find('.nav-item-row').each(function () {
58
- const id = parseInt($(this).data('id'), 10);
59
- const item = flatItems.find(i => i._id === id);
60
- if (!item) return;
61
- item.text = $(this).find('.item-text').val();
62
- item.url = $(this).find('.item-url').val();
63
- item.icon = $(this).find('.item-icon').val();
64
- const parentVal = $(this).find('.item-parent').val();
65
- item.parentId = parentVal ? parseInt(parentVal, 10) : null;
66
- });
67
- // Promote children whose parent is now itself a child
68
- const childIds = new Set(flatItems.filter(i => i.parentId !== null).map(i => i._id));
69
- flatItems.forEach(i => {
70
- if (i.parentId !== null && childIds.has(i.parentId)) i.parentId = null;
71
- });
72
- };
73
-
74
- const renderItems = () => {
75
- const $list = $container.find('#nav-items-list').empty();
76
- const topLevel = flatItems.filter(i => i.parentId === null);
77
-
78
- getOrderedItems(flatItems).forEach(item => {
79
- const isChild = item.parentId !== null;
80
- const parentOptions = `<option value="">— top-level —</option>` +
81
- topLevel
82
- .filter(t => t._id !== item._id)
83
- .map(t => `<option value="${t._id}"${t._id === item.parentId ? ' selected' : ''}>${esc(t.text) || '(untitled)'}</option>`)
84
- .join('');
85
-
86
- $list.append(`
87
- <div class="nav-item-row${isChild ? ' nav-item-row--child' : ''}" data-id="${item._id}">
88
- <span class="nav-col-indent">${isChild ? '↳' : ''}</span>
89
- <input type="text" class="form-input item-text nav-col-main" value="${esc(item.text)}" placeholder="Label">
90
- <input type="text" class="form-input item-url nav-col-main" value="${esc(item.url)}" placeholder="/url">
91
- <input type="text" class="form-input item-icon nav-col-icon" value="${esc(item.icon)}" placeholder="icon">
92
- <select class="form-select item-parent nav-col-parent">${parentOptions}</select>
1
+ import{api as f}from"../api.js";let x=1;const d=t=>String(t||"").replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;");function b(t){const e=[];return(t||[]).forEach(r=>{const o=x++;e.push({_id:o,text:r.text||"",url:r.url||"",icon:r.icon||"",parentId:null}),(r.items||r.children||[]).forEach(l=>{e.push({_id:x++,text:l.text||"",url:l.url||"",icon:l.icon||"",parentId:o})})}),e}function I(t){return t.filter(e=>e.parentId===null).map(e=>{const r=t.filter(l=>l.parentId===e._id).map(l=>({text:l.text,url:l.url,...l.icon&&{icon:l.icon}})),o={text:e.text,url:e.url,...e.icon&&{icon:e.icon}};return r.length&&(o.items=r),o})}function g(t){const e=[];return t.filter(r=>r.parentId===null).forEach(r=>{e.push(r),t.filter(o=>o.parentId===r._id).forEach(o=>e.push(o))}),e}export const navigationView={templateUrl:"/admin/js/templates/navigation.html",async onMount(t){let[e,r]=await Promise.all([f.navigation.get().catch(()=>({brand:{},items:[]})),f.settings.get().catch(()=>({}))]),o=b(e.items),l=(r.footer?.links||[]).map(s=>({text:s.text||"",url:s.url||""}));const v=()=>{t.find(".nav-item-row").each(function(){const i=parseInt($(this).data("id"),10),n=o.find(a=>a._id===i);if(!n)return;n.text=$(this).find(".item-text").val(),n.url=$(this).find(".item-url").val(),n.icon=$(this).find(".item-icon").val();const c=$(this).find(".item-parent").val();n.parentId=c?parseInt(c,10):null});const s=new Set(o.filter(i=>i.parentId!==null).map(i=>i._id));o.forEach(i=>{i.parentId!==null&&s.has(i.parentId)&&(i.parentId=null)})},p=()=>{const s=t.find("#nav-items-list").empty(),i=o.filter(n=>n.parentId===null);g(o).forEach(n=>{const c=n.parentId!==null,a='<option value="">\u2014 top-level \u2014</option>'+i.filter(u=>u._id!==n._id).map(u=>`<option value="${u._id}"${u._id===n.parentId?" selected":""}>${d(u.text)||"(untitled)"}</option>`).join("");s.append(`
2
+ <div class="nav-item-row${c?" nav-item-row--child":""}" data-id="${n._id}">
3
+ <span class="nav-col-indent">${c?"\u21B3":""}</span>
4
+ <input type="text" class="form-input item-text nav-col-main" value="${d(n.text)}" placeholder="Label">
5
+ <input type="text" class="form-input item-url nav-col-main" value="${d(n.url)}" placeholder="/url">
6
+ <input type="text" class="form-input item-icon nav-col-icon" value="${d(n.icon)}" placeholder="icon">
7
+ <select class="form-select item-parent nav-col-parent">${a}</select>
93
8
  <span class="nav-col-action">
94
- <button class="btn btn-sm btn-danger btn-remove-item" data-id="${item._id}" data-tooltip="Remove"><span data-icon="trash"></span></button>
9
+ <button class="btn btn-sm btn-danger btn-remove-item" data-id="${n._id}" data-tooltip="Remove"><span data-icon="trash"></span></button>
95
10
  </span>
96
11
  </div>
97
- `);
98
- });
99
- Domma.icons.scan('#nav-items-list');
100
- document.querySelectorAll('#nav-items-list [data-tooltip]').forEach(el => {
101
- E.tooltip(el, {content: el.getAttribute('data-tooltip'), position: 'top'});
102
- });
103
- };
104
-
105
- const renderFooterLinks = () => {
106
- const $list = $container.find('#footer-links-list').empty();
107
- footerLinks.forEach((link, idx) => {
108
- $list.append(`
109
- <div class="nav-item-row" data-footer-idx="${idx}">
110
- <input type="text" class="form-input footer-link-text nav-col-main" value="${esc(link.text)}" placeholder="Label">
111
- <input type="text" class="form-input footer-link-url nav-col-main" value="${esc(link.url)}" placeholder="/url">
12
+ `)}),Domma.icons.scan("#nav-items-list"),document.querySelectorAll("#nav-items-list [data-tooltip]").forEach(n=>{E.tooltip(n,{content:n.getAttribute("data-tooltip"),position:"top"})})},m=()=>{const s=t.find("#footer-links-list").empty();l.forEach((i,n)=>{s.append(`
13
+ <div class="nav-item-row" data-footer-idx="${n}">
14
+ <input type="text" class="form-input footer-link-text nav-col-main" value="${d(i.text)}" placeholder="Label">
15
+ <input type="text" class="form-input footer-link-url nav-col-main" value="${d(i.url)}" placeholder="/url">
112
16
  <span class="nav-col-action">
113
- <button class="btn btn-sm btn-danger btn-remove-footer" data-idx="${idx}" data-tooltip="Remove"><span data-icon="trash"></span></button>
17
+ <button class="btn btn-sm btn-danger btn-remove-footer" data-idx="${n}" data-tooltip="Remove"><span data-icon="trash"></span></button>
114
18
  </span>
115
19
  </div>
116
- `);
117
- });
118
- Domma.icons.scan('#footer-links-list');
119
- document.querySelectorAll('#footer-links-list [data-tooltip]').forEach(el => {
120
- E.tooltip(el, {content: el.getAttribute('data-tooltip'), position: 'top'});
121
- });
122
- };
123
-
124
- const syncFooterFromDom = () => {
125
- footerLinks = [];
126
- $container.find('#footer-links-list .nav-item-row').each(function () {
127
- footerLinks.push({
128
- text: $(this).find('.footer-link-text').val().trim(),
129
- url: $(this).find('.footer-link-url').val().trim()
130
- });
131
- });
132
- };
133
-
134
- $container.find('#add-footer-link').on('click', () => {
135
- syncFooterFromDom();
136
- footerLinks.push({text: '', url: ''});
137
- renderFooterLinks();
138
- });
139
-
140
- $container.off('click', '.btn-remove-footer').on('click', '.btn-remove-footer', function () {
141
- syncFooterFromDom();
142
- const idx = parseInt($(this).data('idx'), 10);
143
- footerLinks.splice(idx, 1);
144
- renderFooterLinks();
145
- });
146
-
147
- $container.find('#field-brand-text').val(nav.brand?.text || '');
148
- $container.find('#field-brand-url').val(nav.brand?.url || '/');
149
- $container.find('#field-brand-icon').val(nav.brand?.icon || '');
150
- $container.find('#field-nav-variant').val(nav.variant || 'dark');
151
- renderItems();
152
- renderFooterLinks();
153
-
154
- $container.find('#add-nav-item').on('click', () => {
155
- syncFromDom();
156
- flatItems.push({ _id: _nextId++, text: '', url: '', icon: '', parentId: null });
157
- renderItems();
158
- });
159
-
160
- $container.off('click', '.btn-remove-item').on('click', '.btn-remove-item', function () {
161
- syncFromDom();
162
- const id = parseInt($(this).data('id'), 10);
163
- flatItems = flatItems.filter(i => i._id !== id && i.parentId !== id);
164
- renderItems();
165
- });
166
-
167
- // Re-render when a parent changes so other dropdowns update
168
- $container.off('change', '.item-parent').on('change', '.item-parent', function () {
169
- syncFromDom();
170
- renderItems();
171
- });
172
-
173
- $container.find('#save-nav-btn').on('click', async () => {
174
- syncFromDom();
175
- syncFooterFromDom();
176
- const nested = nestItems(
177
- flatItems.map(i => ({ ...i, text: i.text.trim(), url: i.url.trim(), icon: i.icon.trim() }))
178
- ).filter(i => i.text || i.url);
179
-
180
- const brandIcon = $container.find('#field-brand-icon').val().trim();
181
- const navData = {
182
- brand: {
183
- text: $container.find('#field-brand-text').val().trim(),
184
- url: $container.find('#field-brand-url').val().trim() || '/',
185
- ...(brandIcon && {icon: brandIcon})
186
- },
187
- items: nested,
188
- variant: $container.find('#field-nav-variant').val(),
189
- position: nav.position || 'sticky'
190
- };
191
-
192
- const validFooterLinks = footerLinks.filter(l => l.text || l.url);
193
-
194
- try {
195
- await api.navigation.save(navData);
196
- nav = navData;
197
- flatItems = flattenItems(nav.items);
198
- renderItems();
199
- E.toast('Navigation saved.', {type: 'success'});
200
- } catch (e) {
201
- console.error('[navigation] save failed:', e);
202
- E.toast('Failed to save navigation.', {type: 'error'});
203
- return;
204
- }
205
-
206
- // Save footer links into site config — best-effort, independent of nav save
207
- try {
208
- const currentSite = await api.settings.get().catch(() => ({}));
209
- await api.settings.save({...currentSite, footer: {...(currentSite.footer || {}), links: validFooterLinks}});
210
- footerLinks = validFooterLinks;
211
- renderFooterLinks();
212
- } catch (e) {
213
- console.error('[navigation] footer links save failed:', e);
214
- E.toast('Footer links could not be saved.', {type: 'warning'});
215
- }
216
- });
217
- }
218
- };
20
+ `)}),Domma.icons.scan("#footer-links-list"),document.querySelectorAll("#footer-links-list [data-tooltip]").forEach(i=>{E.tooltip(i,{content:i.getAttribute("data-tooltip"),position:"top"})})},h=()=>{l=[],t.find("#footer-links-list .nav-item-row").each(function(){l.push({text:$(this).find(".footer-link-text").val().trim(),url:$(this).find(".footer-link-url").val().trim()})})};t.find("#add-footer-link").on("click",()=>{h(),l.push({text:"",url:""}),m()}),t.off("click",".btn-remove-footer").on("click",".btn-remove-footer",function(){h();const s=parseInt($(this).data("idx"),10);l.splice(s,1),m()}),t.find("#field-brand-text").val(e.brand?.text||""),t.find("#field-brand-url").val(e.brand?.url||"/"),t.find("#field-brand-icon").val(e.brand?.icon||""),t.find("#field-nav-variant").val(e.variant||"dark"),p(),m(),t.find("#add-nav-item").on("click",()=>{v(),o.push({_id:x++,text:"",url:"",icon:"",parentId:null}),p()}),t.off("click",".btn-remove-item").on("click",".btn-remove-item",function(){v();const s=parseInt($(this).data("id"),10);o=o.filter(i=>i._id!==s&&i.parentId!==s),p()}),t.off("change",".item-parent").on("change",".item-parent",function(){v(),p()}),t.find("#save-nav-btn").on("click",async()=>{v(),h();const s=I(o.map(a=>({...a,text:a.text.trim(),url:a.url.trim(),icon:a.icon.trim()}))).filter(a=>a.text||a.url),i=t.find("#field-brand-icon").val().trim(),n={brand:{text:t.find("#field-brand-text").val().trim(),url:t.find("#field-brand-url").val().trim()||"/",...i&&{icon:i}},items:s,variant:t.find("#field-nav-variant").val(),position:e.position||"sticky"},c=l.filter(a=>a.text||a.url);try{await f.navigation.save(n),e=n,o=b(e.items),p(),E.toast("Navigation saved.",{type:"success"})}catch(a){console.error("[navigation] save failed:",a),E.toast("Failed to save navigation.",{type:"error"});return}try{const a=await f.settings.get().catch(()=>({}));await f.settings.save({...a,footer:{...a.footer||{},links:c}}),l=c,m()}catch(a){console.error("[navigation] footer links save failed:",a),E.toast("Footer links could not be saved.",{type:"warning"})}})}};