Files
volumen/lib/volumen/web/views/form.erb
T
petrbalvin 73be893bf8 feat: add scheduled publishing with publish_at frontmatter field
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.
2026-06-25 22:06:49 +02:00

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">&bull; List</button>
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
<button type="button" data-rich="quote" title="Quote">&ldquo;</button>
<button type="button" data-rich="code" title="Inline code">&lt;/&gt;</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">&ldquo;</button>
<button type="button" data-md="code" title="Code">&lt;/&gt;</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 "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
}
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("![](" + url + ")");
} 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>