feat(admin): redesign admin UI with modern layout
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+174
-84
@@ -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 %>
|
<% 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">• 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">“</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"></></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">/></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">“</button>
|
<button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)">↗</button>
|
||||||
<button type="button" data-md="code" title="Code"></></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"></></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
@@ -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>
|
||||||
|
|||||||
+105
-26
@@ -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"
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
data-title="<%= h(post.title.to_s.downcase) %>"
|
||||||
<button type="submit" class="danger">Delete</button>
|
data-slug="<%= h(post.slug) %>"
|
||||||
</form>
|
data-status="<%= status %>">
|
||||||
</td>
|
<% if post.cover %>
|
||||||
</tr>
|
<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 %>
|
<% end %>
|
||||||
</tbody>
|
<div class="post-body">
|
||||||
</table>
|
<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 %>
|
<% 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>
|
||||||
|
|||||||
@@ -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 %>
|
|
||||||
<p class="error"><%= h(@error) %></p>
|
<% if @error %>
|
||||||
<% end %>
|
<div class="alert alert-error">
|
||||||
<% unless users.any? %>
|
<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>
|
||||||
<p class="error">
|
<div><%= h(@error) %></div>
|
||||||
No users are configured. Set <code>admin.password_hash</code> in your
|
</div>
|
||||||
config (you will sign in as <code>admin</code>) or generate one with
|
<% end %>
|
||||||
<code>volumen hash-password</code>.
|
|
||||||
</p>
|
<% unless users.any? %>
|
||||||
<% end %>
|
<div class="alert alert-warn">
|
||||||
<form method="post" action="<%= to("/admin/login") %>">
|
<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 %>">
|
<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
|
</div>
|
||||||
<input type="password" name="password" autocomplete="current-password">
|
<div class="field">
|
||||||
</label>
|
<label for="password">Password</label>
|
||||||
<button type="submit" class="primary">Sign in</button>
|
<input id="password" class="input" type="password" name="password" autocomplete="current-password" required>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||||
|
</form>
|
||||||
|
|||||||
@@ -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 @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 %>
|
||||||
|
|||||||
Reference in New Issue
Block a user