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 module Volumen
# Server-rendered admin routes, registered into Volumen::Server. # Server-rendered admin routes, registered into Volumen::Server.
module AdminRoutes module AdminRoutes
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def self.registered(app) def self.registered(app)
app.get "/admin" do app.get "/admin" do
redirect to("/admin/") redirect to("/admin/")
@@ -11,6 +12,7 @@ module Volumen
app.get "/admin/" do app.get "/admin/" do
require_login! require_login!
@posts = sorted_posts @posts = sorted_posts
@crumbs = [["Posts", nil]]
erb :list erb :list
end end
@@ -43,6 +45,7 @@ module Volumen
require_login! require_login!
@mode = :new @mode = :new
@post = Post.new(metadata: { "lang" => config.language }) @post = Post.new(metadata: { "lang" => config.language })
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
erb :form erb :form
end end
@@ -57,6 +60,8 @@ module Volumen
@mode = :edit @mode = :edit
@post = store.find(params["slug"]) @post = store.find(params["slug"])
halt(404, "Not found") if @post.nil? 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 erb :form
end end
@@ -106,6 +111,7 @@ module Volumen
app.get "/admin/settings" do app.get "/admin/settings" do
require_login! require_login!
@crumbs = [["Settings", nil]]
render_settings render_settings
end end
@@ -141,6 +147,7 @@ module Volumen
check_csrf! check_csrf!
remove_user(params["username"]) remove_user(params["username"])
end end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end end
end end
end end
+173 -83
View File
@@ -1,108 +1,190 @@
<%
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> <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 %> <% 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 %> <% end %>
<form id="post-form" method="post" <form id="post-form" method="post"
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>"> action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="fields"> <div class="form-grid">
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label> <div class="surface full">
<label>Slug <div class="surface-head">
<input type="text" name="slug" value="<%= h(@post.slug) %>" <div>
<%= @mode == :edit ? "readonly" : "required" %>> <h2>Details</h2>
</label> <p class="lede">Title, slug, and publishing options</p>
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label> </div>
<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>
<div class="cover-field"> <div class="field full">
<label for="cover-input">Cover image</label> <label for="title">Title</label>
<div class="cover-row"> <input id="title" class="input" type="text" name="title" value="<%= h(@post.title) %>" placeholder="A clear, descriptive title" required>
<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> </div>
<input type="text" id="cover-alt-input" name="cover_alt" value="<%= h(@post.cover_alt) %>"
placeholder="ALT text (describe the image for accessibility)"> <div class="field-row">
<input type="text" id="cover-caption-input" name="cover_caption" value="<%= h(@post.cover_caption) %>" <div class="field">
placeholder="Caption (e.g. 'Generated by AI', shown in popup)"> <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> <img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden> <input type="file" id="cover-file" accept="image/*" hidden>
</div> </div>
</div>
<div class="editor-tabs"> <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="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button> <button type="button" data-tab="visual">Visual</button>
</div> </div>
<div id="visual-view" hidden> <div id="visual-view" hidden>
<div class="pane"> <div class="editor-grid">
<div class="ed-toolbar"> <div class="editor-pane">
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button> <div class="editor-head">
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button> <span class="label">Editor</span>
<button type="button" data-rich="h2" title="Heading 2">H2</button> <span class="spacer"></span>
<button type="button" data-rich="h3" title="Heading 3">H3</button> <button type="button" class="editor-toolbar-btn bold" data-rich="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button> <button type="button" class="editor-toolbar-btn italic" data-rich="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" data-rich="ul" title="Bullet list">&bull; List</button> <button type="button" class="editor-toolbar-btn" data-rich="h2" title="Heading 2">H2</button>
<button type="button" data-rich="ol" title="Numbered list">1. List</button> <button type="button" class="editor-toolbar-btn" data-rich="h3" title="Heading 3">H3</button>
<button type="button" data-rich="quote" title="Quote">&ldquo;</button> <button type="button" class="editor-toolbar-btn" data-rich="link" title="Link (Ctrl+K)"></button>
<button type="button" data-rich="code" title="Inline code">&lt;/&gt;</button> <button type="button" class="editor-toolbar-btn" data-rich="ul" title="Bullet list"></button>
<button type="button" data-rich="image" title="Image">Img</button> <button type="button" class="editor-toolbar-btn" data-rich="ol" title="Numbered list">1.</button>
<button type="button" data-rich="clear" title="Paragraph">P</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 id="wysiwyg" class="markdown" contenteditable="true"></div>
</div> </div>
</div> </div>
<div id="markdown-view"> <div id="markdown-view">
<div class="editor"> <div class="editor-grid" id="editor-grid">
<div class="pane"> <div class="editor-pane">
<div class="ed-toolbar"> <div class="editor-head">
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button> <span class="label">Markdown</span>
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button> <span class="spacer"></span>
<button type="button" data-md="h2" title="Heading">H2</button> <button type="button" class="editor-toolbar-btn bold" data-md="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button> <button type="button" class="editor-toolbar-btn italic" data-md="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" data-md="ul" title="List">List</button> <button type="button" class="editor-toolbar-btn" data-md="h2" title="Heading">H2</button>
<button type="button" data-md="quote" title="Quote">&ldquo;</button> <button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)"></button>
<button type="button" data-md="code" title="Code">&lt;/&gt;</button> <button type="button" class="editor-toolbar-btn" data-md="ul" title="List"></button>
<button type="button" data-md="image" title="Image">Img</button> <button type="button" class="editor-toolbar-btn" data-md="quote" title="Quote">"</button>
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</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>
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
</div> </div>
<div class="pane preview-pane">
<div class="ed-toolbar"><span>Preview</span></div>
<div id="preview" class="markdown"></div>
</div> </div>
</div> </div>
</div> </div>
<input type="file" id="image-input" accept="image/*" hidden> <input type="file" id="image-input" accept="image/*" hidden>
<button type="submit" class="primary">Save</button>
</form> </form>
<script> <script>
@@ -204,7 +286,7 @@
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"; 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() { function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; }); mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
@@ -456,26 +538,34 @@
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); } 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"); var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() { function applyPreviewPreference() {
var off = false; var on = false;
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {} try { on = localStorage.getItem("volumen.preview") === "on"; } catch (e) {}
editor.classList.toggle("no-preview", off); if (on) {
togglePreviewBtn.classList.toggle("active", !off); editorGrid.classList.add("with-preview");
if (!off) { renderPreview(); } 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 () { togglePreviewBtn.addEventListener("click", function () {
var turningOff = !editor.classList.contains("no-preview"); var turningOn = !editorGrid.classList.contains("with-preview");
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {} try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
applyPreviewPreference(); applyPreviewPreference();
}); });
// Start in Markdown mode (primary). // Start in Markdown mode (primary), preview OFF by default.
applyPreviewPreference(); applyPreviewPreference();
})(); })();
</script> </script>
+498 -197
View File
@@ -4,223 +4,448 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title> <title>volumen admin</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style> <style>
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--fg: #1b1b1f; --muted: #6b7280; --bg: #f6f7f9; --card: #ffffff; --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
--border: #e4e4ec; --accent: #4f46e5; --accent-strong: #4338ca; --font-mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, ui-monospace, monospace;
--accent-weak: #eef2ff; --on-accent: #ffffff; --fg: #1a1a1f; --fg-muted: #6b7280; --fg-subtle: #9ca3af;
--danger: #dc2626; --danger-weak: #fef2f2; --bg: #f7f7f9; --surface: #ffffff; --surface-2: #fafafb;
--ok: #16a34a; --ok-weak: #f0fdf4; --border: #e5e5ea; --border-strong: #d1d1d6;
--ring: 0 0 0 3px rgba(79, 70, 229, 0.30); --accent: #4f46e5; --accent-hover: #4338ca; --accent-soft: #eef2ff; --on-accent: #ffffff;
--shadow-sm: 0 1px 2px rgba(17, 17, 26, 0.06); --danger: #dc2626; --danger-soft: #fef2f2;
--shadow: 0 1px 3px rgba(17, 17, 26, 0.06), 0 10px 30px -16px rgba(17, 17, 26, 0.25); --ok: #16a34a; --ok-soft: #f0fdf4;
--radius: 12px; --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) { @media (prefers-color-scheme: dark) {
:root { :root {
--fg: #e7e7ea; --muted: #9aa0aa; --bg: #121217; --card: #1c1c22; --fg: #ececef; --fg-muted: #9ca3af; --fg-subtle: #6b7280;
--border: #2c2c35; --accent: #818cf8; --accent-strong: #a5b4fc; --bg: #0c0c10; --surface: #16161b; --surface-2: #1c1c22;
--accent-weak: rgba(129, 140, 248, 0.16); --on-accent: #14141a; --border: #2a2a32; --border-strong: #3a3a44;
--danger: #f87171; --danger-weak: rgba(248, 113, 113, 0.14); --accent: #818cf8; --accent-hover: #a5b4fc; --accent-soft: rgba(129, 140, 248, 0.12); --on-accent: #0c0c10;
--ok: #4ade80; --ok-weak: rgba(74, 222, 128, 0.14); --danger: #f87171; --danger-soft: rgba(248, 113, 113, 0.10);
--ring: 0 0 0 3px rgba(129, 140, 248, 0.40); --ok: #4ade80; --ok-soft: rgba(74, 222, 128, 0.10);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --warn: #fbbf24; --warn-soft: rgba(251, 191, 36, 0.10);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px -16px rgba(0, 0, 0, 0.7); --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; } * { box-sizing: border-box; }
body { margin: 0; display: flex; min-height: 100vh; background: var(--bg); } html, body { margin: 0; padding: 0; }
body {
/* Sidebar */ font-family: var(--font-sans);
.sidebar { font-size: 14px; line-height: 1.5;
width: 230px; flex-shrink: 0; position: sticky; top: 0; height: 100vh; color: var(--fg); background: var(--bg);
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;
-webkit-font-smoothing: antialiased; -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); } /* --- Shell --- */
a { color: var(--accent); } .shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 1.25rem; } .sidebar {
h2 { font-size: 1.15rem; font-weight: 650; margin: 0 0 0.75rem; } background: var(--surface);
h3 { font-size: 0.95rem; font-weight: 650; } border-right: 1px solid var(--border);
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; } display: flex; flex-direction: column;
.toolbar h1 { margin: 0; } 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 */ .sidebar-foot {
button, .button { padding: 0.85rem 0.9rem; border-top: 1px solid var(--border);
font: inherit; font-weight: 550; cursor: pointer; display: inline-flex; display: flex; align-items: center; gap: 0.6rem;
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;
} }
button:hover, .button:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } .avatar {
button:active, .button:active { transform: translateY(1px); } width: 30px; height: 30px; border-radius: 50%;
button:focus-visible, .button:focus-visible, background: var(--accent-soft); color: var(--accent);
input:focus-visible, select:focus-visible, textarea:focus-visible, display: grid; place-items: center; font-weight: 600; font-size: 0.78rem;
[contenteditable]:focus-visible, .sidenav a:focus-visible, .sidenav button:focus-visible { flex-shrink: 0;
outline: none; box-shadow: var(--ring);
} }
button.primary, .button.primary { .user-info { flex: 1; min-width: 0; }
background: var(--accent); border-color: var(--accent); color: var(--on-accent); .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); box-shadow: var(--shadow-sm);
} }
button.primary:hover, .button.primary:hover { .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
background: var(--accent-strong); border-color: var(--accent-strong); .btn-ghost { background: transparent; border-color: transparent; color: var(--fg-muted); }
} .btn-ghost:hover { background: var(--surface-2); color: var(--fg); border-color: transparent; }
button.danger { .btn-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); background: transparent; }
color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); .btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
background: transparent; .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.78rem; gap: 0.3rem; }
} .btn-sm svg { width: 12px; height: 12px; }
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); } .btn-icon { padding: 0.45rem; }
form.inline { display: inline; margin: 0; }
/* Cards */ /* --- Forms --- */
.card { .field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); .field-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; }
padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow); .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; } .field .help { font-size: 0.75rem; color: var(--fg-subtle); margin-top: 0.15rem; }
.card h3 { margin: 1.5rem 0 0.6rem; } .input, .select, textarea {
.card form { margin-bottom: 1.25rem; } font: inherit; color: var(--fg); background: var(--surface);
.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);
border: 1px solid var(--border); border-radius: var(--radius); 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; } .input::placeholder, textarea::placeholder { color: var(--fg-subtle); }
.login label, .login button { width: 100%; } .input:focus, .select:focus, textarea:focus {
.login button { margin-top: 1.25rem; justify-content: center; } 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 */ /* --- Cards & surfaces --- */
.editor-tabs { .surface {
display: inline-flex; gap: 2px; background: var(--card); padding: 3px; background: var(--surface); border: 1px solid var(--border);
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.9rem; border-radius: var(--radius-lg); padding: 1.5rem;
} }
.editor-tabs button { border: none; background: transparent; color: var(--muted); .surface + .surface { margin-top: 1rem; }
border-radius: 7px; padding: 0.35rem 0.95rem; font-size: 0.85rem; } .surface-head {
.editor-tabs button.active { background: var(--accent); color: var(--on-accent); } display: flex; align-items: center; justify-content: space-between;
.editor { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; } margin-bottom: 1.1rem; gap: 1rem;
.editor.no-preview { grid-template-columns: 1fr; } }
.editor.no-preview .preview-pane { display: none; } .surface-head h2 { margin: 0; }
.pane { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; .surface-head .lede { font-size: 0.85rem; color: var(--fg-muted); margin-top: 0.15rem; }
background: var(--card); box-shadow: var(--shadow-sm); }
.ed-toolbar { display: flex; gap: 0.3rem; padding: 0.45rem; align-items: center; /* --- Alerts --- */
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 50%, var(--card)); } .alert {
.ed-toolbar button { padding: 0.28rem 0.55rem; font-size: 0.82rem; border-radius: 7px; display: flex; gap: 0.7rem; padding: 0.85rem 1rem;
background: transparent; } border-radius: var(--radius); border: 1px solid;
.ed-toolbar button:hover { background: var(--accent-weak); } font-size: 0.85rem; margin-bottom: 1.25rem;
.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; } .alert svg { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; }
.ed-toolbar .toggle { margin-left: auto; } .alert-error { color: var(--danger); background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
textarea { width: 100%; border: none; border-radius: 0; padding: 0.85rem 1rem; .alert-success { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; .alert-warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
background: var(--card); color: var(--fg); }
textarea:focus { box-shadow: none; } /* --- Post list cards --- */
#preview, #wysiwyg { padding: 0.85rem 1.1rem; min-height: 22rem; } .post-grid {
#wysiwyg { outline: none; } display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
#wysiwyg:focus { box-shadow: inset 0 0 0 2px var(--accent); } gap: 1rem;
#preview img, #wysiwyg img { max-width: 100%; } }
.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; } [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 { line-height: 1.7; }
.markdown > :first-child { margin-top: 0; } .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, .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.6rem; } .markdown h1 { font-size: 1.5rem; }
.markdown h2 { font-size: 1.3rem; } .markdown h2 { font-size: 1.25rem; }
.markdown h3 { font-size: 1.1rem; } .markdown h3 { font-size: 1.05rem; }
.markdown p { margin: 0.7em 0; } .markdown p { margin: 0.6em 0; }
.markdown a { color: var(--accent); } .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 li { margin: 0.2em 0; }
.markdown blockquote { margin: 0.9em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent); .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; }
color: var(--muted); background: var(--accent-weak); border-radius: 0 8px 8px 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 code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; .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; }
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; } .markdown pre code { background: none; border: none; padding: 0; font-size: 0.85em; }
.markdown pre { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; .markdown table { border-collapse: collapse; width: 100%; margin: 0.8em 0; font-size: 0.92em; box-shadow: none; }
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 th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; } .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 thead th { background: var(--accent-soft); 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 hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: 8px; } .markdown img { max-width: 100%; border-radius: var(--radius); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; } * { transition: none !important; }
@@ -228,27 +453,103 @@
</style> </style>
</head> </head>
<body> <body>
<% 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>
<% else %>
<div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href="<%= to("/admin/") %>"><img class="brand-logo" src="<%= to("/admin/logo.svg") %>" alt="volumen"></a> <div class="sidebar-head">
<div class="logo">V</div>
<div class="name">volumen</div>
</div>
<% if authenticated? %> <% if authenticated? %>
<span class="who"><%= h(current_user) %> · <%= h(current_role) %></span> <nav class="sidebar-nav">
<% settings_active = request.path_info.start_with?("/admin/settings") %> <div class="nav-section">
<nav class="sidenav"> <div class="nav-label">Content</div>
<a href="<%= to("/admin/") %>" class="<%= settings_active ? "" : "active" %>">Posts</a> <a href="<%= to("/admin/") %>" class="nav-item <%= request.path_info == "/admin/" || request.path_info == "/admin" ? "active" : "" %>">
<a href="<%= to("/admin/settings") %>" class="<%= settings_active ? "active" : "" %>">Settings</a> <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> </nav>
<div class="sidebar-footer"> <div class="sidebar-foot">
<form method="post" action="<%= to("/admin/logout") %>"> <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 %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit">Log out</button> <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> </form>
</div> </div>
<% end %> <% end %>
</aside> </aside>
<main> <div class="main">
<div class="main-inner"> <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 %> <%= yield %>
</div>
</main> </main>
</div>
</div>
<% end %>
</body> </body>
</html> </html>
+103 -24
View File
@@ -1,34 +1,113 @@
<div class="toolbar"> <div class="page-head">
<div>
<h1>Posts</h1> <h1>Posts</h1>
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a> <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">
<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> </div>
<% if @posts.empty? %> <% 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 %> <% else %>
<table> <div class="post-grid" id="post-grid">
<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| %> <% @posts.each do |post| %>
<tr> <%
<td><%= h(post.title) %></td> status = if post.draft?
<td><%= h(post.slug) %></td> "draft"
<td><%= h(post.lang) %></td> elsif post.scheduled?
<td><%= h(post.date_string) %></td> "scheduled"
<td><%= post.draft? ? "yes" : "" %></td> else
<td class="actions"> "published"
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a> end
<form class="inline" method="post" %>
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>" <a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
onsubmit="return confirm('Delete this post?');"> 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 %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Delete</button> <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> </form>
</td> </div>
</tr> </a>
<% end %> <% end %>
</tbody> </div>
</table>
<% end %> <% 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>
+22 -16
View File
@@ -1,23 +1,29 @@
<div class="login"> <h2>Sign in</h2>
<h1>Sign in</h1> <p class="lede">Welcome back. Sign in to manage your posts.</p>
<% if @error %> <% 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 %> <% end %>
<% unless users.any? %> <% unless users.any? %>
<p class="error"> <div class="alert alert-warn">
No users are configured. Set <code>admin.password_hash</code> in your <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>
config (you will sign in as <code>admin</code>) or generate one with <div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
<code>volumen hash-password</code>. </div>
</p>
<% end %> <% end %>
<form method="post" action="<%= to("/admin/login") %>"> <form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<label>Username <div class="field">
<input type="text" name="username" autofocus autocomplete="username"> <label for="username">Username</label>
</label> <input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
<label>Password
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit" class="primary">Sign in</button>
</form>
</div> </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>
+87 -37
View File
@@ -1,49 +1,89 @@
<div class="page-head">
<div>
<h1>Settings</h1> <h1>Settings</h1>
<p class="lede">Manage your account and users</p>
</div>
</div>
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %> <% if @notice %>
<% if @error %><p class="error"><%= h(@error) %></p><% end %> <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"> <div class="surface">
<div class="surface-head">
<div>
<h2>Your account</h2> <h2>Your account</h2>
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p> <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") %>"> <form method="post" action="<%= to("/admin/settings/password") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change password</h3> <h3 style="margin: 0 0 0.75rem;">Change password</h3>
<label>Current password <div class="field-row">
<input type="password" name="current_password" autocomplete="current-password"> <div class="field">
</label> <label for="current_password">Current password</label>
<label>New password <input id="current_password" class="input" type="password" name="current_password" autocomplete="current-password">
<input type="password" name="new_password" autocomplete="new-password"> </div>
</label> <div class="field">
<button type="submit" class="primary">Update password</button> <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> </form>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/username") %>"> <form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change username</h3> <h3 style="margin: 0 0 0.75rem;">Change username</h3>
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label> <div class="field">
<button type="submit">Update username</button> <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> </form>
</section> </div>
<% if admin? %> <% if admin? %>
<section class="card"> <div class="surface">
<div class="surface-head">
<div>
<h2>Users</h2> <h2>Users</h2>
<table> <p class="lede">Add or remove admin and editor accounts</p>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead> </div>
</div>
<table class="data-table">
<thead>
<tr><th>Username</th><th>Role</th><th></th></tr>
</thead>
<tbody> <tbody>
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <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> <td>
<% if user.username == current_user %> <% if user.username == current_user %>
<%= h(user.role) %> <span class="badge badge-neutral"><%= h(user.role) %></span>
<% else %> <% else %>
<form class="inline" method="post" <form method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"> action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"
style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <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| %> <% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option> <option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %> <% end %>
@@ -51,13 +91,14 @@
</form> </form>
<% end %> <% end %>
</td> </td>
<td class="actions"> <td>
<% unless user.username == current_user %> <% unless user.username == current_user %>
<form class="inline" method="post" <form method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" 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 %>"> <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> </form>
<% end %> <% end %>
</td> </td>
@@ -66,21 +107,30 @@
</tbody> </tbody>
</table> </table>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/users") %>"> <form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Add user</h3> <h3 style="margin: 0 0 0.75rem;">Add user</h3>
<div class="fields"> <div class="field-row-3">
<label>Username <input type="text" name="username"></label> <div class="field">
<label>Password <input type="password" name="password" autocomplete="new-password"></label> <label for="new-username">Username</label>
<label>Role <input id="new-username" class="input" type="text" name="username">
<select name="role"> </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| %> <% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option> <option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
<% end %> <% end %>
</select> </select>
</label>
</div> </div>
<button type="submit" class="primary">Add user</button> </div>
<button type="submit" class="btn btn-primary">Add user</button>
</form> </form>
</section> </div>
<% end %> <% end %>