feat: initial release of volumen — a file-based Markdown blog engine

This commit is contained in:
2026-06-21 13:15:06 +02:00
commit dd3efe870e
54 changed files with 5324 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# frozen_string_literal: true
require "optparse"
require_relative "version"
require_relative "config"
require_relative "store"
require_relative "password"
module Volumen
# Command-line interface: serve, hash-password, version, help.
class CLI
USAGE = <<~TEXT.freeze
volumen #{Volumen::VERSION} — a small, file-based Markdown blog engine.
Usage:
volumen serve [--config PATH] [--content DIR] [--host HOST] [--port PORT]
volumen hash-password
volumen version
volumen help
TEXT
def self.run(argv)
new(argv).run
end
def initialize(argv)
@argv = argv.dup
end
def run
command = @argv.shift
case command
when "version", "--version", "-v" then puts Volumen::VERSION
when "help", "--help", "-h", nil then puts USAGE
when "serve" then serve
when "hash-password" then hash_password
else
warn "volumen: unknown command #{command.inspect}\n\n#{USAGE}"
exit 1
end
end
private
def serve
options = parse_serve_options
prepare_config(options[:config])
config = Config.load(path: options[:config], overrides: options)
require_relative "server"
store = Store.new(config.content_dir, default_lang: config.language)
app = Server.configured(config: config, store: store)
app.run!(bind: config.host, port: config.port)
end
def parse_serve_options
options = { config: Config::DEFAULT_PATH }
OptionParser.new do |parser|
parser.on("--config PATH") { |value| options[:config] = value }
parser.on("--content DIR") { |value| options[:content] = value }
parser.on("--host HOST") { |value| options[:host] = value }
parser.on("--port PORT", Integer) { |value| options[:port] = value }
end.parse!(@argv)
options
end
def prepare_config(path)
if Config.generate_default(path)
warn "volumen: wrote a default config to #{path}; review it and restart."
elsif !File.exist?(path)
warn "volumen: config #{path} not found; using built-in defaults."
end
end
def hash_password
require "io/console"
print "Password: "
password = ($stdin.noecho(&:gets) || "").chomp
puts
if password.empty?
warn "volumen: empty password"
exit 1
end
puts Password.hash(password)
end
end
end
+136
View File
@@ -0,0 +1,136 @@
# frozen_string_literal: true
require "toml-rb"
require "fileutils"
module Volumen
# Loads and merges volumen configuration from a TOML file, then applies
# command-line overrides on top of the file values and built-in defaults.
class Config
DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 },
"content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml",
"site" => {
"title" => "My Blog",
"description" => "A blog powered by volumen.",
"base_url" => "https://example.com",
"language" => "en",
"author" => "Anonymous"
},
"admin" => {
"password_hash" => "",
"session_key" => "",
"session_ttl" => 86_400
}
}.freeze
TEMPLATE = <<~TOML
# volumen configuration.
[server]
host = "0.0.0.0"
port = 9090
# Directory of your Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File storing admin users (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
[admin]
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
# in as user "admin". Once you manage users in Settings, users_file wins.
password_hash = ""
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
session_key = ""
# Session lifetime in seconds (24h default).
session_ttl = 86400
TOML
def self.load(path: DEFAULT_PATH, overrides: {})
data = File.exist?(path) ? TomlRB.load_file(path) : {}
merged = deep_merge(DEFAULTS, data)
new(apply_overrides(merged, overrides))
end
# Writes the commented template to `path` if it does not exist yet.
# Returns the path on success, or nil if it already existed or could not
# be created.
def self.generate_default(path)
return nil if File.exist?(path)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, TEMPLATE)
path
rescue SystemCallError
nil
end
def self.deep_merge(base, override)
base.merge(override) do |_key, base_val, override_val|
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
deep_merge(base_val, override_val)
else
override_val
end
end
end
def self.apply_overrides(data, overrides)
server = data["server"].dup
server["host"] = overrides[:host] if overrides[:host]
server["port"] = overrides[:port] if overrides[:port]
result = data.dup
result["server"] = server
result["content_dir"] = overrides[:content] if overrides[:content]
result
end
private_class_method :deep_merge, :apply_overrides
attr_reader :data
def initialize(data)
@data = data
end
def host
@data["server"]["host"]
end
def port
@data["server"]["port"]
end
def content_dir
@data["content_dir"]
end
def users_file
@data["users_file"]
end
def site
@data["site"]
end
def language
@data["site"]["language"]
end
def admin
@data["admin"]
end
end
end
+68
View File
@@ -0,0 +1,68 @@
# frozen_string_literal: true
require "toml-rb"
module Volumen
# Parses and serialises the TOML frontmatter block of a Markdown file.
#
# A post file starts with a `+++` line, contains a TOML document, is closed
# by another `+++` line, and is followed by the Markdown body:
#
# +++
# title = "Hello"
# +++
#
# Body in **Markdown**.
module Frontmatter
DELIMITER = "+++"
# Returns `[metadata_hash, body_string]`. If the content has no
# frontmatter, the metadata hash is empty and the body is the full input.
def self.parse(content)
text = normalize(content)
lines = text.lines
return [{}, text] unless delimiter?(lines.first)
closing = closing_index(lines)
return [{}, text] if closing.nil?
toml = lines[1...closing].join
body = lines[(closing + 1)..]&.join.to_s
metadata = toml.strip.empty? ? {} : TomlRB.parse(toml)
[metadata, body.sub(/\A\r?\n/, "")]
end
# Serialises metadata + body back into a single post file string.
def self.dump(metadata, body)
toml = serialize(metadata)
buffer = "#{DELIMITER}\n"
buffer << toml
buffer << "\n" unless toml.empty? || toml.end_with?("\n")
buffer << "#{DELIMITER}\n\n"
buffer << body.to_s.sub(/\A\s+/, "")
buffer.end_with?("\n") ? buffer : "#{buffer}\n"
end
def self.normalize(content)
content.to_s.gsub("\r\n", "\n")
end
def self.closing_index(lines)
index = lines[1..].index { |line| delimiter?(line) }
index.nil? ? nil : index + 1
end
def self.delimiter?(line)
line&.chomp&.strip == DELIMITER
end
def self.serialize(metadata)
data = metadata || {}
return "" if data.empty?
TomlRB.dump(data)
end
private_class_method :normalize, :closing_index, :delimiter?, :serialize
end
end
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
require "kramdown"
module Volumen
# Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
# task lists, strikethrough).
#
# Posts are authored by the single trusted admin, so kramdown's default
# behaviour of passing raw HTML through is intentional.
module Markdown
OPTIONS = {
input: "GFM",
auto_ids: true,
hard_wrap: false,
syntax_highlighter: nil
}.freeze
def self.render(text)
::Kramdown::Document.new(text.to_s, OPTIONS).to_html
end
end
end
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
require "openssl"
require "securerandom"
module Volumen
# Hashes and verifies admin passwords with scrypt (via OpenSSL::KDF).
#
# Encoded form: "scrypt$N$r$p$salt_base64$hash_base64".
module Password
COST_N = 16_384
BLOCK_R = 8
PARALLEL_P = 1
KEY_LENGTH = 32
SALT_BYTES = 16
PREFIX = "scrypt"
def self.hash(password)
salt = SecureRandom.bytes(SALT_BYTES)
derived = OpenSSL::KDF.scrypt(
password.to_s, salt: salt, N: COST_N, r: BLOCK_R, p: PARALLEL_P, length: KEY_LENGTH
)
[PREFIX, COST_N, BLOCK_R, PARALLEL_P, encode(salt), encode(derived)].join("$")
end
def self.verify(password, encoded)
parts = encoded.to_s.split("$")
return false unless parts.length == 6 && parts.first == PREFIX
_, cost, block, parallel, salt_b64, hash_b64 = parts
salt = decode(salt_b64)
expected = decode(hash_b64)
derived = OpenSSL::KDF.scrypt(
password.to_s, salt: salt,
N: cost.to_i, r: block.to_i, p: parallel.to_i, length: expected.bytesize
)
OpenSSL.fixed_length_secure_compare(derived, expected)
rescue ArgumentError, OpenSSL::KDF::KDFError
false
end
def self.encode(bytes)
[bytes].pack("m0")
end
def self.decode(string)
string.to_s.unpack1("m0")
end
private_class_method :encode, :decode
end
end
+119
View File
@@ -0,0 +1,119 @@
# frozen_string_literal: true
require "date"
require_relative "frontmatter"
require_relative "markdown"
module Volumen
# A single blog post: TOML frontmatter metadata plus a Markdown body.
class Post
attr_reader :metadata, :body
attr_accessor :path
def initialize(metadata: {}, body: "")
@metadata = (metadata || {}).transform_keys(&:to_s)
@body = body.to_s
end
def self.parse(content)
metadata, body = Frontmatter.parse(content)
new(metadata: metadata, body: body)
end
def slug
metadata["slug"]
end
def title
metadata["title"]
end
def lang
metadata["lang"]
end
def author
metadata["author"]
end
def tags
Array(metadata["tags"]).map(&:to_s)
end
def draft?
metadata["draft"] == true
end
def all_langs?
metadata["all_langs"] == true
end
def translations
metadata["translations"] || {}
end
def cover
metadata["cover"]
end
def reading_time
words = body.split(/\s+/).count { |word| !word.empty? }
[(words / 200.0).ceil, 1].max
end
def date_string
value = metadata["date"]
case value
when nil then nil
when Date, Time then value.strftime("%Y-%m-%d")
else value.to_s
end
end
def excerpt
text = metadata["excerpt"]
return text if text && !text.to_s.empty?
derived_excerpt
end
def html
Markdown.render(body)
end
def to_file
Frontmatter.dump(metadata, body)
end
def summary
{
"slug" => slug,
"title" => title,
"excerpt" => excerpt,
"date" => date_string,
"lang" => lang,
"tags" => tags,
"author" => author,
"cover" => cover,
"reading_time" => reading_time,
"translations" => translations,
"url" => "/api/volumen/posts/#{slug}"
}
end
def detail
summary.merge("body" => body, "html" => html)
end
private
def derived_excerpt(limit = 200)
paragraph = body.split(/\n\s*\n/).map(&:strip)
.find { |para| !para.empty? && !para.start_with?("#") }
return "" if paragraph.nil?
stripped = paragraph.gsub(/[*_`>#]/, "").strip
stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped
end
end
end
+513
View File
@@ -0,0 +1,513 @@
# frozen_string_literal: true
require "sinatra/base"
require "json"
require "date"
require "securerandom"
require_relative "config"
require_relative "store"
require_relative "markdown"
require_relative "password"
module Volumen
# Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin.
class Server < Sinatra::Base
configure do
set :show_exceptions, false
set :raise_errors, false
set :host_authorization, { permitted_hosts: [] }
set :views, File.expand_path("web/views", __dir__)
end
# Returns a configured subclass bound to a specific config + store.
def self.configured(config:, store:)
secret = session_secret(config)
Class.new(self) do
set :volumen_config, config
set :volumen_store, store
set :sessions, httponly: true, same_site: :strict
set :session_secret, secret
end
end
def self.session_secret(config)
key = config.admin["session_key"].to_s
key.bytesize >= 64 ? key : SecureRandom.hex(64)
end
# Mark the session cookie Secure only when the request is HTTPS (directly or
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
# the cookie stays usable so the admin login round-trips.
before do
options = request.env["rack.session.options"]
options[:secure] = request.ssl? if options
end
# --- Public JSON API -------------------------------------------------
before "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
content_type :json
end
get "/api/volumen/site" do
json(site_payload)
end
get "/api/volumen/posts" do
json(posts_payload)
end
get "/api/volumen/posts/:slug" do
post = store.find(params["slug"], lang: params["lang"])
halt(404, json("error" => "not_found")) if post.nil?
json(post.detail)
end
get "/api/volumen/tags" do
json("tags" => tag_counts)
end
get "/api/volumen/feed.xml" do
content_type "application/rss+xml"
feed_xml
end
get "/api/volumen/sitemap.xml" do
content_type "application/xml"
sitemap_xml
end
# --- Admin -----------------------------------------------------------
get "/admin" do
redirect to("/admin/")
end
get "/admin/" do
require_login!
@posts = sorted_posts
erb :list
end
get "/admin/login" do
redirect to("/admin/") if authenticated?
erb :login
end
post "/admin/login" do
check_csrf!
user = users.authenticate(params["username"], params["password"])
if user
session[:user] = user.username
redirect to("/admin/")
else
@error = "Invalid username or password."
erb :login
end
end
post "/admin/logout" do
check_csrf!
session.clear
redirect to("/admin/login")
end
get "/admin/posts/new" do
require_login!
@mode = :new
@post = Post.new(metadata: { "lang" => config.language })
erb :form
end
post "/admin/posts" do
require_login!
check_csrf!
create_post
end
get "/admin/posts/:slug/edit" do
require_login!
@mode = :edit
@post = store.find(params["slug"])
halt(404, "Not found") if @post.nil?
erb :form
end
post "/admin/posts/:slug" do
require_login!
check_csrf!
update_post
end
post "/admin/posts/:slug/delete" do
require_login!
check_csrf!
store.delete(params["slug"])
redirect to("/admin/")
end
post "/admin/preview" do
require_login!
content_type :html
Markdown.render(params["body"].to_s)
end
post "/admin/uploads" do
require_login!
check_csrf!
content_type :json
upload = params["file"]
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
end
get "/media/*" do
path = store.media_file(params["splat"].first)
halt(404) if path.nil?
send_file(path)
end
get "/admin/logo.svg" do
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
end
get "/admin/settings" do
require_login!
render_settings
end
post "/admin/settings/password" do
require_login!
check_csrf!
change_password
end
post "/admin/settings/username" do
require_login!
check_csrf!
change_username
end
post "/admin/settings/users" do
require_login!
require_admin!
check_csrf!
create_user
end
post "/admin/settings/users/:username/role" do
require_login!
require_admin!
check_csrf!
change_role(params["username"])
end
post "/admin/settings/users/:username/delete" do
require_login!
require_admin!
check_csrf!
remove_user(params["username"])
end
private
def config
settings.volumen_config
end
def store
settings.volumen_store
end
# --- API helpers -----------------------------------------------------
def json(data)
JSON.generate(data)
end
def published_posts
store.all.reject(&:draft?).sort_by { |post| post.date_string || "" }.reverse
end
def site_payload
config.site.slice("title", "description", "base_url", "language", "author")
end
def posts_payload
posts = filtered_posts
page = positive_int(params["page"], 1)
limit = positive_int(params["limit"], 20).clamp(1, 100)
offset = (page - 1) * limit
{
"page" => page,
"page_size" => limit,
"total" => posts.length,
"posts" => posts[offset, limit].to_a.map(&:summary)
}
end
def filtered_posts
posts = published_posts
if params["lang"]
lang = params["lang"]
posts = posts.select { |post| post.lang == lang || post.all_langs? }
end
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
posts
end
def tag_counts
counts = Hash.new(0)
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
counts.sort_by { |name, count| [-count, name] }
.map { |name, count| { "name" => name, "count" => count } }
end
def positive_int(value, default)
number = value.to_i
number.positive? ? number : default
end
def base_url
config.site["base_url"].to_s.chomp("/")
end
def feed_xml
site = config.site
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link>
<description>#{escape(site["description"])}</description>
#{items}
</channel>
</rss>
XML
end
def feed_item(post)
link = "#{base_url}/#{post.slug}"
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>
</item>
XML
end
def sitemap_xml
urls = published_posts.map do |post|
" <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc></url>"
end.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}
</urlset>
XML
end
def escape(text)
Rack::Utils.escape_html(text.to_s)
end
alias h escape
# --- Admin helpers ---------------------------------------------------
def users
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
end
def authenticated?
!session[:user].to_s.empty?
end
def current_user
session[:user]
end
def current_user_record
users.find(current_user)
end
def current_role
current_user_record&.role
end
def admin?
current_role == "admin"
end
def require_login!
redirect to("/admin/login") unless authenticated?
end
def require_admin!
halt(403, "Forbidden") unless admin?
end
def csrf_token
session[:csrf] ||= SecureRandom.hex(32)
end
def check_csrf!
token = params["_csrf"]
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token)
halt(403, "Invalid CSRF token")
end
def render_settings(error: nil, notice: nil)
@error = error
@notice = notice
@users = users.all
erb :settings
end
def change_password
unless users.authenticate(current_user, params["current_password"])
return render_settings(error: "Current password is incorrect.")
end
fresh = presence(params["new_password"])
return render_settings(error: "New password cannot be empty.") if fresh.nil?
users.update_password(current_user, fresh)
render_settings(notice: "Password updated.")
end
def change_username
new_name = presence(params["username"])
return render_settings(error: "Username cannot be empty.") if new_name.nil?
if users.rename(current_user, new_name)
session[:user] = new_name
render_settings(notice: "Username updated.")
else
render_settings(error: "That username is already taken.")
end
end
def create_user
name = presence(params["username"])
password = presence(params["password"])
if name.nil? || password.nil?
return render_settings(error: "Username and password are required.")
end
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
if users.add(name, password, params["role"].to_s)
render_settings(notice: "User added.")
else
render_settings(error: "That username already exists.")
end
end
def remove_user(username)
if username == current_user
return render_settings(error: "You cannot delete your own account.")
end
if users.delete(username)
render_settings(notice: "User removed.")
else
render_settings(error: "Cannot remove the last user or the last admin.")
end
end
def change_role(username)
return render_settings(error: "You cannot change your own role.") if username == current_user
if users.set_role(username, params["role"].to_s)
render_settings(notice: "Role updated.")
else
render_settings(error: "Could not change role (last admin?).")
end
end
def sorted_posts
store.all.sort_by { |post| post.date_string || "" }.reverse
end
def create_post
post = post_from_params(params)
error = creation_error(post)
if error
@mode = :new
@post = post
@error = error
return erb(:form)
end
store.save(post)
redirect to("/admin/")
end
def update_post
existing = store.find(params["slug"])
halt(404, "Not found") if existing.nil?
store.save(post_from_params(params, existing: existing))
redirect to("/admin/")
end
def creation_error(post)
return "Slug is required." if presence(post.slug).nil?
return "A post with that slug already exists." if store.find(post.slug)
nil
end
def post_from_params(http_params, existing: nil)
metadata = {
"title" => presence(http_params["title"]),
"slug" => presence(http_params["slug"]) || existing&.slug,
"lang" => presence(http_params["lang"]),
"author" => presence(http_params["author"]),
"date" => parse_date(http_params["date"]),
"tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]),
"draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on"
}
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end
def clean_metadata(metadata)
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
end
def presence(value)
text = value.to_s.strip
text.empty? ? nil : text
end
def parse_date(value)
text = presence(value)
text && Date.iso8601(text)
rescue ArgumentError
nil
end
def parse_tags(value)
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
end
end
end
+104
View File
@@ -0,0 +1,104 @@
# frozen_string_literal: true
require "fileutils"
require "securerandom"
require_relative "post"
module Volumen
# Reads and writes Markdown posts in a content directory.
#
# Posts live either at `content_dir/<slug>.md` (default language) or at
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
# `slug`/`lang` frontmatter is derived from the file path.
class Store
attr_reader :content_dir
def initialize(content_dir, default_lang: nil)
@content_dir = File.expand_path(content_dir.to_s)
@default_lang = default_lang
end
def all
markdown_files.filter_map { |path| load_post(path) }
end
def find(slug, lang: nil)
all.find do |post|
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
end
end
def save(post)
target = post.path || default_path_for(post)
FileUtils.mkdir_p(File.dirname(target))
File.write(target, post.to_file)
post.path = target
post
end
def delete(slug, lang: nil)
post = find(slug, lang: lang)
File.delete(post.path) if post&.path
post
end
def media_dir
File.join(@content_dir, "media")
end
def store_upload(original_name, source)
FileUtils.mkdir_p(media_dir)
name = safe_media_name(original_name)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}"
end
def media_file(name)
path = File.join(media_dir, File.basename(name.to_s))
return nil unless File.file?(path) && within?(media_dir, path)
path
end
private
def markdown_files
Dir.glob(File.join(@content_dir, "**", "*.md"))
end
def load_post(path)
post = Post.parse(File.read(path))
post.metadata["slug"] ||= File.basename(path, ".md")
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
post.path = path
post
rescue StandardError => e
warn "volumen: skipping #{path}: #{e.message}"
nil
end
def lang_from_path(path)
parent = File.dirname(File.expand_path(path))
return nil if parent == @content_dir
File.basename(parent)
end
def default_path_for(post)
File.join(@content_dir, "#{post.slug}.md")
end
def safe_media_name(original)
base = File.basename(original.to_s)
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
stem = File.basename(base, File.extname(base))
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
stem = "file" if stem.empty?
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
end
def within?(dir, path)
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
end
end
end
+128
View File
@@ -0,0 +1,128 @@
# frozen_string_literal: true
require "fileutils"
require "toml-rb"
require_relative "password"
module Volumen
# File-backed set of admin users, stored as a TOML `[[users]]` array.
#
# Each user has a role: "admin" (full access, including user management) or
# "author" (posts and own account only). If the file does not exist yet, a
# single bootstrap "admin" user is synthesised from the legacy
# `admin.password_hash` config value.
class Users
ROLES = %w[admin author].freeze
DEFAULT_ROLE = "author"
User = Struct.new(:username, :password_hash, :role)
def initialize(path:, bootstrap_hash: nil)
@path = path
@bootstrap_hash = bootstrap_hash.to_s
end
def all
return read_file if File.exist?(@path)
return [] if @bootstrap_hash.empty?
[User.new("admin", @bootstrap_hash, "admin")]
end
def any?
all.any?
end
def find(username)
all.find { |user| user.username == username }
end
def authenticate(username, password)
user = find(username)
user if user && Password.verify(password, user.password_hash)
end
def add(username, password, role = DEFAULT_ROLE)
return nil if username.to_s.empty? || find(username)
user = User.new(username, Password.hash(password), normalize_role(role))
persist(all + [user])
user
end
def update_password(username, password)
mutate(username) { |user| user.password_hash = Password.hash(password) }
end
def rename(current_name, new_name)
return nil if new_name.to_s.empty? || find(new_name)
mutate(current_name) { |user| user.username = new_name }
end
def set_role(username, role)
return nil unless ROLES.include?(role)
users = all
user = users.find { |entry| entry.username == username }
return nil if user.nil? || demoting_last_admin?(users, user, role)
user.role = role
persist(users)
user
end
def delete(username)
users = all
target = users.find { |entry| entry.username == username }
return nil if target.nil? || users.length <= 1 || last_admin?(users, target)
persist(users.reject { |entry| entry.username == username })
username
end
private
def mutate(username)
users = all
user = users.find { |entry| entry.username == username }
return nil if user.nil?
yield user
persist(users)
user
end
def normalize_role(role)
ROLES.include?(role) ? role : DEFAULT_ROLE
end
def admins(users)
users.select { |user| user.role == "admin" }
end
def last_admin?(users, user)
user.role == "admin" && admins(users).length <= 1
end
def demoting_last_admin?(users, user, role)
user.role == "admin" && role != "admin" && admins(users).length <= 1
end
def read_file
data = TomlRB.load_file(@path)
Array(data["users"]).map do |entry|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin")
end
end
def persist(users)
FileUtils.mkdir_p(File.dirname(@path))
File.write(@path, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
end
def user_hash(user)
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role }
end
end
end
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Volumen
VERSION = "0.1.0"
end
+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="volumen — blog engine">
<title>volumen — blog engine</title>
<defs>
<!-- Background gradient: deep night sky -->
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#4a5bd8"/>
<stop offset="55%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#15163a"/>
</radialGradient>
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
</linearGradient>
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fdfdff"/>
<stop offset="100%" stop-color="#b8c4ff"/>
</linearGradient>
<!-- Wordmark gradient: echoes the icon background -->
<linearGradient id="wordmark" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#4a5bd8"/>
</linearGradient>
<linearGradient id="wordmarkAccent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#2a2d6e" stop-opacity="0"/>
<stop offset="50%" stop-color="#4a5bd8" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#2a2d6e" stop-opacity="0"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<!-- Reusable wave path (sinusoidal, centered around y=128 in 256x256 space) -->
<path id="wavePath"
d="M 36 128
C 56 96, 76 96, 96 128
S 136 160, 156 128
S 196 96, 216 128"
fill="none"/>
<!-- Full icon as a reusable symbol (256x256 source) -->
<symbol id="icon" viewBox="0 0 256 256">
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
<g fill="#8aa0ff" opacity="0.55">
<circle cx="128" cy="22" r="1.4"/>
<circle cx="180" cy="32" r="1.2"/>
<circle cx="220" cy="62" r="1.4"/>
<circle cx="234" cy="110" r="1.2"/>
<circle cx="234" cy="146" r="1.4"/>
<circle cx="220" cy="194" r="1.2"/>
<circle cx="180" cy="224" r="1.4"/>
<circle cx="128" cy="234" r="1.2"/>
<circle cx="76" cy="224" r="1.4"/>
<circle cx="36" cy="194" r="1.2"/>
<circle cx="22" cy="146" r="1.4"/>
<circle cx="22" cy="110" r="1.2"/>
<circle cx="36" cy="62" r="1.4"/>
<circle cx="76" cy="32" r="1.2"/>
</g>
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,-46)" opacity="0.6"/>
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,46)" opacity="0.6"/>
</g>
<g filter="url(#softShadow)">
<path d="M 78 70 L 110 70 L 128 142 L 146 70 L 178 70 L 138 186 L 118 186 Z"
fill="url(#vFill)"/>
<path d="M 118 186 L 128 142 L 138 186"
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
<path d="M 82 74 L 108 74 L 100 100 Z" fill="#ffffff" opacity="0.25"/>
</g>
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,18)" opacity="0.7"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,-18)" opacity="0.7"/>
</g>
<g fill="#ffffff">
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
</g>
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
</symbol>
</defs>
<!-- ===== Icon mark (scaled down) ===== -->
<use href="#icon" x="8" y="8" width="144" height="144"/>
<!-- ===== Wordmark ===== -->
<!-- "volumen" — main wordmark -->
<text x="172" y="92"
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="72" font-weight="700" letter-spacing="-2"
fill="url(#wordmark)">volumen</text>
<!-- Decorative wave line under the wordmark -->
<path d="M 172 108
C 196 100, 220 116, 244 108
S 292 100, 316 108
S 364 116, 388 108
S 436 100, 460 108"
fill="none" stroke="url(#wordmarkAccent)" stroke-width="1.4"
stroke-linecap="round" opacity="0.85"/>
<!-- Tagline: BLOG ENGINE -->
<text x="172" y="132"
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="14" font-weight="500" letter-spacing="6"
fill="#4a5bd8" opacity="0.85">BLOG ENGINE</text>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

+465
View File
@@ -0,0 +1,465 @@
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<form id="post-form" method="post"
action="<%= @mode == :new ? to("/admin/posts") : to("/admin/posts/#{@post.slug}") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="fields">
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
<label>Slug
<input type="text" name="slug" value="<%= h(@post.slug) %>"
<%= @mode == :edit ? "readonly" : "required" %>>
</label>
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
<label>Tags
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
placeholder="comma, separated">
</label>
<label class="check">
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
</label>
<label class="check">
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
</label>
</div>
<div class="cover-field">
<label for="cover-input">Cover image</label>
<div class="cover-row">
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
placeholder="/media/… or https://…">
<button type="button" id="cover-upload">Upload</button>
<button type="button" id="cover-clear">Clear</button>
</div>
<img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden>
</div>
<div class="editor-tabs">
<button type="button" data-tab="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button>
</div>
<div id="visual-view" hidden>
<div class="pane">
<div class="ed-toolbar">
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-rich="h2" title="Heading 2">H2</button>
<button type="button" data-rich="h3" title="Heading 3">H3</button>
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
<button type="button" data-rich="ul" title="Bullet list">&bull; List</button>
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
<button type="button" data-rich="quote" title="Quote">&ldquo;</button>
<button type="button" data-rich="code" title="Inline code">&lt;/&gt;</button>
<button type="button" data-rich="image" title="Image">Img</button>
<button type="button" data-rich="clear" title="Paragraph">P</button>
</div>
<div id="wysiwyg" class="markdown" contenteditable="true"></div>
</div>
</div>
<div id="markdown-view">
<div class="editor">
<div class="pane">
<div class="ed-toolbar">
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-md="h2" title="Heading">H2</button>
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button>
<button type="button" data-md="ul" title="List">List</button>
<button type="button" data-md="quote" title="Quote">&ldquo;</button>
<button type="button" data-md="code" title="Code">&lt;/&gt;</button>
<button type="button" data-md="image" title="Image">Img</button>
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button>
</div>
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
</div>
<div class="pane preview-pane">
<div class="ed-toolbar"><span>Preview</span></div>
<div id="preview" class="markdown"></div>
</div>
</div>
</div>
<input type="file" id="image-input" accept="image/*" hidden>
<button type="submit" class="primary">Save</button>
</form>
<script>
(function () {
var form = document.getElementById("post-form");
var textarea = document.getElementById("body");
var preview = document.getElementById("preview");
var wysiwyg = document.getElementById("wysiwyg");
var visualView = document.getElementById("visual-view");
var markdownView = document.getElementById("markdown-view");
var imageInput = document.getElementById("image-input");
var coverInput = document.getElementById("cover-input");
var coverFile = document.getElementById("cover-file");
var coverPreview = document.getElementById("cover-preview");
var previewUrl = "<%= to("/admin/preview") %>";
var uploadUrl = "<%= to("/admin/uploads") %>";
var csrf = "<%= csrf_token %>";
var timer = null;
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
// --- Markdown <-> HTML -------------------------------------------------
function mdToHtml(md) {
var data = new URLSearchParams();
data.set("body", md);
data.set("_csrf", csrf);
return fetch(previewUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: data.toString()
}).then(function (response) { return response.text(); });
}
function inlineToMd(node) {
var out = "";
node.childNodes.forEach(function (child) { out += inlineNode(child); });
return out;
}
function inlineNode(node) {
if (node.nodeType === 3) { return node.nodeValue.replace(/\s+/g, " "); }
if (node.nodeType !== 1) { return ""; }
var tag = node.tagName.toLowerCase();
var inner = inlineToMd(node);
if (tag === "strong" || tag === "b") { return "**" + inner.trim() + "**"; }
if (tag === "em" || tag === "i") { return "*" + inner.trim() + "*"; }
if (tag === "code") { return "`" + node.textContent + "`"; }
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
if (tag === "img") {
return "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
}
if (tag === "br") { return "\n"; }
return inner;
}
function listToMd(list, ordered) {
var index = 1;
var items = [];
list.childNodes.forEach(function (li) {
if (li.nodeType === 1 && li.tagName.toLowerCase() === "li") {
var marker = ordered ? (index++ + ". ") : "- ";
items.push(marker + inlineToMd(li).trim());
}
});
return items.join("\n");
}
function htmlToMd(root) {
var blocks = [];
root.childNodes.forEach(function (node) {
if (node.nodeType === 3) {
var text = node.nodeValue.trim();
if (text) { blocks.push(text); }
return;
}
if (node.nodeType !== 1) { return; }
var tag = node.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) {
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
} else if (tag === "ul" || tag === "ol") {
blocks.push(listToMd(node, tag === "ol"));
} else if (tag === "blockquote") {
blocks.push(inlineToMd(node).trim().split("\n").map(function (line) {
return "> " + line;
}).join("\n"));
} else if (tag === "pre") {
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
} else if (tag === "hr") {
blocks.push("---");
} else {
var content = inlineToMd(node).trim();
if (content) { blocks.push(content); }
}
});
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
}
// --- Live preview (markdown mode) -------------------------------------
function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
}
function schedule() {
clearTimeout(timer);
timer = setTimeout(renderPreview, 250);
}
// --- Image upload ------------------------------------------------------
function uploadImage(file, onDone) {
var data = new FormData();
data.append("file", file);
data.append("_csrf", csrf);
fetch(uploadUrl, { method: "POST", body: data })
.then(function (response) { return response.json(); })
.then(function (json) { if (json.url) { onDone(json.url); } });
}
imageInput.addEventListener("change", function () {
var file = imageInput.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
if (visualView.hidden) {
insertIntoTextarea("![](" + url + ")");
} else {
exec("insertImage", url);
}
});
imageInput.value = "";
});
// --- Cover image -------------------------------------------------------
function refreshCoverPreview() {
if (coverInput.value) {
coverPreview.src = coverInput.value;
coverPreview.hidden = false;
} else {
coverPreview.hidden = true;
coverPreview.removeAttribute("src");
}
}
document.getElementById("cover-upload").addEventListener("click", function () {
coverFile.click();
});
document.getElementById("cover-clear").addEventListener("click", function () {
coverInput.value = "";
refreshCoverPreview();
});
coverFile.addEventListener("change", function () {
var file = coverFile.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
coverInput.value = url;
refreshCoverPreview();
});
coverFile.value = "";
});
coverInput.addEventListener("input", refreshCoverPreview);
refreshCoverPreview();
// --- Markdown toolbar --------------------------------------------------
function insertIntoTextarea(text) {
var start = textarea.selectionStart;
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
schedule();
}
function wrap(before, after) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
textarea.value = textarea.value.slice(0, start) + before + selected + after +
textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = end + before.length;
schedule();
}
function linePrefix(prefix) {
var start = textarea.selectionStart;
var lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
textarea.focus();
schedule();
}
var mdActions = {
bold: function () { wrap("**", "**"); },
italic: function () { wrap("*", "*"); },
code: function () { wrap("`", "`"); },
link: function () { wrap("[", "](https://)"); },
h2: function () { linePrefix("## "); },
ul: function () { linePrefix("- "); },
quote: function () { linePrefix("> "); },
image: function () { imageInput.click(); }
};
// --- Visual toolbar ----------------------------------------------------
function exec(command, value) {
document.execCommand(command, false, value || null);
wysiwyg.focus();
updateToolbarState();
}
function wrapInline(tagName) {
var selection = window.getSelection();
if (!selection.rangeCount) { return; }
var element = document.createElement(tagName);
try { selection.getRangeAt(0).surroundContents(element); } catch (e) { /* spans blocks */ }
wysiwyg.focus();
updateToolbarState();
}
var richActions = {
bold: function () { exec("bold"); },
italic: function () { exec("italic"); },
h2: function () { exec("formatBlock", "H2"); },
h3: function () { exec("formatBlock", "H3"); },
quote: function () { exec("formatBlock", "BLOCKQUOTE"); },
clear: function () { exec("formatBlock", "P"); },
ul: function () { exec("insertUnorderedList"); },
ol: function () { exec("insertOrderedList"); },
code: function () { wrapInline("code"); },
image: function () { imageInput.click(); },
link: function () { var url = prompt("Link URL", "https://"); if (url) { exec("createLink", url); } }
};
// --- Toolbar active state ----------------------------------------------
function queryState(command) {
try { return document.queryCommandState(command); } catch (e) { return false; }
}
function currentBlock() {
try { return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, ""); }
catch (e) { return ""; }
}
function selectionInEditor() {
var node = window.getSelection().anchorNode;
while (node) { if (node === wysiwyg) { return true; } node = node.parentNode; }
return false;
}
function isInside(tagName) {
var node = window.getSelection().anchorNode;
while (node && node !== wysiwyg) {
if (node.nodeType === 1 && node.tagName.toLowerCase() === tagName) { return true; }
node = node.parentNode;
}
return false;
}
function updateToolbarState() {
if (visualView.hidden) { return; }
var active = {};
if (selectionInEditor()) {
var block = currentBlock();
active = {
bold: queryState("bold"),
italic: queryState("italic"),
ul: queryState("insertUnorderedList"),
ol: queryState("insertOrderedList"),
h2: block === "h2",
h3: block === "h3",
quote: block === "blockquote",
link: isInside("a"),
code: isInside("code")
};
}
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.classList.toggle("active", !!active[button.dataset.rich]);
});
}
// --- Mode switching ----------------------------------------------------
function setActiveTab(name) {
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.classList.toggle("active", button.dataset.tab === name);
});
}
function showVisual() {
mdToHtml(textarea.value).then(function (html) { wysiwyg.innerHTML = html; });
visualView.hidden = false;
markdownView.hidden = true;
setActiveTab("visual");
}
function showMarkdown() {
textarea.value = htmlToMd(wysiwyg);
renderPreview();
visualView.hidden = true;
markdownView.hidden = false;
setActiveTab("markdown");
}
// --- Keyboard shortcuts ------------------------------------------------
function shortcut(actions) {
return function (event) {
if (!(event.ctrlKey || event.metaKey)) { return; }
var key = event.key.toLowerCase();
if (key === "b") { event.preventDefault(); actions.bold(); }
else if (key === "i") { event.preventDefault(); actions.italic(); }
else if (key === "k") { event.preventDefault(); actions.link(); }
};
}
// --- Wiring ------------------------------------------------------------
document.querySelectorAll("[data-md]").forEach(function (button) {
button.addEventListener("click", function () { mdActions[button.dataset.md](); });
});
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.addEventListener("click", function () { richActions[button.dataset.rich](); });
});
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.addEventListener("click", function () {
if (button.dataset.tab === "visual") { showVisual(); } else { showMarkdown(); }
});
});
wysiwyg.addEventListener("paste", function (event) {
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, text);
});
wysiwyg.addEventListener("keydown", shortcut(richActions));
wysiwyg.addEventListener("keyup", updateToolbarState);
wysiwyg.addEventListener("mouseup", updateToolbarState);
document.addEventListener("selectionchange", updateToolbarState);
textarea.addEventListener("input", schedule);
textarea.addEventListener("keydown", shortcut(mdActions));
form.addEventListener("submit", function () {
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
});
// --- Preview toggle (persisted) ---------------------------------------
var editor = markdownView.querySelector(".editor");
var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() {
var off = false;
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
editor.classList.toggle("no-preview", off);
togglePreviewBtn.classList.toggle("active", !off);
if (!off) { renderPreview(); }
}
togglePreviewBtn.addEventListener("click", function () {
var turningOff = !editor.classList.contains("no-preview");
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
applyPreviewPreference();
});
// Start in Markdown mode (primary).
applyPreviewPreference();
})();
</script>
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="<%= h(config.language) %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title>
<style>
:root {
color-scheme: light dark;
--fg: #1b1b1f; --muted: #6b7280; --bg: #f6f7f9; --card: #ffffff;
--border: #e4e4ec; --accent: #4f46e5; --accent-strong: #4338ca;
--accent-weak: #eef2ff; --on-accent: #ffffff;
--danger: #dc2626; --danger-weak: #fef2f2;
--ok: #16a34a; --ok-weak: #f0fdf4;
--ring: 0 0 0 3px rgba(79, 70, 229, 0.30);
--shadow-sm: 0 1px 2px rgba(17, 17, 26, 0.06);
--shadow: 0 1px 3px rgba(17, 17, 26, 0.06), 0 10px 30px -16px rgba(17, 17, 26, 0.25);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e7e7ea; --muted: #9aa0aa; --bg: #121217; --card: #1c1c22;
--border: #2c2c35; --accent: #818cf8; --accent-strong: #a5b4fc;
--accent-weak: rgba(129, 140, 248, 0.16); --on-accent: #14141a;
--danger: #f87171; --danger-weak: rgba(248, 113, 113, 0.14);
--ok: #4ade80; --ok-weak: rgba(74, 222, 128, 0.14);
--ring: 0 0 0 3px rgba(129, 140, 248, 0.40);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px -16px rgba(0, 0, 0, 0.7);
}
.brand-logo { background: #f4f4f6; border-radius: 8px; padding: 4px 8px; }
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; min-height: 100vh; background: var(--bg); }
/* Sidebar */
.sidebar {
width: 230px; flex-shrink: 0; position: sticky; top: 0; height: 100vh;
overflow-y: auto; background: var(--card); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 1.25rem 0;
}
.sidebar .brand { display: flex; padding: 0 1.25rem; margin-bottom: 1rem; text-decoration: none; }
.brand-logo { height: 28px; display: block; }
.sidebar .who {
margin: 0 1.25rem; padding: 0.3rem 0.6rem; border: 1px solid var(--border);
border-radius: 999px; font-size: 0.78rem; color: var(--muted); align-self: flex-start;
}
.sidenav { display: flex; flex-direction: column; gap: 1px; padding: 0.75rem 0.75rem; flex: 1; }
.sidenav a, .sidenav button {
display: flex; align-items: center; text-decoration: none; color: var(--fg);
font: inherit; font-size: 0.9rem; font-weight: 500; padding: 0.55rem 0.8rem;
border-radius: 9px; border: none; background: none; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sidenav a:hover, .sidenav button:hover { background: var(--accent-weak); color: var(--accent-strong); }
.sidenav a.active { background: var(--accent); color: var(--on-accent); }
.sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* Main */
main {
flex: 1; overflow: auto; padding: 2rem 2rem 4rem;
color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.main-inner { max-width: 1060px; margin: 0 auto; }
::selection { background: var(--accent-weak); }
a { color: var(--accent); }
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 1.25rem; }
h2 { font-size: 1.15rem; font-weight: 650; margin: 0 0 0.75rem; }
h3 { font-size: 0.95rem; font-weight: 650; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.toolbar h1 { margin: 0; }
/* Buttons */
button, .button {
font: inherit; font-weight: 550; cursor: pointer; display: inline-flex;
align-items: center; gap: 0.4rem; border: 1px solid var(--border);
background: var(--card); color: var(--fg); border-radius: 9px;
padding: 0.45rem 0.9rem; text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.05s, box-shadow 0.15s;
}
button:hover, .button:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
button:active, .button:active { transform: translateY(1px); }
button:focus-visible, .button:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible,
[contenteditable]:focus-visible, .sidenav a:focus-visible, .sidenav button:focus-visible {
outline: none; box-shadow: var(--ring);
}
button.primary, .button.primary {
background: var(--accent); border-color: var(--accent); color: var(--on-accent);
box-shadow: var(--shadow-sm);
}
button.primary:hover, .button.primary:hover {
background: var(--accent-strong); border-color: var(--accent-strong);
}
button.danger {
color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
background: transparent;
}
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
form.inline { display: inline; margin: 0; }
/* Cards */
.card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow);
}
.card h2 { margin-top: 0; }
.card h3 { margin: 1.5rem 0 0.6rem; }
.card form { margin-bottom: 1.25rem; }
.card label { max-width: 380px; margin: 0.5rem 0; }
/* Forms */
.fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; margin-bottom: 1.25rem; }
label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.8rem;
font-weight: 550; color: var(--muted); }
label.check { flex-direction: row; align-items: center; gap: 0.5rem; }
.cover-field { margin-bottom: 1rem; }
.cover-field > label { display: block; font-size: 0.8rem; font-weight: 550;
color: var(--muted); margin-bottom: 0.3rem; }
.cover-row { display: flex; gap: 0.5rem; align-items: center; }
.cover-row input { flex: 1; }
.cover-preview { margin-top: 0.6rem; max-height: 120px; border-radius: 8px;
border: 1px solid var(--border); display: block; }
input[type=text], input[type=password], input[type=date], input:not([type]), select, textarea {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--border); border-radius: 9px; padding: 0.5rem 0.7rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
/* Tables */
table {
width: 100%; border-collapse: collapse; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow); margin-bottom: 1.5rem;
}
th, td { text-align: left; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
background: color-mix(in srgb, var(--bg) 60%, var(--card)); }
tbody tr:hover td { background: color-mix(in srgb, var(--accent-weak) 50%, transparent); }
tr:last-child td { border-bottom: none; }
td.actions { display: flex; gap: 0.5rem; align-items: center; }
/* Alerts */
.error, .notice {
border: 1px solid; border-radius: 10px; padding: 0.7rem 0.95rem; margin: 0 0 1.25rem;
font-size: 0.9rem;
}
.error { color: var(--danger); background: var(--danger-weak);
border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { color: var(--ok); background: var(--ok-weak);
border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
.muted { color: var(--muted); }
/* Login */
.login {
max-width: 380px; margin: 4rem auto; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 2rem; box-shadow: var(--shadow);
}
.login h1 { text-align: center; }
.login label, .login button { width: 100%; }
.login button { margin-top: 1.25rem; justify-content: center; }
/* Editor */
.editor-tabs {
display: inline-flex; gap: 2px; background: var(--card); padding: 3px;
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.9rem;
}
.editor-tabs button { border: none; background: transparent; color: var(--muted);
border-radius: 7px; padding: 0.35rem 0.95rem; font-size: 0.85rem; }
.editor-tabs button.active { background: var(--accent); color: var(--on-accent); }
.editor { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
.editor.no-preview { grid-template-columns: 1fr; }
.editor.no-preview .preview-pane { display: none; }
.pane { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
background: var(--card); box-shadow: var(--shadow-sm); }
.ed-toolbar { display: flex; gap: 0.3rem; padding: 0.45rem; align-items: center;
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 50%, var(--card)); }
.ed-toolbar button { padding: 0.28rem 0.55rem; font-size: 0.82rem; border-radius: 7px;
background: transparent; }
.ed-toolbar button:hover { background: var(--accent-weak); }
.ed-toolbar button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ed-toolbar span { color: var(--muted); font-size: 0.78rem; font-weight: 550; }
.ed-toolbar .toggle { margin-left: auto; }
textarea { width: 100%; border: none; border-radius: 0; padding: 0.85rem 1rem;
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical;
background: var(--card); color: var(--fg); }
textarea:focus { box-shadow: none; }
#preview, #wysiwyg { padding: 0.85rem 1.1rem; min-height: 22rem; }
#wysiwyg { outline: none; }
#wysiwyg:focus { box-shadow: inset 0 0 0 2px var(--accent); }
#preview img, #wysiwyg img { max-width: 100%; }
[hidden] { display: none !important; }
/* Rendered Markdown */
.markdown { line-height: 1.7; }
.markdown > :first-child { margin-top: 0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
.markdown h1 { font-size: 1.6rem; }
.markdown h2 { font-size: 1.3rem; }
.markdown h3 { font-size: 1.1rem; }
.markdown p { margin: 0.7em 0; }
.markdown a { color: var(--accent); }
.markdown ul, .markdown ol { padding-left: 1.5rem; margin: 0.7em 0; }
.markdown li { margin: 0.2em 0; }
.markdown blockquote { margin: 0.9em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent);
color: var(--muted); background: var(--accent-weak); border-radius: 0 8px 8px 0; }
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; }
.markdown pre { background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem 1rem; overflow-x: auto; margin: 0.9em 0; }
.markdown pre code { background: none; border: none; padding: 0; font-size: 0.88em; }
.markdown table { border-collapse: collapse; width: 100%; margin: 0.9em 0; font-size: 0.95em;
box-shadow: none; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; }
.markdown thead th { background: var(--accent-weak); color: var(--fg); }
.markdown tbody tr:hover td { background: transparent; }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: 8px; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
</style>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="<%= to("/admin/") %>"><img class="brand-logo" src="<%= to("/admin/logo.svg") %>" alt="volumen"></a>
<% if authenticated? %>
<span class="who"><%= h(current_user) %> · <%= h(current_role) %></span>
<% settings_active = request.path_info.start_with?("/admin/settings") %>
<nav class="sidenav">
<a href="<%= to("/admin/") %>" class="<%= settings_active ? "" : "active" %>">Posts</a>
<a href="<%= to("/admin/settings") %>" class="<%= settings_active ? "active" : "" %>">Settings</a>
</nav>
<div class="sidebar-footer">
<form method="post" action="<%= to("/admin/logout") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit">Log out</button>
</form>
</div>
<% end %>
</aside>
<main>
<div class="main-inner">
<%= yield %>
</div>
</main>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<div class="toolbar">
<h1>Posts</h1>
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
</div>
<% if @posts.empty? %>
<p>No posts yet. Create your first one.</p>
<% else %>
<table>
<thead>
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= h(post.title) %></td>
<td><%= h(post.slug) %></td>
<td><%= h(post.lang) %></td>
<td><%= h(post.date_string) %></td>
<td><%= post.draft? ? "yes" : "" %></td>
<td class="actions">
<a href="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a>
<form class="inline" method="post"
action="<%= to("/admin/posts/#{post.slug}/delete") %>"
onsubmit="return confirm('Delete this post?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Delete</button>
</form>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
+23
View File
@@ -0,0 +1,23 @@
<div class="login">
<h1>Sign in</h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<% unless users.any? %>
<p class="error">
No users are configured. Set <code>admin.password_hash</code> in your
config (you will sign in as <code>admin</code>) or generate one with
<code>volumen hash-password</code>.
</p>
<% end %>
<form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<label>Username
<input type="text" name="username" autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit" class="primary">Sign in</button>
</form>
</div>
+86
View File
@@ -0,0 +1,86 @@
<h1>Settings</h1>
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %>
<% if @error %><p class="error"><%= h(@error) %></p><% end %>
<section class="card">
<h2>Your account</h2>
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
<form method="post" action="<%= to("/admin/settings/password") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change password</h3>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
</label>
<label>New password
<input type="password" name="new_password" autocomplete="new-password">
</label>
<button type="submit" class="primary">Update password</button>
</form>
<form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change username</h3>
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
<button type="submit">Update username</button>
</form>
</section>
<% if admin? %>
<section class="card">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
<td>
<% if user.username == current_user %>
<%= h(user.role) %>
<% else %>
<form class="inline" method="post"
action="<%= to("/admin/settings/users/#{user.username}/role") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<select name="role" onchange="this.form.submit()">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</form>
<% end %>
</td>
<td class="actions">
<% unless user.username == current_user %>
<form class="inline" method="post"
action="<%= to("/admin/settings/users/#{user.username}/delete") %>"
onsubmit="return confirm('Remove this user?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Remove</button>
</form>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Add user</h3>
<div class="fields">
<label>Username <input type="text" name="username"></label>
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
<label>Role
<select name="role">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</label>
</div>
<button type="submit" class="primary">Add user</button>
</form>
</section>
<% end %>