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

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

Het paneel hoort bij wie je bent, niet bij je bestanden (Robins correctie)

Verhuisd van Beheer -> Audio naar het profielscherm van de site-eigenaar, en
daar staat het NAAST de aliassen -- want dat is dezelfde vraag. Een alias zegt
wie je elders in de fediverse bent, een MBID zegt wie je in het muziekregister
bent. Bij de audiobestanden ging het over beheer van spullen; hier gaat het over
identiteit, en daar hoort het thuis.

DE VERHUIZING VERANDERDE DE VORM, en dat is de moeite waard om te weten. Het
profielscherm IS een formulier, en een formulier in een formulier bestaat niet
in HTML. Dus geen eigen opslaan-knop meer: de keuze zet een verborgen veld en
gaat mee met de Opslaan van de pagina, precies zoals de profielfoto. Dat leest
ook beter -- een halve wijziging die je nog kunt herzien voor je opslaat.

Ontkoppelen is nu het veld leegmaken, geen aparte route. Minder oppervlak.

Het zoek-endpoint ging mee naar /admin/sites/:slug/api/musicbrainz. Een endpoint
onder /admin/audio laten staan voor een scherm in /admin/sites is precies zo'n
naad die later niemand meer kan plaatsen. De teksten heten nu asite.mb_* in
plaats van aaud.mb_*, in alle drie de talen, met een placeholder erbij.

Wat NIET veranderde: de zoekopdracht draait server-side (hun regel geldt per
applicatie), wij kiezen niet voor de artiest, en alleen een echte MBID komt de
kolom in.

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

  • Property mode set to 100644
File size: 23.6 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 <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
129
130 <fieldset>
131 <legend><%= t('asite.behavior') %></legend>
132 <label class="cb">
133 <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
134 <span><%= t('asite.is_public') %></span>
135 </label>
136 <label class="cb">
137 <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
138 <span><%= t('asite.robots_index') %></span>
139 </label>
140 <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
141 <label class="cb">
142 <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
143 <span><%= t('asite.enable_audio') %></span>
144 </label>
145 <% } %>
146 </fieldset>
147
148 <fieldset class="profile-links-fieldset">
149 <legend><%= t('asite.links') %></legend>
150 <p class="form-hint"><%= t('asite.links_hint') %></p>
151
152 <div id="profile-links-rows">
153 <% (parsedLinks || []).forEach(function(l) { %>
154 <div class="profile-link-row">
155 <select name="profile_link_platform[]">
156 <% platforms.forEach(function(p) { %>
157 <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
158 <% }); %>
159 </select>
160 <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required>
161 <button type="button" class="btn btn-danger pl-remove">×</button>
162 </div>
163 <% }); %>
164 </div>
165
166 <template id="profile-link-template">
167 <div class="profile-link-row">
168 <select name="profile_link_platform[]">
169 <% platforms.forEach(function(p) { %>
170 <option value="<%= p.key %>"><%= p.label %></option>
171 <% }); %>
172 </select>
173 <input type="text" name="profile_link_url[]" placeholder="https://…" required>
174 <button type="button" class="btn btn-danger pl-remove">×</button>
175 </div>
176 </template>
177
178 <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
179 </fieldset>
180
181 <fieldset>
182 <legend><%= t('asite.aliases') %></legend>
183 <p class="form-hint"><%= t('asite.aliases_hint') %></p>
184 <label>
185<%# typeof-guard on purpose: during a deploy an old route can render this new
186 template for one request (view cache fills on first hit). A missing local
187 must degrade to an empty field, not take the whole page down with a 500. %>
188 <textarea name="ap_aliases" rows="3" placeholder="@oud@mastodon.social&#10;https://andere-klonkt.example/ap/users/naam"><%= typeof apAliases !== 'undefined' ? apAliases : '' %></textarea>
189 </label>
190 </fieldset>
191
192 <%# ── MUSICBRAINZ ─────────────────────────────────────────────
193 Naast de aliassen, want het is dezelfde vraag: wie ben jij elders?
194 Een MBID is geen vocabulaire maar een register -- eraan koppelen is als
195 je ISBN noemen, en muziekdiensten herkennen je eraan zonder dat wij hun
196 model hoeven over te nemen (shaer-mbz).
197
198 GEEN EIGEN FORMULIER: deze pagina IS er een, en een formulier in een
199 formulier bestaat niet in HTML. De keuze zet een verborgen veld en gaat
200 mee met de Opslaan-knop van de pagina, net als de profielfoto. %>
201 <fieldset id="mb-veld">
202 <legend><%= t('asite.mb_title') %></legend>
203 <p class="form-hint"><%= t('asite.mb_hint') %></p>
204
205 <input type="hidden" name="mb_artist_id" id="mb-id" value="<%= site.mb_artist_id || '' %>">
206 <input type="hidden" name="mb_artist_name" id="mb-naam" value="<%= site.mb_artist_name || '' %>">
207
208 <p class="mb-huidig" id="mb-huidig" <%= site.mb_artist_id ? '' : 'hidden' %>>
209 <%= t('asite.mb_linked') %>
210 <strong id="mb-huidig-naam"><%= site.mb_artist_name || site.mb_artist_id || '' %></strong>
211 <a id="mb-huidig-link" target="_blank" rel="noopener"
212 href="https://musicbrainz.org/artist/<%= site.mb_artist_id || '' %>"><%= t('asite.mb_open') %></a>
213 <button type="button" class="btn" id="mb-wis"><%= t('asite.mb_unlink') %></button>
214 </p>
215
216 <div class="mb-zoek">
217 <input type="text" id="mb-q" value="<%= site.title || '' %>" autocomplete="off"
218 placeholder="<%= t('asite.mb_placeholder') %>">
219 <button type="button" class="btn" id="mb-zoek-btn"><%= t('asite.mb_search') %></button>
220 </div>
221 <div id="mb-uit" class="mb-uit" hidden></div>
222 </fieldset>
223
224 <fieldset>
225 <legend><%= t('asite.feed_view') %></legend>
226 <label>
227 <span><%= t('asite.feed_default') %></span>
228 <select name="feed_view_default">
229 <option value="timeline" <%= (site.feed_view_default || 'timeline') === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_timeline') %></option>
230 <option value="grid" <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
231 </select>
232 </label>
233 <label class="cb">
234 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
235 <span><%= t('asite.feed_switch') %></span>
236 </label>
237 <label class="cb">
238 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
239 <span><%= t('asite.show_search') %></span>
240 </label>
241 <label class="cb">
242 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
243 <span><%= t('asite.show_archive') %></span>
244 </label>
245 </fieldset>
246
247 <fieldset class="seo-pointer">
248 <legend><%= t('asite.seo') %></legend>
249 <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
250 </fieldset>
251
252 <fieldset>
253 <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
254 <label>
255 <span><%= t('asite.custom_css') %></span>
256 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
257 </label>
258 <label>
259 <span><%= t('asite.custom_head') %></span>
260 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
261 </label>
262 <label>
263 <span><%= t('asite.custom_foot') %></span>
264 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
265 </label>
266 </fieldset>
267
268 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
269 </form>
270
271<% if (!isNew) { %>
272 <form method="post" action="/admin/sites/<%= site.slug %>/move" class="admin-form"
273 onsubmit="return confirm('<%= t('asite.move_confirm') %>');">
274 <fieldset>
275 <legend><%= t('asite.move') %></legend>
276 <p class="form-hint"><%= t('asite.move_hint') %></p>
277 <% if (typeof site.moved_to === 'string' && site.moved_to) { %>
278 <p class="form-hint"><strong><%= t('asite.moved_to') %>:</strong> <%= site.moved_to %></p>
279 <% } %>
280 <label>
281 <input type="text" name="move_target" placeholder="@nieuw@andere-server.example" required>
282 </label>
283 <button type="submit" class="btn btn-danger"><%= t('asite.move_btn') %></button>
284 </fieldset>
285 </form>
286<% } %>
287</div>
288
289
290
291<style>
292.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
293.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
294
295.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
296/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
297 therefore does NOT shrink → the entire form became wider than the screen. min-width:0
298 forces it to shrink; form fields get max-width:100% + border-box so they
299 never overflow their fieldset. */
300.site-form fieldset { min-width: 0; }
301.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
302.site-form fieldset {
303 border: 1px solid var(--rule);
304 border-radius: 8px;
305 padding: 1rem 1.25rem;
306 background: var(--paper-2);
307 display: flex;
308 flex-direction: column;
309 gap: 0.75rem;
310}
311.site-form fieldset.palette-grid {
312 display: grid;
313 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
314 background: var(--paper);
315 margin-top: 0.5rem;
316}
317/* Theme default radios — horizontal, sits between accent + palette */
318.site-form fieldset.theme-default {
319 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
320 background: var(--paper);
321 margin-top: 0.5rem;
322}
323.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
324.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
325.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
326.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
327.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
328 padding: 0.55rem 0.85rem;
329 border: 1px solid var(--rule);
330 border-radius: 5px;
331 background: var(--paper);
332 color: var(--ink);
333 font-family: inherit;
334 font-size: 0.95rem;
335}
336.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
337.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
338.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
339
340/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
341.slug-url {
342 display: flex; align-items: stretch;
343 border: 1px solid var(--rule); border-radius: 5px;
344 background: var(--paper); overflow: hidden;
345 transition: border-color 120ms;
346}
347.slug-url:focus-within { border-color: var(--accent); }
348.slug-url-host {
349 display: inline-flex; align-items: center;
350 padding-left: 0.85rem;
351 color: var(--ink-muted, var(--ink-soft));
352 white-space: nowrap; user-select: none;
353 font-size: 0.95rem;
354}
355.site-form .slug-url-input {
356 flex: 1 1 auto; min-width: 0;
357 border: none !important; border-radius: 0 !important;
358 background: transparent !important;
359 color: var(--accent) !important; font-weight: 700;
360 padding: 0.55rem 0.85rem 0.55rem 0.1rem;
361}
362.site-form .slug-url-input:focus { outline: none; }
363.site-form .slug-url-input[readonly] {
364 opacity: 1; cursor: default; background: transparent !important;
365}
366
367/* Selects: same look as the inputs, with a custom chevron instead of the bare
368 native arrow. The open option list is still rendered by the browser. */
369.site-form select {
370 appearance: none;
371 -webkit-appearance: none;
372 padding: 0.55rem 2.2rem 0.55rem 0.85rem;
373 border: 1px solid var(--rule);
374 border-radius: 5px;
375 background-color: var(--paper);
376 color: var(--ink);
377 font-family: inherit;
378 font-size: 0.95rem;
379 cursor: pointer;
380 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");
381 background-repeat: no-repeat;
382 background-position: right 0.7rem center;
383 transition: border-color 0.15s ease, box-shadow 0.15s ease;
384}
385.site-form select:hover { border-color: var(--ink-soft, #888); }
386.site-form select:focus,
387.site-form input[type="text"]:focus,
388.site-form textarea:focus {
389 outline: none;
390 border-color: var(--accent, #c2410c);
391 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
392}
393
394/* Reuse the palette-swatch styles from account.ejs */
395.palette-swatch {
396 display: flex; flex-direction: column; gap: 0.4rem;
397 padding: 0.5rem;
398 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
399 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
400}
401.palette-swatch:hover { border-color: var(--accent); }
402.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
403.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
404.palette-preview {
405 display: grid; grid-template-columns: 1fr 1fr;
406 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
407 border: 1px solid var(--rule);
408}
409.pp-half {
410 position: relative;
411 display: flex; align-items: center; justify-content: center;
412 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
413}
414.pp-letter { line-height: 1; }
415.pp-accent {
416 position: absolute; bottom: 4px; right: 4px;
417 width: 9px; height: 9px; border-radius: 50%;
418 border: 1px solid rgba(0,0,0,0.12);
419}
420.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
421
422/* Accent picker: row of small colored swatches */
423.site-form fieldset.accent-grid {
424 display: grid;
425 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
426 gap: 0.5rem;
427 background: var(--paper);
428 margin-top: 0.5rem;
429}
430.accent-swatch {
431 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
432 padding: 0.55rem 0.4rem;
433 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
434 cursor: pointer; font-size: 0.78rem;
435 transition: border-color 120ms, box-shadow 120ms;
436}
437.accent-swatch:hover { border-color: var(--accent); }
438.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
439.accent-swatch:has(input[type="radio"]:checked) {
440 border-color: var(--accent);
441 box-shadow: 0 0 0 2px var(--accent);
442}
443.accent-dot {
444 width: 28px; height: 28px; border-radius: 50%;
445 border: 1px solid rgba(0,0,0,0.1);
446 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
447}
448.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
449
450.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
451.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
452.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
453
454.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
455.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
456
457/* P63 — Profile photo picker
458 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
459 to the left and the controls fill the remaining width. */
460.photo-picker {
461 display: flex;
462 flex-direction: column;
463 gap: .65rem;
464 align-items: stretch;
465}
466.photo-thumb {
467 position: relative;
468 width: 88px; height: 88px;
469 border-radius: 50%;
470 border: 1px solid var(--rule);
471 background: var(--paper-2);
472 overflow: hidden;
473 flex-shrink: 0;
474 display: flex; align-items: center; justify-content: center;
475}
476.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
477.photo-thumb .photo-empty {
478 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
479}
480.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
481.photo-thumb:not([data-empty]) .photo-empty { display: none; }
482.photo-thumb[data-empty] img { display: none; }
483
484.photo-controls {
485 display: flex;
486 flex-direction: column;
487 gap: .5rem;
488 flex: 1;
489 min-width: 0;
490}
491.photo-controls input[type="text"] { width: 100%; }
492.photo-actions {
493 display: flex; align-items: center; flex-wrap: wrap;
494 gap: .4rem;
495}
496.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
497.photo-status { margin-left: .25rem; }
498.photo-status.is-error { color: #c2410c; }
499
500@media (min-width: 520px) {
501 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
502 .photo-controls { gap: .55rem; }
503}
504/* Mobile-first: platform selector on its own line (full width), URL input + remove
505 button below. Prevents horizontal overflow on phones (fixed 160px column +
506 non-shrinkable input together ~447px > screen width). */
507.profile-link-row {
508 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
509}
510.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
511.profile-link-row input { min-width: 0; }
512@media (min-width: 520px) {
513 .profile-link-row { grid-template-columns: 160px 1fr auto; }
514 .profile-link-row select { grid-column: auto; }
515}
516.profile-links-fieldset details > label { margin-top: 0.4rem; }
517</style>
Note: See TracBrowser for help on using the repository browser.