diff --git a/soapbox/admin.py b/soapbox/admin.py index 7ed5d38..10efd7c 100644 --- a/soapbox/admin.py +++ b/soapbox/admin.py @@ -149,7 +149,12 @@ def owner_required(view): @bp.context_processor def _inject(): - return {"csrf": csrf_token(), "user": getattr(g, "user", None), "site": _cfg().site} + return { + "csrf": csrf_token(), + "user": getattr(g, "user", None), + "site": _cfg().site, + "cfg": _cfg(), + } @bp.route("/login", methods=["GET", "POST"]) @@ -477,5 +482,5 @@ def settings(): @owner_required def rebuild(): n = build_site(_cfg()) - flash(f"Bygde siden på nytt ({n} innlegg).", "ok") + flash(f"Bygde nettstedet på nytt ({n} innlegg).", "ok") return redirect(url_for("admin.settings")) diff --git a/soapbox/static/admin/admin.css b/soapbox/static/admin/admin.css index 696154c..4b102e7 100644 --- a/soapbox/static/admin/admin.css +++ b/soapbox/static/admin/admin.css @@ -1,56 +1,69 @@ -/* Admin-grensesnitt. Mobil først; editor og forhåndsvisning side om side fra 900px. */ +/* Admin-grensesnitt. Mobil først; editor og forhåndsvisning side om side fra 900px. + Berøringsmål ≥ 44px på alle knapper og lenker i navigasjon/verktøylinje. */ :root { --bg: #f4f8f3; --fg: #1c2a1e; --muted: #4d6350; --accent: #1f6b3a; --accent-strong: #154d29; - --surface: #fff; --border: #c9dccb; --code-bg: #e6efe6; --danger: #a12626; + --on-accent: #fff; --surface: #fff; --border: #c9dccb; --code-bg: #e6efe6; --danger: #a12626; } @media (prefers-color-scheme: dark) { :root { --bg: #101a12; --fg: #e4ede4; --muted: #a7bda9; --accent: #7fd39a; --accent-strong: #a9e6bb; - --surface: #182619; --border: #2f4532; --code-bg: #1e2f20; --danger: #ff8a8a; + --on-accent: #0f1f13; --surface: #182619; --border: #2f4532; --code-bg: #1e2f20; --danger: #ff8a8a; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; } -.skip { position: absolute; left: -999px; } .skip:focus { left: 0; background: var(--accent); color: #fff; padding: .5rem; z-index: 10; } +.skip { position: absolute; left: -999px; } .skip:focus { left: 0; background: var(--accent); color: var(--on-accent); padding: .5rem; z-index: 10; } +.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } a { color: var(--accent); } :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } -.topbar { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; padding: .6rem 1rem; border-bottom: 3px solid var(--accent); } -.topbar nav { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: center; } -.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; } +code { overflow-wrap: anywhere; } +.topbar { display: flex; flex-wrap: wrap; gap: 0 1rem; align-items: center; padding: .4rem 1rem; border-bottom: 3px solid var(--accent); } +.topbar nav { display: flex; flex-wrap: wrap; gap: 0 .5rem; align-items: center; } +.topbar nav a, .topbar nav button.link { display: inline-block; padding: .7rem .25rem; min-height: 44px; } +.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; padding: .7rem 0; } main { max-width: 70rem; margin: 0 auto; padding: 1rem; } -h1 { font-size: 1.5rem; margin: .5rem 0; } +h1 { font-size: 1.75rem; line-height: 1.2; margin: .5rem 0; } +h2 { font-size: 1.25rem; margin: 1.5rem 0 .5rem; } .row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; } .grow { flex: 1 1 18rem; } .stack { display: flex; flex-direction: column; gap: .75rem; } .narrow { max-width: 28rem; } label { display: flex; flex-direction: column; gap: .25rem; font-weight: 600; } -input, textarea, button { font: inherit; } -input[type=text], input:not([type]), input[type=password], textarea { - padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%; +.hint { font-weight: normal; color: var(--muted); font-size: .9rem; } +input, textarea, button, select { font: inherit; } +input[type=text], input:not([type]), input[type=password], textarea, select { + padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%; min-height: 44px; } -button { padding: .55rem .9rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--accent-strong); cursor: pointer; min-height: 44px; } -button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } -button.link { border: none; background: none; padding: .25rem; min-height: auto; text-decoration: underline; color: var(--accent); } +button { + padding: .55rem .9rem; border: 1px solid var(--border); border-radius: 4px; + background: var(--surface); color: var(--accent-strong); cursor: pointer; min-height: 44px; +} +button.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; } +button.link { border: none; background: none; padding: .6rem .25rem; text-decoration: underline; color: var(--accent); } button.danger { color: var(--danger); } .inline { display: inline; } .meta { color: var(--muted); font-size: .9rem; } -.badge { background: var(--accent); color: #fff; padding: .05rem .45rem; border-radius: 999px; font-size: .8rem; } +.badge { background: var(--accent); color: var(--on-accent); padding: .05rem .45rem; border-radius: 999px; font-size: .8rem; } .flash { padding: .6rem .9rem; border-radius: 4px; border-left: 4px solid var(--accent); background: var(--surface); } .flash.error { border-color: var(--danger); } -.posts, .users { list-style: none; padding: 0; } +.posts, .users { list-style: none; padding: 0; max-width: 48rem; } .posts li, .users li { padding: .6rem 0; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; } +.posts li a { padding: .4rem 0; display: inline-block; } /* Editor */ -.tabs { display: flex; gap: .25rem; margin-top: 1rem; } -.tabs button { border-radius: 4px 4px 0 0; border-bottom: none; } -.tabs button[aria-selected=true] { background: var(--accent); color: #fff; } -.panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; } +.tabs { display: flex; gap: .25rem; margin-top: 1rem; position: relative; z-index: 1; margin-bottom: -1px; } +.tabs button { border-radius: 4px 4px 0 0; } +.tabs button[aria-selected=true] { background: var(--surface); border-color: var(--accent); border-bottom-color: var(--surface); font-weight: 600; } +.panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; display: flex; flex-direction: column; } +.panes section[hidden] { display: none; } /* display:flex over ville ellers slått ut [hidden] */ .toolbar { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; } -.toolbar button { min-height: 40px; padding: .3rem .6rem; } -textarea#body { min-height: 50vh; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; } +.toolbar button { min-width: 44px; padding: .3rem .6rem; } +textarea#body { min-height: 50vh; flex: 1; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; } textarea#body.dragover { outline: 3px dashed var(--accent); } -.preview { min-height: 50vh; padding: 0 .5rem; overflow-wrap: anywhere; } +#upload-status { min-height: 1.5rem; margin: .5rem 0 0; } +.preview { flex: 1; min-height: 50vh; padding: 0 .5rem; overflow-wrap: anywhere; } +.preview:has(> .meta:only-child) { display: grid; place-items: center; } .preview img { max-width: 100%; height: auto; } .preview pre, .preview code { background: var(--code-bg); } .preview pre { padding: 1rem; overflow-x: auto; } @@ -61,18 +74,21 @@ textarea#body.dragover { outline: 3px dashed var(--accent); } .tabs { display: none; } .panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; } .panes section { border-radius: 4px; } - .panes section[hidden] { display: block; } + .panes section[hidden] { display: flex; } } +.editor ~ * { margin-top: 1.25rem; } .images ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 1rem; } .images li { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; } .images img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); } -.danger-zone { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1rem; } +.danger-zone { border: 1px solid var(--danger); border-radius: 4px; padding: .5rem 1rem; } pre.log { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; } dl.meta dt { font-weight: 600; margin-top: .5rem; } dl.meta dd { margin: 0; } -.history { padding-left: 1.2rem; } -.history li { padding: .4rem 0; border-bottom: 1px solid var(--border); } -.history li[aria-current] { font-weight: 600; } +.history { padding-left: 1.5rem; } +.history li { padding: .4rem .5rem; border-bottom: 1px solid var(--border); } +.history li[aria-current] { font-weight: 600; border-left: 4px solid var(--accent); } +.history li[aria-current]::marker { color: var(--accent); } .history .meta { display: block; font-weight: normal; } +.history li a { display: inline-block; padding: .3rem 0; } pre.diff { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; line-height: 1.4; } pre.diff .add { color: #14632c; background: #d8f2df; display: inline-block; width: 100%; } pre.diff .del { color: #9a1c1c; background: #f8dcdc; display: inline-block; width: 100%; } diff --git a/soapbox/static/admin/editor.js b/soapbox/static/admin/editor.js index 9d03a1f..7fef042 100644 --- a/soapbox/static/admin/editor.js +++ b/soapbox/static/admin/editor.js @@ -7,21 +7,37 @@ const ta = document.getElementById("body"); const preview = document.getElementById("preview"); const status = document.getElementById("upload-status"); + const dirtyStatus = document.getElementById("dirty-status"); const csrf = form.dataset.csrf; let dirty = false; - // ---- tabs (kun synlige på små skjermer) ---- - const tabs = form.querySelectorAll('[role="tab"]'); - tabs.forEach((tab) => - tab.addEventListener("click", () => { - tabs.forEach((t) => { - const on = t === tab; - t.setAttribute("aria-selected", on); - document.getElementById(t.getAttribute("aria-controls")).hidden = !on; - }); - if (tab.id === "tab-preview") renderPreview(); - }) - ); + function setDirty(v) { + dirty = v; + dirtyStatus.textContent = v ? "Ulagrede endringer" : ""; + } + + // ---- faner (kun synlige på små skjermer). Roving tabindex + piltaster + // i henhold til WAI-ARIA-mønsteret for tabs. ---- + const tabs = [...form.querySelectorAll('[role="tab"]')]; + function selectTab(tab) { + tabs.forEach((t) => { + const on = t === tab; + t.setAttribute("aria-selected", on); + t.tabIndex = on ? 0 : -1; + document.getElementById(t.getAttribute("aria-controls")).hidden = !on; + }); + if (tab.id === "tab-preview") renderPreview(); + } + tabs.forEach((tab, i) => { + tab.addEventListener("click", () => selectTab(tab)); + tab.addEventListener("keydown", (ev) => { + if (ev.key !== "ArrowRight" && ev.key !== "ArrowLeft") return; + ev.preventDefault(); + const next = tabs[(i + (ev.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length]; + selectTab(next); + next.focus(); + }); + }); // ---- forhåndsvisning ---- let timer = null; @@ -41,10 +57,10 @@ } } ta.addEventListener("input", () => { - dirty = true; clearTimeout(timer); timer = setTimeout(renderPreview, 400); }); + form.addEventListener("input", () => setDirty(true)); // også tittelfeltet renderPreview(); // ---- tekstinnsetting ---- @@ -54,7 +70,7 @@ ta.setRangeText(before + sel + after, s, e, "select"); ta.setSelectionRange(s + before.length, s + before.length + sel.length); ta.focus(); - ta.dispatchEvent(new Event("input")); + ta.dispatchEvent(new Event("input", { bubbles: true })); } function prefixLines(prefix) { const s = ta.selectionStart, e = ta.selectionEnd; @@ -63,7 +79,7 @@ const out = block.split("\n").map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : prefix + l)).join("\n"); ta.setRangeText(out, start, e, "end"); ta.focus(); - ta.dispatchEvent(new Event("input")); + ta.dispatchEvent(new Event("input", { bubbles: true })); } form.querySelectorAll(".toolbar button").forEach((b) => { b.addEventListener("click", () => { @@ -106,19 +122,33 @@ fd.append("file", file, file.name); try { const res = await fetch(form.dataset.uploadUrl, { method: "POST", headers: { "X-CSRF": csrf }, body: fd }); - const data = await res.json(); + if (res.status === 413) throw new Error("Filen er for stor (maks 32 MB)."); + const data = res.ok ? await res.json() : await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText); - // Bilder skal stå på egen linje for å bli egne avsnitt i Markdown. - const before = ta.value.slice(0, ta.selectionStart); - const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"; - insert(lead + data.markdown + "\n", "", ""); - status.textContent = "Lastet opp " + data.filename + ". Husk å lagre."; + insertImage(data.filename); + status.textContent = "Lastet opp " + data.filename + ". Skriv en bildebeskrivelse i klammene, og husk å lagre."; } catch (e) { status.textContent = "Opplasting feilet: " + e.message; } } + function insertImage(filename) { + // Bildet settes inn som eget avsnitt etter linjen markøren står i, så vi + // aldri splitter markup midt i et ord eller en markering. Alt-teksten er + // en plassholder som blir markert, klar til å skrives over. + const lineEnd = ta.value.indexOf("\n", ta.selectionEnd); + const pos = lineEnd === -1 ? ta.value.length : lineEnd; + const before = ta.value.slice(0, pos); + const lead = before === "" ? "" : before.endsWith("\n") ? "\n" : "\n\n"; + const alt = "Beskriv bildet"; + const md = lead + "\n"; + ta.setRangeText(md, pos, pos, "end"); + const altStart = pos + lead.length + 2; + ta.setSelectionRange(altStart, altStart + alt.length); + ta.focus(); + ta.dispatchEvent(new Event("input", { bubbles: true })); + } // ---- ikke mist tekst ved uhell ---- - form.addEventListener("submit", () => { dirty = false; }); + form.addEventListener("submit", () => setDirty(false)); window.addEventListener("beforeunload", (ev) => { if (dirty) { ev.preventDefault(); ev.returnValue = ""; } }); })(); diff --git a/soapbox/templates/admin/edit.html b/soapbox/templates/admin/edit.html index 265f7de..7cf2879 100644 --- a/soapbox/templates/admin/edit.html +++ b/soapbox/templates/admin/edit.html @@ -5,36 +5,39 @@ data-upload-url="{{ url_for('admin.upload', post_id=post.id) }}" data-csrf="{{ csrf }}">
{{ cfg.base_path or '/' }}{{ cfg.site_url }}{{ cfg.theme }} (standard fra miljø: {{ cfg.default_theme }}){{ cfg.content_dir }} (git-repo){{ cfg.content_dir }} (git-repo)