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

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

Alle migratie-opties bij elkaar op één pagina

Robin: ik wil alle migratie-opties op de migrate-pagina hebben, dus ook
fediverse alias en move.

Ze stonden op de site-bewerkpagina, tussen de kleuren, de feedinstellingen en
de custom head-html. Dat is de verkeerde plek in twee opzichten: het zijn stap
1 en de laatste stap van een verhuizing, en dat scherm gaat over hoe je site
eruitziet. Een verhuizing is geen uiterlijk.

De pagina vertelt nu het verhaal in de goede volgorde:

1 je oude account claimen (alsoKnownAs)

meenemen (zip)
ophalen bij je oude Klonkt
hierheen halen (zip)

4 de verhuizing aankondigen (Move)

Claimen bovenaan omdat de bron zonder die claim niets afgeeft en de Move
weigert met no_backreference. Aankondigen onderaan met een rode knop: het is de
enige stap op deze pagina die niet terug te draaien is.

De verhuisknop post naar de BESTAANDE /admin/sites/:slug/move. Een tweede
implementatie van een onomkeerbare actie is precies wat je niet wilt; die route
kreeg alleen een next mee zodat je terugkomt waar je vandaan kwam.

EEN VAL DIE IK ONDERWEG MAAKTE. De opslagroute van het site-bewerkscherm
schreef ap_aliases uit het formulier. Haal je dat veld weg, dan komt er niets
binnen, en dan wist een gewone opslag je claim op je oude account. Je verandert
je accentkleur en drie stappen later weigert de Move met no_backreference,
zonder dat iets die twee met elkaar verbindt. Nu geldt: veld niet meegestuurd
is niet aanraken.

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

  • POST /admin/migrate/aliases, met de bestaande parseApAliases
  • aliassen en moved_to in de locals van alle drie de renders

src/routes/admin-sites.js

  • parseApAliases geexporteerd in plaats van gekopieerd
  • de save raakt ap_aliases alleen aan als het veld er echt is
  • /move mag terugsturen naar een eigen pad (geen open redirect)

src/views/pages/admin-migrate.ejs

  • alias bovenaan, verhuizen onderaan met een gewaarschuwde rode knop

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

  • beide blokken eruit, een wegwijzer ervoor in de plaats

src/services/i18n.js

  • mig.alias_* en mig.move_* plus asite.moved_to_migrate, in nl/en/de

New file:
test/migrate-page.test.js

  • de velden staan op de goede pagina en niet meer op de oude
  • de volgorde: claimen boven ophalen, aankondigen onderaan
  • en een alias overleeft het opslaan van je uiterlijk

remarks: gedraaid op een echte instantie. Alias opslaan werkt, een onzin-adres
geeft een nette fout EN laat de bestaande alias staan, en de pagina rendert
schoon in nl/en/de. Suite 1005 groen.

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

  • Property mode set to 100644
File size: 21.0 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 <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met
182 exporteren, importeren en ophalen (Robin: alle migratie-opties op de
183 migrate-pagina). Ze hoorden hier ook niet thuis: dit scherm gaat over hoe
184 je site eruitziet, en een verhuizing is geen uiterlijk. Alleen de
185 wegwijzer blijft, want wie ze hier zocht moet ze kunnen vinden. %>
186 <fieldset>
187 <legend><%= t('asite.aliases') %></legend>
188 <p class="form-hint"><%= t('asite.moved_to_migrate') %></p>
189 <p><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a></p>
190 </fieldset>
191
192 <fieldset>
193 <legend><%= t('asite.feed_view') %></legend>
194 <label>
195 <span><%= t('asite.feed_default') %></span>
196 <select name="feed_view_default">
197 <option value="timeline" <%= (site.feed_view_default || 'timeline') === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_timeline') %></option>
198 <option value="grid" <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
199 </select>
200 </label>
201 <label class="cb">
202 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
203 <span><%= t('asite.feed_switch') %></span>
204 </label>
205 <label class="cb">
206 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
207 <span><%= t('asite.show_search') %></span>
208 </label>
209 <label class="cb">
210 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
211 <span><%= t('asite.show_archive') %></span>
212 </label>
213 </fieldset>
214
215 <fieldset class="seo-pointer">
216 <legend><%= t('asite.seo') %></legend>
217 <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
218 </fieldset>
219
220 <fieldset>
221 <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
222 <label>
223 <span><%= t('asite.custom_css') %></span>
224 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
225 </label>
226 <label>
227 <span><%= t('asite.custom_head') %></span>
228 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
229 </label>
230 <label>
231 <span><%= t('asite.custom_foot') %></span>
232 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
233 </label>
234 </fieldset>
235
236 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
237 </form>
238
239</div>
240
241
242
243<style>
244.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
245.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
246
247.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
248/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
249 therefore does NOT shrink → the entire form became wider than the screen. min-width:0
250 forces it to shrink; form fields get max-width:100% + border-box so they
251 never overflow their fieldset. */
252.site-form fieldset { min-width: 0; }
253.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
254.site-form fieldset {
255 border: 1px solid var(--rule);
256 border-radius: 8px;
257 padding: 1rem 1.25rem;
258 background: var(--paper-2);
259 display: flex;
260 flex-direction: column;
261 gap: 0.75rem;
262}
263.site-form fieldset.palette-grid {
264 display: grid;
265 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
266 background: var(--paper);
267 margin-top: 0.5rem;
268}
269/* Theme default radios — horizontal, sits between accent + palette */
270.site-form fieldset.theme-default {
271 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
272 background: var(--paper);
273 margin-top: 0.5rem;
274}
275.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
276.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
277.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
278.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
279.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
280 padding: 0.55rem 0.85rem;
281 border: 1px solid var(--rule);
282 border-radius: 5px;
283 background: var(--paper);
284 color: var(--ink);
285 font-family: inherit;
286 font-size: 0.95rem;
287}
288.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
289.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
290.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
291
292/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
293.slug-url {
294 display: flex; align-items: stretch;
295 border: 1px solid var(--rule); border-radius: 5px;
296 background: var(--paper); overflow: hidden;
297 transition: border-color 120ms;
298}
299.slug-url:focus-within { border-color: var(--accent); }
300.slug-url-host {
301 display: inline-flex; align-items: center;
302 padding-left: 0.85rem;
303 color: var(--ink-muted, var(--ink-soft));
304 white-space: nowrap; user-select: none;
305 font-size: 0.95rem;
306}
307.site-form .slug-url-input {
308 flex: 1 1 auto; min-width: 0;
309 border: none !important; border-radius: 0 !important;
310 background: transparent !important;
311 color: var(--accent) !important; font-weight: 700;
312 padding: 0.55rem 0.85rem 0.55rem 0.1rem;
313}
314.site-form .slug-url-input:focus { outline: none; }
315.site-form .slug-url-input[readonly] {
316 opacity: 1; cursor: default; background: transparent !important;
317}
318
319/* Selects: same look as the inputs, with a custom chevron instead of the bare
320 native arrow. The open option list is still rendered by the browser. */
321.site-form select {
322 appearance: none;
323 -webkit-appearance: none;
324 padding: 0.55rem 2.2rem 0.55rem 0.85rem;
325 border: 1px solid var(--rule);
326 border-radius: 5px;
327 background-color: var(--paper);
328 color: var(--ink);
329 font-family: inherit;
330 font-size: 0.95rem;
331 cursor: pointer;
332 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");
333 background-repeat: no-repeat;
334 background-position: right 0.7rem center;
335 transition: border-color 0.15s ease, box-shadow 0.15s ease;
336}
337.site-form select:hover { border-color: var(--ink-soft, #888); }
338.site-form select:focus,
339.site-form input[type="text"]:focus,
340.site-form textarea:focus {
341 outline: none;
342 border-color: var(--accent, #c2410c);
343 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
344}
345
346/* Reuse the palette-swatch styles from account.ejs */
347.palette-swatch {
348 display: flex; flex-direction: column; gap: 0.4rem;
349 padding: 0.5rem;
350 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
351 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
352}
353.palette-swatch:hover { border-color: var(--accent); }
354.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
355.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
356.palette-preview {
357 display: grid; grid-template-columns: 1fr 1fr;
358 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
359 border: 1px solid var(--rule);
360}
361.pp-half {
362 position: relative;
363 display: flex; align-items: center; justify-content: center;
364 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
365}
366.pp-letter { line-height: 1; }
367.pp-accent {
368 position: absolute; bottom: 4px; right: 4px;
369 width: 9px; height: 9px; border-radius: 50%;
370 border: 1px solid rgba(0,0,0,0.12);
371}
372.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
373
374/* Accent picker: row of small colored swatches */
375.site-form fieldset.accent-grid {
376 display: grid;
377 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
378 gap: 0.5rem;
379 background: var(--paper);
380 margin-top: 0.5rem;
381}
382.accent-swatch {
383 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
384 padding: 0.55rem 0.4rem;
385 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
386 cursor: pointer; font-size: 0.78rem;
387 transition: border-color 120ms, box-shadow 120ms;
388}
389.accent-swatch:hover { border-color: var(--accent); }
390.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
391.accent-swatch:has(input[type="radio"]:checked) {
392 border-color: var(--accent);
393 box-shadow: 0 0 0 2px var(--accent);
394}
395.accent-dot {
396 width: 28px; height: 28px; border-radius: 50%;
397 border: 1px solid rgba(0,0,0,0.1);
398 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
399}
400.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
401
402.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
403.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
404.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
405
406.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
407.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
408
409/* P63 — Profile photo picker
410 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
411 to the left and the controls fill the remaining width. */
412.photo-picker {
413 display: flex;
414 flex-direction: column;
415 gap: .65rem;
416 align-items: stretch;
417}
418.photo-thumb {
419 position: relative;
420 width: 88px; height: 88px;
421 border-radius: 50%;
422 border: 1px solid var(--rule);
423 background: var(--paper-2);
424 overflow: hidden;
425 flex-shrink: 0;
426 display: flex; align-items: center; justify-content: center;
427}
428.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
429.photo-thumb .photo-empty {
430 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
431}
432.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
433.photo-thumb:not([data-empty]) .photo-empty { display: none; }
434.photo-thumb[data-empty] img { display: none; }
435
436.photo-controls {
437 display: flex;
438 flex-direction: column;
439 gap: .5rem;
440 flex: 1;
441 min-width: 0;
442}
443.photo-controls input[type="text"] { width: 100%; }
444.photo-actions {
445 display: flex; align-items: center; flex-wrap: wrap;
446 gap: .4rem;
447}
448.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
449.photo-status { margin-left: .25rem; }
450.photo-status.is-error { color: #c2410c; }
451
452@media (min-width: 520px) {
453 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
454 .photo-controls { gap: .55rem; }
455}
456/* Mobile-first: platform selector on its own line (full width), URL input + remove
457 button below. Prevents horizontal overflow on phones (fixed 160px column +
458 non-shrinkable input together ~447px > screen width). */
459.profile-link-row {
460 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
461}
462.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
463.profile-link-row input { min-width: 0; }
464@media (min-width: 520px) {
465 .profile-link-row { grid-template-columns: 160px 1fr auto; }
466 .profile-link-row select { grid-column: auto; }
467}
468.profile-links-fieldset details > label { margin-top: 0.4rem; }
469</style>
Note: See TracBrowser for help on using the repository browser.