Posts with a future publish_at date are hidden from the public API and feeds, same as drafts. The admin form exposes a Publish at field.
471 lines
18 KiB
ERB
471 lines
18 KiB
ERB
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
|
|
|
|
<% if @error %>
|
|
<p class="error"><%= h(@error) %></p>
|
|
<% end %>
|
|
|
|
<form id="post-form" method="post"
|
|
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
|
|
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
|
|
<div class="fields">
|
|
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
|
|
<label>Slug
|
|
<input type="text" name="slug" value="<%= h(@post.slug) %>"
|
|
<%= @mode == :edit ? "readonly" : "required" %>>
|
|
</label>
|
|
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
|
|
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
|
|
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
|
|
<label>Publish at
|
|
<input type="date" name="publish_at"
|
|
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>"
|
|
placeholder="leave empty for immediate">
|
|
</label>
|
|
<label>Tags
|
|
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
|
|
placeholder="comma, separated">
|
|
</label>
|
|
<label class="check">
|
|
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
|
</label>
|
|
<label class="check">
|
|
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
|
|
</label>
|
|
</div>
|
|
|
|
<div class="cover-field">
|
|
<label for="cover-input">Cover image</label>
|
|
<div class="cover-row">
|
|
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
|
|
placeholder="/media/… or https://…">
|
|
<button type="button" id="cover-upload">Upload</button>
|
|
<button type="button" id="cover-clear">Clear</button>
|
|
</div>
|
|
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
|
<input type="file" id="cover-file" accept="image/*" hidden>
|
|
</div>
|
|
|
|
<div class="editor-tabs">
|
|
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
|
<button type="button" data-tab="visual">Visual</button>
|
|
</div>
|
|
|
|
<div id="visual-view" hidden>
|
|
<div class="pane">
|
|
<div class="ed-toolbar">
|
|
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
|
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
|
<button type="button" data-rich="h2" title="Heading 2">H2</button>
|
|
<button type="button" data-rich="h3" title="Heading 3">H3</button>
|
|
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
|
|
<button type="button" data-rich="ul" title="Bullet list">• List</button>
|
|
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
|
|
<button type="button" data-rich="quote" title="Quote">“</button>
|
|
<button type="button" data-rich="code" title="Inline code"></></button>
|
|
<button type="button" data-rich="image" title="Image">Img</button>
|
|
<button type="button" data-rich="clear" title="Paragraph">P</button>
|
|
</div>
|
|
<div id="wysiwyg" class="markdown" contenteditable="true"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="markdown-view">
|
|
<div class="editor">
|
|
<div class="pane">
|
|
<div class="ed-toolbar">
|
|
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
|
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
|
<button type="button" data-md="h2" title="Heading">H2</button>
|
|
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button>
|
|
<button type="button" data-md="ul" title="List">List</button>
|
|
<button type="button" data-md="quote" title="Quote">“</button>
|
|
<button type="button" data-md="code" title="Code"></></button>
|
|
<button type="button" data-md="image" title="Image">Img</button>
|
|
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button>
|
|
</div>
|
|
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
|
|
</div>
|
|
<div class="pane preview-pane">
|
|
<div class="ed-toolbar"><span>Preview</span></div>
|
|
<div id="preview" class="markdown"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input type="file" id="image-input" accept="image/*" hidden>
|
|
<button type="submit" class="primary">Save</button>
|
|
</form>
|
|
|
|
<script>
|
|
(function () {
|
|
var form = document.getElementById("post-form");
|
|
var textarea = document.getElementById("body");
|
|
var preview = document.getElementById("preview");
|
|
var wysiwyg = document.getElementById("wysiwyg");
|
|
var visualView = document.getElementById("visual-view");
|
|
var markdownView = document.getElementById("markdown-view");
|
|
var imageInput = document.getElementById("image-input");
|
|
var coverInput = document.getElementById("cover-input");
|
|
var coverFile = document.getElementById("cover-file");
|
|
var coverPreview = document.getElementById("cover-preview");
|
|
var previewUrl = "<%= to("/admin/preview") %>";
|
|
var uploadUrl = "<%= to("/admin/uploads") %>";
|
|
var csrf = "<%= csrf_token %>";
|
|
var timer = null;
|
|
|
|
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
|
|
|
|
// --- Markdown <-> HTML -------------------------------------------------
|
|
|
|
function mdToHtml(md) {
|
|
var data = new URLSearchParams();
|
|
data.set("body", md);
|
|
data.set("_csrf", csrf);
|
|
return fetch(previewUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: data.toString()
|
|
}).then(function (response) { return response.text(); });
|
|
}
|
|
|
|
function inlineToMd(node) {
|
|
var out = "";
|
|
node.childNodes.forEach(function (child) { out += inlineNode(child); });
|
|
return out;
|
|
}
|
|
|
|
function inlineNode(node) {
|
|
if (node.nodeType === 3) { return node.nodeValue.replace(/\s+/g, " "); }
|
|
if (node.nodeType !== 1) { return ""; }
|
|
var tag = node.tagName.toLowerCase();
|
|
var inner = inlineToMd(node);
|
|
if (tag === "strong" || tag === "b") { return "**" + inner.trim() + "**"; }
|
|
if (tag === "em" || tag === "i") { return "*" + inner.trim() + "*"; }
|
|
if (tag === "code") { return "`" + node.textContent + "`"; }
|
|
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
|
|
if (tag === "img") {
|
|
return " || "") + ")";
|
|
}
|
|
if (tag === "br") { return "\n"; }
|
|
return inner;
|
|
}
|
|
|
|
function listToMd(list, ordered) {
|
|
var index = 1;
|
|
var items = [];
|
|
list.childNodes.forEach(function (li) {
|
|
if (li.nodeType === 1 && li.tagName.toLowerCase() === "li") {
|
|
var marker = ordered ? (index++ + ". ") : "- ";
|
|
items.push(marker + inlineToMd(li).trim());
|
|
}
|
|
});
|
|
return items.join("\n");
|
|
}
|
|
|
|
function htmlToMd(root) {
|
|
var blocks = [];
|
|
root.childNodes.forEach(function (node) {
|
|
if (node.nodeType === 3) {
|
|
var text = node.nodeValue.trim();
|
|
if (text) { blocks.push(text); }
|
|
return;
|
|
}
|
|
if (node.nodeType !== 1) { return; }
|
|
var tag = node.tagName.toLowerCase();
|
|
if (/^h[1-6]$/.test(tag)) {
|
|
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
|
|
} else if (tag === "ul" || tag === "ol") {
|
|
blocks.push(listToMd(node, tag === "ol"));
|
|
} else if (tag === "blockquote") {
|
|
blocks.push(inlineToMd(node).trim().split("\n").map(function (line) {
|
|
return "> " + line;
|
|
}).join("\n"));
|
|
} else if (tag === "pre") {
|
|
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
|
|
} else if (tag === "hr") {
|
|
blocks.push("---");
|
|
} else {
|
|
var content = inlineToMd(node).trim();
|
|
if (content) { blocks.push(content); }
|
|
}
|
|
});
|
|
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
|
}
|
|
|
|
// --- Live preview (markdown mode) -------------------------------------
|
|
|
|
function renderPreview() {
|
|
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
|
|
}
|
|
|
|
function schedule() {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(renderPreview, 250);
|
|
}
|
|
|
|
// --- Image upload ------------------------------------------------------
|
|
|
|
function uploadImage(file, onDone) {
|
|
var data = new FormData();
|
|
data.append("file", file);
|
|
data.append("_csrf", csrf);
|
|
fetch(uploadUrl, { method: "POST", body: data })
|
|
.then(function (response) { return response.json(); })
|
|
.then(function (json) { if (json.url) { onDone(json.url); } });
|
|
}
|
|
|
|
imageInput.addEventListener("change", function () {
|
|
var file = imageInput.files[0];
|
|
if (!file) { return; }
|
|
uploadImage(file, function (url) {
|
|
if (visualView.hidden) {
|
|
insertIntoTextarea("");
|
|
} else {
|
|
exec("insertImage", url);
|
|
}
|
|
});
|
|
imageInput.value = "";
|
|
});
|
|
|
|
// --- Cover image -------------------------------------------------------
|
|
|
|
function refreshCoverPreview() {
|
|
if (coverInput.value) {
|
|
coverPreview.src = coverInput.value;
|
|
coverPreview.hidden = false;
|
|
} else {
|
|
coverPreview.hidden = true;
|
|
coverPreview.removeAttribute("src");
|
|
}
|
|
}
|
|
|
|
document.getElementById("cover-upload").addEventListener("click", function () {
|
|
coverFile.click();
|
|
});
|
|
document.getElementById("cover-clear").addEventListener("click", function () {
|
|
coverInput.value = "";
|
|
refreshCoverPreview();
|
|
});
|
|
coverFile.addEventListener("change", function () {
|
|
var file = coverFile.files[0];
|
|
if (!file) { return; }
|
|
uploadImage(file, function (url) {
|
|
coverInput.value = url;
|
|
refreshCoverPreview();
|
|
});
|
|
coverFile.value = "";
|
|
});
|
|
coverInput.addEventListener("input", refreshCoverPreview);
|
|
refreshCoverPreview();
|
|
|
|
// --- Markdown toolbar --------------------------------------------------
|
|
|
|
function insertIntoTextarea(text) {
|
|
var start = textarea.selectionStart;
|
|
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
|
|
textarea.focus();
|
|
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
|
schedule();
|
|
}
|
|
|
|
function wrap(before, after) {
|
|
var start = textarea.selectionStart;
|
|
var end = textarea.selectionEnd;
|
|
var selected = textarea.value.slice(start, end);
|
|
textarea.value = textarea.value.slice(0, start) + before + selected + after +
|
|
textarea.value.slice(end);
|
|
textarea.focus();
|
|
textarea.selectionStart = start + before.length;
|
|
textarea.selectionEnd = end + before.length;
|
|
schedule();
|
|
}
|
|
|
|
function linePrefix(prefix) {
|
|
var start = textarea.selectionStart;
|
|
var lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
|
|
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
|
|
textarea.focus();
|
|
schedule();
|
|
}
|
|
|
|
var mdActions = {
|
|
bold: function () { wrap("**", "**"); },
|
|
italic: function () { wrap("*", "*"); },
|
|
code: function () { wrap("`", "`"); },
|
|
link: function () { wrap("[", "](https://)"); },
|
|
h2: function () { linePrefix("## "); },
|
|
ul: function () { linePrefix("- "); },
|
|
quote: function () { linePrefix("> "); },
|
|
image: function () { imageInput.click(); }
|
|
};
|
|
|
|
// --- Visual toolbar ----------------------------------------------------
|
|
|
|
function exec(command, value) {
|
|
document.execCommand(command, false, value || null);
|
|
wysiwyg.focus();
|
|
updateToolbarState();
|
|
}
|
|
|
|
function wrapInline(tagName) {
|
|
var selection = window.getSelection();
|
|
if (!selection.rangeCount) { return; }
|
|
var element = document.createElement(tagName);
|
|
try { selection.getRangeAt(0).surroundContents(element); } catch (e) { /* spans blocks */ }
|
|
wysiwyg.focus();
|
|
updateToolbarState();
|
|
}
|
|
|
|
var richActions = {
|
|
bold: function () { exec("bold"); },
|
|
italic: function () { exec("italic"); },
|
|
h2: function () { exec("formatBlock", "H2"); },
|
|
h3: function () { exec("formatBlock", "H3"); },
|
|
quote: function () { exec("formatBlock", "BLOCKQUOTE"); },
|
|
clear: function () { exec("formatBlock", "P"); },
|
|
ul: function () { exec("insertUnorderedList"); },
|
|
ol: function () { exec("insertOrderedList"); },
|
|
code: function () { wrapInline("code"); },
|
|
image: function () { imageInput.click(); },
|
|
link: function () { var url = prompt("Link URL", "https://"); if (url) { exec("createLink", url); } }
|
|
};
|
|
|
|
// --- Toolbar active state ----------------------------------------------
|
|
|
|
function queryState(command) {
|
|
try { return document.queryCommandState(command); } catch (e) { return false; }
|
|
}
|
|
|
|
function currentBlock() {
|
|
try { return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, ""); }
|
|
catch (e) { return ""; }
|
|
}
|
|
|
|
function selectionInEditor() {
|
|
var node = window.getSelection().anchorNode;
|
|
while (node) { if (node === wysiwyg) { return true; } node = node.parentNode; }
|
|
return false;
|
|
}
|
|
|
|
function isInside(tagName) {
|
|
var node = window.getSelection().anchorNode;
|
|
while (node && node !== wysiwyg) {
|
|
if (node.nodeType === 1 && node.tagName.toLowerCase() === tagName) { return true; }
|
|
node = node.parentNode;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function updateToolbarState() {
|
|
if (visualView.hidden) { return; }
|
|
var active = {};
|
|
if (selectionInEditor()) {
|
|
var block = currentBlock();
|
|
active = {
|
|
bold: queryState("bold"),
|
|
italic: queryState("italic"),
|
|
ul: queryState("insertUnorderedList"),
|
|
ol: queryState("insertOrderedList"),
|
|
h2: block === "h2",
|
|
h3: block === "h3",
|
|
quote: block === "blockquote",
|
|
link: isInside("a"),
|
|
code: isInside("code")
|
|
};
|
|
}
|
|
document.querySelectorAll("[data-rich]").forEach(function (button) {
|
|
button.classList.toggle("active", !!active[button.dataset.rich]);
|
|
});
|
|
}
|
|
|
|
// --- Mode switching ----------------------------------------------------
|
|
|
|
function setActiveTab(name) {
|
|
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
|
|
button.classList.toggle("active", button.dataset.tab === name);
|
|
});
|
|
}
|
|
|
|
function showVisual() {
|
|
mdToHtml(textarea.value).then(function (html) { wysiwyg.innerHTML = html; });
|
|
visualView.hidden = false;
|
|
markdownView.hidden = true;
|
|
setActiveTab("visual");
|
|
}
|
|
|
|
function showMarkdown() {
|
|
textarea.value = htmlToMd(wysiwyg);
|
|
renderPreview();
|
|
visualView.hidden = true;
|
|
markdownView.hidden = false;
|
|
setActiveTab("markdown");
|
|
}
|
|
|
|
// --- Keyboard shortcuts ------------------------------------------------
|
|
|
|
function shortcut(actions) {
|
|
return function (event) {
|
|
if (!(event.ctrlKey || event.metaKey)) { return; }
|
|
var key = event.key.toLowerCase();
|
|
if (key === "b") { event.preventDefault(); actions.bold(); }
|
|
else if (key === "i") { event.preventDefault(); actions.italic(); }
|
|
else if (key === "k") { event.preventDefault(); actions.link(); }
|
|
};
|
|
}
|
|
|
|
// --- Wiring ------------------------------------------------------------
|
|
|
|
document.querySelectorAll("[data-md]").forEach(function (button) {
|
|
button.addEventListener("click", function () { mdActions[button.dataset.md](); });
|
|
});
|
|
document.querySelectorAll("[data-rich]").forEach(function (button) {
|
|
button.addEventListener("click", function () { richActions[button.dataset.rich](); });
|
|
});
|
|
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
|
|
button.addEventListener("click", function () {
|
|
if (button.dataset.tab === "visual") { showVisual(); } else { showMarkdown(); }
|
|
});
|
|
});
|
|
|
|
wysiwyg.addEventListener("paste", function (event) {
|
|
event.preventDefault();
|
|
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
|
|
document.execCommand("insertText", false, text);
|
|
});
|
|
wysiwyg.addEventListener("keydown", shortcut(richActions));
|
|
wysiwyg.addEventListener("keyup", updateToolbarState);
|
|
wysiwyg.addEventListener("mouseup", updateToolbarState);
|
|
document.addEventListener("selectionchange", updateToolbarState);
|
|
|
|
textarea.addEventListener("input", schedule);
|
|
textarea.addEventListener("keydown", shortcut(mdActions));
|
|
|
|
form.addEventListener("submit", function () {
|
|
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
|
|
});
|
|
|
|
// --- Preview toggle (persisted) ---------------------------------------
|
|
|
|
var editor = markdownView.querySelector(".editor");
|
|
var togglePreviewBtn = document.getElementById("toggle-preview");
|
|
|
|
function applyPreviewPreference() {
|
|
var off = false;
|
|
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
|
|
editor.classList.toggle("no-preview", off);
|
|
togglePreviewBtn.classList.toggle("active", !off);
|
|
if (!off) { renderPreview(); }
|
|
}
|
|
|
|
togglePreviewBtn.addEventListener("click", function () {
|
|
var turningOff = !editor.classList.contains("no-preview");
|
|
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
|
|
applyPreviewPreference();
|
|
});
|
|
|
|
// Start in Markdown mode (primary).
|
|
applyPreviewPreference();
|
|
})();
|
|
</script>
|