UX, tilgjengelighet og design etter gjennomgang
WCAG: - Mørk modus: --on-accent-token gir mørk tekst på lys aksent (knapper, badges, valgt fane, skip-lenke) – var 1.8:1, nå ~11:1. - Editor-feltet har label og aria-describedby; forhåndsvisningen er ikke lenger aria-live (leste opp hele innlegget for hvert tastetrykk). - Bilder settes inn med alt-plassholder markert for overskriving, som eget avsnitt etter gjeldende linje. - Berøringsmål ≥ 44px (nav, verktøylinje, lenkeknapper, select). - Faner: roving tabindex + piltaster. Knapper med tekst i stedet for emoji, aria-keyshortcuts på fet/kursiv. h1 på forsiden. - code bryter lange stier (innstillinger rullet horisontalt på mobil). UX: - Ulagret-vakt dekker også tittelen, med synlig «Ulagrede endringer». - 413 ved for stor fil gir lesbar melding; brukernavn beholdes ved feil innlogging; full publiserings-URL vises i editoren; historikk- lenke i meta-linjen; norsk datoformat i historikken; passord-hint. Design: - Header med favicon, større brødtekst/overskrifter, smalere måltekst, bedre avstander i innlegg og liste, footer som flex uten løse «·», nøytral sekundærknapp, innrammet faresone, editor-textarea fyller panelet, valgt fane henger sammen med panelet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
parent
d47558c6c1
commit
ae3d6a4c0c
12 changed files with 182 additions and 92 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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%; }
|
||||
|
|
|
|||
|
|
@ -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 = ""; } });
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -5,36 +5,39 @@
|
|||
data-upload-url="{{ url_for('admin.upload', post_id=post.id) }}" data-csrf="{{ csrf }}">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row">
|
||||
<label class="grow">Tittel <input name="title" value="{{ post.title }}" required></label>
|
||||
<label class="grow">Tittel <input name="title" value="{{ post.title }}" required autofocus></label>
|
||||
<span class="meta">
|
||||
{% if post.draft %}<span class="badge">utkast</span> URL: /{{ post.url_path }} (følger tittelen)
|
||||
{% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">/{{ post.url_path }}</a>{% endif %}
|
||||
{% if post.draft %}<span class="badge">utkast</span> Publiseres på <code>{{ cfg.site_url }}/{{ post.url_path }}</code> (følger tittelen)
|
||||
{% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">{{ cfg.site_url }}/{{ post.url_path }}</a>
|
||||
· <a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk</a>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label="Visning">
|
||||
<button type="button" role="tab" aria-selected="true" aria-controls="pane-write" id="tab-write">Skriv</button>
|
||||
<button type="button" role="tab" aria-selected="false" aria-controls="pane-preview" id="tab-preview">Forhåndsvisning</button>
|
||||
<button type="button" role="tab" aria-selected="true" aria-controls="pane-write" id="tab-write" tabindex="0">Skriv</button>
|
||||
<button type="button" role="tab" aria-selected="false" aria-controls="pane-preview" id="tab-preview" tabindex="-1">Forhåndsvisning</button>
|
||||
</div>
|
||||
|
||||
<div class="panes">
|
||||
<section id="pane-write" role="tabpanel" aria-labelledby="tab-write">
|
||||
<div class="toolbar" role="toolbar" aria-label="Formatering">
|
||||
<button type="button" data-wrap="**" title="Fet (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-wrap="_" title="Kursiv (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-prefix="## " title="Overskrift">H2</button>
|
||||
<button type="button" data-prefix="- " title="Liste">• liste</button>
|
||||
<button type="button" data-prefix="> " title="Sitat">“ sitat</button>
|
||||
<button type="button" data-link title="Lenke">🔗</button>
|
||||
<button type="button" id="btn-image" title="Last opp bilde">📷 Bilde</button>
|
||||
<button type="button" data-wrap="**" aria-label="Fet" aria-keyshortcuts="Control+B" title="Fet (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-wrap="_" aria-label="Kursiv" aria-keyshortcuts="Control+I" title="Kursiv (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-prefix="## " aria-label="Overskrift">H2</button>
|
||||
<button type="button" data-prefix="- ">Liste</button>
|
||||
<button type="button" data-prefix="> ">Sitat</button>
|
||||
<button type="button" data-link>Lenke</button>
|
||||
<button type="button" id="btn-image">Bilde</button>
|
||||
<input type="file" id="file-input" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
<textarea name="body" id="body" rows="18" spellcheck="true"
|
||||
placeholder="Skriv i Markdown. Dra inn eller lim inn bilder for å laste dem opp.">{{ post.body }}</textarea>
|
||||
<label for="body" class="visually-hidden">Innhold</label>
|
||||
<textarea name="body" id="body" rows="18" spellcheck="true" aria-describedby="body-hint"
|
||||
placeholder="Skriv i Markdown.">{{ post.body }}</textarea>
|
||||
<p class="meta" id="body-hint">Markdown. Dra inn, lim inn eller trykk «Bilde» for å laste opp bilder.</p>
|
||||
<p class="meta" id="upload-status" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
<section id="pane-preview" role="tabpanel" aria-labelledby="tab-preview" hidden>
|
||||
<div class="preview body" id="preview" aria-live="polite"><p class="meta">Forhåndsvisningen kommer her.</p></div>
|
||||
<div class="preview body" id="preview"><p class="meta">Forhåndsvisningen kommer her.</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -45,6 +48,7 @@
|
|||
{% else %}
|
||||
<button type="submit" name="action" value="unpublish">Avpubliser</button>
|
||||
{% endif %}
|
||||
<span class="meta" id="dirty-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -67,7 +71,7 @@
|
|||
</details>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk for dette innlegget</a></p>
|
||||
{% if post.draft %}<p><a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk for dette innlegget</a></p>{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('admin.delete', post_id=post.id) }}" class="danger-zone" data-confirm="Slette hele innlegget «{{ post.title }}»?">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
{% if not entries %}<p>Ingen historikk ennå.</p>{% endif %}
|
||||
<ol class="history">
|
||||
{% for e in entries %}
|
||||
<li{% if e.sha == sha %} aria-current="true"{% endif %}>
|
||||
<li{% if e.sha == sha %} aria-current="page"{% endif %}>
|
||||
<a href="{{ url_for('admin.history', post_id=post.id, sha=e.sha) }}">{{ e.subject }}</a>
|
||||
<span class="meta"><time datetime="{{ e.date }}">{{ e.date[:16].replace('T', ' ') }}</time> · {{ e.author }} · <code>{{ e.sha[:7] }}</code></span>
|
||||
<span class="meta"><time datetime="{{ e.date }}">{{ e.date[8:10] }}.{{ e.date[5:7] }}.{{ e.date[:4] }} {{ e.date[11:16] }}</time> · {{ e.author }} · <code>{{ e.sha[:7] }}</code></span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<form method="post" class="stack narrow">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label>Brukernavn <input name="username" autocomplete="username" autocapitalize="none" required autofocus></label>
|
||||
<label>Brukernavn <input name="username" value="{{ request.form.username or '' }}" autocomplete="username" autocapitalize="none" required autofocus></label>
|
||||
<label>Passord <input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<button type="submit" class="primary">Logg inn</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
<form method="post" class="stack narrow">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<label>Nåværende passord <input type="password" name="current" required autocomplete="current-password"></label>
|
||||
<label>Nytt passord <input type="password" name="password" minlength="8" required autocomplete="new-password"></label>
|
||||
<label>Nytt passord <span class="hint" id="pw-hint">Minst 8 tegn.</span>
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password" aria-describedby="pw-hint"></label>
|
||||
<button type="submit" class="primary">Bytt passord</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@
|
|||
<dt>Base-path</dt><dd><code>{{ cfg.base_path or '/' }}</code></dd>
|
||||
<dt>Site-URL (for RSS)</dt><dd><code>{{ cfg.site_url }}</code></dd>
|
||||
<dt>Tema</dt><dd><code>{{ cfg.theme }}</code> (standard fra miljø: <code>{{ cfg.default_theme }}</code>)</dd>
|
||||
<dt>Innhold</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd>
|
||||
<dt>Innholdsmappe</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd>
|
||||
</dl>
|
||||
<form method="post" action="{{ url_for('admin.rebuild') }}">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<button type="submit">Bygg siden på nytt</button>
|
||||
<button type="submit">Bygg nettstedet på nytt</button>
|
||||
</form>
|
||||
|
||||
<h2>Siste endringer i git</h2>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
/* Tema: "grønt er skjønt".
|
||||
Alle farger er CSS-variabler; et nytt tema kan nøye seg med å overstyre :root.
|
||||
Kontrast: tekst/bakgrunn ≥ 7:1 (AAA), lenker ≥ 4.5:1 på begge skjemaer. */
|
||||
Kontrast: tekst/bakgrunn ≥ 7:1 (AAA), lenker ≥ 4.5:1 på begge skjemaer.
|
||||
--on-accent er tekstfargen oppå --accent og byttes i mørk modus, der
|
||||
aksenten er lys. */
|
||||
:root {
|
||||
--bg: #f4f8f3;
|
||||
--fg: #1c2a1e;
|
||||
--muted: #4d6350;
|
||||
--accent: #1f6b3a;
|
||||
--accent-strong: #154d29;
|
||||
--on-accent: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--border: #c9dccb;
|
||||
--code-bg: #e6efe6;
|
||||
--max-width: 42rem;
|
||||
--max-width: 40rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
|
|
@ -19,6 +22,7 @@
|
|||
--muted: #a7bda9;
|
||||
--accent: #7fd39a;
|
||||
--accent-strong: #a9e6bb;
|
||||
--on-accent: #0f1f13;
|
||||
--surface: #182619;
|
||||
--border: #2f4532;
|
||||
--code-bg: #1e2f20;
|
||||
|
|
@ -34,13 +38,18 @@ body {
|
|||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.skip {
|
||||
position: absolute; left: -999px; top: 0;
|
||||
background: var(--accent); color: #fff; padding: .5rem 1rem;
|
||||
background: var(--accent); color: var(--on-accent); padding: .5rem 1rem;
|
||||
}
|
||||
.skip:focus { left: 0; z-index: 10; }
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
||||
clip: rect(0 0 0 0); white-space: nowrap;
|
||||
}
|
||||
.site-header, main, .site-footer {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
|
|
@ -48,28 +57,45 @@ body {
|
|||
}
|
||||
.site-header { border-bottom: 3px solid var(--accent); padding-top: 1.5rem; }
|
||||
.site-title {
|
||||
font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--accent-strong);
|
||||
display: inline-flex; align-items: center; gap: .6rem;
|
||||
font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em;
|
||||
text-decoration: none; color: var(--accent-strong);
|
||||
}
|
||||
.site-title::before {
|
||||
content: ""; width: 2rem; height: 2rem; flex: none;
|
||||
background: url("favicon.svg") center / contain no-repeat;
|
||||
}
|
||||
.site-description { margin: .25rem 0 0; color: var(--muted); }
|
||||
main { padding-top: 1.5rem; }
|
||||
a { color: var(--accent); }
|
||||
a:hover { color: var(--accent-strong); }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
|
||||
outline: 3px solid var(--accent); outline-offset: 2px;
|
||||
}
|
||||
h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .25rem; }
|
||||
h2 { font-size: 1.35rem; margin: 1.25rem 0 .25rem; }
|
||||
.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1rem; }
|
||||
.summary { padding: .5rem 0; border-bottom: 1px solid var(--border); }
|
||||
.summary h2 a { text-decoration: none; }
|
||||
h1 { font-size: 2.1rem; line-height: 1.15; letter-spacing: -.015em; margin: .5rem 0 .35rem; }
|
||||
h2 { font-size: 1.4rem; line-height: 1.25; margin: 2rem 0 .5rem; }
|
||||
.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1.5rem; font-variant-numeric: tabular-nums; }
|
||||
.summary { padding: 1.25rem 0; border-bottom: 1px solid var(--border); }
|
||||
.summary h2 { margin: 0 0 .25rem; }
|
||||
.summary h2 a { color: var(--accent-strong); text-decoration: none; }
|
||||
.summary h2 a:hover { text-decoration: underline; }
|
||||
.body img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
.summary .meta { margin: 0; }
|
||||
.body p, .body ul, .body ol { margin: 0 0 1.1rem; }
|
||||
.body img { max-width: 100%; height: auto; display: block; margin: 1.5rem 0; border-radius: 4px; }
|
||||
.body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }
|
||||
.body code { background: var(--code-bg); padding: .1em .3em; border-radius: 3px; font-size: .95em; }
|
||||
.body code { background: var(--code-bg); padding: .1em .3em; border-radius: 3px; font-size: .95em; overflow-wrap: anywhere; }
|
||||
.body pre code { background: none; padding: 0; }
|
||||
.body blockquote {
|
||||
margin: 1rem 0; padding: .25rem 1rem; border-left: 4px solid var(--accent); color: var(--muted);
|
||||
}
|
||||
.body table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
|
||||
.body th, .body td { border: 1px solid var(--border); padding: .4rem .6rem; text-align: left; }
|
||||
.site-footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .9rem; margin-top: 2rem; }
|
||||
.colophon { margin: .5rem 0 0; font-size: .85rem; }
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border); color: var(--muted); font-size: .9rem;
|
||||
margin-top: 3rem; padding-top: 1.25rem;
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem 1rem;
|
||||
}
|
||||
.site-footer nav a { text-decoration: none; display: inline-block; padding: .5rem 0; }
|
||||
.site-footer nav a:hover { text-decoration: underline; }
|
||||
.site-footer nav a + a::before { content: "·"; margin: 0 .5rem; color: var(--border); }
|
||||
.colophon { margin: 0; padding: .5rem 0; }
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@
|
|||
</main>
|
||||
<footer class="site-footer">
|
||||
<nav aria-label="Bunnmeny">
|
||||
<a href="{{ rel('') }}">Forsiden</a> ·
|
||||
<a href="{{ rel('feed.xml') }}">RSS</a> ·
|
||||
<a href="{{ rel('admin/') }}">Logg inn</a>
|
||||
<a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
|
||||
</nav>
|
||||
{# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #}
|
||||
<p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if heading %}{{ heading }} – {% endif %}{{ site.title }}{% endblock %}
|
||||
{% block content %}
|
||||
{% if heading %}<h1>{{ heading }}</h1>{% endif %}
|
||||
{% if heading %}<h1>{{ heading }}</h1>{% else %}<h1 class="visually-hidden">Siste innlegg</h1>{% endif %}
|
||||
{% if not posts %}<p>Ingen innlegg ennå.</p>{% endif %}
|
||||
{% for post in posts %}
|
||||
<article class="summary">
|
||||
|
|
|
|||
|
|
@ -247,3 +247,13 @@ def test_slug_uniqueness(client, cfg):
|
|||
)
|
||||
ids.append(r.headers["Location"].split("/admin/edit/")[1])
|
||||
assert ids == ["_owner/samme", "_owner/samme-2"]
|
||||
|
||||
|
||||
def test_editor_accessibility_markup(client):
|
||||
post_id = _create_post(client)
|
||||
html = client.get(f"/admin/edit/{post_id}").text
|
||||
assert '<label for="body" class="visually-hidden">Innhold</label>' in html
|
||||
assert "aria-live" not in html.split('id="preview"')[1].split("</div>")[0]
|
||||
assert 'aria-keyshortcuts="Control+B"' in html
|
||||
front = client.get("/").text
|
||||
assert "<h1" in front
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue