feat: initial release of volumen — a file-based Markdown blog engine
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Volumen
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
@@ -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 |
@@ -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">• List</button>
|
||||
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
|
||||
<button type="button" data-rich="quote" title="Quote">“</button>
|
||||
<button type="button" data-rich="code" title="Inline code"></></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">“</button>
|
||||
<button type="button" data-md="code" title="Code"></></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 " || "") + ")";
|
||||
}
|
||||
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("");
|
||||
} 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>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
Reference in New Issue
Block a user