source: Klonkt/src/views/pages/admin-site-edit.ejs@ 07775b7

main
Last change on this file since 07775b7 was 07775b7, checked in by roboburr <roboburr@…>, 3 months ago

tenancy: Appearance page back link is mode-aware (solo -> Admin instead of Sites)

In solo there is no Sites list, so the "back" button on site-edit (Appearance)
pointed to a non-existent context. Now: solo -> /admin (Admin), hub -> /admin/sites.
Title shows "Appearance:" instead of "Edit:".

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 22.0 KB
Line 
1<div class="container admin-site-edit-page">
2 <% if (typeof tenancy !== 'undefined' && tenancy === 'solo') { %>
3 <p><a href="/admin" class="btn">&larr; Beheer</a></p>
4 <% } else { %>
5 <p><a href="/admin/sites" class="btn">&larr; Sites</a></p>
6 <% } %>
7 <h1><%= isNew ? 'New site' : 'Uiterlijk: ' + site.title %></h1>
8
9 <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
10 <% if (error) { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
11
12 <form method="post" action="<%= isNew ? '/admin/sites/create' : '/admin/sites/' + site.slug + '/save' %>" class="site-form">
13
14 <fieldset>
15 <legend>Identity</legend>
16 <label>
17 <span>Slug <% if (!isNew) { %><em>(immutable)</em><% } %></span>
18 <input type="text" name="slug" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
19 pattern="[a-z0-9_-]{2,40}"
20 placeholder="bedrijf1">
21 </label>
22 <label>
23 <span>Title <small class="form-hint-inline">— shown in the header and as display name</small></span>
24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
25 </label>
26 <label>
27 <span>Tagline <small class="form-hint-inline">— short one-liner</small></span>
28 <input type="text" name="tagline" value="<%= site.tagline || '' %>" maxlength="200">
29 </label>
30 <label>
31 <span>Bio / description <small class="form-hint-inline">— shown in the profile header and used for SEO</small></span>
32 <textarea name="description" rows="3" maxlength="500"><%= site.description || '' %></textarea>
33 </label>
34 <label>
35 <span>Profile photo</span>
36 <div class="photo-picker" id="photo-picker">
37 <div class="photo-thumb" id="photo-thumb" <%= site.profile_photo ? '' : 'data-empty' %>>
38 <img id="photo-preview" src="<%= site.profile_photo || '' %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
39 <span class="photo-empty" aria-hidden="true">
40 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
41 </span>
42 </div>
43 <div class="photo-controls">
44 <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="/media/avatars/foo.jpg of https://…">
45 <div class="photo-actions">
46 <button type="button" class="btn" id="photo-upload-trigger">📷 Upload</button>
47 <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>>Verwijder</button>
48 <small id="photo-status" class="form-hint photo-status"></small>
49 </div>
50 <input type="file" id="photo-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
51 </div>
52 </div>
53 </label>
54 <label>
55 <span>Language (ISO code)</span>
56 <input type="text" name="language" value="<%= site.language || 'nl' %>" maxlength="8">
57 </label>
58 <label class="cb">
59 <input type="checkbox" name="profile_enabled" value="1" <%= site.profile_enabled ? 'checked' : '' %>>
60 <span>Show profile header below the nav</span>
61 </label>
62 </fieldset>
63
64 <fieldset>
65 <legend>Appearance</legend>
66
67 <%# ── Curated accent picker ───────────────────────────────────────
68 Was a free-form <input type="color">; now a fixed set of 8 hand-
69 picked colors that work in both light and dark themes. The hex is
70 still what's stored in the DB — we just constrain WHICH hex. %>
71 <fieldset class="accent-grid">
72 <legend>Accent color</legend>
73 <% const _curAccent = (site.accent || '#c2410c').toLowerCase(); %>
74 <% accents.forEach(function(a) { %>
75 <label class="accent-swatch" title="<%= a.name %>">
76 <input type="radio" name="accent" value="<%= a.color %>" <%= a.color.toLowerCase() === _curAccent ? 'checked' : '' %>>
77 <span class="accent-dot" style="background: <%= a.color %>;" aria-hidden="true"></span>
78 <span class="accent-swatch-name"><%= a.name %></span>
79 </label>
80 <% }); %>
81 </fieldset>
82
83 <%# ── Default theme for new visitors ───────────────────────────
84 'auto' (empty value) lets the browser/OS decide via prefers-color-scheme.
85 'light' / 'dark' force the site default for visitors who haven't picked
86 their own preference yet. Logged-in users with users.theme set, and
87 visitors with a localStorage override, still win over this default. %>
88 <fieldset class="theme-default">
89 <legend>Default theme for new visitors</legend>
90 <% const td = site.theme_override || ''; %>
91 <label class="cb">
92 <input type="radio" name="theme_override" value="" <%= td === '' ? 'checked' : '' %>>
93 <span>Auto (volg device-voorkeur)</span>
94 </label>
95 <label class="cb">
96 <input type="radio" name="theme_override" value="light" <%= td === 'light' ? 'checked' : '' %>>
97 <span>Light</span>
98 </label>
99 <label class="cb">
100 <input type="radio" name="theme_override" value="dark" <%= td === 'dark' ? 'checked' : '' %>>
101 <span>Dark</span>
102 </label>
103 </fieldset>
104
105 <fieldset class="palette-grid">
106 <legend>Palette</legend>
107 <% palettes.forEach(function(p) { %>
108 <label class="palette-swatch">
109 <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'sage') === p.key ? 'checked' : '' %>>
110 <span class="palette-preview" aria-hidden="true">
111 <span class="pp-half pp-light" style="background: <%= p.light.paper %>; color: <%= p.light.ink %>;">
112 <span class="pp-letter">Aa</span>
113 <span class="pp-accent" style="background: <%= p.light.accent %>;"></span>
114 </span>
115 <span class="pp-half pp-dark" style="background: <%= p.dark.paper %>; color: <%= p.dark.ink %>;">
116 <span class="pp-letter">Aa</span>
117 <span class="pp-accent" style="background: <%= p.dark.accent %>;"></span>
118 </span>
119 </span>
120 <span class="palette-swatch-name"><%= p.name %></span>
121 </label>
122 <% }); %>
123 </fieldset>
124 </fieldset>
125
126 <fieldset>
127 <legend>Behaviour</legend>
128 <label class="cb">
129 <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
130 <span>Public site (uncheck for closed circle)</span>
131 </label>
132 <label class="cb">
133 <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
134 <span>Search engines may index (sitemap.xml is hidden if off)</span>
135 </label>
136 <label class="cb">
137 <input type="checkbox" name="require_login_to_comment" value="1" <%= site.require_login_to_comment ? 'checked' : '' %>>
138 <span>Require login to comment</span>
139 </label>
140 <label>
141 <span>Comment moderation</span>
142 <select name="comments_moderation_mode">
143 <option value="trust" <%= (site.comments_moderation_mode || 'trust') === 'trust' ? 'selected' : '' %>>Trust mode (auto-approve)</option>
144 <option value="moderate" <%= site.comments_moderation_mode === 'moderate' ? 'selected' : '' %>>Moderate (queue for review)</option>
145 </select>
146 </label>
147 <label class="cb">
148 <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
149 <span>Enable audio player + embeds</span>
150 </label>
151 <label class="cb">
152 <input type="checkbox" name="enable_prutter" value="1" <%= site.enable_prutter ? 'checked' : '' %>>
153 <span>Enable Prutter (direct messaging)</span>
154 </label>
155 </fieldset>
156
157 <fieldset class="profile-links-fieldset">
158 <legend>Social / streaming links</legend>
159 <p class="form-hint">Shown as branded icons on the profile header. Add as many as you like.</p>
160
161 <div id="profile-links-rows">
162 <% (parsedLinks || []).forEach(function(l) { %>
163 <div class="profile-link-row">
164 <select name="profile_link_platform[]">
165 <% platforms.forEach(function(p) { %>
166 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
167 <% }); %>
168 </select>
169 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required>
170 <button type="button" class="btn btn-danger pl-remove">×</button>
171 </div>
172 <% }); %>
173 </div>
174
175 <template id="profile-link-template">
176 <div class="profile-link-row">
177 <select name="profile_link_platform[]">
178 <% platforms.forEach(function(p) { %>
179 <option value="<%= p.key %>"><%= p.label %></option>
180 <% }); %>
181 </select>
182 <input type="text" name="profile_link_url[]" placeholder="https://…" required>
183 <button type="button" class="btn btn-danger pl-remove">×</button>
184 </div>
185 </template>
186
187 <button type="button" id="profile-link-add" class="btn">+ Add link</button>
188 </fieldset>
189
190 <fieldset>
191 <legend>Feed view</legend>
192 <label>
193 <span>Default view for the homepage</span>
194 <select name="feed_view_default">
195 <option value="timeline" <%= (site.feed_view_default || 'timeline') === 'timeline' ? 'selected' : '' %>>Timeline (chronological list)</option>
196 <option value="grid" <%= site.feed_view_default === 'grid' ? 'selected' : '' %>>Grid (cards)</option>
197 </select>
198 </label>
199 <label class="cb">
200 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
201 <span>Show timeline ↔ grid switcher above the feed</span>
202 </label>
203 <label class="cb">
204 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
205 <span>Show search button in nav</span>
206 </label>
207 <label class="cb">
208 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
209 <span>Show archive link in nav</span>
210 </label>
211 </fieldset>
212
213 <fieldset>
214 <legend>SEO &amp; social</legend>
215 <label>
216 <span>Title template (use {title} and {site})</span>
217 <input type="text" name="title_template" value="<%= site.title_template || '{title} — {site}' %>" maxlength="200">
218 </label>
219 <label>
220 <span>Canonical base URL (for production HTTPS)</span>
221 <input type="text" name="canonical" value="<%= site.canonical || '' %>" placeholder="https://your-domain.com">
222 </label>
223 <label>
224 <span>Twitter handle (with @)</span>
225 <input type="text" name="twitter" value="<%= site.twitter || '' %>" placeholder="@yourname" maxlength="64">
226 </label>
227 <details>
228 <summary>Search-engine verification</summary>
229 <label>
230 <span>Google site verification</span>
231 <input type="text" name="google_verification" value="<%= site.google_verification || '' %>" maxlength="200">
232 </label>
233 <label>
234 <span>Bing (msvalidate.01)</span>
235 <input type="text" name="bing_verification" value="<%= site.bing_verification || '' %>" maxlength="200">
236 </label>
237 <label>
238 <span>Pinterest (p:domain_verify)</span>
239 <input type="text" name="pinterest_verification" value="<%= site.pinterest_verification || '' %>" maxlength="200">
240 </label>
241 <label>
242 <span>Yandex</span>
243 <input type="text" name="yandex_verification" value="<%= site.yandex_verification || '' %>" maxlength="200">
244 </label>
245 </details>
246 </fieldset>
247
248 <fieldset>
249 <legend>Custom CSS &amp; HTML <em>(optional)</em></legend>
250 <label>
251 <span>Custom CSS (injected as &lt;style&gt; in &lt;head&gt;)</span>
252 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
253 </label>
254 <label>
255 <span>Custom &lt;head&gt; HTML (analytics, extra metas)</span>
256 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
257 </label>
258 <label>
259 <span>Custom footer HTML</span>
260 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
261 </label>
262 </fieldset>
263
264 <button type="submit" class="btn btn-primary"><%= isNew ? 'Create site' : 'Save changes' %></button>
265 </form>
266</div>
267
268<script>
269(function() {
270 // Profile-links repeater: add row from <template>, remove on click.
271 var rows = document.getElementById('profile-links-rows');
272 var tpl = document.getElementById('profile-link-template');
273 var add = document.getElementById('profile-link-add');
274 if (!rows || !tpl || !add) return;
275
276 add.addEventListener('click', function() {
277 var clone = tpl.content.cloneNode(true);
278 rows.appendChild(clone);
279 });
280 rows.addEventListener('click', function(e) {
281 if (e.target && e.target.classList.contains('pl-remove')) {
282 var row = e.target.closest('.profile-link-row');
283 if (row) row.remove();
284 }
285 });
286})();
287
288// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
289// then write the returned URL into the visible input. Live thumb preview.
290(function() {
291 var picker = document.getElementById('photo-picker');
292 if (!picker) return;
293 var thumb = document.getElementById('photo-thumb');
294 var preview = document.getElementById('photo-preview');
295 var urlEl = document.getElementById('photo-url');
296 var trigger = document.getElementById('photo-upload-trigger');
297 var clear = document.getElementById('photo-clear');
298 var field = document.getElementById('photo-upload-field');
299 var status = document.getElementById('photo-status');
300
301 function showPreview(url) {
302 if (url) {
303 preview.src = url;
304 preview.hidden = false;
305 thumb.removeAttribute('data-empty');
306 clear.hidden = false;
307 } else {
308 preview.src = '';
309 preview.hidden = true;
310 thumb.setAttribute('data-empty', '');
311 clear.hidden = true;
312 }
313 }
314
315 if (urlEl) {
316 urlEl.addEventListener('input', function() {
317 showPreview(urlEl.value.trim());
318 });
319 }
320
321 if (trigger && field) {
322 trigger.addEventListener('click', function() { field.click(); });
323 field.addEventListener('change', async function() {
324 var file = field.files && field.files[0];
325 if (!file) return;
326 status.classList.remove('is-error');
327 status.textContent = 'Uploaden…';
328 try {
329 var fd = new FormData();
330 fd.append('photo', file);
331 var r = await fetch('/admin/sites/upload-photo', {
332 method: 'POST',
333 body: fd,
334 credentials: 'same-origin',
335 });
336 var j = await r.json();
337 if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
338 urlEl.value = j.url;
339 showPreview(j.url);
340 status.textContent = 'Geüpload ✓';
341 setTimeout(function() { status.textContent = ''; }, 2000);
342 } catch (e) {
343 status.classList.add('is-error');
344 status.textContent = 'Mislukt: ' + e.message;
345 } finally {
346 field.value = '';
347 }
348 });
349 }
350
351 if (clear) {
352 clear.addEventListener('click', function() {
353 urlEl.value = '';
354 showPreview('');
355 status.textContent = '';
356 // Note: doesn't delete the file from disk — saving the form with empty
357 // URL leaves the file orphaned on the server. Acceptable for now.
358 });
359 }
360})();
361</script>
362
363<style>
364.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
365.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
366
367.site-form { display: flex; flex-direction: column; gap: 1.5rem; }
368.site-form fieldset {
369 border: 1px solid var(--rule);
370 border-radius: 8px;
371 padding: 1rem 1.25rem;
372 background: var(--paper-2);
373 display: flex;
374 flex-direction: column;
375 gap: 0.75rem;
376}
377.site-form fieldset.palette-grid {
378 display: grid;
379 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
380 background: var(--paper);
381 margin-top: 0.5rem;
382}
383/* Theme default radios — horizontal, sits between accent + palette */
384.site-form fieldset.theme-default {
385 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
386 background: var(--paper);
387 margin-top: 0.5rem;
388}
389.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
390.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
391.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
392.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
393.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
394 padding: 0.55rem 0.85rem;
395 border: 1px solid var(--rule);
396 border-radius: 5px;
397 background: var(--paper);
398 color: var(--ink);
399 font-family: inherit;
400 font-size: 0.95rem;
401}
402.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
403.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
404.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
405
406/* Reuse the palette-swatch styles from account.ejs */
407.palette-swatch {
408 display: flex; flex-direction: column; gap: 0.4rem;
409 padding: 0.5rem;
410 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
411 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
412}
413.palette-swatch:hover { border-color: var(--accent); }
414.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
415.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
416.palette-preview {
417 display: grid; grid-template-columns: 1fr 1fr;
418 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
419 border: 1px solid var(--rule);
420}
421.pp-half {
422 position: relative;
423 display: flex; align-items: center; justify-content: center;
424 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
425}
426.pp-letter { line-height: 1; }
427.pp-accent {
428 position: absolute; bottom: 4px; right: 4px;
429 width: 9px; height: 9px; border-radius: 50%;
430 border: 1px solid rgba(0,0,0,0.12);
431}
432.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
433
434/* Accent picker: row of small colored swatches */
435.site-form fieldset.accent-grid {
436 display: grid;
437 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
438 gap: 0.5rem;
439 background: var(--paper);
440 margin-top: 0.5rem;
441}
442.accent-swatch {
443 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
444 padding: 0.55rem 0.4rem;
445 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
446 cursor: pointer; font-size: 0.78rem;
447 transition: border-color 120ms, box-shadow 120ms;
448}
449.accent-swatch:hover { border-color: var(--accent); }
450.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
451.accent-swatch:has(input[type="radio"]:checked) {
452 border-color: var(--accent);
453 box-shadow: 0 0 0 2px var(--accent);
454}
455.accent-dot {
456 width: 28px; height: 28px; border-radius: 50%;
457 border: 1px solid rgba(0,0,0,0.1);
458 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
459}
460.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
461
462.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
463.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
464.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
465
466.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
467.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
468
469/* P63 — Profile photo picker
470 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
471 to the left and the controls fill the remaining width. */
472.photo-picker {
473 display: flex;
474 flex-direction: column;
475 gap: .65rem;
476 align-items: stretch;
477}
478.photo-thumb {
479 position: relative;
480 width: 88px; height: 88px;
481 border-radius: 50%;
482 border: 1px solid var(--rule);
483 background: var(--paper-2);
484 overflow: hidden;
485 flex-shrink: 0;
486 display: flex; align-items: center; justify-content: center;
487}
488.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
489.photo-thumb .photo-empty {
490 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
491}
492.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
493.photo-thumb:not([data-empty]) .photo-empty { display: none; }
494.photo-thumb[data-empty] img { display: none; }
495
496.photo-controls {
497 display: flex;
498 flex-direction: column;
499 gap: .5rem;
500 flex: 1;
501 min-width: 0;
502}
503.photo-controls input[type="text"] { width: 100%; }
504.photo-actions {
505 display: flex; align-items: center; flex-wrap: wrap;
506 gap: .4rem;
507}
508.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
509.photo-status { margin-left: .25rem; }
510.photo-status.is-error { color: #c2410c; }
511
512@media (min-width: 520px) {
513 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
514 .photo-controls { gap: .55rem; }
515}
516.profile-link-row {
517 display: grid; grid-template-columns: 160px 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
518}
519.profile-link-row select { padding: 0.45rem 0.6rem; }
520.profile-links-fieldset details > label { margin-top: 0.4rem; }
521</style>
Note: See TracBrowser for help on using the repository browser.