| index b09bde8..ecd8e5e 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -19,6 +19,27 @@ import { changelogParagraphs } from "../format.js"; |
| // follows — the prefix can't drift. |
| const N = namespace("admin"); |
| |
| +// A textarea that tracks its content height (the composer's behaviour): no |
| +// inner scrollbar, the field is as tall as what's written. Returns the fit |
| +// function so callers can re-run it after setting .value programmatically — |
| +// an input event only fires for typing. |
| +function fitHeight(ta) { |
| + const fit = () => { |
| + ta.style.height = "auto"; |
| + ta.style.height = ta.scrollHeight + 2 + "px"; // +2: the two 1px borders (border-box) |
| + }; |
| + ta.style.resize = "none"; |
| + ta.style.overflow = "hidden"; |
| + ta.addEventListener("input", fit); |
| + // A width change re-wraps the text (rows stack on narrow panes, panes |
| + // resize), so re-fit on any size change; the initial observe callback also |
| + // covers first layout. Settles immediately: re-applying the same height |
| + // stops the observer cascade. |
| + if (typeof ResizeObserver !== "undefined") new ResizeObserver(fit).observe(ta); |
| + else requestAnimationFrame(fit); |
| + return fit; |
| +} |
| + |
| // ── Release notes → the Chat view ───────────────────────────────────────── |
| // In place of the retired Announcer bot, the Admin app POSTS each new release |
| // note into this thread as an ordinary encrypted event; chatMessage projects it |
| @@ -144,7 +165,14 @@ export const app = defineApp({ |
| chatIntro: "Updates about this space are posted here — you'll see the release notes whenever your space updates itself.", |
| |
| styles: ` |
| -.admin-root { max-width: 480px; margin: 0 auto; padding: 28px 20px 48px; } |
| +/* width:100% — as a flex item with auto margins the root SHRINK-TO-FITS, so |
| + one unshrinkable row used to size the whole page past the pane and the |
| + admin panel side-scrolled on phones. Also the container the row rules |
| + below query against. */ |
| +.admin-root { |
| + max-width: 480px; width: 100%; margin: 0 auto; padding: 28px 20px 48px; |
| + container-type: inline-size; |
| +} |
| |
| /* The one card on this page: a live miniature of the invitation card — the |
| actual surface these settings control (branding renders where no key exists |
| @@ -187,9 +215,21 @@ export const app = defineApp({ |
| flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px; |
| border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink); |
| } |
| -.admin-row textarea { resize: vertical; } |
| +/* Textareas autogrow (the fitHeight helper owns the height); the floor keeps |
| + an empty field inviting. lh needs a current browser — without it the floor |
| + is just absent and autogrow still works. */ |
| +.admin-row textarea { resize: none; overflow: hidden; min-height: calc(3lh + 18px); } |
| .admin-row-top { align-items: flex-start; } /* textarea rows: label hugs the top */ |
| .admin-row-top label { padding-top: 8px; } |
| +/* Narrow pane (phones, the Mac's split sidebar): the label column doesn't fit |
| + beside its field — stack each row instead. The switch rows are untouched |
| + (their label IS the row; the more-specific rule below keeps them one line). |
| + The query measures the root's CONTENT box (its 40px padding excluded), so |
| + the full 480px root reads as 440 — 420 keeps desktop side-by-side. */ |
| +@container (max-width: 420px) { |
| + .admin-row label { flex: 1 1 100%; } |
| + .admin-row-top label { padding-top: 0; } |
| +} |
| .admin-row input:focus-visible, .admin-row textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } |
| .admin-row button { flex: none; } |
| .admin-status { font-size: 13px; color: var(--muted); min-height: 1.2em; } |
| @@ -344,6 +384,7 @@ export const app = defineApp({ |
| descInput.rows = 3; |
| descInput.maxLength = 500; |
| descInput.placeholder = "What is this space? Members and the people they invite can read this."; |
| + const descFit = fitHeight(descInput); |
| descRow.append(descLabel, descInput); |
| space.appendChild(descRow); |
| const paintPreviewDesc = () => { |
| @@ -606,8 +647,11 @@ export const app = defineApp({ |
| box.dataset.level = level.id; |
| box.style.cssText = "display:block;padding:10px 0"; |
| const head = document.createElement("div"); |
| - head.style.cssText = "display:flex;align-items:center;gap:8px"; |
| + // wrap + a shrinkable name: a long level name used to make this row |
| + // unshrinkable, which forced the whole page wider than a phone pane. |
| + head.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap"; |
| const name = document.createElement("strong"); |
| + name.style.cssText = "min-width:0;overflow-wrap:anywhere"; |
| name.textContent = level.name; |
| const count = document.createElement("span"); |
| count.style.cssText = "font-size:13px;color:var(--muted)"; |
| @@ -1048,9 +1092,11 @@ export const app = defineApp({ |
| openDetail((d) => { |
| const ta = document.createElement("textarea"); |
| ta.rows = 3; |
| + ta.style.width = "100%"; |
| ta.value = |
| "A report was filed about your behaviour here. Please keep this space welcoming — continued reports can mean removal."; |
| ta.setAttribute("aria-label", `Warning to ${rep.reported_name}`); |
| + fitHeight(ta); |
| const send = document.createElement("button"); |
| send.textContent = `Send warning to ${rep.reported_name}`; |
| send.onclick = async () => { |
| @@ -1425,6 +1471,7 @@ export const app = defineApp({ |
| if (!info) return flash(spaceStatus, "Couldn't load settings — are you an admin?"); |
| nameInput.value = info.name || ""; |
| descInput.value = info.description || ""; |
| + descFit(); |
| paintPreviewDesc(); |
| emailInput.value = info.email || ""; |
| invCheck.checked = !!info.userInvites; |