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

Last change on this file since 495b019 was 495b019, checked in by Bart <bart@…>, 2 days ago

Lay-outthema's: een pakket, de beheerder kiest, en de zijbalk als eerste

Een lay-out is nu een pakket: een map in src/themes/ met een theme.json (id,
naam, versie, contract, stylesheet) en de stylesheet zelf. De zijbalk is het
eerste ingebouwde thema; een thema van buiten installeren komt later en gaat
door dezelfde lader, zodat er één vorm is.

Dezelfde markup, een ander stelsel: een thema verandert geen template. Het
contract (1) is de afspraak over de ids en klassen waar het op mag bouwen; een
thema op een ander contract valt terug op de gewone kop in plaats van half te
werken. Elke regel staat onder html[data-layout="<id>"], en een toets bewaakt
dat, zodat een thema niets raakt zolang het niet gekozen is.

WIE KIEST: de beheerder, in het sitebeheer (sites.layout). Een bezoeker niet
-- de server zet data-layout zelf, het bezoekersscript met localStorage is
weg, en ?layout= werkt alleen als voorproefje voor wie de site beheert. Het
beheer houdt altijd de gewone kop, ook bij navigeren via htmx (de lay-out reist
mee in pcmsNav): een kapot thema mag je nooit buitensluiten van de instelling
waarmee je het uitzet.

Geserveerd op /themes/<id>/<versie>/<bestand>, met de versie in het pad als
cache-buster; alleen wat het pakket zelf noemt komt eruit. De actor noemt het
gekozen thema als shaer:theme, zodat Shaer het van zijn eigen site kan halen
in plaats van er zelf een te kiezen.

Co-Authored-By: Claude Opus 5.5 <claude@…>

  • Property mode set to 100644
File size: 22.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 <%# ── De lay-out (experimental/theme) ──────────────────────────────
106 Een thema uit src/themes/. Alleen de beheerder kiest; een bezoeker
107 niet. Het beheer zelf houdt altijd de gewone kop, zodat een kapot
108 thema je nooit buitensluit van deze instelling. %>
109 <% if (typeof layouts !== 'undefined' && layouts && layouts.length) { %>
110 <fieldset class="layout-choice">
111 <legend><%= t('asite.layout') %></legend>
112 <% const lc = site.layout || ''; %>
113 <label class="cb">
114 <input type="radio" name="layout" value="" <%= lc === '' ? 'checked' : '' %>>
115 <span><%= t('asite.layout_top') %></span>
116 </label>
117 <% layouts.forEach(function (l) { %>
118 <label class="cb">
119 <input type="radio" name="layout" value="<%= l.id %>" <%= lc === l.id ? 'checked' : '' %>>
120 <span><%= l.name %></span>
121 <% if (site.slug) { %><a class="layout-preview" href="/?layout=<%= encodeURIComponent(l.id) %>" target="_blank" rel="noopener">voorproefje</a><% } %>
122 </label>
123 <% }); %>
124 <p class="hint"><%= t('asite.layout_hint') %></p>
125 </fieldset>
126 <% } %>
127
128 <fieldset class="palette-grid">
129 <legend><%= t('asite.palette') %></legend>
130 <% palettes.forEach(function(p) { %>
131 <label class="palette-swatch">
132 <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'klonkt') === p.key ? 'checked' : '' %>>
133 <span class="palette-preview" aria-hidden="true">
134 <span class="pp-half pp-light" style="background: <%= p.light.paper %>; color: <%= p.light.ink %>;">
135 <span class="pp-letter">Aa</span>
136 <span class="pp-accent" style="background: <%= p.light.accent %>;"></span>
137 </span>
138 <span class="pp-half pp-dark" style="background: <%= p.dark.paper %>; color: <%= p.dark.ink %>;">
139 <span class="pp-letter">Aa</span>
140 <span class="pp-accent" style="background: <%= p.dark.accent %>;"></span>
141 </span>
142 </span>
143 <span class="palette-swatch-name"><%= p.name %></span>
144 </label>
145 <% }); %>
146 </fieldset>
147 </fieldset>
148
149 <%# Live preview: selecting an accent / theme / palette re-themes this page
150 instantly (reverts on reload; persists on save). %>
151 <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
152
153 <fieldset>
154 <legend><%= t('asite.behavior') %></legend>
155 <label class="cb">
156 <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
157 <span><%= t('asite.is_public') %></span>
158 </label>
159 <label class="cb">
160 <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
161 <span><%= t('asite.robots_index') %></span>
162 </label>
163 <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
164 <label class="cb">
165 <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
166 <span><%= t('asite.enable_audio') %></span>
167 </label>
168 <% } %>
169 <label class="cb">
170 <input type="checkbox" name="approve_followers" value="1" <%= site.approve_followers ? 'checked' : '' %>>
171 <span><%= t('asite.approve_followers') %></span>
172 </label>
173 </fieldset>
174
175 <%# De profiellinks staan sinds 30-9 bij je profiel, naast je naam, bio en foto:
176 dat is waar ze bij horen, en in de wardmodus is dat profiel het enige dat
177 een bezoeker ziet. %>
178 <p class="form-hint profile-links-moved"><%= t('asite.links_moved') %> <a href="/account#links"><%= t('asite.links_moved_link') %></a></p>
179
180 <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met
181 exporteren, importeren en ophalen (Robin: alle migratie-opties op de
182 migrate-pagina). Ze hoorden hier ook niet thuis: dit scherm gaat over hoe
183 je site eruitziet, en een verhuizing is geen uiterlijk. Alleen de
184 wegwijzer blijft, want wie ze hier zocht moet ze kunnen vinden. %>
185 <fieldset>
186 <legend><%= t('asite.aliases') %></legend>
187 <p class="form-hint"><%= t('asite.moved_to_migrate') %></p>
188 <p><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a></p>
189 </fieldset>
190
191 <fieldset>
192 <legend><%= t('asite.feed_view') %></legend>
193 <%# Twee vragen, en ze zijn niet hetzelfde: WELKE tweede weergave je site
194 aanbiedt naast Grid, en waarin hij opent. %>
195 <label>
196 <span><%= t('asite.feed_alt') %></span>
197 <select name="feed_alt_view">
198 <% var _a = ['timeline','auto'].indexOf(site.feed_alt_view) >= 0 ? site.feed_alt_view : 'reader'; %>
199 <option value="reader" <%= _a === 'reader' ? 'selected' : '' %>><%= t('asite.feed_alt_reader') %></option>
200 <option value="timeline" <%= _a === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_alt_timeline') %></option>
201 <option value="auto" <%= _a === 'auto' ? 'selected' : '' %>><%= t('asite.feed_alt_auto') %></option>
202 </select>
203 </label>
204 <label>
205 <span><%= t('asite.feed_default') %></span>
206 <select name="feed_view_default">
207 <option value="reader" <%= site.feed_view_default !== 'grid' ? 'selected' : '' %>><%= t('asite.feed_alt_' + _a) %></option>
208 <option value="grid" <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
209 </select>
210 </label>
211 <label class="cb">
212 <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
213 <span><%= t('asite.feed_switch') %></span>
214 </label>
215 <%# Alleen van toepassing op Lezen: geeft elk bericht daar een eigen scherm. %>
216 <label class="cb">
217 <input type="checkbox" name="reader_full_page" value="1" <%= site.reader_full_page ? 'checked' : '' %>>
218 <span><%= t('asite.reader_full_page') %></span>
219 </label>
220 <label class="cb">
221 <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
222 <span><%= t('asite.show_search') %></span>
223 </label>
224 <label class="cb">
225 <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
226 <span><%= t('asite.show_archive') %></span>
227 </label>
228 </fieldset>
229
230 <fieldset class="seo-pointer">
231 <legend><%= t('asite.seo') %></legend>
232 <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
233 </fieldset>
234
235 <fieldset>
236 <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
237 <label>
238 <span><%= t('asite.custom_css') %></span>
239 <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
240 </label>
241 <label>
242 <span><%= t('asite.custom_head') %></span>
243 <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
244 </label>
245 <label>
246 <span><%= t('asite.custom_foot') %></span>
247 <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
248 </label>
249 </fieldset>
250
251 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
252 </form>
253
254</div>
255
256
257
258<style>
259.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
260.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
261
262.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
263/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
264 therefore does NOT shrink → the entire form became wider than the screen. min-width:0
265 forces it to shrink; form fields get max-width:100% + border-box so they
266 never overflow their fieldset. */
267.site-form fieldset { min-width: 0; }
268.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
269.site-form fieldset {
270 border: 1px solid var(--rule);
271 border-radius: 8px;
272 padding: 1rem 1.25rem;
273 background: var(--paper-2);
274 display: flex;
275 flex-direction: column;
276 gap: 0.75rem;
277}
278.site-form fieldset.palette-grid {
279 display: grid;
280 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
281 background: var(--paper);
282 margin-top: 0.5rem;
283}
284/* Theme default radios — horizontal, sits between accent + palette */
285.site-form fieldset.theme-default {
286 display: flex; flex-direction: row; flex-wrap: wrap; gap: 1rem;
287 background: var(--paper);
288 margin-top: 0.5rem;
289}
290.site-form fieldset.theme-default label.cb { font-size: 0.9rem; color: var(--ink); }
291.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
292.site-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
293.site-form label.cb { flex-direction: row; align-items: center; gap: 0.5rem; }
294.site-form input[type="text"], .site-form input[type="color"], .site-form textarea {
295 padding: 0.55rem 0.85rem;
296 border: 1px solid var(--rule);
297 border-radius: 5px;
298 background: var(--paper);
299 color: var(--ink);
300 font-family: inherit;
301 font-size: 0.95rem;
302}
303.site-form input[type="color"] { padding: 0.2rem; height: 38px; }
304.site-form textarea { resize: vertical; min-height: 60px; font-family: var(--font-mono, monospace); font-size: 0.85rem; }
305.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
306
307/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
308.slug-url {
309 display: flex; align-items: stretch;
310 border: 1px solid var(--rule); border-radius: 5px;
311 background: var(--paper); overflow: hidden;
312 transition: border-color 120ms;
313}
314.slug-url:focus-within { border-color: var(--accent); }
315.slug-url-host {
316 display: inline-flex; align-items: center;
317 padding-left: 0.85rem;
318 color: var(--ink-muted, var(--ink-soft));
319 white-space: nowrap; user-select: none;
320 font-size: 0.95rem;
321}
322.site-form .slug-url-input {
323 flex: 1 1 auto; min-width: 0;
324 border: none !important; border-radius: 0 !important;
325 background: transparent !important;
326 color: var(--accent) !important; font-weight: 700;
327 padding: 0.55rem 0.85rem 0.55rem 0.1rem;
328}
329.site-form .slug-url-input:focus { outline: none; }
330.site-form .slug-url-input[readonly] {
331 opacity: 1; cursor: default; background: transparent !important;
332}
333
334/* Selects: same look as the inputs, with a custom chevron instead of the bare
335 native arrow. The open option list is still rendered by the browser. */
336.site-form select {
337 appearance: none;
338 -webkit-appearance: none;
339 padding: 0.55rem 2.2rem 0.55rem 0.85rem;
340 border: 1px solid var(--rule);
341 border-radius: 5px;
342 background-color: var(--paper);
343 color: var(--ink);
344 font-family: inherit;
345 font-size: 0.95rem;
346 cursor: pointer;
347 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");
348 background-repeat: no-repeat;
349 background-position: right 0.7rem center;
350 transition: border-color 0.15s ease, box-shadow 0.15s ease;
351}
352.site-form select:hover { border-color: var(--ink-soft, #888); }
353.site-form select:focus,
354.site-form input[type="text"]:focus,
355.site-form textarea:focus {
356 outline: none;
357 border-color: var(--accent, #c2410c);
358 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
359}
360
361/* Reuse the palette-swatch styles from account.ejs */
362.palette-swatch {
363 display: flex; flex-direction: column; gap: 0.4rem;
364 padding: 0.5rem;
365 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
366 cursor: pointer; font-size: 0.85rem; transition: border-color 120ms;
367}
368.palette-swatch:hover { border-color: var(--accent); }
369.palette-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
370.palette-swatch:has(input[type="radio"]:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
371.palette-preview {
372 display: grid; grid-template-columns: 1fr 1fr;
373 width: 100%; height: 44px; border-radius: 4px; overflow: hidden;
374 border: 1px solid var(--rule);
375}
376.pp-half {
377 position: relative;
378 display: flex; align-items: center; justify-content: center;
379 font-family: var(--font-display, serif); font-weight: 600; font-size: 1rem;
380}
381.pp-letter { line-height: 1; }
382.pp-accent {
383 position: absolute; bottom: 4px; right: 4px;
384 width: 9px; height: 9px; border-radius: 50%;
385 border: 1px solid rgba(0,0,0,0.12);
386}
387.palette-swatch-name { font-weight: 500; color: var(--ink); text-align: center; }
388
389/* Accent picker: row of small colored swatches */
390.site-form fieldset.accent-grid {
391 display: grid;
392 grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
393 gap: 0.5rem;
394 background: var(--paper);
395 margin-top: 0.5rem;
396}
397.accent-swatch {
398 display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
399 padding: 0.55rem 0.4rem;
400 border: 1px solid var(--rule); border-radius: 6px; background: var(--paper);
401 cursor: pointer; font-size: 0.78rem;
402 transition: border-color 120ms, box-shadow 120ms;
403}
404.accent-swatch:hover { border-color: var(--accent); }
405.accent-swatch input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
406.accent-swatch:has(input[type="radio"]:checked) {
407 border-color: var(--accent);
408 box-shadow: 0 0 0 2px var(--accent);
409}
410.accent-dot {
411 width: 28px; height: 28px; border-radius: 50%;
412 border: 1px solid rgba(0,0,0,0.1);
413 box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
414}
415.accent-swatch-name { color: var(--ink); text-align: center; font-weight: 500; }
416
417.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
418.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
419.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
420
421.form-hint { font-size: 0.8rem; color: var(--ink-muted, var(--ink-soft)); margin: 0; }
422.form-hint-inline { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); font-weight: 400; margin-left: .25rem; }
423
424/* P63 — Profile photo picker
425 Mobile-first: thumb stacks above input/buttons. ≥520px the thumb floats
426 to the left and the controls fill the remaining width. */
427.photo-picker {
428 display: flex;
429 flex-direction: column;
430 gap: .65rem;
431 align-items: stretch;
432}
433.photo-thumb {
434 position: relative;
435 width: 88px; height: 88px;
436 border-radius: 50%;
437 border: 1px solid var(--rule);
438 background: var(--paper-2);
439 overflow: hidden;
440 flex-shrink: 0;
441 display: flex; align-items: center; justify-content: center;
442}
443.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
444.photo-thumb .photo-empty {
445 display: flex; color: var(--ink-muted, var(--ink-soft)); opacity: .55;
446}
447.photo-thumb .photo-empty svg { width: 36px; height: 36px; }
448.photo-thumb:not([data-empty]) .photo-empty { display: none; }
449.photo-thumb[data-empty] img { display: none; }
450
451.photo-controls {
452 display: flex;
453 flex-direction: column;
454 gap: .5rem;
455 flex: 1;
456 min-width: 0;
457}
458.photo-controls input[type="text"] { width: 100%; }
459.photo-actions {
460 display: flex; align-items: center; flex-wrap: wrap;
461 gap: .4rem;
462}
463.photo-actions .btn { padding: .45rem .75rem; font-size: .85rem; }
464.photo-status { margin-left: .25rem; }
465.photo-status.is-error { color: #c2410c; }
466
467@media (min-width: 520px) {
468 .photo-picker { flex-direction: row; align-items: flex-start; gap: 1rem; }
469 .photo-controls { gap: .55rem; }
470}
471/* Mobile-first: platform selector on its own line (full width), URL input + remove
472 button below. Prevents horizontal overflow on phones (fixed 160px column +
473 non-shrinkable input together ~447px > screen width). */
474</style>
Note: See TracBrowser for help on using the repository browser.