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

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

Account-verhuizingen slice 2: de uitgaande Move (FEP-7628)

De vertrekkende helft van shaer-0j2: deze Klonkt als het oude huis. Twee
eisen voordat er iets de deur uit gaat. Een: geen guardians; een warded
account verhuizen zonder de guardianship te hertargeten zou het vangnet
van het kind stil breken, dus tot shaer-tge er is weigert een bewaakt
account met een heldere melding. Twee: het nieuwe profiel claimt dit
adres in alsoKnownAs, dezelfde back-reference die elke ontvangende
server (onze slice 1 incluis) eist; zonder die claim is de Move overal
dood bij aankomst.

De Move gaat duurzaam (retry-queue) naar elke volger-inbox; hun servers
doen de re-follow. moved_to wordt vastgelegd; het serveren ervan op de
actor en het beleid van de oude site zijn slice 3. In Beheer > Sites >
bewerken zit de aankondiging als eigen formulier met bevestiging, nooit
als bijeffect van Opslaan: een verhuizing is een deur die je achter je
dichttrekt.

Changed files:
src/services/ActivityPubService.js

  • moveAccount(site, target, {fetchActorFn, deliverFn}): guardian- weigering, resolutie (URL/handle), back-reference-check, moved_to, Move naar alle volger-inboxen (shared inbox de-dupe)

src/config/database.js

  • sites.moved_to (TEXT)

src/routes/admin-sites.js

  • POST /:slug/move met per-fout een heldere redirect-melding

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

  • Verhuizen-sectie (eigen form + confirm), toont moved_to

src/services/i18n.js

  • asite.move* in nl/en/de

test/move-actor.test.js

  • guarded weigert (niets vastgelegd), zonder back-reference weigert, happy path: moved_to + een Move per volger-inbox (shared voorop)

remarks: gebouwd op vps/main (f434294) in een worktree, los van de
lopende boost-toggle-sessie in de hoofd-clone. Suite op 370 groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 27.0 KB
RevLine 
[7bc636b]1<div class="container admin-site-edit-page">
[bbf9d1a]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>
[7bc636b]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>
[bbf9d1a]11 <legend><%= t('asite.identity') %></legend>
[7bc636b]12 <label>
[bbf9d1a]13 <span><%= t('asite.slug') %> <% if (!isNew) { %><em><%= t('asite.slug_fixed') %></em><% } %></span>
[3068cc0]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<% } %>
[bbf9d1a]18 pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>"
[3068cc0]19 autocapitalize="none" autocorrect="off" spellcheck="false">
20 </span>
[7bc636b]21 </label>
22 <label>
[bbf9d1a]23 <span><%= t('asite.field_title') %> <small class="form-hint-inline"><%= t('asite.field_title_hint') %></small></span>
[7bc636b]24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
25 </label>
[834bcc3]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. %>
[face47a]29 <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %>
[98ecf51]30 <label>
[bbf9d1a]31 <span><%= t('asite.owner') %> <small class="form-hint-inline"><%= t('asite.owner_hint') %></small></span>
32 <% var _godSuffix = t('asite.owner_god_suffix'); %>
[98ecf51]33 <select name="owner_id">
34 <% (typeof users !== 'undefined' ? users : []).forEach(function(u){ %>
[bbf9d1a]35 <option value="<%= u.id %>" <%= (site.owner_id === u.id) ? 'selected' : '' %>><%= u.username %><%= u.role === 'god' ? _godSuffix : '' %></option>
[98ecf51]36 <% }); %>
37 </select>
38 </label>
39 <% } %>
[7bc636b]40 <label>
[bbf9d1a]41 <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span>
[7bc636b]42 <input type="text" name="tagline" value="<%= site.tagline || '' %>" maxlength="200">
43 </label>
44 <label>
[bbf9d1a]45 <span><%= t('asite.bio') %> <small class="form-hint-inline"><%= t('asite.bio_hint') %></small></span>
[7bc636b]46 <textarea name="description" rows="3" maxlength="500"><%= site.description || '' %></textarea>
47 </label>
48 <label>
[bbf9d1a]49 <span><%= t('asite.profile_photo') %></span>
[7bc636b]50 <div class="photo-picker" id="photo-picker">
51 <div class="photo-thumb" id="photo-thumb" <%= site.profile_photo ? '' : 'data-empty' %>>
[99989f9]52 <img id="photo-preview" src="<%= avatar(site.profile_photo || '', 128) %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
[7bc636b]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">
[bbf9d1a]58 <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="<%= t('asite.photo_url_placeholder') %>">
[7bc636b]59 <div class="photo-actions">
[bbf9d1a]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>
[7bc636b]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>
[bbf9d1a]69 <span><%= t('asite.language') %></span>
[7bc636b]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' : '' %>>
[bbf9d1a]74 <span><%= t('asite.profile_enabled') %></span>
[7bc636b]75 </label>
76 </fieldset>
77
78 <fieldset>
[bbf9d1a]79 <legend><%= t('asite.appearance') %></legend>
[7bc636b]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">
[bbf9d1a]86 <legend><%= t('asite.accent_color') %></legend>
[7bc636b]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">
[bbf9d1a]103 <legend><%= t('asite.theme_default') %></legend>
[7bc636b]104 <% const td = site.theme_override || ''; %>
105 <label class="cb">
106 <input type="radio" name="theme_override" value="" <%= td === '' ? 'checked' : '' %>>
[bbf9d1a]107 <span><%= t('asite.theme_auto') %></span>
[7bc636b]108 </label>
109 <label class="cb">
110 <input type="radio" name="theme_override" value="light" <%= td === 'light' ? 'checked' : '' %>>
[bbf9d1a]111 <span><%= t('asite.theme_light') %></span>
[7bc636b]112 </label>
113 <label class="cb">
114 <input type="radio" name="theme_override" value="dark" <%= td === 'dark' ? 'checked' : '' %>>
[bbf9d1a]115 <span><%= t('asite.theme_dark') %></span>
[7bc636b]116 </label>
117 </fieldset>
118
119 <fieldset class="palette-grid">
[bbf9d1a]120 <legend><%= t('asite.palette') %></legend>
[7bc636b]121 <% palettes.forEach(function(p) { %>
122 <label class="palette-swatch">
[34076d7]123 <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'klonkt') === p.key ? 'checked' : '' %>>
[7bc636b]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
[34076d7]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
[7bc636b]164 <fieldset>
[bbf9d1a]165 <legend><%= t('asite.behavior') %></legend>
[7bc636b]166 <label class="cb">
167 <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
[bbf9d1a]168 <span><%= t('asite.is_public') %></span>
[7bc636b]169 </label>
170 <label class="cb">
171 <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
[bbf9d1a]172 <span><%= t('asite.robots_index') %></span>
[7bc636b]173 </label>
[cb01666]174 <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
[7bc636b]175 <label class="cb">
176 <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
[bbf9d1a]177 <span><%= t('asite.enable_audio') %></span>
[7bc636b]178 </label>
[cb01666]179 <% } %>
[7bc636b]180 </fieldset>
181
182 <fieldset class="profile-links-fieldset">
[bbf9d1a]183 <legend><%= t('asite.links') %></legend>
184 <p class="form-hint"><%= t('asite.links_hint') %></p>
[7bc636b]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
[bbf9d1a]212 <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
[7bc636b]213 </fieldset>
214
[ccaa530]215 <fieldset>
216 <legend><%= t('asite.aliases') %></legend>
217 <p class="form-hint"><%= t('asite.aliases_hint') %></p>
218 <label>
[5462bab]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>
[ccaa530]223 </label>
224 </fieldset>
225
[7bc636b]226 <fieldset>
[bbf9d1a]227 <legend><%= t('asite.feed_view') %></legend>
[7bc636b]228 <label>
[bbf9d1a]229 <span><%= t('asite.feed_default') %></span>
[7bc636b]230 <select name="feed_view_default">
[bbf9d1a]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>
[7bc636b]233 </select>
234 </label>
235 <label class="cb">
236 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
[bbf9d1a]237 <span><%= t('asite.feed_switch') %></span>
[7bc636b]238 </label>
239 <label class="cb">
240 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
[bbf9d1a]241 <span><%= t('asite.show_search') %></span>
[7bc636b]242 </label>
243 <label class="cb">
244 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
[bbf9d1a]245 <span><%= t('asite.show_archive') %></span>
[7bc636b]246 </label>
247 </fieldset>
248
[6623453]249 <fieldset class="seo-pointer">
[bbf9d1a]250 <legend><%= t('asite.seo') %></legend>
251 <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
[7bc636b]252 </fieldset>
253
254 <fieldset>
[bbf9d1a]255 <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
[7bc636b]256 <label>
[bbf9d1a]257 <span><%= t('asite.custom_css') %></span>
[7bc636b]258 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
259 </label>
260 <label>
[bbf9d1a]261 <span><%= t('asite.custom_head') %></span>
[7bc636b]262 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
263 </label>
264 <label>
[bbf9d1a]265 <span><%= t('asite.custom_foot') %></span>
[7bc636b]266 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
267 </label>
268 </fieldset>
269
[bbf9d1a]270 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
[7bc636b]271 </form>
[0ca7e9a4]272
273<% if (!isNew) { %>
274 <form method="post" action="/admin/sites/<%= site.slug %>/move" class="admin-form"
275 onsubmit="return confirm('<%= t('asite.move_confirm') %>');">
276 <fieldset>
277 <legend><%= t('asite.move') %></legend>
278 <p class="form-hint"><%= t('asite.move_hint') %></p>
279 <% if (typeof site.moved_to === 'string' && site.moved_to) { %>
280 <p class="form-hint"><strong><%= t('asite.moved_to') %>:</strong> <%= site.moved_to %></p>
281 <% } %>
282 <label>
283 <input type="text" name="move_target" placeholder="@nieuw@andere-server.example" required>
284 </label>
285 <button type="submit" class="btn btn-danger"><%= t('asite.move_btn') %></button>
286 </fieldset>
287 </form>
288<% } %>
[7bc636b]289</div>
290
291<script>
[3068cc0]292(function() {
[834bcc3]293 // Slug URL field: show the real host as a dimmed prefix (the slug itself is
294 // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
[3068cc0]295 var sh = document.getElementById('slug-host');
296 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
297})();
[7bc636b]298(function() {
299 // Profile-links repeater: add row from <template>, remove on click.
300 var rows = document.getElementById('profile-links-rows');
301 var tpl = document.getElementById('profile-link-template');
302 var add = document.getElementById('profile-link-add');
303 if (!rows || !tpl || !add) return;
304
305 add.addEventListener('click', function() {
306 var clone = tpl.content.cloneNode(true);
307 rows.appendChild(clone);
308 });
309 rows.addEventListener('click', function(e) {
310 if (e.target && e.target.classList.contains('pl-remove')) {
311 var row = e.target.closest('.profile-link-row');
312 if (row) row.remove();
313 }
314 });
315})();
316
317// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
318// then write the returned URL into the visible input. Live thumb preview.
319(function() {
320 var picker = document.getElementById('photo-picker');
321 if (!picker) return;
322 var thumb = document.getElementById('photo-thumb');
323 var preview = document.getElementById('photo-preview');
324 var urlEl = document.getElementById('photo-url');
325 var trigger = document.getElementById('photo-upload-trigger');
326 var clear = document.getElementById('photo-clear');
327 var field = document.getElementById('photo-upload-field');
328 var status = document.getElementById('photo-status');
329
330 function showPreview(url) {
331 if (url) {
332 preview.src = url;
333 preview.hidden = false;
334 thumb.removeAttribute('data-empty');
335 clear.hidden = false;
336 } else {
337 preview.src = '';
338 preview.hidden = true;
339 thumb.setAttribute('data-empty', '');
340 clear.hidden = true;
341 }
342 }
343
344 if (urlEl) {
345 urlEl.addEventListener('input', function() {
346 showPreview(urlEl.value.trim());
347 });
348 }
349
350 if (trigger && field) {
351 trigger.addEventListener('click', function() { field.click(); });
352 field.addEventListener('change', async function() {
353 var file = field.files && field.files[0];
354 if (!file) return;
355 status.classList.remove('is-error');
356 status.textContent = 'Uploaden…';
357 try {
358 var fd = new FormData();
359 fd.append('photo', file);
360 var r = await fetch('/admin/sites/upload-photo', {
361 method: 'POST',
362 body: fd,
363 credentials: 'same-origin',
364 });
365 var j = await r.json();
366 if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
367 urlEl.value = j.url;
368 showPreview(j.url);
369 status.textContent = 'Geüpload ✓';
370 setTimeout(function() { status.textContent = ''; }, 2000);
371 } catch (e) {
372 status.classList.add('is-error');
373 status.textContent = 'Mislukt: ' + e.message;
374 } finally {
375 field.value = '';
376 }
377 });
378 }
379
380 if (clear) {
381 clear.addEventListener('click', function() {
382 urlEl.value = '';
383 showPreview('');
384 status.textContent = '';
385 // Note: doesn't delete the file from disk — saving the form with empty
386 // URL leaves the file orphaned on the server. Acceptable for now.
387 });
388 }
389})();
390</script>
391
392<style>
393.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
394.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
395
[75c6919]396.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
[834bcc3]397/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
398 therefore does NOT shrink → the entire form became wider than the screen. min-width:0
399 forces it to shrink; form fields get max-width:100% + border-box so they
400 never overflow their fieldset. */
[75c6919]401.site-form fieldset { min-width: 0; }
402.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
[7bc636b]403.site-form fieldset {
404 border: 1px solid var(--rule);
405 border-radius: 8px;
406 padding: 1rem 1.25rem;
407 background: var(--paper-2);
408 display: flex;
409 flex-direction: column;
410 gap: 0.75rem;
411}
412.site-form fieldset.palette-grid {
413 display: grid;
414 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
415 background: var(--paper);
416 margin-top: 0.5rem;
417}
418/* Theme default radios — horizontal, sits between accent + palette */
419.site-form fieldset.theme-default {
420 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
421 background: var(--paper);
422 margin-top: 0.5rem;
423}
424.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
425.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
426.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
427.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
428.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
429 padding: 0.55rem 0.85rem;
430 border: 1px solid var(--rule);
431 border-radius: 5px;
432 background: var(--paper);
433 color: var(--ink);
434 font-family: inherit;
435 font-size: 0.95rem;
436}
437.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
438.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
439.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
440
[834bcc3]441/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
[3068cc0]442.slug-url {
443 display: flex; align-items: stretch;
444 border: 1px solid var(--rule); border-radius: 5px;
445 background: var(--paper); overflow: hidden;
446 transition: border-color 120ms;
447}
448.slug-url:focus-within { border-color: var(--accent); }
449.slug-url-host {
450 display: inline-flex; align-items: center;
451 padding-left: 0.85rem;
452 color: var(--ink-muted, var(--ink-soft));
453 white-space: nowrap; user-select: none;
454 font-size: 0.95rem;
455}
456.site-form .slug-url-input {
457 flex: 1 1 auto; min-width: 0;
458 border: none !important; border-radius: 0 !important;
459 background: transparent !important;
460 color: var(--accent) !important; font-weight: 700;
461 padding: 0.55rem 0.85rem 0.55rem 0.1rem;
462}
463.site-form .slug-url-input:focus { outline: none; }
464.site-form .slug-url-input[readonly] {
465 opacity: 1; cursor: default; background: transparent !important;
466}
467
[834bcc3]468/* Selects: same look as the inputs, with a custom chevron instead of the bare
469 native arrow. The open option list is still rendered by the browser. */
[974de34]470.site-form select {
471 appearance: none;
472 -webkit-appearance: none;
473 padding: 0.55rem 2.2rem 0.55rem 0.85rem;
474 border: 1px solid var(--rule);
475 border-radius: 5px;
476 background-color: var(--paper);
477 color: var(--ink);
478 font-family: inherit;
479 font-size: 0.95rem;
480 cursor: pointer;
481 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");
482 background-repeat: no-repeat;
483 background-position: right 0.7rem center;
484 transition: border-color 0.15s ease, box-shadow 0.15s ease;
485}
486.site-form select:hover { border-color: var(--ink-soft, #888); }
487.site-form select:focus,
488.site-form input[type="text"]:focus,
489.site-form textarea:focus {
490 outline: none;
491 border-color: var(--accent, #c2410c);
492 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
493}
494
[7bc636b]495/* Reuse the palette-swatch styles from account.ejs */
496.palette-swatch {
497 display: flex; flex-direction: column; gap: 0.4rem;
498 padding: 0.5rem;
499 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
500 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
501}
502.palette-swatch:hover { border-color: var(--accent); }
503.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
504.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
505.palette-preview {
506 display: grid; grid-template-columns: 1fr 1fr;
507 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
508 border: 1px solid var(--rule);
509}
510.pp-half {
511 position: relative;
512 display: flex; align-items: center; justify-content: center;
513 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
514}
515.pp-letter { line-height: 1; }
516.pp-accent {
517 position: absolute; bottom: 4px; right: 4px;
518 width: 9px; height: 9px; border-radius: 50%;
519 border: 1px solid rgba(0,0,0,0.12);
520}
521.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
522
523/* Accent picker: row of small colored swatches */
524.site-form fieldset.accent-grid {
525 display: grid;
526 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
527 gap: 0.5rem;
528 background: var(--paper);
529 margin-top: 0.5rem;
530}
531.accent-swatch {
532 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
533 padding: 0.55rem 0.4rem;
534 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
535 cursor: pointer; font-size: 0.78rem;
536 transition: border-color 120ms, box-shadow 120ms;
537}
538.accent-swatch:hover { border-color: var(--accent); }
539.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
540.accent-swatch:has(input[type="radio"]:checked) {
541 border-color: var(--accent);
542 box-shadow: 0 0 0 2px var(--accent);
543}
544.accent-dot {
545 width: 28px; height: 28px; border-radius: 50%;
546 border: 1px solid rgba(0,0,0,0.1);
547 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
548}
549.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
550
551.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
552.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
553.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
554
555.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
556.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
557
558/* P63 — Profile photo picker
559 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
560 to the left and the controls fill the remaining width. */
561.photo-picker {
562 display: flex;
563 flex-direction: column;
564 gap: .65rem;
565 align-items: stretch;
566}
567.photo-thumb {
568 position: relative;
569 width: 88px; height: 88px;
570 border-radius: 50%;
571 border: 1px solid var(--rule);
572 background: var(--paper-2);
573 overflow: hidden;
574 flex-shrink: 0;
575 display: flex; align-items: center; justify-content: center;
576}
577.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
578.photo-thumb .photo-empty {
579 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
580}
581.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
582.photo-thumb:not([data-empty]) .photo-empty { display: none; }
583.photo-thumb[data-empty] img { display: none; }
584
585.photo-controls {
586 display: flex;
587 flex-direction: column;
588 gap: .5rem;
589 flex: 1;
590 min-width: 0;
591}
592.photo-controls input[type="text"] { width: 100%; }
593.photo-actions {
594 display: flex; align-items: center; flex-wrap: wrap;
595 gap: .4rem;
596}
597.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
598.photo-status { margin-left: .25rem; }
599.photo-status.is-error { color: #c2410c; }
600
601@media (min-width: 520px) {
602 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
603 .photo-controls { gap: .55rem; }
604}
[834bcc3]605/* Mobile-first: platform selector on its own line (full width), URL input + remove
606 button below. Prevents horizontal overflow on phones (fixed 160px column +
607 non-shrinkable input together ~447px > screen width). */
[7bc636b]608.profile-link-row {
[1664f31]609 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
610}
611.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
612.profile-link-row input { min-width: 0; }
613@media (min-width: 520px) {
614 .profile-link-row { grid-template-columns: 160px 1fr auto; }
615 .profile-link-row select { grid-column: auto; }
[7bc636b]616}
617.profile-links-fieldset details > label { margin-top: 0.4rem; }
618</style>
Note: See TracBrowser for help on using the repository browser.