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

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

Hub-modus en guardian-lite eruit

Robins besluit (31-7): een instance is een eigenaar. Twee dingen weg.

HUB-MODUS was al dood: getTenancy() gaf sinds 24-6 hardcoded 'solo'
terug, dus elke tenancy === 'hub'-tak was onbereikbaar. Nu ook echt
verwijderd: getTenancy/setTenancy zelf, de /user/:slug-routing in
resolveSite, de hub-takken in admin, zoeken, audio, posts (neighbours
en related over alle sites), download, en de push-prefix. In de views
verdwijnen de hub-tagline, de hub-navigatie, de sites- en
users-tabellen (die kwamen alleen in hub-modus gevuld en verwezen nu
naar locals die niemand meer meegeeft), de eigenaar-toewijzing bij een
site, de /user/-slugprefix, het hub-brandblok en de hub-thuisknop.

GUARDIAN-LITE was de laatste multi-user-rest: /guardian/invite gaf een
link waarmee iemand via /guardian/join een echte user plus een site met
guardian_only=1 aanmaakte. Dat zette andermans wachtwoordhash, sessie
en PRIVATE actor-sleutel in jouw database, waardoor een verhuizing of
export nooit netjes kon (shaer-qw6q). Routes, formulier, kolom en
uitnodigingstabel zijn weg. Het guardian-DASHBOARD blijft: dat is
FEP-633c en werkt voor guardians met een eigen Klonkt. Bestaande
installaties houden kolom en tabel ongebruikt; nieuwe krijgen ze niet.

Changed files:
src/services/SettingsService.js

  • getTenancy/setTenancy verwijderd; kop herschreven

src/middleware/site.js, src/middleware/render.js

  • /user/:slug-routing weg; tenancy en hubTitle uit de locals

src/routes/admin.js, admin-settings.js, audio.js, download.js,
src/routes/posts.js, search.js

  • hub-takken en hub-queries weg; postNeighbors zonder isHub

src/services/ActivityPubService.js

  • pushPrefix is nu gewoon ; getTenancy-import weg

src/routes/guardian.js

  • /invite en /join verwijderd (dashboard blijft), imports opgeschoond

src/config/database.js

  • guardian_only-kolom en ap_guardian_invites-tabel niet meer aangemaakt

src/views/pages/admin.ejs, admin-users.ejs, admin-site-edit.ejs,
src/views/pages/guardian.ejs, partials/topnav.ejs, chrome.ejs, bottom-tab.ejs

  • alle hub-takken en de uitnodigingsknop weg

remarks: 320 regels weg, 63 erbij. Suite 372 groen; alle 85 templates
compileren; en met een wegwerp-kopie van de database daadwerkelijk
gedraaid en ingelogd: /, /admin, /admin/users, /admin/sites,
/admin/settings, /admin/sites/demo/edit, /admin/media en /guardian
geven alle 200 zonder fouten in het log, en /guardian/invite en
/guardian/join geven nu 404.

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

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