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.
- package/admin/css/admin.css +1 -1200
- package/admin/js/api.js +1 -242
- package/admin/js/app.js +5 -279
- package/admin/js/config/sidebar-config.js +1 -115
- package/admin/js/lib/card.js +1 -63
- package/admin/js/lib/image-editor.js +1 -869
- package/admin/js/lib/markdown-toolbar.js +46 -421
- package/admin/js/templates/layouts.html +44 -7
- package/admin/js/templates/page-editor.html +9 -0
- package/admin/js/templates/settings.html +18 -1
- package/admin/js/templates/users.html +29 -4
- package/admin/js/views/collection-editor.js +3 -487
- package/admin/js/views/collection-entries.js +1 -484
- package/admin/js/views/collections.js +1 -153
- package/admin/js/views/dashboard.js +1 -56
- package/admin/js/views/documentation.js +1 -12
- package/admin/js/views/index.js +1 -39
- package/admin/js/views/layouts.js +9 -42
- package/admin/js/views/login.js +7 -251
- package/admin/js/views/media.js +1 -240
- package/admin/js/views/navigation.js +14 -212
- package/admin/js/views/page-editor.js +53 -661
- package/admin/js/views/pages.js +5 -72
- package/admin/js/views/plugins.js +13 -90
- package/admin/js/views/settings.js +1 -199
- package/admin/js/views/tutorials.js +1 -12
- package/admin/js/views/user-editor.js +1 -88
- package/admin/js/views/users.js +7 -76
- package/config/auth.json +1 -17
- package/config/navigation.json +15 -0
- package/config/site.json +5 -4
- package/package.json +1 -1
- package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
- package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
- package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
- package/plugins/domma-effects/public/celebrations/index.js +1 -535
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
- package/plugins/example-analytics/stats.json +16 -12
- package/plugins/form-builder/admin/templates/form-editor.html +158 -130
- package/plugins/form-builder/admin/views/form-editor.js +3 -1
- package/plugins/form-builder/data/forms/contact-details.json +71 -35
- package/plugins/form-builder/data/forms/feedback.json +130 -0
- package/plugins/form-builder/data/submissions/feedback.json +1 -0
- package/plugins/form-builder/public/form-logic-engine.js +1 -568
- package/public/css/site.css +1 -302
- package/public/js/btt.js +1 -90
- package/public/js/cookie-consent.js +1 -61
- package/public/js/site.js +1 -204
- package/scripts/setup.js +4 -4
- package/server/middleware/auth.js +44 -21
- package/server/routes/api/auth.js +38 -8
- package/server/routes/api/collections.js +18 -5
- package/server/routes/api/layouts.js +18 -4
- package/server/routes/api/media.js +2 -3
- package/server/routes/api/navigation.js +2 -3
- package/server/routes/api/pages.js +3 -3
- package/server/routes/api/settings.js +2 -3
- package/server/routes/api/users.js +4 -6
- package/server/routes/public.js +3 -3
- package/server/server.js +8 -0
- package/server/services/markdown.js +102 -3
- package/server/services/userTypes.js +167 -0
- package/plugins/form-builder/email.js +0 -103
package/admin/js/views/media.js
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const esc = s => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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,"&").replace(/"/g,""").replace(/</g,"<");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="${
|
|
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
|
-
|
|
100
|
-
|
|
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="${
|
|
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"})}})}};
|