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

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

Account-verhuizingen: inkomende Move plus alsoKnownAs-aliassen (FEP-7628)

De ontvangende helft van accountverhuizingen, in twee delen die samen een
verhuizing NAAR Klonkt mogelijk maken (shaer-0j2, slice 1).

Deel 1: een account dat onze sites volgen verhuist. Op een geldige Move volgt
elke lokale volger automatisch mee naar het nieuwe adres, met behoud van de
eigen auto-boost-keuze. Geldigheid heeft twee onafhankelijke benen en beide
zijn verplicht: de ondertekenaar is zelf partij in de verhuizing (oude actor
in push-modus of nieuwe actor in pull-modus), en de nieuwe actor claimt de
oude identiteit in alsoKnownAs. Zonder het eerste kan elke ondertekende
vreemde onze follows ompointen; zonder het tweede kan wie een kant beheerst
de volgers van de andere kant kapen. Herbezorging is idempotent, een
geblokkeerde bestemming wordt geweigerd, en guardianship-relaties worden
bewust NIET geretarget: een guardian is een beveiligingsanker en verhuist
pas onder de regels van shaer-tge.

Deel 2: eigen aliassen instellen via Beheer, Sites, site bewerken. Een regel
per oud account, als @naam@server of als actor-URL. Handles resolven via
WebFinger op het moment van opslaan, zodat een typfout direct zichtbaar is
in plaats van pas bij een mislukkende Move op de oude server. De aliassen
verschijnen als alsoKnownAs op de publieke actor; dat is de claim die de
oude server controleert voor hij volgers hierheen verhuist.

Let op: FEP-7628 heeft status DRAFT. De vorm is sinds 2019 de facto
Mastodon-standaard, maar de spec kan nog wijzigen.

Changed files:
src/services/ActivityPubService.js

  • Move toegevoegd aan de GATED-lijst (handtekening verplicht)
  • dispatch-blok voor Move, voor de Follow-afhandeling (geen race met de re-Follow-golf van de nieuwe actor)
  • handleMoveInbox met injecteerbare afhankelijkheden voor de tests
  • alsoKnownAs en movedTo als JSON-LD-termen in de context
  • buildActor publiceert alsoKnownAs uit sites.ap_aliases, eigen id en niet-URLs gefilterd

src/config/database.js

  • kolom sites.ap_aliases (JSON-array van actor-URIs)

src/routes/admin-sites.js

  • parseApAliases: max 5, dedupe, WebFinger-resolutie bij opslaan, fout met de betreffende regel terug naar het formulier
  • save-route asynchroon; ap_aliases in de UPDATE
  • actor-Update naar volgers wanneer de lijst wijzigt (best-effort)

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

  • veld Fediverse-aliassen onder de profiel-links

src/services/i18n.js

  • asite.aliases en asite.aliases_hint in nl, en en de

New file:
test/move-actor.test.js

  • 11 tests: derde-partij-weigering, ontbrekend aliasbewijs, push- en pull-modus, idempotentie, al-volgend overslaan, geblokkeerd doel, misvormde activiteiten, ongesigneerd, actor-publicatie met filtering

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

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