aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/templates
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/templates')
-rw-r--r--internal/web/templates/dashboard/page_form.html2
-rw-r--r--internal/web/templates/dashboard/post_form.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html2
-rw-r--r--internal/web/templates/partials/dashnav.html4
-rw-r--r--internal/web/templates/partials/editor.html177
5 files changed, 172 insertions, 15 deletions
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
index 18d1119..775222d 100644
--- a/internal/web/templates/dashboard/page_form.html
+++ b/internal/web/templates/dashboard/page_form.html
@@ -8,7 +8,7 @@
<label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br>
<input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
<label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "Markdown, shown above the posts; optional"}})</span></label>
- {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}
+ {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html
index d12d15f..9f188f7 100644
--- a/internal/web/templates/dashboard/post_form.html
+++ b/internal/web/templates/dashboard/post_form.html
@@ -15,7 +15,7 @@
<input type="datetime-local" name="posted_at" value="{{.Data.post.CreatedAt.Format "2006-01-02T15:04"}}" placeholder="YYYY-MM-DD HH:MM"></label>
</div>
<label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label>
- {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}
+ {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html
index b401d9c..dc971e5 100644
--- a/internal/web/templates/dashboard/section_form.html
+++ b/internal/web/templates/dashboard/section_form.html
@@ -24,7 +24,7 @@
</div>
<p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p>
<label for="ed-body">{{t "Text"}} <span class="muted">(Markdown)</span></label>
- {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}
+ {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
index 8b44e55..40ca33f 100644
--- a/internal/web/templates/partials/dashnav.html
+++ b/internal/web/templates/partials/dashnav.html
@@ -16,7 +16,7 @@
<div class="blogbar">
<div class="topbar-inner">
<strong class="blogname">{{.Blog.Title}}</strong>
- <span class="tabs">
+ <nav class="tabs">
<a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>{{t "Overview"}}</a>
<a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a>
<a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a>
@@ -25,7 +25,7 @@
<a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a>
<a href="{{$b}}files"{{if hasPrefix .Path (print $b "files")}} class="active"{{end}}>{{t "Files"}}</a>
<a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a>
- </span>
+ </nav>
<a class="view" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
</div>
</div>
diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html
index 4371624..33b2a1c 100644
--- a/internal/web/templates/partials/editor.html
+++ b/internal/web/templates/partials/editor.html
@@ -1,4 +1,36 @@
-{{define "editor"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea>
+{{define "editor"}}<div class="editor" id="ed-{{.name}}-wrap">
+<div class="ed-toolbar" id="ed-{{.name}}-bar" hidden>
+ <span class="ed-group">
+ <button type="button" data-cmd="bold" title="{{t "Bold"}} (Ctrl+B)"><svg viewBox="0 0 16 16"><path d="M4 2h5a3.5 3.5 0 0 1 2.4 6 3.75 3.75 0 0 1-2.2 6.8H4zM6 4v3.5h3a1.75 1.75 0 0 0 0-3.5zm0 5.5V13h3.2a1.75 1.75 0 0 0 0-3.5z"/></svg></button>
+ <button type="button" data-cmd="italic" title="{{t "Italic"}} (Ctrl+I)"><svg viewBox="0 0 16 16"><path d="M6.5 2h6v2h-2l-2.5 8h2v2h-6v-2h2l2.5-8h-2z"/></svg></button>
+ <button type="button" data-cmd="strike" title="{{t "Strikethrough"}}"><svg viewBox="0 0 16 16"><path d="M2 7.5h12V9H2z"/><path d="M8 2c2.3 0 3.8 1 4.1 2.8l-1.9.3C10 4.2 9.2 3.7 8 3.7c-1.3 0-2 .5-2 1.3 0 .5.3.9 1.2 1.2H4.3C4 5.8 4 5.3 4 5c0-1.8 1.6-3 4-3zm3.9 8.4c0 1.9-1.6 3.1-4 3.1-2.5 0-4.1-1.2-4.4-3.2l1.9-.3c.2 1.1 1.1 1.7 2.5 1.7 1.3 0 2-.5 2-1.3 0-.2 0-.4-.1-.5h2c.1.2.1.4.1.5z"/></svg></button>
+ </span>
+ <span class="ed-group">
+ <button type="button" data-cmd="heading" title="{{t "Heading"}}"><svg viewBox="0 0 16 16"><path d="M3 2h2v5h6V2h2v12h-2V9H5v5H3z"/></svg></button>
+ <button type="button" data-cmd="quote" title="{{t "Quote"}}"><svg viewBox="0 0 16 16"><path d="M3 4h4v4H5v1.5c0 .6.4 1 1 1h1V12H6a3 3 0 0 1-3-3zm6 0h4v4h-2v1.5c0 .6.4 1 1 1h1V12h-1a3 3 0 0 1-3-3z"/></svg></button>
+ <button type="button" data-cmd="code" title="{{t "Code"}}"><svg viewBox="0 0 16 16"><path d="M5.5 3.5 1 8l4.5 4.5 1.4-1.4L3.8 8l3.1-3.1zm5 0L9.1 4.9 12.2 8l-3.1 3.1 1.4 1.4L15 8z"/></svg></button>
+ </span>
+ <span class="ed-group">
+ <button type="button" data-cmd="ul" title="{{t "Bulleted list"}}"><svg viewBox="0 0 16 16"><path d="M2 3h2v2H2zm4 0h8v2H6zM2 7h2v2H2zm4 0h8v2H6zm-4 4h2v2H2zm4 0h8v2H6z"/></svg></button>
+ <button type="button" data-cmd="ol" title="{{t "Numbered list"}}"><svg viewBox="0 0 16 16"><path d="M2.2 2h1.3v3.5H2.4v-2.3l-.7.4V2.5zM6 3h8v2H6zm-4.1 4.2c0-.8.6-1.3 1.5-1.3s1.4.5 1.4 1.2c0 .5-.3.9-.8 1.3l-.7.6h1.6v1H1.9v-.8L3.3 8c.3-.3.4-.5.4-.7 0-.3-.2-.4-.4-.4-.3 0-.5.2-.5.5zM6 7h8v2H6zm-4.2 4.1c0-.7.6-1.1 1.5-1.1.8 0 1.4.4 1.4 1 0 .4-.3.7-.7.8.5.1.8.5.8.9 0 .7-.6 1.2-1.5 1.2-.9 0-1.5-.4-1.6-1.1h1c0 .2.2.4.5.4s.5-.1.5-.4-.2-.4-.6-.4h-.3v-.8h.3c.3 0 .5-.1.5-.4 0-.2-.2-.3-.4-.3s-.4.1-.4.3zM6 11h8v2H6z"/></svg></button>
+ <button type="button" data-cmd="hr" title="{{t "Horizontal rule"}}"><svg viewBox="0 0 16 16"><path d="M2 7h12v2H2z"/></svg></button>
+ </span>
+ <span class="ed-group">
+ <button type="button" data-cmd="link" title="{{t "Link"}} (Ctrl+K)"><svg viewBox="0 0 16 16"><path d="M6.4 9.6a3.5 3.5 0 0 0 4.9 0l2-2a3.5 3.5 0 0 0-4.9-5l-1 1 1.1 1.1 1-1a2 2 0 0 1 2.8 2.9l-2 2a2 2 0 0 1-2.9 0zm3.2-3.2a3.5 3.5 0 0 0-4.9 0l-2 2a3.5 3.5 0 0 0 4.9 5l1-1-1.1-1.1-1 1a2 2 0 0 1-2.8-2.9l2-2a2 2 0 0 1 2.9 0z"/></svg></button>
+ <button type="button" data-cmd="file" title="{{t "Insert file"}}"><svg viewBox="0 0 16 16"><path d="M10.5 1.5a3.5 3.5 0 0 1 2.5 6l-5.6 5.6a2.25 2.25 0 0 1-3.2-3.2L9.6 4.6 10.7 5.7l-5.4 5.3a.75.75 0 0 0 1.1 1.1L12 6.4a2 2 0 0 0-2.9-2.8L3.6 9.1 2.5 8l5.5-5.5a3.5 3.5 0 0 1 2.5-1z"/></svg></button>
+ </span>
+ <span class="ed-modes">
+ <button type="button" data-mode="write" aria-pressed="true">{{t "Write"}}</button><button type="button" data-mode="preview" aria-pressed="false">{{t "Preview"}}</button>
+ </span>
+</div>
+<div class="ed-linkbox" id="ed-{{.name}}-link" hidden>
+ <input type="url" placeholder="https://…" aria-label="URL" data-link="url">
+ <input type="text" placeholder="{{t "link text"}}" aria-label="{{t "link text"}}" data-link="text">
+ <button type="button" class="small" data-link="ok">{{t "Insert link"}}</button>
+ <button type="button" class="small secondary" data-link="cancel">{{t "Cancel"}}</button>
+</div>
+<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="tall"{{end}}>{{.value}}</textarea>
+<div class="ed-preview" id="ed-{{.name}}-preview" hidden></div>
<div class="editor-tools">
<label class="upload">{{t "Insert file"}} <input type="file" name="inline_file" id="ed-{{.name}}-file"></label>
<span class="upload-status" id="ed-{{.name}}-status"></span>
@@ -6,17 +38,142 @@
<noscript><span class="muted small">{{t "The file is added at the end of the text when you save."}}</span></noscript>
</div>
{{template "mdhelp"}}
+</div>
<script>
(function () {
- var ta = document.getElementById("ed-{{.name}}"), inp = document.getElementById("ed-{{.name}}-file"), st = document.getElementById("ed-{{.name}}-status");
- if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: the file is uploaded on save instead
- function insert(text) {
- var s = ta.selectionStart, e = ta.selectionEnd, pre = ta.value.slice(0, s), post = ta.value.slice(e);
- var chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n";
- ta.value = pre + chunk + post;
- ta.selectionStart = ta.selectionEnd = (pre + chunk).length;
- ta.focus();
+ var id = "ed-{{.name}}", ta = document.getElementById(id), inp = document.getElementById(id + "-file"), st = document.getElementById(id + "-status"),
+ wrap = document.getElementById(id + "-wrap"), bar = document.getElementById(id + "-bar"), lk = document.getElementById(id + "-link"), pv = document.getElementById(id + "-preview");
+ if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: plain textarea, the file is uploaded on save
+ bar.hidden = false;
+ wrap.className = "editor js";
+
+ // ---- text primitives: everything goes through replace() so Ctrl+Z works
+ function setSel(s, e) { ta.focus(); ta.setSelectionRange(s, e); }
+ function replace(s, e, text) {
+ setSel(s, e);
+ var ok = false;
+ try { ok = document.execCommand(text ? "insertText" : "delete", false, text); } catch (err) {}
+ if (!ok || ta.value.slice(s, s + text.length) !== text) ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
+ }
+ function insertLine(text) { // a block on its own line, after the caret
+ var s = ta.selectionStart, pre = ta.value.slice(0, s), chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n";
+ replace(s, ta.selectionEnd, chunk);
+ setSel(s + chunk.length, s + chunk.length);
+ }
+ function wrapSel(before, after, ph) { // wrap the selection, or unwrap it when it already is
+ var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value, sel = v.slice(s, e), bl = before.length, al = after.length;
+ if (sel.length >= bl + al && sel.slice(0, bl) === before && sel.slice(-al) === after) {
+ replace(s, e, sel.slice(bl, sel.length - al)); setSel(s, e - bl - al); return;
+ }
+ if (v.slice(s - bl, s) === before && v.slice(e, e + al) === after) {
+ replace(s - bl, e + al, sel); setSel(s - bl, e - bl); return;
+ }
+ var inner = sel || ph;
+ replace(s, e, before + inner + after);
+ setSel(s + bl, s + bl + inner.length);
+ }
+ function block() { // the whole lines the selection touches
+ var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd, ls = v.lastIndexOf("\n", s - 1) + 1, le = v.indexOf("\n", e > s ? e - 1 : e);
+ if (le < 0) le = v.length;
+ return { s: ls, e: le, text: v.slice(ls, le) };
+ }
+ function prefixLines(strip, own, mark) { // toggle a line marker: off when every line has this one, else replace whatever strip matches
+ var b = block(), ls = b.text.split("\n"), on = ls.every(function (l) { return own.test(l); });
+ var out = ls.map(function (l, i) { l = l.replace(strip, ""); return on ? l : (typeof mark === "function" ? mark(i) : mark) + l; }).join("\n");
+ replace(b.s, b.e, out); setSel(b.s, b.s + out.length);
+ }
+ var listRe = /^[-*+] (\[[ x]\] )?|^\d+\. /;
+ var cmds = {
+ bold: function () { wrapSel("**", "**", {{t "bold"}}); },
+ italic: function () { wrapSel("_", "_", {{t "italic"}}); },
+ strike: function () { wrapSel("~~", "~~", {{t "text"}}); },
+ code: function () {
+ if (ta.value.slice(ta.selectionStart, ta.selectionEnd).indexOf("\n") >= 0) { var b = block(); replace(b.s, b.e, "```\n" + b.text + "\n```"); setSel(b.s + 4, b.s + 4 + b.text.length); }
+ else wrapSel("`", "`", {{t "code"}});
+ },
+ heading: function () { // cycles ## → ### → plain
+ var b = block(), m = /^(#{1,6}) /.exec(b.text), next = !m ? "## " : m[1].length === 2 ? "### " : "";
+ var out = next + b.text.replace(/^#{1,6} /, "");
+ replace(b.s, b.e, out); setSel(b.s + next.length, b.s + out.length);
+ },
+ quote: function () { prefixLines(/^> /, /^> /, "> "); },
+ ul: function () { prefixLines(listRe, /^[-*+] /, "- "); },
+ ol: function () { prefixLines(listRe, /^\d+\. /, function (i) { return (i + 1) + ". "; }); },
+ hr: function () { insertLine("\n---\n"); },
+ link: openLink,
+ file: function () { inp.click(); }
+ };
+ bar.addEventListener("click", function (ev) {
+ var b = ev.target.closest("button");
+ if (!b) return;
+ if (b.getAttribute("data-mode")) setMode(b.getAttribute("data-mode") === "preview");
+ else if (cmds[b.getAttribute("data-cmd")]) cmds[b.getAttribute("data-cmd")]();
+ });
+
+ // ---- link box: replaces window.prompt, keeps the selection it was opened on
+ var linkSel = null, lkURL = lk.querySelector("[data-link=url]"), lkText = lk.querySelector("[data-link=text]");
+ function openLink() {
+ linkSel = [ta.selectionStart, ta.selectionEnd];
+ lkText.value = ta.value.slice(linkSel[0], linkSel[1]);
+ lkURL.value = "";
+ lk.hidden = false;
+ lkURL.focus();
+ }
+ function closeLink(insert) {
+ lk.hidden = true;
+ if (!insert || !linkSel) { setSel(linkSel ? linkSel[0] : 0, linkSel ? linkSel[1] : 0); return; }
+ var url = lkURL.value.trim(), text = lkText.value.trim() || url, md = "[" + text + "](" + url + ")";
+ replace(linkSel[0], linkSel[1], md);
+ setSel(linkSel[0] + md.length, linkSel[0] + md.length);
+ linkSel = null;
+ }
+ lk.addEventListener("click", function (ev) {
+ var b = ev.target.closest("button");
+ if (b) closeLink(b.getAttribute("data-link") === "ok" && lkURL.value.trim() !== "");
+ });
+ lk.addEventListener("keydown", function (ev) {
+ if (ev.key === "Enter") { ev.preventDefault(); if (lkURL.value.trim()) closeLink(true); }
+ else if (ev.key === "Escape") { ev.preventDefault(); closeLink(false); }
+ });
+
+ // ---- shortcuts and list continuation
+ ta.addEventListener("keydown", function (ev) {
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
+ var k = { b: "bold", i: "italic", k: "link" }[ev.key.toLowerCase()];
+ if (k) { ev.preventDefault(); cmds[k](); }
+ return;
+ }
+ if (ev.key !== "Enter" || ev.shiftKey || ta.selectionStart !== ta.selectionEnd) return;
+ var s = ta.selectionStart, v = ta.value, ls = v.lastIndexOf("\n", s - 1) + 1, m = /^(\s*)([-*+] (?:\[[ x]\] )?|(\d+)\. )(.*)$/.exec(v.slice(ls, s));
+ if (!m) return;
+ ev.preventDefault();
+ if (!m[4]) { replace(ls, s, ""); return; } // Enter on an empty item ends the list
+ var mark = m[3] ? (parseInt(m[3], 10) + 1) + ". " : m[2].replace("[x]", "[ ]");
+ replace(s, s, "\n" + m[1] + mark);
+ });
+
+ // ---- preview: the server renders exactly what a save would
+ var modes = bar.querySelectorAll("[data-mode]");
+ function setMode(preview) {
+ wrap.className = "editor js" + (preview ? " previewing" : "");
+ for (var i = 0; i < modes.length; i++) modes[i].setAttribute("aria-pressed", String((modes[i].getAttribute("data-mode") === "preview") === preview));
+ lk.hidden = true;
+ if (!preview) { pv.hidden = true; ta.hidden = false; ta.focus(); return; }
+ pv.style.minHeight = ta.offsetHeight + "px";
+ pv.innerHTML = "";
+ pv.hidden = false; ta.hidden = true;
+ st.textContent = {{t "Rendering…"}};
+ var fd = new FormData();
+ fd.append("_csrf", {{.csrf}});
+ fd.append("body", ta.value);
+ fetch({{.preview}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } })
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || r.statusText); return j; }); })
+ .then(function (j) { pv.innerHTML = j.html || "<p class=\"muted\">" + {{t "Nothing to show yet."}} + "</p>"; st.textContent = ""; })
+ .catch(function (err) { st.textContent = {{t "Could not render:"}} + " " + err.message; setMode(false); });
}
+ pv.addEventListener("keydown", function (ev) { if (ev.key === "Escape") setMode(false); });
+
+ // ---- uploads: button, paste and drop all land at the caret
function upload(file) {
st.textContent = {{t "Uploading"}} + " " + file.name + "…";
var fd = new FormData();
@@ -24,7 +181,7 @@
fd.append("file", file);
return fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } })
.then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); })
- .then(function (j) { insert(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; })
+ .then(function (j) { insertLine(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; })
.catch(function (err) { st.textContent = {{t "Could not upload:"}} + " " + err.message; });
}
function uploadAll(files) { // one after the other, so each lands where the caret is