Files
volumen/lib/volumen/web/views/form.erb
T

572 lines
23 KiB
ERB

<%
crumbs = @mode == :new ? [["Posts", to("/admin/")], ["New post", nil]] : [["Posts", to("/admin/")], [@post.title.to_s.empty? ? "Untitled" : @post.title, nil]]
%>
<div class="page-head">
<div>
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
<p class="lede"><%= @mode == :new ? "Draft a new article in Markdown." : "Update and publish this post." %></p>
</div>
<div style="display: flex; gap: 0.5rem;">
<% if @mode == :edit %>
<a class="btn btn-ghost" href="<%= to("/api/volumen/posts/#{@post.slug}") %>" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Preview
</a>
<% end %>
<a class="btn btn-ghost" href="<%= to("/admin/") %>">Cancel</a>
<button type="submit" form="post-form" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
</div>
</div>
<% if @error %>
<div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div><%= h(@error) %></div>
</div>
<% 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="form-grid">
<div class="surface full">
<div class="surface-head">
<div>
<h2>Details</h2>
<p class="lede">Title, slug, and publishing options</p>
</div>
</div>
<div class="field full">
<label for="title">Title</label>
<input id="title" class="input" type="text" name="title" value="<%= h(@post.title) %>" placeholder="A clear, descriptive title" required>
</div>
<div class="field-row">
<div class="field">
<label for="slug">Slug</label>
<input id="slug" class="input" type="text" name="slug" value="<%= h(@post.slug) %>"
<%= @mode == :edit ? "readonly" : "required" %> placeholder="my-post-slug">
</div>
<div class="field">
<label for="lang">Language</label>
<input id="lang" class="input" type="text" name="lang" value="<%= h(@post.lang) %>" placeholder="en">
</div>
</div>
<div class="field-row">
<div class="field">
<label for="author">Author</label>
<input id="author" class="input" type="text" name="author" value="<%= h(@post.author) %>" placeholder="Petr Balvín">
</div>
<div class="field">
<label for="fediverse_creator">Fediverse creator</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
value="<%= h(@post.fediverse_creator) %>" placeholder="@user@instance.tld">
</div>
</div>
<div class="field-row-3">
<div class="field">
<label for="date">Publish date</label>
<input id="date" class="input" type="date" name="date" value="<%= h(@post.date_string || Date.today.iso8601) %>">
</div>
<div class="field">
<label for="publish_at">Schedule for</label>
<input id="publish_at" class="input" type="date" name="publish_at"
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>">
<p class="help">Leave empty to publish immediately</p>
</div>
<div class="field">
<label for="tags">Tags</label>
<input id="tags" class="input" type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>" placeholder="comma, separated">
</div>
</div>
<div class="field full">
<label for="excerpt">Excerpt</label>
<input id="excerpt" class="input" type="text" name="excerpt" value="<%= h(@post.excerpt) %>" placeholder="Short summary for listings and previews">
<p class="help">Auto-generated from the first paragraph if left empty</p>
</div>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<label class="checkbox">
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
</label>
<label class="checkbox">
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> Available in all languages
</label>
</div>
</div>
<div class="surface full">
<div class="surface-head">
<div>
<h2>Cover image</h2>
<p class="lede">Header image shown on the article page</p>
</div>
</div>
<div class="cover-field">
<div class="cover-input-row">
<input id="cover-input" class="input" type="text" name="cover" value="<%= h(@post.cover) %>"
placeholder="/media/… or https://…">
<button type="button" id="cover-upload" class="btn">Upload</button>
<button type="button" id="cover-clear" class="btn btn-ghost">Clear</button>
</div>
<input id="cover-alt-input" class="input" type="text" name="cover_alt" value="<%= h(@post.cover_alt) %>"
placeholder="ALT text (for accessibility)">
<input id="cover-caption-input" class="input" type="text" name="cover_caption" value="<%= h(@post.cover_caption) %>"
placeholder="Caption (e.g. 'Generated by AI')">
<img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden>
</div>
</div>
<div class="surface full">
<div class="editor-tabs" style="margin-bottom: 0.75rem;">
<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="editor-grid">
<div class="editor-pane">
<div class="editor-head">
<span class="label">Editor</span>
<span class="spacer"></span>
<button type="button" class="editor-toolbar-btn bold" data-rich="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" class="editor-toolbar-btn italic" data-rich="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" class="editor-toolbar-btn" data-rich="h2" title="Heading 2">H2</button>
<button type="button" class="editor-toolbar-btn" data-rich="h3" title="Heading 3">H3</button>
<button type="button" class="editor-toolbar-btn" data-rich="link" title="Link (Ctrl+K)"></button>
<button type="button" class="editor-toolbar-btn" data-rich="ul" title="Bullet list"></button>
<button type="button" class="editor-toolbar-btn" data-rich="ol" title="Numbered list">1.</button>
<button type="button" class="editor-toolbar-btn" data-rich="quote" title="Quote">"</button>
<button type="button" class="editor-toolbar-btn" data-rich="code" title="Code">/&gt;</button>
<button type="button" class="editor-toolbar-btn" data-rich="image" title="Image">IMG</button>
</div>
<div id="wysiwyg" class="markdown editor-preview" contenteditable="true"></div>
</div>
</div>
</div>
<div id="markdown-view">
<div class="editor-grid" id="editor-grid">
<div class="editor-pane">
<div class="editor-head">
<span class="label">Markdown</span>
<span class="spacer"></span>
<button type="button" class="editor-toolbar-btn bold" data-md="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" class="editor-toolbar-btn italic" data-md="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" class="editor-toolbar-btn" data-md="h2" title="Heading">H2</button>
<button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)"></button>
<button type="button" class="editor-toolbar-btn" data-md="ul" title="List"></button>
<button type="button" class="editor-toolbar-btn" data-md="quote" title="Quote">"</button>
<button type="button" class="editor-toolbar-btn" data-md="code" title="Code">&lt;/&gt;</button>
<button type="button" class="editor-toolbar-btn" data-md="image" title="Image">IMG</button>
<button type="button" id="toggle-preview" class="editor-toolbar-btn" title="Toggle preview">PREVIEW</button>
</div>
<textarea id="body" name="body" class="editor-textarea" rows="22" placeholder="Write your post in Markdown…"><%= h(@post.body) %></textarea>
</div>
<div class="editor-pane" id="preview-pane">
<div class="editor-head">
<span class="label">Preview</span>
</div>
<div id="preview" class="markdown editor-preview"></div>
</div>
</div>
</div>
</div>
</div>
<input type="file" id="image-input" accept="image/*" hidden>
</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") {
var title = node.getAttribute("title") || "";
var imgMd = "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
if (title) { imgMd = imgMd.replace(")", ' "' + title + '")'); }
return imgMd;
}
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) — disabled by default ----------------
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 (disabled by default) ------------------------------
var editorGrid = document.getElementById("editor-grid");
var previewPane = document.getElementById("preview-pane");
var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() {
var on = false;
try { on = localStorage.getItem("volumen.preview") === "on"; } catch (e) {}
if (on) {
editorGrid.classList.add("with-preview");
previewPane.hidden = false;
togglePreviewBtn.classList.add("active");
renderPreview();
} else {
editorGrid.classList.remove("with-preview");
previewPane.hidden = true;
togglePreviewBtn.classList.remove("active");
}
}
togglePreviewBtn.addEventListener("click", function () {
var turningOn = !editorGrid.classList.contains("with-preview");
try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
applyPreviewPreference();
});
// Start in Markdown mode (primary), preview OFF by default.
applyPreviewPreference();
})();
</script>