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