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

main
Last change on this file since f434294 was 5462bab, checked in by Robin <roboburr@…>, 6 weeks ago

Fix 500 op de Uiterlijk-pagina na een halve update (boiert.eu)

Bart kreeg een 500 op /admin/sites/<slug>/edit. Oorzaak was een gemengde
staat: zijn klonkt-update was gegenereerd voor de oude single-service-layout
en herstart nog klonkt.service, de unit die de migratie juist heeft
uitgezet. De code op schijf werd dus wel bijgewerkt, maar het draaiende
proces nooit herstart. De oude route rendert dan het nieuwe template, dat
een variabele verwacht die de oude route niet meegeeft: ReferenceError, en
Express cachet het gecompileerde template tot de volgende herstart.

Drie lagen gefixt:

  1. Het aliasveld degradeert bij een ontbrekende variabele naar leeg in plaats van de hele pagina mee te nemen (typeof-guard, zelfde patroon als auth-register). Een deploy-moment mag nooit een 500 opleveren.
  2. De nieuw-site-form gaf dezelfde ReferenceError ook met volledig nieuwe code: de /new-route rendert hetzelfde template maar gaf apAliases niet mee. Lokaal gereproduceerd en bevestigd gefixt (beide pagina's 200).
  3. De wortel: een gedeeld script dat /usr/local/bin/klonkt-update herschrijft voor de actuele layout. De migratie draait het voortaan zelf, install.sh genereert de updater er ook mee (kan nooit meer uiteenlopen), en al gemigreerde servers repareren het met een los commando.

Changed files:
src/routes/admin-sites.js

  • /new geeft apAliases mee aan het template

src/views/pages/admin-site-edit.ejs

  • typeof-guard op apAliases met uitleg waarom

scripts/klonkt-migrate-data.sh

  • herschrijft de updater na de unit-omschakeling; waarschuwt als het script in een oudere checkout ontbreekt

scripts/install.sh

  • inline updater-generatie vervangen door het gedeelde script

deploy/MULTI-INSTANCE.md

  • reparatie-instructie voor servers die voor deze fix zijn gemigreerd

New file:
scripts/klonkt-refresh-updater.sh

  • idempotent; detecteert de branch uit de checkout; herstart alle klonkt@<slug>-instances, of klonkt.service als er geen zijn

-robo
Co-Authored-By: Claude Fable 5 <noreply@…>

  • Property mode set to 100644
File size: 26.3 KB
Line 
1<div class="container admin-site-edit-page">
2 <p><a href="/admin" class="btn">&larr; <%= t('asite.back_admin') %></a></p>
3 <h1><%= isNew ? t('asite.title_new') : t('asite.title_edit') + ': ' + site.title %></h1>
4
5 <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
6 <% if (error) { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
7
8 <form method="post" action="<%= isNew ? '/admin/sites/create' : '/admin/sites/' + site.slug + '/save' %>" class="site-form">
9
10 <fieldset>
11 <legend><%= t('asite.identity') %></legend>
12 <label>
13 <span><%= t('asite.slug') %> <% if (!isNew) { %><em><%= t('asite.slug_fixed') %></em><% } %></span>
14 <span class="slug-url">
15 <span class="slug-url-host" id="slug-host"
16 data-prefix="<%= (typeof tenancy !== 'undefined' && tenancy === 'hub') ? '/user/' : '/' %>">website.com/</span><input
17 type="text" name="slug" id="slug-input" class="slug-url-input" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
18 pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>"
19 autocapitalize="none" autocorrect="off" spellcheck="false">
20 </span>
21 </label>
22 <label>
23 <span><%= t('asite.field_title') %> <small class="form-hint-inline"><%= t('asite.field_title_hint') %></small></span>
24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
25 </label>
26 <%# Assign (or reassign) owner — ONLY god AND only in hub mode. In hub this
27 gives each user their own self-managed Klonkt. In solo (one site,
28 one owner) this is pointless/confusing, so it's hidden. %>
29 <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %>
30 <label>
31 <span><%= t('asite.owner') %> <small class="form-hint-inline"><%= t('asite.owner_hint') %></small></span>
32 <% var _godSuffix = t('asite.owner_god_suffix'); %>
33 <select name="owner_id">
34 <% (typeof users !== 'undefined' ? users : []).forEach(function(u){ %>
35 <option value="<%= u.id %>" <%= (site.owner_id === u.id) ? 'selected' : '' %>><%= u.username %><%= u.role === 'god' ? _godSuffix : '' %></option>
36 <% }); %>
37 </select>
38 </label>
39 <% } %>
40 <label>
41 <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span>
42 <input type="text" name="tagline" value="<%= site.tagline || '' %>" maxlength="200">
43 </label>
44 <label>
45 <span><%= t('asite.bio') %> <small class="form-hint-inline"><%= t('asite.bio_hint') %></small></span>
46 <textarea name="description" rows="3" maxlength="500"><%= site.description || '' %></textarea>
47 </label>
48 <label>
49 <span><%= t('asite.profile_photo') %></span>
50 <div class="photo-picker" id="photo-picker">
51 <div class="photo-thumb" id="photo-thumb" <%= site.profile_photo ? '' : 'data-empty' %>>
52 <img id="photo-preview" src="<%= avatar(site.profile_photo || '', 128) %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
53 <span class="photo-empty" aria-hidden="true">
54 <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>
55 </span>
56 </div>
57 <div class="photo-controls">
58 <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="<%= t('asite.photo_url_placeholder') %>">
59 <div class="photo-actions">
60 <button type="button" class="btn" id="photo-upload-trigger"><%= t('asite.photo_upload') %></button>
61 <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>><%= t('asite.photo_clear') %></button>
62 <small id="photo-status" class="form-hint photo-status"></small>
63 </div>
64 <input type="file" id="photo-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
65 </div>
66 </div>
67 </label>
68 <label>
69 <span><%= t('asite.language') %></span>
70 <input type="text" name="language" value="<%= site.language || 'nl' %>" maxlength="8">
71 </label>
72 <label class="cb">
73 <input type="checkbox" name="profile_enabled" value="1" <%= site.profile_enabled ? 'checked' : '' %>>
74 <span><%= t('asite.profile_enabled') %></span>
75 </label>
76 </fieldset>
77
78 <fieldset>
79 <legend><%= t('asite.appearance') %></legend>
80
81 <%# ── Curated accent picker ───────────────────────────────────────
82 Was a free-form <input type="color">; now a fixed set of 8 hand-
83 picked colors that work in both light and dark themes. The hex is
84 still what's stored in the DB — we just constrain WHICH hex. %>
85 <fieldset class="accent-grid">
86 <legend><%= t('asite.accent_color') %></legend>
87 <% const _curAccent = (site.accent || '#c2410c').toLowerCase(); %>
88 <% accents.forEach(function(a) { %>
89 <label class="accent-swatch" title="<%= a.name %>">
90 <input type="radio" name="accent" value="<%= a.color %>" <%= a.color.toLowerCase() === _curAccent ? 'checked' : '' %>>
91 <span class="accent-dot" style="background: <%= a.color %>;" aria-hidden="true"></span>
92 <span class="accent-swatch-name"><%= a.name %></span>
93 </label>
94 <% }); %>
95 </fieldset>
96
97 <%# ── Default theme for new visitors ───────────────────────────
98 'auto' (empty value) lets the browser/OS decide via prefers-color-scheme.
99 'light' / 'dark' force the site default for visitors who haven't picked
100 their own preference yet. Logged-in users with users.theme set, and
101 visitors with a localStorage override, still win over this default. %>
102 <fieldset class="theme-default">
103 <legend><%= t('asite.theme_default') %></legend>
104 <% const td = site.theme_override || ''; %>
105 <label class="cb">
106 <input type="radio" name="theme_override" value="" <%= td === '' ? 'checked' : '' %>>
107 <span><%= t('asite.theme_auto') %></span>
108 </label>
109 <label class="cb">
110 <input type="radio" name="theme_override" value="light" <%= td === 'light' ? 'checked' : '' %>>
111 <span><%= t('asite.theme_light') %></span>
112 </label>
113 <label class="cb">
114 <input type="radio" name="theme_override" value="dark" <%= td === 'dark' ? 'checked' : '' %>>
115 <span><%= t('asite.theme_dark') %></span>
116 </label>
117 </fieldset>
118
119 <fieldset class="palette-grid">
120 <legend><%= t('asite.palette') %></legend>
121 <% palettes.forEach(function(p) { %>
122 <label class="palette-swatch">
123 <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'klonkt') === p.key ? 'checked' : '' %>>
124 <span class="palette-preview" aria-hidden="true">
125 <span class="pp-half pp-light" style="background: <%= p.light.paper %>; color: <%= p.light.ink %>;">
126 <span class="pp-letter">Aa</span>
127 <span class="pp-accent" style="background: <%= p.light.accent %>;"></span>
128 </span>
129 <span class="pp-half pp-dark" style="background: <%= p.dark.paper %>; color: <%= p.dark.ink %>;">
130 <span class="pp-letter">Aa</span>
131 <span class="pp-accent" style="background: <%= p.dark.accent %>;"></span>
132 </span>
133 </span>
134 <span class="palette-swatch-name"><%= p.name %></span>
135 </label>
136 <% }); %>
137 </fieldset>
138 </fieldset>
139
140 <%# Live preview: selecting an accent / theme / palette re-themes this page
141 instantly (reverts on reload; persists on save). %>
142 <script>
143 (function(){
144 if (window.__themePreviewWired) return; window.__themePreviewWired = true;
145 var html = document.documentElement;
146 function applyAccent(hex){
147 if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
148 var sa = document.getElementById('pcms-site-accent');
149 if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
150 sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
151 }
152 document.addEventListener('change', function(e){
153 var t = e.target; if(!t || !t.name) return;
154 if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
155 else if(t.name === 'accent'){ applyAccent(t.value); }
156 else if(t.name === 'theme_override'){
157 if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
158 else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
159 }
160 });
161 })();
162 </script>
163
164 <fieldset>
165 <legend><%= t('asite.behavior') %></legend>
166 <label class="cb">
167 <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
168 <span><%= t('asite.is_public') %></span>
169 </label>
170 <label class="cb">
171 <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
172 <span><%= t('asite.robots_index') %></span>
173 </label>
174 <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
175 <label class="cb">
176 <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
177 <span><%= t('asite.enable_audio') %></span>
178 </label>
179 <% } %>
180 </fieldset>
181
182 <fieldset class="profile-links-fieldset">
183 <legend><%= t('asite.links') %></legend>
184 <p class="form-hint"><%= t('asite.links_hint') %></p>
185
186 <div id="profile-links-rows">
187 <% (parsedLinks || []).forEach(function(l) { %>
188 <div class="profile-link-row">
189 <select name="profile_link_platform[]">
190 <% platforms.forEach(function(p) { %>
191 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
192 <% }); %>
193 </select>
194 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required>
195 <button type="button" class="btn btn-danger pl-remove">×</button>
196 </div>
197 <% }); %>
198 </div>
199
200 <template id="profile-link-template">
201 <div class="profile-link-row">
202 <select name="profile_link_platform[]">
203 <% platforms.forEach(function(p) { %>
204 <option value="<%= p.key %>"><%= p.label %></option>
205 <% }); %>
206 </select>
207 <input type="text" name="profile_link_url[]" placeholder="https://…" required>
208 <button type="button" class="btn btn-danger pl-remove">×</button>
209 </div>
210 </template>
211
212 <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
213 </fieldset>
214
215 <fieldset>
216 <legend><%= t('asite.aliases') %></legend>
217 <p class="form-hint"><%= t('asite.aliases_hint') %></p>
218 <label>
219<%# typeof-guard on purpose: during a deploy an old route can render this new
220 template for one request (view cache fills on first hit). A missing local
221 must degrade to an empty field, not take the whole page down with a 500. %>
222 <textarea name="ap_aliases" rows="3" placeholder="@oud@mastodon.social&#10;https://andere-klonkt.example/ap/users/naam"><%= typeof apAliases !== 'undefined' ? apAliases : '' %></textarea>
223 </label>
224 </fieldset>
225
226 <fieldset>
227 <legend><%= t('asite.feed_view') %></legend>
228 <label>
229 <span><%= t('asite.feed_default') %></span>
230 <select name="feed_view_default">
231 <option value="timeline" <%= (site.feed_view_default || 'timeline') === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_timeline') %></option>
232 <option value="grid" <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
233 </select>
234 </label>
235 <label class="cb">
236 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
237 <span><%= t('asite.feed_switch') %></span>
238 </label>
239 <label class="cb">
240 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
241 <span><%= t('asite.show_search') %></span>
242 </label>
243 <label class="cb">
244 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
245 <span><%= t('asite.show_archive') %></span>
246 </label>
247 </fieldset>
248
249 <fieldset class="seo-pointer">
250 <legend><%= t('asite.seo') %></legend>
251 <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
252 </fieldset>
253
254 <fieldset>
255 <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
256 <label>
257 <span><%= t('asite.custom_css') %></span>
258 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
259 </label>
260 <label>
261 <span><%= t('asite.custom_head') %></span>
262 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
263 </label>
264 <label>
265 <span><%= t('asite.custom_foot') %></span>
266 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
267 </label>
268 </fieldset>
269
270 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
271 </form>
272</div>
273
274<script>
275(function() {
276 // Slug URL field: show the real host as a dimmed prefix (the slug itself is
277 // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
278 var sh = document.getElementById('slug-host');
279 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
280})();
281(function() {
282 // Profile-links repeater: add row from <template>, remove on click.
283 var rows = document.getElementById('profile-links-rows');
284 var tpl = document.getElementById('profile-link-template');
285 var add = document.getElementById('profile-link-add');
286 if (!rows || !tpl || !add) return;
287
288 add.addEventListener('click', function() {
289 var clone = tpl.content.cloneNode(true);
290 rows.appendChild(clone);
291 });
292 rows.addEventListener('click', function(e) {
293 if (e.target && e.target.classList.contains('pl-remove')) {
294 var row = e.target.closest('.profile-link-row');
295 if (row) row.remove();
296 }
297 });
298})();
299
300// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
301// then write the returned URL into the visible input. Live thumb preview.
302(function() {
303 var picker = document.getElementById('photo-picker');
304 if (!picker) return;
305 var thumb = document.getElementById('photo-thumb');
306 var preview = document.getElementById('photo-preview');
307 var urlEl = document.getElementById('photo-url');
308 var trigger = document.getElementById('photo-upload-trigger');
309 var clear = document.getElementById('photo-clear');
310 var field = document.getElementById('photo-upload-field');
311 var status = document.getElementById('photo-status');
312
313 function showPreview(url) {
314 if (url) {
315 preview.src = url;
316 preview.hidden = false;
317 thumb.removeAttribute('data-empty');
318 clear.hidden = false;
319 } else {
320 preview.src = '';
321 preview.hidden = true;
322 thumb.setAttribute('data-empty', '');
323 clear.hidden = true;
324 }
325 }
326
327 if (urlEl) {
328 urlEl.addEventListener('input', function() {
329 showPreview(urlEl.value.trim());
330 });
331 }
332
333 if (trigger && field) {
334 trigger.addEventListener('click', function() { field.click(); });
335 field.addEventListener('change', async function() {
336 var file = field.files && field.files[0];
337 if (!file) return;
338 status.classList.remove('is-error');
339 status.textContent = 'Uploaden…';
340 try {
341 var fd = new FormData();
342 fd.append('photo', file);
343 var r = await fetch('/admin/sites/upload-photo', {
344 method: 'POST',
345 body: fd,
346 credentials: 'same-origin',
347 });
348 var j = await r.json();
349 if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
350 urlEl.value = j.url;
351 showPreview(j.url);
352 status.textContent = 'Geüpload ✓';
353 setTimeout(function() { status.textContent = ''; }, 2000);
354 } catch (e) {
355 status.classList.add('is-error');
356 status.textContent = 'Mislukt: ' + e.message;
357 } finally {
358 field.value = '';
359 }
360 });
361 }
362
363 if (clear) {
364 clear.addEventListener('click', function() {
365 urlEl.value = '';
366 showPreview('');
367 status.textContent = '';
368 // Note: doesn't delete the file from disk — saving the form with empty
369 // URL leaves the file orphaned on the server. Acceptable for now.
370 });
371 }
372})();
373</script>
374
375<style>
376.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
377.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
378
379.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
380/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
381 therefore does NOT shrink → the entire form became wider than the screen. min-width:0
382 forces it to shrink; form fields get max-width:100% + border-box so they
383 never overflow their fieldset. */
384.site-form fieldset { min-width: 0; }
385.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
386.site-form fieldset {
387 border: 1px solid var(--rule);
388 border-radius: 8px;
389 padding: 1rem 1.25rem;
390 background: var(--paper-2);
391 display: flex;
392 flex-direction: column;
393 gap: 0.75rem;
394}
395.site-form fieldset.palette-grid {
396 display: grid;
397 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
398 background: var(--paper);
399 margin-top: 0.5rem;
400}
401/* Theme default radios — horizontal, sits between accent + palette */
402.site-form fieldset.theme-default {
403 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
404 background: var(--paper);
405 margin-top: 0.5rem;
406}
407.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
408.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
409.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
410.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
411.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
412 padding: 0.55rem 0.85rem;
413 border: 1px solid var(--rule);
414 border-radius: 5px;
415 background: var(--paper);
416 color: var(--ink);
417 font-family: inherit;
418 font-size: 0.95rem;
419}
420.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
421.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
422.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
423
424/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
425.slug-url {
426 display: flex; align-items: stretch;
427 border: 1px solid var(--rule); border-radius: 5px;
428 background: var(--paper); overflow: hidden;
429 transition: border-color 120ms;
430}
431.slug-url:focus-within { border-color: var(--accent); }
432.slug-url-host {
433 display: inline-flex; align-items: center;
434 padding-left: 0.85rem;
435 color: var(--ink-muted, var(--ink-soft));
436 white-space: nowrap; user-select: none;
437 font-size: 0.95rem;
438}
439.site-form .slug-url-input {
440 flex: 1 1 auto; min-width: 0;
441 border: none !important; border-radius: 0 !important;
442 background: transparent !important;
443 color: var(--accent) !important; font-weight: 700;
444 padding: 0.55rem 0.85rem 0.55rem 0.1rem;
445}
446.site-form .slug-url-input:focus { outline: none; }
447.site-form .slug-url-input[readonly] {
448 opacity: 1; cursor: default; background: transparent !important;
449}
450
451/* Selects: same look as the inputs, with a custom chevron instead of the bare
452 native arrow. The open option list is still rendered by the browser. */
453.site-form select {
454 appearance: none;
455 -webkit-appearance: none;
456 padding: 0.55rem 2.2rem 0.55rem 0.85rem;
457 border: 1px solid var(--rule);
458 border-radius: 5px;
459 background-color: var(--paper);
460 color: var(--ink);
461 font-family: inherit;
462 font-size: 0.95rem;
463 cursor: pointer;
464 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
465 background-repeat: no-repeat;
466 background-position: right 0.7rem center;
467 transition: border-color 0.15s ease, box-shadow 0.15s ease;
468}
469.site-form select:hover { border-color: var(--ink-soft, #888); }
470.site-form select:focus,
471.site-form input[type="text"]:focus,
472.site-form textarea:focus {
473 outline: none;
474 border-color: var(--accent, #c2410c);
475 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
476}
477
478/* Reuse the palette-swatch styles from account.ejs */
479.palette-swatch {
480 display: flex; flex-direction: column; gap: 0.4rem;
481 padding: 0.5rem;
482 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
483 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
484}
485.palette-swatch:hover { border-color: var(--accent); }
486.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
487.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
488.palette-preview {
489 display: grid; grid-template-columns: 1fr 1fr;
490 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
491 border: 1px solid var(--rule);
492}
493.pp-half {
494 position: relative;
495 display: flex; align-items: center; justify-content: center;
496 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
497}
498.pp-letter { line-height: 1; }
499.pp-accent {
500 position: absolute; bottom: 4px; right: 4px;
501 width: 9px; height: 9px; border-radius: 50%;
502 border: 1px solid rgba(0,0,0,0.12);
503}
504.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
505
506/* Accent picker: row of small colored swatches */
507.site-form fieldset.accent-grid {
508 display: grid;
509 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
510 gap: 0.5rem;
511 background: var(--paper);
512 margin-top: 0.5rem;
513}
514.accent-swatch {
515 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
516 padding: 0.55rem 0.4rem;
517 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
518 cursor: pointer; font-size: 0.78rem;
519 transition: border-color 120ms, box-shadow 120ms;
520}
521.accent-swatch:hover { border-color: var(--accent); }
522.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
523.accent-swatch:has(input[type="radio"]:checked) {
524 border-color: var(--accent);
525 box-shadow: 0 0 0 2px var(--accent);
526}
527.accent-dot {
528 width: 28px; height: 28px; border-radius: 50%;
529 border: 1px solid rgba(0,0,0,0.1);
530 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
531}
532.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
533
534.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
535.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
536.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
537
538.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
539.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
540
541/* P63 — Profile photo picker
542 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
543 to the left and the controls fill the remaining width. */
544.photo-picker {
545 display: flex;
546 flex-direction: column;
547 gap: .65rem;
548 align-items: stretch;
549}
550.photo-thumb {
551 position: relative;
552 width: 88px; height: 88px;
553 border-radius: 50%;
554 border: 1px solid var(--rule);
555 background: var(--paper-2);
556 overflow: hidden;
557 flex-shrink: 0;
558 display: flex; align-items: center; justify-content: center;
559}
560.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
561.photo-thumb .photo-empty {
562 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
563}
564.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
565.photo-thumb:not([data-empty]) .photo-empty { display: none; }
566.photo-thumb[data-empty] img { display: none; }
567
568.photo-controls {
569 display: flex;
570 flex-direction: column;
571 gap: .5rem;
572 flex: 1;
573 min-width: 0;
574}
575.photo-controls input[type="text"] { width: 100%; }
576.photo-actions {
577 display: flex; align-items: center; flex-wrap: wrap;
578 gap: .4rem;
579}
580.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
581.photo-status { margin-left: .25rem; }
582.photo-status.is-error { color: #c2410c; }
583
584@media (min-width: 520px) {
585 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
586 .photo-controls { gap: .55rem; }
587}
588/* Mobile-first: platform selector on its own line (full width), URL input + remove
589 button below. Prevents horizontal overflow on phones (fixed 160px column +
590 non-shrinkable input together ~447px > screen width). */
591.profile-link-row {
592 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
593}
594.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
595.profile-link-row input { min-width: 0; }
596@media (min-width: 520px) {
597 .profile-link-row { grid-template-columns: 160px 1fr auto; }
598 .profile-link-row select { grid-column: auto; }
599}
600.profile-links-fieldset details > label { margin-top: 0.4rem; }
601</style>
Note: See TracBrowser for help on using the repository browser.