feat(admin): redesign admin UI with modern layout

This commit is contained in:
2026-07-12 10:50:49 +02:00
parent 5fdf8e5ee8
commit 10ef258e84
6 changed files with 948 additions and 415 deletions
+7
View File
@@ -3,6 +3,7 @@
module Volumen
# Server-rendered admin routes, registered into Volumen::Server.
module AdminRoutes
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def self.registered(app)
app.get "/admin" do
redirect to("/admin/")
@@ -11,6 +12,7 @@ module Volumen
app.get "/admin/" do
require_login!
@posts = sorted_posts
@crumbs = [["Posts", nil]]
erb :list
end
@@ -43,6 +45,7 @@ module Volumen
require_login!
@mode = :new
@post = Post.new(metadata: { "lang" => config.language })
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
erb :form
end
@@ -57,6 +60,8 @@ module Volumen
@mode = :edit
@post = store.find(params["slug"])
halt(404, "Not found") if @post.nil?
title = @post.title.to_s.empty? ? params["slug"] : @post.title
@crumbs = [["Posts", to("/admin/")], [title, nil]]
erb :form
end
@@ -106,6 +111,7 @@ module Volumen
app.get "/admin/settings" do
require_login!
@crumbs = [["Settings", nil]]
render_settings
end
@@ -141,6 +147,7 @@ module Volumen
check_csrf!
remove_user(params["username"])
end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end
end
end
+204 -114
View File
@@ -1,108 +1,190 @@
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
<%
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 %>
<p class="error"><%= h(@error) %></p>
<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="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>Fediverse creator
<input type="text" name="fediverse_creator" value="<%= h(@post.fediverse_creator) %>"
placeholder="@user@instance.tld">
</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>
<input type="text" id="cover-alt-input" name="cover_alt" value="<%= h(@post.cover_alt) %>"
placeholder="ALT text (describe the image for accessibility)">
<input type="text" id="cover-caption-input" name="cover_caption" value="<%= h(@post.cover_caption) %>"
placeholder="Caption (e.g. 'Generated by AI', shown in popup)">
<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 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>
<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 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) %>">
</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>
<button type="submit" class="primary">Save</button>
</form>
<script>
@@ -204,7 +286,7 @@
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
}
// --- Live preview (markdown mode) -------------------------------------
// --- Live preview (markdown mode) — disabled by default ----------------
function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
@@ -227,17 +309,17 @@
}
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 = "";
});
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 -------------------------------------------------------
@@ -456,26 +538,34 @@
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
});
// --- Preview toggle (persisted) ---------------------------------------
// --- Preview toggle (disabled by default) ------------------------------
var editor = markdownView.querySelector(".editor");
var editorGrid = document.getElementById("editor-grid");
var previewPane = document.getElementById("preview-pane");
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(); }
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 turningOff = !editor.classList.contains("no-preview");
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
var turningOn = !editorGrid.classList.contains("with-preview");
try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
applyPreviewPreference();
});
// Start in Markdown mode (primary).
applyPreviewPreference();
})();
// Start in Markdown mode (primary), preview OFF by default.
applyPreviewPreference();
})();
</script>
+511 -210
View File
@@ -4,251 +4,552 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root {
color-scheme: light dark;
--fg: #1b1b1f; --muted: #6b7280; --bg: #f6f7f9; --card: #ffffff;
--border: #e4e4ec; --accent: #4f46e5; --accent-strong: #4338ca;
--accent-weak: #eef2ff; --on-accent: #ffffff;
--danger: #dc2626; --danger-weak: #fef2f2;
--ok: #16a34a; --ok-weak: #f0fdf4;
--ring: 0 0 0 3px rgba(79, 70, 229, 0.30);
--shadow-sm: 0 1px 2px rgba(17, 17, 26, 0.06);
--shadow: 0 1px 3px rgba(17, 17, 26, 0.06), 0 10px 30px -16px rgba(17, 17, 26, 0.25);
--radius: 12px;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, ui-monospace, monospace;
--fg: #1a1a1f; --fg-muted: #6b7280; --fg-subtle: #9ca3af;
--bg: #f7f7f9; --surface: #ffffff; --surface-2: #fafafb;
--border: #e5e5ea; --border-strong: #d1d1d6;
--accent: #4f46e5; --accent-hover: #4338ca; --accent-soft: #eef2ff; --on-accent: #ffffff;
--danger: #dc2626; --danger-soft: #fef2f2;
--ok: #16a34a; --ok-soft: #f0fdf4;
--warn: #d97706; --warn-soft: #fffbeb;
--shadow-xs: 0 1px 2px rgba(0,0,0,0.04);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(0,0,0,0.04);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.04);
--radius-sm: 6px; --radius: 10px; --radius-lg: 14px; --radius-xl: 20px;
--ring: 0 0 0 3px rgba(79, 70, 229, 0.18);
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e7e7ea; --muted: #9aa0aa; --bg: #121217; --card: #1c1c22;
--border: #2c2c35; --accent: #818cf8; --accent-strong: #a5b4fc;
--accent-weak: rgba(129, 140, 248, 0.16); --on-accent: #14141a;
--danger: #f87171; --danger-weak: rgba(248, 113, 113, 0.14);
--ok: #4ade80; --ok-weak: rgba(74, 222, 128, 0.14);
--ring: 0 0 0 3px rgba(129, 140, 248, 0.40);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px -16px rgba(0, 0, 0, 0.7);
--fg: #ececef; --fg-muted: #9ca3af; --fg-subtle: #6b7280;
--bg: #0c0c10; --surface: #16161b; --surface-2: #1c1c22;
--border: #2a2a32; --border-strong: #3a3a44;
--accent: #818cf8; --accent-hover: #a5b4fc; --accent-soft: rgba(129, 140, 248, 0.12); --on-accent: #0c0c10;
--danger: #f87171; --danger-soft: rgba(248, 113, 113, 0.10);
--ok: #4ade80; --ok-soft: rgba(74, 222, 128, 0.10);
--warn: #fbbf24; --warn-soft: rgba(251, 191, 36, 0.10);
--ring: 0 0 0 3px rgba(129, 140, 248, 0.28);
}
.brand-logo { background: #f4f4f6; border-radius: 8px; padding: 4px 8px; }
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; min-height: 100vh; background: var(--bg); }
/* Sidebar */
.sidebar {
width: 230px; flex-shrink: 0; position: sticky; top: 0; height: 100vh;
overflow-y: auto; background: var(--card); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 1.25rem 0;
}
.sidebar .brand { display: flex; padding: 0 1.25rem; margin-bottom: 1rem; text-decoration: none; }
.brand-logo { height: 28px; display: block; }
.sidebar .who {
margin: 0 1.25rem; padding: 0.3rem 0.6rem; border: 1px solid var(--border);
border-radius: 999px; font-size: 0.78rem; color: var(--muted); align-self: flex-start;
}
.sidenav { display: flex; flex-direction: column; gap: 1px; padding: 0.75rem 0.75rem; flex: 1; }
.sidenav a, .sidenav button {
display: flex; align-items: center; text-decoration: none; color: var(--fg);
font: inherit; font-size: 0.9rem; font-weight: 500; padding: 0.55rem 0.8rem;
border-radius: 9px; border: none; background: none; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sidenav a:hover, .sidenav button:hover { background: var(--accent-weak); color: var(--accent-strong); }
.sidenav a.active { background: var(--accent); color: var(--on-accent); }
.sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* Main */
main {
flex: 1; overflow: auto; padding: 2rem 2rem 4rem;
color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
font-size: 14px; line-height: 1.5;
color: var(--fg); background: var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "cv11", "ss01", "ss03";
}
.main-inner { max-width: 1060px; margin: 0 auto; }
::selection { background: var(--accent-weak); }
a { color: var(--accent); }
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 1.25rem; }
h2 { font-size: 1.15rem; font-weight: 650; margin: 0 0 0.75rem; }
h3 { font-size: 0.95rem; font-weight: 650; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.toolbar h1 { margin: 0; }
/* --- Shell --- */
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
}
.sidebar-head {
display: flex; align-items: center; gap: 0.6rem;
padding: 1.25rem 1.1rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar-head .logo {
width: 28px; height: 28px; display: grid; place-items: center;
background: var(--accent); color: var(--on-accent);
border-radius: 8px; font-weight: 700; font-size: 0.85rem;
letter-spacing: -0.02em;
}
.sidebar-head .name {
font-size: 0.95rem; font-weight: 600; letter-spacing: -0.01em;
}
.sidebar-nav { padding: 0.75rem; flex: 1; overflow-y: auto; }
.nav-section { margin-bottom: 0.75rem; }
.nav-label {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--fg-subtle); font-weight: 600;
padding: 0.5rem 0.75rem 0.4rem;
}
.nav-item {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 0.75rem; border-radius: var(--radius-sm);
color: var(--fg-muted); text-decoration: none; font-weight: 500;
font-size: 0.875rem;
transition: background 0.12s, color 0.12s;
border: none; background: none; cursor: pointer; width: 100%; text-align: left;
font-family: inherit;
}
.nav-item:hover { background: var(--surface-2); color: var(--fg); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; }
.nav-divider { height: 1px; background: var(--border); margin: 0.75rem 0; }
/* Buttons */
button, .button {
font: inherit; font-weight: 550; cursor: pointer; display: inline-flex;
align-items: center; gap: 0.4rem; border: 1px solid var(--border);
background: var(--card); color: var(--fg); border-radius: 9px;
padding: 0.45rem 0.9rem; text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.05s, box-shadow 0.15s;
.sidebar-foot {
padding: 0.85rem 0.9rem; border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 0.6rem;
}
button:hover, .button:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
button:active, .button:active { transform: translateY(1px); }
button:focus-visible, .button:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible,
[contenteditable]:focus-visible, .sidenav a:focus-visible, .sidenav button:focus-visible {
outline: none; box-shadow: var(--ring);
.avatar {
width: 30px; height: 30px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
display: grid; place-items: center; font-weight: 600; font-size: 0.78rem;
flex-shrink: 0;
}
button.primary, .button.primary {
background: var(--accent); border-color: var(--accent); color: var(--on-accent);
.user-info { flex: 1; min-width: 0; }
.user-info .name { font-size: 0.82rem; font-weight: 600; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-info .role { font-size: 0.7rem; color: var(--fg-subtle); text-transform: capitalize; }
.icon-btn {
width: 30px; height: 30px; display: grid; place-items: center;
background: transparent; border: none; border-radius: var(--radius-sm);
color: var(--fg-muted); cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.icon-btn:hover { background: var(--surface-2); color: var(--fg); }
.icon-btn svg { width: 16px; height: 16px; }
/* --- Main --- */
.main { display: flex; flex-direction: column; min-width: 0; }
.topbar {
position: sticky; top: 0; z-index: 10;
background: color-mix(in srgb, var(--bg) 80%, transparent);
backdrop-filter: saturate(180%) blur(12px);
-webkit-backdrop-filter: saturate(180%) blur(12px);
border-bottom: 1px solid var(--border);
padding: 0.85rem 1.5rem;
display: flex; align-items: center; gap: 1rem;
}
.crumbs {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; color: var(--fg-muted);
}
.crumbs a { color: var(--fg-muted); text-decoration: none; }
.crumbs a:hover { color: var(--fg); }
.crumbs .sep { color: var(--fg-subtle); }
.crumbs .current { color: var(--fg); font-weight: 500; }
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
.content { padding: 1.75rem 1.5rem 3rem; max-width: 1200px; width: 100%; margin: 0 auto; }
.content-narrow { max-width: 820px; }
h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.3rem; }
h2 { font-size: 1.15rem; font-weight: 650; letter-spacing: -0.01em; margin: 0 0 0.5rem; }
h3 { font-size: 0.95rem; font-weight: 650; margin: 0 0 0.4rem; }
.page-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; margin-bottom: 1.5rem;
}
.page-head .lede { color: var(--fg-muted); font-size: 0.9rem; margin-top: 0.25rem; }
/* --- Buttons --- */
.btn {
display: inline-flex; align-items: center; gap: 0.45rem;
font: inherit; font-weight: 550; font-size: 0.85rem;
padding: 0.5rem 0.9rem; border-radius: var(--radius);
background: var(--surface); color: var(--fg);
border: 1px solid var(--border);
cursor: pointer; text-decoration: none;
transition: background 0.12s, border-color 0.12s, transform 0.05s, box-shadow 0.12s;
}
.btn:hover { background: var(--surface-2); border-color: var(--border-strong); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: none; box-shadow: var(--ring); }
.btn svg { width: 14px; height: 14px; }
.btn-primary {
background: var(--accent); color: var(--on-accent);
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
button.primary:hover, .button.primary:hover {
background: var(--accent-strong); border-color: var(--accent-strong);
}
button.danger {
color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
background: transparent;
}
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
form.inline { display: inline; margin: 0; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--fg-muted); }
.btn-ghost:hover { background: var(--surface-2); color: var(--fg); border-color: transparent; }
.btn-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); background: transparent; }
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.78rem; gap: 0.3rem; }
.btn-sm svg { width: 12px; height: 12px; }
.btn-icon { padding: 0.45rem; }
/* Cards */
.card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow);
/* --- Forms --- */
.field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
.field-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; }
.field-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; }
.field label, .field > .label {
font-size: 0.78rem; font-weight: 600; color: var(--fg-muted);
letter-spacing: 0.01em;
}
.card h2 { margin-top: 0; }
.card h3 { margin: 1.5rem 0 0.6rem; }
.card form { margin-bottom: 1.25rem; }
.card label { max-width: 380px; margin: 0.5rem 0; }
/* Forms */
.fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; margin-bottom: 1.25rem; }
label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.8rem;
font-weight: 550; color: var(--muted); }
label.check { flex-direction: row; align-items: center; gap: 0.5rem; }
.cover-field { margin-bottom: 1rem; }
.cover-field > label { display: block; font-size: 0.8rem; font-weight: 550;
color: var(--muted); margin-bottom: 0.3rem; }
.cover-row { display: flex; gap: 0.5rem; align-items: center; }
.cover-row input { flex: 1; }
.cover-preview { margin-top: 0.6rem; max-height: 120px; border-radius: 8px;
border: 1px solid var(--border); display: block; }
input[type=text], input[type=password], input[type=date], input:not([type]), select, textarea {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--border); border-radius: 9px; padding: 0.5rem 0.7rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
/* Tables */
table {
width: 100%; border-collapse: collapse; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow); margin-bottom: 1.5rem;
}
th, td { text-align: left; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
background: color-mix(in srgb, var(--bg) 60%, var(--card)); }
tbody tr:hover td { background: color-mix(in srgb, var(--accent-weak) 50%, transparent); }
tr:last-child td { border-bottom: none; }
td.actions { display: flex; gap: 0.5rem; align-items: center; }
/* Alerts */
.error, .notice {
border: 1px solid; border-radius: 10px; padding: 0.7rem 0.95rem; margin: 0 0 1.25rem;
font-size: 0.9rem;
}
.error { color: var(--danger); background: var(--danger-weak);
border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { color: var(--ok); background: var(--ok-weak);
border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
.muted { color: var(--muted); }
/* Login */
.login {
max-width: 380px; margin: 4rem auto; background: var(--card);
.field .help { font-size: 0.75rem; color: var(--fg-subtle); margin-top: 0.15rem; }
.input, .select, textarea {
font: inherit; color: var(--fg); background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 2rem; box-shadow: var(--shadow);
padding: 0.55rem 0.75rem; width: 100%;
transition: border-color 0.12s, box-shadow 0.12s;
}
.login h1 { text-align: center; }
.login label, .login button { width: 100%; }
.login button { margin-top: 1.25rem; justify-content: center; }
.input::placeholder, textarea::placeholder { color: var(--fg-subtle); }
.input:focus, .select:focus, textarea:focus {
outline: none; border-color: var(--accent); box-shadow: var(--ring);
}
.input:disabled, .select:disabled { background: var(--surface-2); color: var(--fg-muted); cursor: not-allowed; }
textarea { font-family: var(--font-mono); font-size: 0.85rem; line-height: 1.65; resize: vertical; min-height: 6rem; }
.checkbox {
display: inline-flex; align-items: center; gap: 0.5rem;
font-size: 0.85rem; color: var(--fg); cursor: pointer;
}
.checkbox input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
/* Editor */
.editor-tabs {
display: inline-flex; gap: 2px; background: var(--card); padding: 3px;
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.9rem;
/* --- Cards & surfaces --- */
.surface {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.5rem;
}
.editor-tabs button { border: none; background: transparent; color: var(--muted);
border-radius: 7px; padding: 0.35rem 0.95rem; font-size: 0.85rem; }
.editor-tabs button.active { background: var(--accent); color: var(--on-accent); }
.editor { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
.editor.no-preview { grid-template-columns: 1fr; }
.editor.no-preview .preview-pane { display: none; }
.pane { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
background: var(--card); box-shadow: var(--shadow-sm); }
.ed-toolbar { display: flex; gap: 0.3rem; padding: 0.45rem; align-items: center;
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 50%, var(--card)); }
.ed-toolbar button { padding: 0.28rem 0.55rem; font-size: 0.82rem; border-radius: 7px;
background: transparent; }
.ed-toolbar button:hover { background: var(--accent-weak); }
.ed-toolbar button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ed-toolbar span { color: var(--muted); font-size: 0.78rem; font-weight: 550; }
.ed-toolbar .toggle { margin-left: auto; }
textarea { width: 100%; border: none; border-radius: 0; padding: 0.85rem 1rem;
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical;
background: var(--card); color: var(--fg); }
textarea:focus { box-shadow: none; }
#preview, #wysiwyg { padding: 0.85rem 1.1rem; min-height: 22rem; }
#wysiwyg { outline: none; }
#wysiwyg:focus { box-shadow: inset 0 0 0 2px var(--accent); }
#preview img, #wysiwyg img { max-width: 100%; }
.surface + .surface { margin-top: 1rem; }
.surface-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.1rem; gap: 1rem;
}
.surface-head h2 { margin: 0; }
.surface-head .lede { font-size: 0.85rem; color: var(--fg-muted); margin-top: 0.15rem; }
/* --- Alerts --- */
.alert {
display: flex; gap: 0.7rem; padding: 0.85rem 1rem;
border-radius: var(--radius); border: 1px solid;
font-size: 0.85rem; margin-bottom: 1.25rem;
}
.alert svg { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; }
.alert-error { color: var(--danger); background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
.alert-success { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.alert-warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
/* --- Post list cards --- */
.post-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.post-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
display: flex; flex-direction: column; text-decoration: none; color: inherit;
}
.post-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
.post-cover {
aspect-ratio: 16 / 9; background: var(--surface-2);
background-size: cover; background-position: center;
border-bottom: 1px solid var(--border);
}
.post-cover.placeholder {
display: grid; place-items: center; color: var(--fg-subtle);
}
.post-cover.placeholder svg { width: 32px; height: 32px; }
.post-body { padding: 1rem 1.1rem 1.1rem; flex: 1; display: flex; flex-direction: column; gap: 0.4rem; }
.post-title { font-size: 0.95rem; font-weight: 600; letter-spacing: -0.01em; color: var(--fg); margin: 0; }
.post-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; font-size: 0.75rem; color: var(--fg-muted); }
.post-excerpt { font-size: 0.82rem; color: var(--fg-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 0.25rem; }
.post-actions { display: flex; gap: 0.4rem; padding: 0.5rem 0.75rem; border-top: 1px solid var(--border); background: var(--surface-2); }
.badge {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.02em;
padding: 0.18rem 0.5rem; border-radius: 999px;
text-transform: uppercase;
}
.badge-neutral { background: var(--surface-2); color: var(--fg-muted); border: 1px solid var(--border); }
.badge-accent { background: var(--accent-soft); color: var(--accent); }
.badge-warn { background: var(--warn-soft); color: var(--warn); }
.badge-ok { background: var(--ok-soft); color: var(--ok); }
.badge-lang { font-family: var(--font-mono); font-size: 0.65rem; }
.toolbar {
display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.25rem; flex-wrap: wrap;
}
.toolbar .spacer { flex: 1; }
.search {
position: relative; flex: 1; max-width: 360px; min-width: 200px;
}
.search input { padding-left: 2.2rem; }
.search svg { position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--fg-subtle); }
.filters { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.filter-chip {
font: inherit; font-size: 0.78rem; font-weight: 550;
padding: 0.35rem 0.75rem; border-radius: 999px;
background: var(--surface); border: 1px solid var(--border); color: var(--fg-muted);
cursor: pointer; text-decoration: none;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.filter-chip:hover { background: var(--surface-2); color: var(--fg); }
.filter-chip.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.empty {
text-align: center; padding: 3rem 1rem; color: var(--fg-muted);
background: var(--surface); border: 1px dashed var(--border);
border-radius: var(--radius-lg);
}
.empty svg { width: 40px; height: 40px; color: var(--fg-subtle); margin-bottom: 0.75rem; }
/* --- Editor --- */
.editor-grid { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
.editor-grid.with-preview { grid-template-columns: 1fr 1fr; }
.editor-pane {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); overflow: hidden;
display: flex; flex-direction: column;
}
.editor-head {
display: flex; align-items: center; gap: 0.3rem;
padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.editor-head .label { font-size: 0.72rem; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; padding: 0 0.4rem; }
.editor-head .spacer { flex: 1; }
.editor-toolbar-btn {
width: 30px; height: 30px; display: grid; place-items: center;
background: transparent; border: none; border-radius: var(--radius-sm);
color: var(--fg-muted); cursor: pointer; font: inherit; font-size: 0.85rem; font-weight: 600;
transition: background 0.12s, color 0.12s;
}
.editor-toolbar-btn:hover { background: var(--surface); color: var(--fg); }
.editor-toolbar-btn.active { background: var(--accent); color: var(--on-accent); }
.editor-toolbar-btn svg { width: 14px; height: 14px; }
.editor-toolbar-btn.bold { font-weight: 700; }
.editor-toolbar-btn.italic { font-style: italic; }
.editor-textarea {
border: none; border-radius: 0; min-height: 480px;
padding: 1.25rem 1.5rem; background: var(--surface);
font-family: var(--font-mono); font-size: 0.85rem; line-height: 1.7;
resize: vertical;
}
.editor-textarea:focus { box-shadow: none; }
.editor-preview { padding: 1.25rem 1.5rem; min-height: 480px; overflow: auto; }
.editor-tabs {
display: inline-flex; gap: 2px; background: var(--surface-2);
padding: 3px; border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 1rem;
}
.editor-tabs button {
font: inherit; font-size: 0.8rem; font-weight: 550;
padding: 0.35rem 0.85rem; border-radius: 7px; border: none;
background: transparent; color: var(--fg-muted); cursor: pointer;
}
.editor-tabs button.active { background: var(--surface); color: var(--fg); box-shadow: var(--shadow-sm); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.form-grid .full { grid-column: 1 / -1; }
.form-actions { display: flex; gap: 0.6rem; align-items: center; margin-top: 1.5rem; }
.form-actions .spacer { flex: 1; }
/* --- Cover field --- */
.cover-field { display: flex; flex-direction: column; gap: 0.5rem; }
.cover-input-row { display: flex; gap: 0.5rem; }
.cover-input-row .input { flex: 1; min-width: 0; }
.cover-preview { max-height: 180px; border-radius: var(--radius); border: 1px solid var(--border); object-fit: cover; }
/* --- Tables --- */
.data-table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.data-table th, .data-table td { text-align: left; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); }
.data-table th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-muted); background: var(--surface-2); font-weight: 600; }
.data-table tbody tr:hover td { background: var(--surface-2); }
.data-table tr:last-child td { border-bottom: none; }
/* --- Settings --- */
.settings-nav {
display: flex; gap: 0.4rem; margin-bottom: 1.25rem;
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.settings-nav a {
padding: 0.55rem 0.9rem; color: var(--fg-muted); text-decoration: none;
font-size: 0.85rem; font-weight: 550; border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.settings-nav a:hover { color: var(--fg); }
.settings-nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
[hidden] { display: none !important; }
/* Rendered Markdown */
/* --- Login split-screen --- */
.login-page {
min-height: 100vh; display: grid; grid-template-columns: 1fr 1fr;
background: var(--bg);
}
.login-side {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%);
color: #fff; display: flex; flex-direction: column; justify-content: space-between;
padding: 3rem; position: relative; overflow: hidden;
}
.login-side::before {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.18) 1px, transparent 0);
background-size: 28px 28px; opacity: 0.7;
}
.login-side > * { position: relative; }
.login-side .brand-mark {
display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; letter-spacing: -0.01em;
}
.login-side .brand-mark .logo {
width: 32px; height: 32px; display: grid; place-items: center;
background: rgba(255,255,255,0.2); border-radius: 8px; backdrop-filter: blur(8px);
font-weight: 700;
}
.login-side .pitch { max-width: 480px; }
.login-side .pitch h1 { font-size: 2.5rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; margin: 0 0 1rem; }
.login-side .pitch p { font-size: 1.05rem; line-height: 1.55; opacity: 0.9; margin: 0; }
.login-side .features { display: flex; flex-direction: column; gap: 0.65rem; }
.login-side .feature { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; opacity: 0.95; }
.login-side .feature svg { width: 16px; height: 16px; opacity: 0.85; }
.login-form-side {
display: flex; align-items: center; justify-content: center;
padding: 2rem;
}
.login-card { width: 100%; max-width: 380px; }
.login-card h2 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 0.4rem; }
.login-card .lede { color: var(--fg-muted); font-size: 0.9rem; margin: 0 0 1.5rem; }
.login-card .btn { width: 100%; justify-content: center; padding: 0.6rem; font-size: 0.9rem; }
@media (max-width: 900px) {
.login-page { grid-template-columns: 1fr; }
.login-side { display: none; }
}
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.form-grid, .field-row, .field-row-3 { grid-template-columns: 1fr; }
.editor-grid.with-preview { grid-template-columns: 1fr; }
.editor-grid { grid-template-columns: 1fr; }
}
/* --- Markdown render --- */
.markdown { line-height: 1.7; }
.markdown > :first-child { margin-top: 0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
.markdown h1 { font-size: 1.6rem; }
.markdown h2 { font-size: 1.3rem; }
.markdown h3 { font-size: 1.1rem; }
.markdown p { margin: 0.7em 0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.25; margin: 1.4em 0 0.5em; letter-spacing: -0.01em; }
.markdown h1 { font-size: 1.5rem; }
.markdown h2 { font-size: 1.25rem; }
.markdown h3 { font-size: 1.05rem; }
.markdown p { margin: 0.6em 0; }
.markdown a { color: var(--accent); }
.markdown ul, .markdown ol { padding-left: 1.5rem; margin: 0.7em 0; }
.markdown ul, .markdown ol { padding-left: 1.5rem; margin: 0.6em 0; }
.markdown li { margin: 0.2em 0; }
.markdown blockquote { margin: 0.9em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent);
color: var(--muted); background: var(--accent-weak); border-radius: 0 8px 8px 0; }
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; }
.markdown pre { background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem 1rem; overflow-x: auto; margin: 0.9em 0; }
.markdown pre code { background: none; border: none; padding: 0; font-size: 0.88em; }
.markdown table { border-collapse: collapse; width: 100%; margin: 0.9em 0; font-size: 0.95em;
box-shadow: none; }
.markdown blockquote { margin: 0.8em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent); color: var(--fg-muted); background: var(--accent-soft); border-radius: 0 var(--radius) var(--radius) 0; }
.markdown code { font-family: var(--font-mono); font-size: 0.88em; background: var(--surface-2); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; }
.markdown pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.9rem 1rem; overflow-x: auto; margin: 0.8em 0; }
.markdown pre code { background: none; border: none; padding: 0; font-size: 0.85em; }
.markdown table { border-collapse: collapse; width: 100%; margin: 0.8em 0; font-size: 0.92em; box-shadow: none; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; }
.markdown thead th { background: var(--accent-weak); color: var(--fg); }
.markdown tbody tr:hover td { background: transparent; }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: 8px; }
.markdown thead th { background: var(--accent-soft); color: var(--fg); }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: var(--radius); }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
</style>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="<%= to("/admin/") %>"><img class="brand-logo" src="<%= to("/admin/logo.svg") %>" alt="volumen"></a>
<% if authenticated? %>
<span class="who"><%= h(current_user) %> · <%= h(current_role) %></span>
<% settings_active = request.path_info.start_with?("/admin/settings") %>
<nav class="sidenav">
<a href="<%= to("/admin/") %>" class="<%= settings_active ? "" : "active" %>">Posts</a>
<a href="<%= to("/admin/settings") %>" class="<%= settings_active ? "active" : "" %>">Settings</a>
</nav>
<div class="sidebar-footer">
<form method="post" action="<%= to("/admin/logout") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit">Log out</button>
</form>
</div>
<% end %>
</aside>
<main>
<div class="main-inner">
<%= yield %>
<% if request.path_info == "/admin/login" || (defined?(login_page) && login_page) %>
<div class="login-page">
<aside class="login-side">
<div class="brand-mark">
<span class="logo">V</span>
<span>volumen</span>
</div>
<div class="pitch">
<h1>Markdown that ships.</h1>
<p>A tiny, file-backed blog engine. No database, no admin panel bloat — just write, preview, and publish.</p>
</div>
<div class="features">
<div class="feature">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>File-backed Markdown with TOML frontmatter</span>
</div>
<div class="feature">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>Live preview, draft scheduling, multi-language</span>
</div>
<div class="feature">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>JSON API, RSS, ActivityPub-ready</span>
</div>
</div>
</aside>
<main class="login-form-side">
<div class="login-card">
<%= yield %>
</div>
</main>
</div>
</main>
<% else %>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<div class="logo">V</div>
<div class="name">volumen</div>
</div>
<% if authenticated? %>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-label">Content</div>
<a href="<%= to("/admin/") %>" class="nav-item <%= request.path_info == "/admin/" || request.path_info == "/admin" ? "active" : "" %>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
Posts
</a>
<a href="<%= to("/admin/posts/new") %>" class="nav-item">
<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="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
New post
</a>
</div>
<div class="nav-divider"></div>
<div class="nav-section">
<div class="nav-label">Configure</div>
<a href="<%= to("/admin/settings") %>" class="nav-item <%= request.path_info.start_with?("/admin/settings") ? "active" : "" %>">
<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</div>
</nav>
<div class="sidebar-foot">
<div class="avatar"><%= h(current_user.to_s[0].upcase) %></div>
<div class="user-info">
<div class="name"><%= h(current_user) %></div>
<div class="role"><%= h(current_role) %></div>
</div>
<form method="post" action="<%= to("/admin/logout") %>" style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="icon-btn" title="Log out" aria-label="Log out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</form>
</div>
<% end %>
</aside>
<div class="main">
<header class="topbar">
<% if defined?(@crumbs) && @crumbs %>
<nav class="crumbs">
<% @crumbs.each_with_index do |(label, href), i| %>
<% if i > 0 %><span class="sep">/</span><% end %>
<% if href && i < @crumbs.length - 1 %>
<a href="<%= href %>"><%= label %></a>
<% else %>
<span class="current"><%= label %></span>
<% end %>
<% end %>
</nav>
<% end %>
</header>
<main class="content">
<%= yield %>
</main>
</div>
</div>
<% end %>
</body>
</html>
+107 -28
View File
@@ -1,34 +1,113 @@
<div class="page-head">
<div>
<h1>Posts</h1>
<p class="lede"><%= @posts.length %> post<%= @posts.length == 1 ? "" : "s" %> in total</p>
</div>
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New post
</a>
</div>
<div class="toolbar">
<h1>Posts</h1>
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="post-search" class="input" type="text" placeholder="Search posts...">
</div>
<div class="filters" id="post-filters">
<button type="button" class="filter-chip active" data-filter="all">All</button>
<button type="button" class="filter-chip" data-filter="published">Published</button>
<button type="button" class="filter-chip" data-filter="draft">Drafts</button>
<button type="button" class="filter-chip" data-filter="scheduled">Scheduled</button>
</div>
</div>
<% if @posts.empty? %>
<p>No posts yet. Create your first one.</p>
<div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<h3>No posts yet</h3>
<p>Create your first post to get started.</p>
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>" style="margin-top: 0.75rem;">Create post</a>
</div>
<% else %>
<table>
<thead>
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= h(post.title) %></td>
<td><%= h(post.slug) %></td>
<td><%= h(post.lang) %></td>
<td><%= h(post.date_string) %></td>
<td><%= post.draft? ? "yes" : "" %></td>
<td class="actions">
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a>
<form class="inline" method="post"
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
onsubmit="return confirm('Delete this post?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Delete</button>
</form>
</td>
</tr>
<% end %>
</tbody>
</table>
<div class="post-grid" id="post-grid">
<% @posts.each do |post| %>
<%
status = if post.draft?
"draft"
elsif post.scheduled?
"scheduled"
else
"published"
end
%>
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
class="post-card"
data-title="<%= h(post.title.to_s.downcase) %>"
data-slug="<%= h(post.slug) %>"
data-status="<%= status %>">
<% if post.cover %>
<div class="post-cover" style="background-image: url('<%= h(post.cover) %>');"></div>
<% else %>
<div class="post-cover placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<% end %>
<div class="post-body">
<h3 class="post-title"><%= h(post.title) %></h3>
<% if post.excerpt && !post.excerpt.to_s.empty? %>
<p class="post-excerpt"><%= h(post.excerpt) %></p>
<% end %>
<div class="post-meta">
<span class="badge badge-lang"><%= h(post.lang) %></span>
<% if post.draft? %><span class="badge badge-warn">Draft</span><% end %>
<% if post.scheduled? %><span class="badge badge-accent">Scheduled</span><% end %>
<% if post.date_string %><span><%= h(post.date_string) %></span><% end %>
</div>
</div>
<div class="post-actions">
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
<span class="spacer" style="flex: 1;"></span>
<form method="post" action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
</div>
</a>
<% end %>
</div>
<% end %>
<script>
(function () {
var search = document.getElementById("post-search");
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
var cards = document.querySelectorAll("#post-grid .post-card");
var currentFilter = "all";
function applyFilters() {
var q = (search.value || "").toLowerCase().trim();
cards.forEach(function (card) {
var title = card.dataset.title;
var slug = card.dataset.slug;
var status = card.dataset.status;
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
var matchesFilter = currentFilter === "all" || status === currentFilter;
card.style.display = matchesSearch && matchesFilter ? "" : "none";
});
}
if (search) search.addEventListener("input", applyFilters);
filterButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
filterButtons.forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
currentFilter = btn.dataset.filter;
applyFilters();
});
});
})();
</script>
+29 -23
View File
@@ -1,23 +1,29 @@
<div class="login">
<h1>Sign in</h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<% unless users.any? %>
<p class="error">
No users are configured. Set <code>admin.password_hash</code> in your
config (you will sign in as <code>admin</code>) or generate one with
<code>volumen hash-password</code>.
</p>
<% end %>
<form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<label>Username
<input type="text" name="username" autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit" class="primary">Sign in</button>
</form>
</div>
<h2>Sign in</h2>
<p class="lede">Welcome back. Sign in to manage your posts.</p>
<% 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 %>
<% unless users.any? %>
<div class="alert alert-warn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
</div>
<% end %>
<form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field">
<label for="username">Username</label>
<input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" class="input" type="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
+90 -40
View File
@@ -1,49 +1,89 @@
<h1>Settings</h1>
<div class="page-head">
<div>
<h1>Settings</h1>
<p class="lede">Manage your account and users</p>
</div>
</div>
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %>
<% if @error %><p class="error"><%= h(@error) %></p><% end %>
<% if @notice %>
<div class="alert alert-success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<div><%= h(@notice) %></div>
</div>
<% end %>
<% 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 %>
<section class="card">
<h2>Your account</h2>
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
<div class="surface">
<div class="surface-head">
<div>
<h2>Your account</h2>
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
</div>
</div>
<form method="post" action="<%= to("/admin/settings/password") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change password</h3>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
</label>
<label>New password
<input type="password" name="new_password" autocomplete="new-password">
</label>
<button type="submit" class="primary">Update password</button>
<h3 style="margin: 0 0 0.75rem;">Change password</h3>
<div class="field-row">
<div class="field">
<label for="current_password">Current password</label>
<input id="current_password" class="input" type="password" name="current_password" autocomplete="current-password">
</div>
<div class="field">
<label for="new_password">New password</label>
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn btn-primary">Update password</button>
</form>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change username</h3>
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
<button type="submit">Update username</button>
<h3 style="margin: 0 0 0.75rem;">Change username</h3>
<div class="field">
<label for="username">New username</label>
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>" style="max-width: 360px;">
</div>
<button type="submit" class="btn">Update username</button>
</form>
</section>
</div>
<% if admin? %>
<section class="card">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
<div class="surface">
<div class="surface-head">
<div>
<h2>Users</h2>
<p class="lede">Add or remove admin and editor accounts</p>
</div>
</div>
<table class="data-table">
<thead>
<tr><th>Username</th><th>Role</th><th></th></tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
<td>
<strong><%= h(user.username) %></strong>
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
</td>
<td>
<% if user.username == current_user %>
<%= h(user.role) %>
<span class="badge badge-neutral"><%= h(user.role) %></span>
<% else %>
<form class="inline" method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>">
<form method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"
style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<select name="role" onchange="this.form.submit()">
<select class="select" name="role" onchange="this.form.submit()" style="width: 130px; padding: 0.35rem 0.6rem; font-size: 0.82rem;">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %>
@@ -51,13 +91,14 @@
</form>
<% end %>
</td>
<td class="actions">
<td>
<% unless user.username == current_user %>
<form class="inline" method="post"
<form method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
onsubmit="return confirm('Remove this user?');">
onsubmit="return confirm('Remove this user?');"
style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Remove</button>
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
<% end %>
</td>
@@ -66,21 +107,30 @@
</tbody>
</table>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Add user</h3>
<div class="fields">
<label>Username <input type="text" name="username"></label>
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
<label>Role
<select name="role">
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
<div class="field-row-3">
<div class="field">
<label for="new-username">Username</label>
<input id="new-username" class="input" type="text" name="username">
</div>
<div class="field">
<label for="new-password">Password</label>
<input id="new-password" class="input" type="password" name="password" autocomplete="new-password">
</div>
<div class="field">
<label for="new-role">Role</label>
<select id="new-role" class="select" name="role">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</label>
</div>
</div>
<button type="submit" class="primary">Add user</button>
<button type="submit" class="btn btn-primary">Add user</button>
</form>
</section>
</div>
<% end %>