Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adminAssets carries the interface sheets, the self-hosted fonts and the
|
||||
// glyph sprite of the admin UI, packaged next to the templates so one
|
||||
// binary serves the whole product.
|
||||
//
|
||||
//go:embed static/*.css static/graphis.svg static/fonts/*.woff2
|
||||
var adminAssets embed.FS
|
||||
|
||||
// asset is one packaged file with its content type and an ETag derived
|
||||
// from its bytes.
|
||||
type asset struct {
|
||||
body []byte
|
||||
contentType string
|
||||
etag string
|
||||
}
|
||||
|
||||
// assets indexes the packaged files by their URL tail under
|
||||
// /admin/assets/. The index doubles as the allow-list: a path the binary
|
||||
// does not hold is a 404, so no traversal can reach the disk.
|
||||
var assets = func() map[string]*asset {
|
||||
entries, err := fs.ReadDir(adminAssets, "static")
|
||||
if err != nil {
|
||||
panic("web: embedded assets unreadable: " + err.Error())
|
||||
}
|
||||
out := map[string]*asset{}
|
||||
add := func(name, tail, contentType string) {
|
||||
body, err := adminAssets.ReadFile(name)
|
||||
if err != nil {
|
||||
panic("web: embedded asset unreadable: " + name)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
out[tail] = &asset{
|
||||
body: body,
|
||||
contentType: contentType,
|
||||
etag: `"` + hex.EncodeToString(sum[:])[:16] + `"`,
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".css"):
|
||||
add("static/"+name, name, "text/css; charset=utf-8")
|
||||
case strings.HasSuffix(name, ".svg"):
|
||||
add("static/"+name, name, "image/svg+xml; charset=utf-8")
|
||||
}
|
||||
}
|
||||
fonts, err := fs.ReadDir(adminAssets, "static/fonts")
|
||||
if err != nil {
|
||||
panic("web: embedded fonts unreadable: " + err.Error())
|
||||
}
|
||||
for _, font := range fonts {
|
||||
add("static/fonts/"+font.Name(), "fonts/"+font.Name(), "font/woff2")
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// AssetHandler serves one embedded asset by its tail under
|
||||
// /admin/assets/. Responses are revalidatable: the ETag is the content,
|
||||
// so a deploy refreshes every page while a visit fetches nothing new.
|
||||
func AssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tail := path.Clean(strings.TrimPrefix(r.URL.Path, "/admin/assets/"))
|
||||
found, ok := assets[strings.TrimPrefix(tail, "./")]
|
||||
if !ok || tail == "." {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", found.contentType)
|
||||
w.Header().Set("ETag", found.etag)
|
||||
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
http.ServeContent(w, r, tail, time.Time{}, bytes.NewReader(found.body))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssetHandlerServesThePackagedFiles(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/admin/assets/admin.css": "text/css",
|
||||
"/admin/assets/fonts.css": "text/css",
|
||||
"/admin/assets/graphis.svg": "image/svg+xml",
|
||||
"/admin/assets/fonts/ubuntu-normal-400-latin.woff2": "font/woff2",
|
||||
}
|
||||
for path, wantType := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
AssetHandler(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
response := rec.Result()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status %d", path, response.StatusCode)
|
||||
}
|
||||
if got := response.Header.Get("Content-Type"); !strings.HasPrefix(got, wantType) {
|
||||
t.Errorf("%s: content type %q, want %q", path, got, wantType)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
t.Fatalf("%s: empty body: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetHandlerRejectsUnknownAndTraversal(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/admin/assets/",
|
||||
"/admin/assets/nope.css",
|
||||
"/admin/assets/../etc/passwd",
|
||||
"/admin/assets/fonts/../admin.css",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
AssetHandler(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if code := rec.Result().StatusCode; code != http.StatusOK && code != http.StatusNotFound {
|
||||
t.Fatalf("%s: status %d", path, code)
|
||||
}
|
||||
// Only the packaged files are a 200; a re-served parent is a hit
|
||||
// only if the tail resolves inside the set after Clean.
|
||||
if path == "/admin/assets/" || path == "/admin/assets/nope.css" {
|
||||
if rec.Result().StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: want 404, got %d", path, rec.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetHandlerRevalidatesWithTheETag(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
AssetHandler(rec, httptest.NewRequest(http.MethodGet, "/admin/assets/admin.css", nil))
|
||||
etag := rec.Result().Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("no ETag on the asset")
|
||||
}
|
||||
if got := rec.Result().Header.Get("Cache-Control"); !strings.Contains(got, "must-revalidate") {
|
||||
t.Errorf("cache-control %q, want revalidation", got)
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/assets/admin.css", nil)
|
||||
request.Header.Set("If-None-Match", etag)
|
||||
AssetHandler(second, request)
|
||||
if code := second.Result().StatusCode; code != http.StatusNotModified {
|
||||
t.Fatalf("revalidation status %d, want 304", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTemplateGlyphReferenceResolves(t *testing.T) {
|
||||
sprite := assets["graphis.svg"]
|
||||
if sprite == nil {
|
||||
t.Fatal("the sprite is not packaged")
|
||||
}
|
||||
symbol := regexp.MustCompile(`<symbol id="([^"]+)"`)
|
||||
defined := map[string]bool{}
|
||||
for _, m := range symbol.FindAllStringSubmatch(string(sprite.body), -1) {
|
||||
defined[m[1]] = true
|
||||
}
|
||||
|
||||
ref := regexp.MustCompile(`icons\.svg#([a-z0-9-]+)"`)
|
||||
// The date-picker builds its glyphs in JavaScript, so its ids are not
|
||||
// literal in the template; they are named here to keep them covered.
|
||||
jsRefs := []string{"chevron-left", "chevron-right", "calendar"}
|
||||
|
||||
tpls, err := fs.ReadDir(TemplateFS(), "templates")
|
||||
if err != nil {
|
||||
t.Fatalf("templates unreadable: %v", err)
|
||||
}
|
||||
for _, entry := range tpls {
|
||||
body, err := TemplateFS().ReadFile("templates/" + entry.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", entry.Name(), err)
|
||||
}
|
||||
for _, m := range ref.FindAllStringSubmatch(string(body), -1) {
|
||||
if !defined[m[1]] {
|
||||
t.Errorf("%s: references the glyph %q, which the sprite does not define", entry.Name(), m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range jsRefs {
|
||||
if !defined[name] {
|
||||
t.Errorf("the date picker references the glyph %q, which the sprite does not define", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersLetAssetsBeCached(t *testing.T) {
|
||||
handler := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/assets/admin.css", nil))
|
||||
if got := rec.Result().Header.Get("Cache-Control"); got != "" {
|
||||
t.Errorf("asset cache-control = %q, want the handler to own it", got)
|
||||
}
|
||||
if strings.Contains(rec.Result().Header.Get("Content-Security-Policy"), "nonce-") {
|
||||
t.Error("asset response carries a page CSP with a nonce")
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
|
||||
if got := rec.Result().Header.Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("admin page cache-control = %q, want no-store", got)
|
||||
}
|
||||
if !strings.Contains(rec.Result().Header.Get("Content-Security-Policy"), "nonce-") {
|
||||
t.Error("admin page CSP lost its nonce")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json/v2"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CrossOrigin refuses a state-changing request that a browser sent from
|
||||
// another origin, using the standard library's Fetch Metadata check:
|
||||
// Sec-Fetch-Site when the browser sends it, and the Origin header against
|
||||
// the Host header otherwise.
|
||||
//
|
||||
// It is the outer gate, and the admin's per-session CSRF token is the
|
||||
// inner one, because the two cover different cases: this refuses a
|
||||
// cross-site request before any handler runs, and the token also refuses
|
||||
// a same-site request (another port on the same host) and a browser that
|
||||
// sends neither header, which this check deliberately allows as a
|
||||
// non-browser client.
|
||||
func CrossOrigin() func(http.Handler) http.Handler {
|
||||
protection := http.NewCrossOriginProtection()
|
||||
protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Warn("web: refused a cross-origin request",
|
||||
"method", r.Method, "path", r.URL.Path, "origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.MarshalWrite(w, map[string]any{"error": "cross_origin"}, json.Deterministic(true))
|
||||
}))
|
||||
return protection.Handler
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type loggerKey struct{}
|
||||
|
||||
// Logger returns the logger the request carries, or the process default
|
||||
// logger outside a request. A handler uses it instead of the package-level
|
||||
// slog functions, so every line it writes carries the request id and the
|
||||
// route it happened on.
|
||||
func Logger(ctx context.Context) *slog.Logger {
|
||||
if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
|
||||
return logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// WithLogger returns a context carrying logger, for a layer that has
|
||||
// learned something worth adding to every line of the request.
|
||||
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, loggerKey{}, logger)
|
||||
}
|
||||
|
||||
// RequestLogger gives every request an id, puts a logger carrying it into
|
||||
// the context, answers with the same id in X-Request-Id, and writes one
|
||||
// line when the request finishes.
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := requestID()
|
||||
logger := slog.Default().With("request", id)
|
||||
w.Header().Set("X-Request-Id", id)
|
||||
|
||||
ctx := WithLogger(r.Context(), logger)
|
||||
recorder := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
started := time.Now()
|
||||
next.ServeHTTP(recorder, r.WithContext(ctx))
|
||||
logger.LogAttrs(ctx, slog.LevelInfo, "request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", recorder.status),
|
||||
slog.Duration("took", time.Since(started)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter records the status code so the access line can carry it.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (s *statusWriter) WriteHeader(code int) {
|
||||
if !s.written {
|
||||
s.status = code
|
||||
s.written = true
|
||||
}
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusWriter) Write(body []byte) (int, error) {
|
||||
s.written = true
|
||||
return s.ResponseWriter.Write(body)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the writer underneath, so a
|
||||
// handler that streams or flushes still can.
|
||||
func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }
|
||||
|
||||
func requestID() string {
|
||||
// Short enough to read in a log line, wide enough that two live
|
||||
// requests collide with negligible probability.
|
||||
return rand.Text()[:16]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed static/*.svg
|
||||
var staticFS embed.FS
|
||||
|
||||
// StaticFile returns a packaged static asset by file name.
|
||||
func StaticFile(name string) ([]byte, error) {
|
||||
return staticFS.ReadFile("static/" + name)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
/* Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 */
|
||||
|
||||
/* Ubuntu and Ubuntu Mono, self-hosted from the same subsets the author's
|
||||
website carries: latin and latin-ext only, which is all Czech and English
|
||||
need, and the unicode-range keeps a page from downloading a face it does
|
||||
not render. Licence: Ubuntu Font Licence 1.0. */
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-italic-400-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA,
|
||||
U+02BD-02C5,
|
||||
U+02C7-02CC,
|
||||
U+02CE-02D7,
|
||||
U+02DD-02FF,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+1D00-1DBF,
|
||||
U+1E00-1E9F,
|
||||
U+1EF2-1EFF,
|
||||
U+2020,
|
||||
U+20A0-20AB,
|
||||
U+20AD-20C0,
|
||||
U+2113,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-italic-400-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0131,
|
||||
U+0152-0153,
|
||||
U+02BB-02BC,
|
||||
U+02C6,
|
||||
U+02DA,
|
||||
U+02DC,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+2000-206F,
|
||||
U+20AC,
|
||||
U+2122,
|
||||
U+2191,
|
||||
U+2193,
|
||||
U+2212,
|
||||
U+2215,
|
||||
U+FEFF,
|
||||
U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-normal-400-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA,
|
||||
U+02BD-02C5,
|
||||
U+02C7-02CC,
|
||||
U+02CE-02D7,
|
||||
U+02DD-02FF,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+1D00-1DBF,
|
||||
U+1E00-1E9F,
|
||||
U+1EF2-1EFF,
|
||||
U+2020,
|
||||
U+20A0-20AB,
|
||||
U+20AD-20C0,
|
||||
U+2113,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-normal-400-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0131,
|
||||
U+0152-0153,
|
||||
U+02BB-02BC,
|
||||
U+02C6,
|
||||
U+02DA,
|
||||
U+02DC,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+2000-206F,
|
||||
U+20AC,
|
||||
U+2122,
|
||||
U+2191,
|
||||
U+2193,
|
||||
U+2212,
|
||||
U+2215,
|
||||
U+FEFF,
|
||||
U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-normal-500-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA,
|
||||
U+02BD-02C5,
|
||||
U+02C7-02CC,
|
||||
U+02CE-02D7,
|
||||
U+02DD-02FF,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+1D00-1DBF,
|
||||
U+1E00-1E9F,
|
||||
U+1EF2-1EFF,
|
||||
U+2020,
|
||||
U+20A0-20AB,
|
||||
U+20AD-20C0,
|
||||
U+2113,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-normal-500-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0131,
|
||||
U+0152-0153,
|
||||
U+02BB-02BC,
|
||||
U+02C6,
|
||||
U+02DA,
|
||||
U+02DC,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+2000-206F,
|
||||
U+20AC,
|
||||
U+2122,
|
||||
U+2191,
|
||||
U+2193,
|
||||
U+2212,
|
||||
U+2215,
|
||||
U+FEFF,
|
||||
U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-normal-700-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA,
|
||||
U+02BD-02C5,
|
||||
U+02C7-02CC,
|
||||
U+02CE-02D7,
|
||||
U+02DD-02FF,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+1D00-1DBF,
|
||||
U+1E00-1E9F,
|
||||
U+1EF2-1EFF,
|
||||
U+2020,
|
||||
U+20A0-20AB,
|
||||
U+20AD-20C0,
|
||||
U+2113,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-normal-700-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0131,
|
||||
U+0152-0153,
|
||||
U+02BB-02BC,
|
||||
U+02C6,
|
||||
U+02DA,
|
||||
U+02DC,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+2000-206F,
|
||||
U+20AC,
|
||||
U+2122,
|
||||
U+2191,
|
||||
U+2193,
|
||||
U+2212,
|
||||
U+2215,
|
||||
U+FEFF,
|
||||
U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-mono-normal-400-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA,
|
||||
U+02BD-02C5,
|
||||
U+02C7-02CC,
|
||||
U+02CE-02D7,
|
||||
U+02DD-02FF,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+1D00-1DBF,
|
||||
U+1E00-1E9F,
|
||||
U+1EF2-1EFF,
|
||||
U+2020,
|
||||
U+20A0-20AB,
|
||||
U+20AD-20C0,
|
||||
U+2113,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-mono-normal-400-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0131,
|
||||
U+0152-0153,
|
||||
U+02BB-02BC,
|
||||
U+02C6,
|
||||
U+02DA,
|
||||
U+02DC,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+2000-206F,
|
||||
U+20AC,
|
||||
U+2122,
|
||||
U+2191,
|
||||
U+2193,
|
||||
U+2212,
|
||||
U+2215,
|
||||
U+FEFF,
|
||||
U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu Mono";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-mono-normal-700-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA,
|
||||
U+02BD-02C5,
|
||||
U+02C7-02CC,
|
||||
U+02CE-02D7,
|
||||
U+02DD-02FF,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+1D00-1DBF,
|
||||
U+1E00-1E9F,
|
||||
U+1EF2-1EFF,
|
||||
U+2020,
|
||||
U+20A0-20AB,
|
||||
U+20AD-20C0,
|
||||
U+2113,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ubuntu Mono";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/admin/assets/fonts/ubuntu-mono-normal-700-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0131,
|
||||
U+0152-0153,
|
||||
U+02BB-02BC,
|
||||
U+02C6,
|
||||
U+02DA,
|
||||
U+02DC,
|
||||
U+0304,
|
||||
U+0308,
|
||||
U+0329,
|
||||
U+2000-206F,
|
||||
U+20AC,
|
||||
U+2122,
|
||||
U+2191,
|
||||
U+2193,
|
||||
U+2212,
|
||||
U+2215,
|
||||
U+FEFF,
|
||||
U+FFFD;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org) -->
|
||||
<!-- SPDX-License-Identifier: CC-BY-NC-4.0 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Graphis, the house frontend icon set</title>
|
||||
<desc>Stroke-only interface glyphs drawn in one hand style, geometry without presentation attributes, the look carried by the consuming sheet.</desc>
|
||||
|
||||
<symbol id="menu" viewBox="0 0 24 24"><path d="M3.5 7h17M3.5 12h17M3.5 17h17"/></symbol>
|
||||
<symbol id="chevron-up" viewBox="0 0 24 24"><path d="M6.5 14.5 12 9l5.5 5.5"/></symbol>
|
||||
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="M6.5 9.5 12 15l5.5-5.5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M4.5 12h15M13 5.5 19.5 12 13 18.5"/></symbol>
|
||||
<symbol id="arrow-up" viewBox="0 0 24 24"><path d="M12 19.5V5M5.5 11.5 12 5l6.5 6.5"/></symbol>
|
||||
<symbol id="sort" viewBox="0 0 24 24"><path d="M4.5 9.5h15l-3.5-3.5M19.5 14.5h-15l3.5 3.5"/></symbol>
|
||||
|
||||
<symbol id="external-link" viewBox="0 0 24 24"><path d="M13.5 4.5h6v6M19.5 4.5 11 13M19.5 13v5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5.5 18V7A1.5 1.5 0 0 1 7 5.5h6"/></symbol>
|
||||
<symbol id="link" viewBox="0 0 24 24"><path d="M10.5 13.5a3.54 3.54 0 0 0 5 0l3-3a3.54 3.54 0 0 0-5-5l-1.5 1.5M13.5 10.5a3.54 3.54 0 0 0-5 0l-3 3a3.54 3.54 0 0 0 5 5l1.5-1.5"/></symbol>
|
||||
<symbol id="code" viewBox="0 0 24 24"><path d="M8 5l-5 7 5 7M16 5l5 7-5 7"/></symbol>
|
||||
<symbol id="sidebar" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M10 4.5v15M15.5 9 13 12l2.5 3"/></symbol>
|
||||
<symbol id="log-in" viewBox="0 0 24 24"><path d="M15 4.5h3A1.5 1.5 0 0 1 19.5 6v12a1.5 1.5 0 0 1-1.5 1.5h-3"/><path d="M8.5 8.5 12.5 12l-4 3.5M12.5 12H3"/></symbol>
|
||||
<symbol id="log-out" viewBox="0 0 24 24"><path d="M9 4.5H6A1.5 1.5 0 0 0 4.5 6v12A1.5 1.5 0 0 0 6 19.5h3"/><path d="M15.5 8.5 19.5 12l-4 3.5M19.5 12H9.5"/></symbol>
|
||||
|
||||
<symbol id="chart" viewBox="0 0 24 24"><path d="M4 19.5V4.5M4 19.5h16"/><path d="M7.5 15.5l4-5 3 2.5 4.5-6"/></symbol>
|
||||
<symbol id="activity" viewBox="0 0 24 24"><path d="M3.5 12h4l3-7 4 14 3-7h3"/></symbol>
|
||||
<symbol id="gauge" viewBox="0 0 24 24"><path d="M4.5 17.5a8.5 8.5 0 1 1 15 0M12.9 12.6 16 8M12 14h.01"/></symbol>
|
||||
<symbol id="target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="9"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2"/></symbol>
|
||||
|
||||
<symbol id="file" viewBox="0 0 24 24"><path d="M6 3.5h9L19.5 8v12.5H6V3.5z"/><path d="M14.5 3.5V8h5"/></symbol>
|
||||
<symbol id="file-text" viewBox="0 0 24 24"><path d="M6 3.5h9L19.5 8v12.5H6V3.5z"/><path d="M14.5 3.5V8h5M9 12.5h6.5M9 16h6.5"/></symbol>
|
||||
<symbol id="file-check" viewBox="0 0 24 24"><path d="M6 3.5h9L19.5 8v12.5H6V3.5z"/><path d="M14.5 3.5V8h5"/><path d="M9 13.5l2 2 4-4"/></symbol>
|
||||
<symbol id="image" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="8.75" cy="9.75" r="1.5"/><path d="M20.5 15.5l-4.5-4.5-9 9"/></symbol>
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="12" height="12" rx="2"/><path d="M15.5 8.5V5.5a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1H8.5"/></symbol>
|
||||
<symbol id="save" viewBox="0 0 24 24"><path d="M4.5 4.5h11l4 4v11h-15V4.5zM8.5 4.5v5h7v-5M8.5 19.5v-6h7v6"/></symbol>
|
||||
<symbol id="upload" viewBox="0 0 24 24"><path d="M12 3.5V14.5M7 8.5l5-5 5 5"/><path d="M19.5 15v4.5h-15V15"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M12 3.5V14.5M7 9.5l5 5 5-5"/><path d="M19.5 15v4.5h-15V15"/></symbol>
|
||||
<symbol id="trash" viewBox="0 0 24 24"><path d="M4.5 7h15M9.5 7V4.5h5V7"/><path d="M6.5 7l1 13h9l1-13"/><path d="M10.5 10.5v6M13.5 10.5v6"/></symbol>
|
||||
<symbol id="box" viewBox="0 0 24 24"><path d="M12 3.5l7.5 4v9l-7.5 4-7.5-4v-9z"/><path d="M4.5 7.5l7.5 4 7.5-4M12 11.5v9"/></symbol>
|
||||
|
||||
<symbol id="plus" viewBox="0 0 24 24"><path d="M12 5.5v13M5.5 12h13"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 6.5"/></symbol>
|
||||
<symbol id="x" viewBox="0 0 24 24"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
|
||||
<symbol id="search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="M15.6 15.6 20 20"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M3 12s3.5-6.5 9-6.5 9 6.5 9 6.5-3.5 6.5-9 6.5-9-6.5-9-6.5z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M4.5 8.5h7M15.5 8.5h4M4.5 15.5h4M12.5 15.5h7"/><circle cx="13.5" cy="8.5" r="2"/><circle cx="8.5" cy="15.5" r="2"/></symbol>
|
||||
|
||||
<symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3.5 2"/></symbol>
|
||||
<symbol id="history" viewBox="0 0 24 24"><path d="M12 7.5V12l3.5 2"/><path d="M19.6 16.2A8.5 8.5 0 1 1 12 3.5a8.5 8.5 0 0 1 8.2 6.2"/><path d="M20 9.7h-4M20 9.7V5.7"/></symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M17.5 3.5V7h-3.5"/></symbol>
|
||||
<symbol id="sync" viewBox="0 0 24 24"><path d="M19.5 10a7.7 7.7 0 0 0-14.6-2M4.9 4.6V8h3.4M4.5 14a7.7 7.7 0 0 0 14.6 2M19.1 19.4V16h-3.4"/></symbol>
|
||||
<symbol id="repeat" viewBox="0 0 24 24"><path d="M4.5 8.5h13M14.5 4.5l4 4-4 4M19.5 15.5h-13M9.5 11.5l-4 4 4 4"/></symbol>
|
||||
<symbol id="compare" viewBox="0 0 24 24"><path d="M8.5 4.5v15M5 16l3.5 3.5L12 16M15.5 19.5v-15M12 8l3.5-3.5L19 8"/></symbol>
|
||||
|
||||
<symbol id="user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.5"/><path d="M5.5 19.5a6.5 6.5 0 0 1 13 0"/></symbol>
|
||||
<symbol id="users" viewBox="0 0 24 24"><circle cx="9.5" cy="8" r="3.5"/><path d="M3.5 19.5a6 6 0 0 1 12 0"/><path d="M16 5.5a3.5 3.5 0 0 1 0 5M17.5 14.5a6 6 0 0 1 3 5"/></symbol>
|
||||
<symbol id="lock" viewBox="0 0 24 24"><rect x="4.5" y="10.5" width="15" height="9.5" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/></symbol>
|
||||
<symbol id="shield" viewBox="0 0 24 24"><path d="M12 3.5l7.5 3.5v5c0 4.5-3.2 7.5-7.5 8.5-4.3-1-7.5-4-7.5-8.5v-5L12 3.5z"/><path d="M9 12l2.2 2.2L15.5 9.7"/></symbol>
|
||||
|
||||
<symbol id="alert-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v5M12 16.5h.01"/></symbol>
|
||||
<symbol id="alert-triangle" viewBox="0 0 24 24"><path d="M12 4.5 21 19.5H3z"/><path d="M12 10v4M12 17h.01"/></symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M8.5 12.25 11 14.75l4.75-5.25"/></symbol>
|
||||
<symbol id="plus-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 8.5v7M8.5 12h7"/></symbol>
|
||||
|
||||
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.3"/><path d="M12 2.4v2.3M12 19.3v2.3M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2.4 12h2.3M19.3 12h2.3M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6"/></symbol>
|
||||
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.5 13.4A8.8 8.8 0 0 1 10.6 3.5a8.8 8.8 0 1 0 9.9 9.9z"/></symbol>
|
||||
<symbol id="monitor" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="12" rx="2"/><path d="M9 20.5h6M12 16.5v4"/></symbol>
|
||||
<symbol id="droplet" viewBox="0 0 24 24"><path d="M12 3s6 6.6 6 11a6 6 0 0 1-12 0c0-4.4 6-11 6-11z"/></symbol>
|
||||
<symbol id="type" viewBox="0 0 24 24"><path d="M5.5 6.5v-2h13v2M12 4.5v15M8.5 19.5h7"/></symbol>
|
||||
<symbol id="keyboard" viewBox="0 0 24 24"><rect x="3" y="6.5" width="18" height="11" rx="2"/><path d="M7 10h.01M11 10h.01M15 10h.01M17 10h.01M7 13.5h10"/></symbol>
|
||||
<symbol id="chevron-left" viewBox="0 0 24 24"><path d="M14.5 6.5 9 12l5.5 5.5"/></symbol>
|
||||
<symbol id="chevron-right" viewBox="0 0 24 24"><path d="M9.5 6.5 15 12l-5.5 5.5"/></symbol>
|
||||
<symbol id="calendar" viewBox="0 0 24 24"><rect x="3.5" y="5.5" width="17" height="15" rx="2"/><path d="M8 3.5v4M16 3.5v4M3.5 10.5h17"/></symbol>
|
||||
<symbol id="key" viewBox="0 0 24 24"><circle cx="15.5" cy="8.5" r="4.5"/><path d="M12.35 11.65 4.5 19.5M7 17l2 2M9.75 14.25l2 2"/></symbol>
|
||||
|
||||
<symbol id="server" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="6.5" rx="1.5"/><rect x="3.5" y="13" width="17" height="6.5" rx="1.5"/><path d="M7 7.75h.01M7 16.25h.01M16 7.75h2M16 16.25h2"/></symbol>
|
||||
<symbol id="cpu" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/><rect x="10" y="10" width="4" height="4"/><path d="M9.5 4v2.5M14.5 4v2.5M9.5 17.5V20M14.5 17.5V20M4 9.5h2.5M4 14.5h2.5M17.5 9.5H20M17.5 14.5H20"/></symbol>
|
||||
<symbol id="browser" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 8.5h17"/><path d="M6.5 6.5h.01M9 6.5h.01M11.5 6.5h.01"/></symbol>
|
||||
<symbol id="compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M15.5 8.5l-2.1 4.9-4.9 2.1 2.1-4.9z"/></symbol>
|
||||
|
||||
<symbol id="play" viewBox="0 0 24 24"><path d="M8.5 5.5v13l10-6.5z"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="M19.5 12h-15M11 5.5 4.5 12 11 18.5"/></symbol>
|
||||
<symbol id="info-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/></symbol>
|
||||
<symbol id="globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17"/><path d="M12 3.5c2.5 2.3 3.9 5.2 3.9 8.5S14.5 18.2 12 20.5c-2.5-2.3-3.9-5.2-3.9-8.5S9.5 5.8 12 3.5z"/></symbol>
|
||||
<symbol id="unlock" viewBox="0 0 24 24"><rect x="4.5" y="10.5" width="15" height="9.5" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 7.7-1.5"/></symbol>
|
||||
<symbol id="table" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 9.5h17M3.5 14.5h17M9 9.5v10"/></symbol>
|
||||
<symbol id="star" viewBox="0 0 24 24"><path d="M12 3.5l2.5 5.1 5.6.8-4.1 4 1 5.6-5-2.7-5 2.7 1-5.6-4.1-4 5.6-.8z"/></symbol>
|
||||
<symbol id="music" viewBox="0 0 24 24"><path d="M9 17.5V6l9-2v11.5"/><circle cx="6.5" cy="17.5" r="2.5"/><circle cx="15.5" cy="15.5" r="2.5"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><path d="M20 6c0 1.7-3.6 3-8 3S4 7.7 4 6s3.6-3 8-3 8 1.3 8 3z"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></symbol>
|
||||
<symbol id="file-import" viewBox="0 0 24 24"><path d="M6 3.5h9L19.5 8v12.5H6V3.5z"/><path d="M14.5 3.5V8h5"/><path d="M12 11v4.5M9.75 13.25 12 15.5l2.25-2.25"/></symbol>
|
||||
<symbol id="merge" viewBox="0 0 24 24"><circle cx="17.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/><circle cx="6.5" cy="12" r="2.5"/><path d="M15.4 7.8C11 9 9.6 10.4 9 12M15.4 16.2C11 15 9.6 13.6 9 12"/></symbol>
|
||||
<symbol id="trophy" viewBox="0 0 24 24"><path d="M8 4.5h8v4a4 4 0 0 1-8 0z"/><path d="M8 5.5H5a3 3 0 0 0 3 5M16 5.5h3a3 3 0 0 1-3 5"/><path d="M12 12.5v3.5M8.5 19.5h7M10 16h4l1 3.5h-6z"/></symbol>
|
||||
<symbol id="medal" viewBox="0 0 24 24"><circle cx="12" cy="15" r="5"/><path d="M9.3 10.8 6.5 4h3.6l2 3.8L14 4h3.6l-2.8 6.8"/></symbol>
|
||||
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M7 8.5h10a4 4 0 0 1 4 4.3l-.4 3a2.2 2.2 0 0 1-3.9 1.1L15.3 15H8.7l-1.4 1.9a2.2 2.2 0 0 1-3.9-1.1l-.4-3A4 4 0 0 1 7 8.5z"/><path d="M7.5 11.5v2.5M6.25 12.75h2.5"/><path d="M15.5 12h.01M17.75 14h.01"/></symbol>
|
||||
<symbol id="home" viewBox="0 0 24 24"><path d="m4.5 10.5 7.5-6.5 7.5 6.5V19a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 19z"/><path d="M10 20.5V14h4v6.5"/></symbol>
|
||||
<symbol id="pause" viewBox="0 0 24 24"><path d="M9.5 5v14M14.5 5v14"/></symbol>
|
||||
<symbol id="volume" viewBox="0 0 24 24"><path d="M4.5 9.5H8l4-4v13l-4-4H4.5z"/><path d="M15.5 9.75a3.4 3.4 0 0 1 0 4.5M18 7a7 7 0 0 1 0 10"/></symbol>
|
||||
<symbol id="fast-forward" viewBox="0 0 24 24"><path d="m4.5 6.5 6.5 5.5-6.5 5.5zM12.5 6.5 19 12l-6.5 5.5z"/></symbol>
|
||||
<symbol id="fire" viewBox="0 0 24 24"><path d="M12 3.5c2.5 3 4 5 4 7.5 0 1.2-.5 2.2-1.2 3 .1-1.3-.6-2.6-1.8-3.4.2 2.4-1 3.4-2.2 4.4C9.4 12.6 9 11 9 9.5c-1.2 1.2-2 2.9-2 4.5A5 5 0 0 0 12 20.5a5 5 0 0 0 5-5c0-4-3-6.5-5-8.5z"/></symbol>
|
||||
<symbol id="bolt" viewBox="0 0 24 24"><path d="M13.5 3.5 6 13h4.5l-1 7.5L18 11h-4.5z"/></symbol>
|
||||
|
||||
<symbol id="pencil" viewBox="0 0 24 24"><path d="M14.5 5.5l4 4L8 20.5H3.5V16z"/><path d="M12.5 7.5l4 4M3.5 16l4.5 4.5"/></symbol>
|
||||
<symbol id="filter" viewBox="0 0 24 24"><path d="M3.5 5h17l-6.5 7.5v6L10 20.5v-8z"/></symbol>
|
||||
<symbol id="tag" viewBox="0 0 24 24"><path d="M4.5 4.5H12l7.5 7.5a2 2 0 0 1 0 2.8l-4.7 4.7a2 2 0 0 1-2.8 0L4.5 12z"/><path d="M8.5 8.5h.01"/></symbol>
|
||||
<symbol id="hyperspace" viewBox="0 0 24 24"><path d="M13 4.5 19.5 12 13 19.5 6.5 12z"/><path d="M4.5 9.5H2M5.5 14.5H2"/></symbol>
|
||||
<symbol id="hold" viewBox="0 0 24 24"><path d="M6 4.5v15M18 4.5v15"/><rect x="9.5" y="10" width="5" height="4"/></symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org) -->
|
||||
<!-- SPDX-License-Identifier: CC-BY-NC-4.0 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Volumen, a lightweight publishing platform for scientists</title>
|
||||
<desc id="desc">An open volume, two white pages carrying indigo text lines and tilted apart from an indigo spine, an amber bookmark hanging from the spine, on an indigo badge.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#818CF8"/>
|
||||
<stop offset="1" stop-color="#312E81"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="halo" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#EEF2FF" stop-opacity="0.12"/>
|
||||
<stop offset="0.6" stop-color="#EEF2FF" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#EEF2FF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="shadowBook" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="9" stdDeviation="11" flood-color="#1E1B4B" flood-opacity="0.45"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="509" height="509" rx="110.5" fill="none" stroke="#A5B4FC" stroke-width="3" opacity="0.5"/>
|
||||
<circle cx="256" cy="256" r="225" fill="url(#halo)"/>
|
||||
|
||||
<g filter="url(#shadowBook)">
|
||||
<g transform="rotate(-6 190 270)">
|
||||
<rect x="126" y="172" width="128" height="196" rx="14" fill="#FFFFFF"/>
|
||||
<g fill="#312E81">
|
||||
<rect x="146" y="204" width="92" height="12" rx="6"/>
|
||||
<rect x="146" y="232" width="92" height="12" rx="6"/>
|
||||
<rect x="146" y="260" width="64" height="12" rx="6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="rotate(6 322 270)">
|
||||
<rect x="258" y="172" width="128" height="196" rx="14" fill="#FFFFFF"/>
|
||||
<g fill="#312E81">
|
||||
<rect x="274" y="204" width="92" height="12" rx="6"/>
|
||||
<rect x="274" y="232" width="92" height="12" rx="6"/>
|
||||
<rect x="302" y="260" width="64" height="12" rx="6"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="247" y="178" width="18" height="180" rx="9" fill="#312E81"/>
|
||||
<path d="M236 164 H276 V248 L256 228 L236 248 Z" fill="#FCD34D"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
// TemplateFS exposes the embedded admin templates.
|
||||
func TemplateFS() embed.FS { return templateFS }
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "title"}}{{ tr "Changes" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Changes" }}: {{ .Heading }}</h1>
|
||||
<p class="page-head__lede">
|
||||
{{ tr "Archived version" }} <code>{{ .DiffName }}</code>{{if .DiffWhen}} · {{ .DiffWhen }}{{end}} · {{ tr "compared with the current content." }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/posts/{{ .Slug }}/history">{{ tr "Back to history" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diff-legend" aria-hidden="true">
|
||||
<span class="tag tag--warn">{{ tr "removed" }}</span>
|
||||
<span class="tag tag--ok">{{ tr "added" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card card--flush">
|
||||
<div class="diff">
|
||||
{{if not .DiffChunks}}
|
||||
<div class="diff__line diff__line--equal">{{ tr "No differences from the current content." }}</div>
|
||||
{{end}}
|
||||
{{range .DiffChunks}}
|
||||
{{if .Op.Is "skipped"}}
|
||||
<div class="diff__line diff__line--skipped">··· {{ trn .Skipped "diff.unchanged" }} ···</div>
|
||||
{{else if .Op.Is "removed"}}
|
||||
{{range .Lines}}<div class="diff__line diff__line--removed"><span class="diff__sign">−</span>{{ . }}</div>{{end}}
|
||||
{{else if .Op.Is "added"}}
|
||||
{{range .Lines}}<div class="diff__line diff__line--added"><span class="diff__sign">+</span>{{ . }}</div>{{end}}
|
||||
{{else}}
|
||||
{{range .Lines}}<div class="diff__line diff__line--equal"><span class="diff__sign"> </span>{{ . }}</div>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{{define "title"}}{{ tr "History" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "History" }}: {{ .Heading }}</h1>
|
||||
<p class="page-head__lede">{{ trn (len .Revisions) "revisions.count" }}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/posts/{{ .Slug }}/edit">{{ tr "Back to editor" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .Revisions}}
|
||||
<div class="empty">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#clock"></use></svg>
|
||||
<h2>{{ tr "No revisions yet" }}</h2>
|
||||
<p class="empty__note">{{ tr "Every time this post is saved, the previous version is archived here." }}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tr "Saved" }}</th>
|
||||
<th>{{ tr "Size" }}</th>
|
||||
<th class="table__actions">{{ tr "Actions" }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Revisions}}
|
||||
<tr>
|
||||
<td class="mono">{{ .When }}</td>
|
||||
<td class="mono">{{ .SizeKB }} kB</td>
|
||||
<td class="table__actions">
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/posts/{{ $.Slug }}/history/{{ .Name }}">{{ tr "Download" }}</a>
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/posts/{{ $.Slug }}/history/{{ .Name }}/diff">{{ tr "Compare" }}</a>
|
||||
<form method="post" action="/admin/posts/{{ $.Slug }}/history/{{ .Name }}/restore"
|
||||
class="form-inline"
|
||||
data-confirm="{{ tr "Restore this revision? The current content will be archived first." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Restore" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,189 @@
|
||||
{{define "title"}}{{ tr "Import post" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Import post" }}</h1>
|
||||
<p class="page-head__lede">{{ tr "Bring a post written anywhere: a Markdown file with optional TOML frontmatter." }}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/">{{ tr "Cancel" }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/posts/import" enctype="multipart/form-data" id="import-form" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
|
||||
<label class="dropzone" id="dropzone">
|
||||
<input id="import-file" type="file" name="file" accept=".md,text/markdown" class="dropzone__input">
|
||||
<span class="dropzone__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
</span>
|
||||
<span class="dropzone__title">{{ tr "Drop your post here" }}</span>
|
||||
<span class="dropzone__sub">{{ tr "or click to browse" }} · .md · UTF-8</span>
|
||||
<span class="dropzone__hint">markdown + toml frontmatter ( +++ )</span>
|
||||
<span class="dropzone__error" id="dz-error" role="alert" hidden></span>
|
||||
</label>
|
||||
|
||||
<div class="file-row" id="dz-file" hidden>
|
||||
<span class="file-row__ok" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#check"></use></svg>
|
||||
</span>
|
||||
<span class="file-row__info">
|
||||
<span class="file-row__name" id="dz-name"></span>
|
||||
<span class="file-row__meta" id="dz-meta"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn--ghost btn--sm" id="dz-clear">{{ tr "Remove" }}</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="dz-preview" hidden>
|
||||
<div class="card__head">
|
||||
<h2 class="mono">{{ tr "frontmatter" }}</h2>
|
||||
</div>
|
||||
<dl class="kv" id="dz-kv"></dl>
|
||||
<p class="field__hint" id="dz-note" hidden>
|
||||
{{ tr "No frontmatter found; the slug will be derived from the file name." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--primary" id="dz-submit" disabled>
|
||||
{{ tr "Import post" }}
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const zone = document.getElementById("dropzone");
|
||||
const input = document.getElementById("import-file");
|
||||
const fileRow = document.getElementById("dz-file");
|
||||
const nameEl = document.getElementById("dz-name");
|
||||
const metaEl = document.getElementById("dz-meta");
|
||||
const preview = document.getElementById("dz-preview");
|
||||
const kvEl = document.getElementById("dz-kv");
|
||||
const noteEl = document.getElementById("dz-note");
|
||||
const errorEl = document.getElementById("dz-error");
|
||||
const clearBtn = document.getElementById("dz-clear");
|
||||
const submitBtn = document.getElementById("dz-submit");
|
||||
let depth = 0;
|
||||
|
||||
/* Keep the browser from navigating away when a file misses the zone. */
|
||||
["dragover", "drop"].forEach((evt) =>
|
||||
window.addEventListener(evt, (event) => event.preventDefault()));
|
||||
window.addEventListener("dragenter", () => {
|
||||
depth += 1;
|
||||
zone.classList.add("is-over");
|
||||
});
|
||||
window.addEventListener("dragleave", () => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) zone.classList.remove("is-over");
|
||||
});
|
||||
zone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
depth = 0;
|
||||
zone.classList.remove("is-over");
|
||||
const files = Array.from(event.dataTransfer?.files || []);
|
||||
if (files.length) acceptFile(files[0]);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
if (input.files?.length) acceptFile(input.files[0]);
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
zone.classList.remove("has-file");
|
||||
fileRow.hidden = true;
|
||||
preview.hidden = true;
|
||||
errorEl.hidden = true;
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
function acceptFile(file) {
|
||||
errorEl.hidden = true;
|
||||
zone.classList.remove("is-rejected");
|
||||
if (!/\.md$/i.test(file.name)) {
|
||||
zone.classList.add("is-rejected");
|
||||
errorEl.textContent = window.__f("import.rejected", file.name);
|
||||
errorEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
/* Re-attach a dropped file so the real form carries it. */
|
||||
if (!input.files?.length || input.files[0] !== file) {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
}
|
||||
zone.classList.add("has-file");
|
||||
nameEl.textContent = file.name;
|
||||
metaEl.textContent = formatSize(file.size);
|
||||
fileRow.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
readPreview(file);
|
||||
}
|
||||
|
||||
async function readPreview(file) {
|
||||
const text = await file.text();
|
||||
const fm = parseFrontmatter(text);
|
||||
const body = text.replace(/^\+\+\+[\s\S]*?\+\+\+\r?\n?/, "");
|
||||
const words = (body.match(/\S+/g) || []).length;
|
||||
metaEl.textContent += " · " + window.__n("editor.words", words);
|
||||
|
||||
kvEl.replaceChildren();
|
||||
let shown = 0;
|
||||
for (const key of ["title", "slug", "tags", "date", "lang"]) {
|
||||
if (!fm || !(key in fm)) continue;
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = key;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = formatValue(fm[key]);
|
||||
kvEl.append(dt, dd);
|
||||
shown += 1;
|
||||
}
|
||||
noteEl.hidden = shown > 0;
|
||||
preview.hidden = false;
|
||||
}
|
||||
|
||||
/* The preview shows values the way they read, not the way TOML quotes
|
||||
them: surrounding quotes go, and an array becomes a comma list. */
|
||||
function formatValue(raw) {
|
||||
const value = String(raw).trim();
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return value.slice(1, -1)
|
||||
.split(",")
|
||||
.map((item) => unquote(item.trim()))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
return unquote(value);
|
||||
}
|
||||
|
||||
function unquote(value) {
|
||||
const quoted = value.length > 1 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")));
|
||||
return quoted ? value.slice(1, -1) : value;
|
||||
}
|
||||
|
||||
function parseFrontmatter(text) {
|
||||
const m = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+/.exec(text);
|
||||
if (!m) return null;
|
||||
const out = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const pair = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+?)\s*$/.exec(line);
|
||||
if (pair) out[pair[1]] = pair[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const formatSize = (bytes) =>
|
||||
bytes < 1024 ? bytes + " B" : (bytes / 1024).toFixed(1) + " kB";
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,699 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="{{ .Lang }}" data-palette="{{ .Theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}{{ tr "Volumen admin" }}{{end}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/icon.svg">
|
||||
<link rel="stylesheet" href="/admin/assets/fonts.css">
|
||||
<link rel="stylesheet" href="/admin/assets/admin.css">
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
// The scheme follows the system until this account pins one: read
|
||||
// the pin before the first paint so a reload never flashes.
|
||||
try {
|
||||
var scheme = localStorage.getItem("volumen.scheme");
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
document.documentElement.dataset.scheme = scheme;
|
||||
}
|
||||
} catch (error) {}
|
||||
// The rail fold is a device pin too: applying it before the first
|
||||
// paint is what lets the fold survive a reload without a flash,
|
||||
// and the data-rail attribute is the signal the fold toggle is
|
||||
// styled by: without JavaScript there is no toggle at all.
|
||||
try {
|
||||
document.documentElement.dataset.rail =
|
||||
localStorage.getItem("volumen.rail") === "compact" ? "compact" : "full";
|
||||
} catch (error) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{{/* The translation and toast helpers run before any page script:
|
||||
the page init blocks sit inline inside the content and call
|
||||
window.__, __n and the toast at parse time, so this block must
|
||||
be the first thing the body executes. The blocks that walk the
|
||||
DOM (scheme, rail, confirm, the date picker) stay at the end,
|
||||
where the tree they query already exists. */}}
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
// --- Toast and translation helpers ---------------------------------
|
||||
window.__volumen = window.__volumen || {};
|
||||
/* The strings the page scripts compose live in the same catalogue as
|
||||
the server-rendered ones and arrive as JSON for this request's
|
||||
language. Plural entries carry [one, few, other]; the selector is
|
||||
the CLDR Czech cardinal rule, matching the server's N(). */
|
||||
window.__volumen.i18n = {{ i18nJSON }};
|
||||
window.__ = (id) => (window.__volumen.i18n || {})[id] ?? id;
|
||||
window.__f = (id, arg) => {
|
||||
const s = window.__(id);
|
||||
return s.includes("%s") ? s.replace("%s", arg) : s;
|
||||
};
|
||||
window.__n = (id, n) => {
|
||||
const forms = window.__(id);
|
||||
if (!(forms instanceof Array)) return forms;
|
||||
let form = forms[2];
|
||||
if (n === 1) form = forms[0];
|
||||
else if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) form = forms[1] ?? forms[2];
|
||||
return String(form).replace("%d", String(n));
|
||||
};
|
||||
window.__volumen.toast = (message, opts = {}) => {
|
||||
let stack = document.getElementById("toast-stack");
|
||||
if (!stack) {
|
||||
stack = document.createElement("div");
|
||||
stack.id = "toast-stack";
|
||||
stack.className = "toast-stack";
|
||||
stack.setAttribute("role", "status");
|
||||
stack.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(stack);
|
||||
}
|
||||
const kind = opts.kind ?? "ok";
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast toast--" + kind;
|
||||
toast.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||
const text = document.createElement("span");
|
||||
text.className = "toast__text";
|
||||
text.textContent = message;
|
||||
toast.append(text);
|
||||
if (opts.action?.label && typeof opts.action.onClick === "function") {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "toast__action";
|
||||
btn.textContent = opts.action.label;
|
||||
btn.addEventListener("click", () => {
|
||||
opts.action.onClick();
|
||||
dismiss();
|
||||
});
|
||||
toast.append(btn);
|
||||
}
|
||||
stack.append(toast);
|
||||
requestAnimationFrame(() => toast.classList.add("is-visible"));
|
||||
let timer = setTimeout(dismiss, opts.ttl ?? 3500);
|
||||
function dismiss() {
|
||||
clearTimeout(timer);
|
||||
toast.classList.remove("is-visible");
|
||||
toast.addEventListener("transitionend", () => toast.remove(), { once: true });
|
||||
setTimeout(() => toast.remove(), 400);
|
||||
}
|
||||
toast.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".toast__action")) dismiss();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
{{if or .IsLogin .IsSetup}}
|
||||
<div class="login">
|
||||
<div class="login__tools">{{template "scheme-toggle" .}}</div>
|
||||
<main class="login__panel" id="main">
|
||||
<div class="login__brand">
|
||||
<a class="login__mark" href="/admin/">
|
||||
<img src="/admin/icon.svg" alt="">
|
||||
<span class="grad">Volumen</span>
|
||||
</a>
|
||||
<span class="eyebrow">{{ tr "Volumen · administration" }}</span>
|
||||
</div>
|
||||
{{block "login_content" .}}{{end}}
|
||||
<p class="login__foot">
|
||||
<a href="/api/volumen/feed.xml">rss</a> ·
|
||||
<a href="/api/volumen/site">api</a> ·
|
||||
<a href="/healthz">status</a>
|
||||
</p>
|
||||
<p class="login__version">{{ .Version }}</p>
|
||||
</main>
|
||||
</div>
|
||||
{{else}}
|
||||
<a class="skip-link" href="#main">{{ tr "Skip to content" }}</a>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
{{if .IsAuthenticated}}
|
||||
<button type="button" class="icon-btn menu-toggle" popovertarget="menu"
|
||||
aria-label="{{ tr "Menu" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#menu"></use></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<a class="brand" href="/admin/">
|
||||
<img class="brand__mark" src="/admin/icon.svg" alt="">
|
||||
<span class="brand__text">
|
||||
<span class="brand__name">Volumen</span>
|
||||
<span class="brand__role">{{ tr "administration" }}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="topbar__tools">
|
||||
{{template "scheme-toggle" .}}
|
||||
{{if .IsAuthenticated}}
|
||||
<span class="topbar__sep" aria-hidden="true"></span>
|
||||
<form method="post" action="/admin/logout" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<button type="submit" class="icon-btn" title="{{ tr "Log out" }}" aria-label="{{ tr "Log out" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#log-out"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
<span class="topbar__user">
|
||||
{{if .UserPhoto}}
|
||||
<span class="avatar"><img src="{{ .UserPhoto }}" alt=""></span>
|
||||
{{else}}
|
||||
<span class="avatar">{{ .UserInitial }}</span>
|
||||
{{end}}
|
||||
<span class="topbar__user-text">
|
||||
<span class="topbar__user-name">{{ .DisplayName }}</span>
|
||||
<span class="topbar__user-role">{{ tr .CurrentRole }}</span>
|
||||
</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
<aside class="rail">
|
||||
{{if .IsAuthenticated}}
|
||||
<button type="button" class="icon-btn rail-toggle"
|
||||
aria-label="{{ tr "Collapse sidebar" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#sidebar"></use></svg>
|
||||
</button>
|
||||
<nav class="rail__nav" aria-label="{{ tr "Main menu" }}">
|
||||
<p class="rail__label">{{ tr "Content" }}</p>
|
||||
<a href="/admin/" class="rail__link" {{if .NavPosts}}aria-current="page"{{end}} title="{{ tr "Posts" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
||||
<span class="rail__text">{{ tr "Posts" }}</span>
|
||||
</a>
|
||||
<a href="/admin/posts/new" class="rail__link" {{if .NavNew}}aria-current="page"{{end}} title="{{ tr "New post" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus-circle"></use></svg>
|
||||
<span class="rail__text">{{ tr "New post" }}</span>
|
||||
</a>
|
||||
<a href="/admin/posts/import" class="rail__link" {{if .NavImport}}aria-current="page"{{end}} title="{{ tr "Import" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
<span class="rail__text">{{ tr "Import" }}</span>
|
||||
</a>
|
||||
<a href="/admin/media" class="rail__link" {{if .NavMedia}}aria-current="page"{{end}} title="{{ tr "Media" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
<span class="rail__text">{{ tr "Media" }}</span>
|
||||
</a>
|
||||
<p class="rail__label">{{ tr "Configure" }}</p>
|
||||
<a href="/admin/settings" class="rail__link" {{if .NavSettings}}aria-current="page"{{end}} title="{{ tr "Settings" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#settings"></use></svg>
|
||||
<span class="rail__text">{{ tr "Settings" }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<p class="rail__version" title="{{ .Version }}">{{ .Version }}</p>
|
||||
{{end}}
|
||||
</aside>
|
||||
<div class="main">
|
||||
<main class="content" id="main">
|
||||
{{if and .IsAuthenticated .UpdateAvailable}}
|
||||
<div class="status status--info" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
<span><strong>Volumen {{ .UpdateAvailable }}</strong> {{ tr "is available; you are running" }} {{ .Version }}.</span>
|
||||
<span class="status__actions">
|
||||
<form method="post" action="/admin/settings/update"
|
||||
data-confirm="{{ trf "Update to %s now?" .UpdateAvailable }} {{ tr "The server will restart." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Update now" }}</button>
|
||||
</form>
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/settings#version">{{ tr "Details" }}</a>
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if gt (len .Crumbs) 1}}
|
||||
<nav class="crumbs" aria-label="{{ tr "Breadcrumb" }}">
|
||||
{{range $i, $c := .Crumbs}}
|
||||
{{if $i}}<span aria-hidden="true">/</span>{{end}}
|
||||
{{if and $c.Href (not $c.IsLast)}}
|
||||
<a href="{{ $c.Href }}">{{ $c.Label }}</a>
|
||||
{{else}}
|
||||
<span class="crumbs__current">{{ $c.Label }}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div id="menu" class="menu" popover>
|
||||
<nav aria-label="{{ tr "Main menu" }}">
|
||||
<ul>
|
||||
<li><a href="/admin/" {{if .NavPosts}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
||||
{{ tr "Posts" }}</a></li>
|
||||
<li><a href="/admin/posts/new" {{if .NavNew}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus-circle"></use></svg>
|
||||
{{ tr "New post" }}</a></li>
|
||||
<li><a href="/admin/posts/import" {{if .NavImport}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
{{ tr "Import" }}</a></li>
|
||||
<li><a href="/admin/media" {{if .NavMedia}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
{{ tr "Media" }}</a></li>
|
||||
<li><a href="/admin/settings" {{if .NavSettings}}aria-current="page"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#settings"></use></svg>
|
||||
{{ tr "Settings" }}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "confirm-dialog" .}}
|
||||
|
||||
<div id="toast-stack" class="toast-stack" role="status" aria-live="polite"></div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* The scheme toggle: system, light, dark, remembered on this
|
||||
device. The head script re-applies the pin before the first
|
||||
paint of every page. */
|
||||
(() => {
|
||||
const SCHEMES = [null, "light", "dark"];
|
||||
document.querySelectorAll("[data-scheme-toggle]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const current = document.documentElement.dataset.scheme ?? null;
|
||||
const next = SCHEMES[(SCHEMES.indexOf(current) + 1) % SCHEMES.length];
|
||||
if (next) {
|
||||
document.documentElement.dataset.scheme = next;
|
||||
} else {
|
||||
delete document.documentElement.dataset.scheme;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem("volumen.scheme", next ?? "");
|
||||
} catch (error) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* The rail fold: the head script pins the device choice before
|
||||
the first paint, and the button flips it. The icon turns by
|
||||
itself, the sheet reads the attribute. */
|
||||
(() => {
|
||||
const btn = document.querySelector(".rail-toggle");
|
||||
if (!btn) return;
|
||||
const sync = () => {
|
||||
const compact = document.documentElement.dataset.rail === "compact";
|
||||
const label = window.__(compact ? "Expand sidebar" : "Collapse sidebar");
|
||||
btn.setAttribute("aria-label", label);
|
||||
btn.title = label;
|
||||
};
|
||||
btn.addEventListener("click", () => {
|
||||
const next = document.documentElement.dataset.rail === "compact" ? "full" : "compact";
|
||||
document.documentElement.dataset.rail = next;
|
||||
try {
|
||||
localStorage.setItem("volumen.rail", next);
|
||||
} catch (error) {}
|
||||
sync();
|
||||
});
|
||||
sync();
|
||||
})();
|
||||
|
||||
/* The strength meter of any field that sets a new password: the
|
||||
field opts in with data-pw-meter, the meter is the pw-level
|
||||
element that follows it (or its .pw wrapper). It scores length
|
||||
against the configured floor and the spread of character
|
||||
classes. Advice, not the gate: the common-password blocklist
|
||||
stays on the server. */
|
||||
(() => {
|
||||
const LEVELS = ["", "pw.weak", "pw.fair", "pw.good", "pw.strong"];
|
||||
const score = (value, min) => {
|
||||
if (!value) return 0;
|
||||
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9\s]/]
|
||||
.filter((re) => re.test(value)).length;
|
||||
let steps = 0;
|
||||
if (value.length >= min) steps += 1;
|
||||
if (value.length >= min * 2) steps += 1;
|
||||
if (value.length >= min * 3) steps += 1;
|
||||
if (steps === 0) return value.length >= Math.ceil(min / 2) ? 1 : 0;
|
||||
return Math.min(4, steps + (classes >= 3 ? 1 : 0));
|
||||
};
|
||||
document.querySelectorAll("input[data-pw-meter]").forEach((input) => {
|
||||
let host = input.nextElementSibling;
|
||||
if (!host || !host.classList.contains("pw-level")) {
|
||||
const wrap = input.closest(".pw");
|
||||
host = wrap ? wrap.nextElementSibling : null;
|
||||
}
|
||||
if (!host || !host.classList.contains("pw-level")) return;
|
||||
const label = host.querySelector(".pw-level__label");
|
||||
const min = Number.parseInt(input.dataset.minLength || "10", 10);
|
||||
input.addEventListener("input", () => {
|
||||
const s = score(input.value, min);
|
||||
host.dataset.level = String(s);
|
||||
if (label) label.textContent = LEVELS[s] ? window.__(LEVELS[s]) : "";
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* A form that asks before it acts opts in with data-confirm: the
|
||||
native dialog answers in place of confirm(), which the platform
|
||||
retired. */
|
||||
(() => {
|
||||
const dialog = document.getElementById("confirm-dialog");
|
||||
if (!dialog) return;
|
||||
const message = document.getElementById("confirm-message");
|
||||
let pending = null;
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (form.dataset.confirmAccepted || !form.dataset.confirm) return;
|
||||
event.preventDefault();
|
||||
pending = form;
|
||||
message.textContent = form.dataset.confirm;
|
||||
dialog.showModal();
|
||||
});
|
||||
dialog.addEventListener("close", () => {
|
||||
if (dialog.returnValue === "ok" && pending) {
|
||||
pending.dataset.confirmAccepted = "1";
|
||||
pending.requestSubmit();
|
||||
}
|
||||
pending = null;
|
||||
});
|
||||
})();
|
||||
|
||||
/* The one style the pages compute at run time: the cover image of
|
||||
a post card, which belongs to the data and not to the sheet. */
|
||||
document.querySelectorAll("[data-cover]").forEach((node) => {
|
||||
node.style.setProperty("--cover-url", "url('" + node.dataset.cover + "')");
|
||||
});
|
||||
|
||||
/* A control that acts on change opts in with data-submit-on-change. */
|
||||
document.addEventListener("change", (event) => {
|
||||
const field = event.target;
|
||||
if (field.dataset.submitOnChange !== undefined && field.form) {
|
||||
field.form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
/* The prompt the editor needs for a link URL and the clipboard
|
||||
fallback needs for a link to copy: a dialog with one field,
|
||||
because prompt() is gone from the platform. */
|
||||
window.__volumen.prompt = (label, initial = "") => {
|
||||
const dialog = document.getElementById("prompt-dialog");
|
||||
const field = document.getElementById("prompt-field");
|
||||
return new Promise((resolve) => {
|
||||
document.getElementById("prompt-label").textContent = label;
|
||||
field.value = initial;
|
||||
dialog.showModal();
|
||||
field.focus();
|
||||
const once = () => {
|
||||
resolve(dialog.returnValue === "ok" ? field.value : null);
|
||||
};
|
||||
dialog.addEventListener("close", once, { once: true });
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* Custom date picker: replaces <input type="date"> with a styled
|
||||
trigger that opens a calendar matching the design system. The
|
||||
native input stays in the DOM, so submitting works the same with
|
||||
or without JavaScript. */
|
||||
(() => {
|
||||
const MONTH_CACHE = {};
|
||||
const WEEK_CACHE = {};
|
||||
const FIRSTDAY_CACHE = {};
|
||||
|
||||
const langFor = (node) => node.closest("[lang]")?.getAttribute("lang")
|
||||
|| document.documentElement.lang || "en";
|
||||
|
||||
const monthNames = (lang) => MONTH_CACHE[lang] ??= Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => new Intl.DateTimeFormat(lang, { month: "long" })
|
||||
.format(new Date(2000, i, 15)),
|
||||
);
|
||||
|
||||
const weekdayNames = (lang, firstDay) => {
|
||||
const key = lang + "|" + firstDay;
|
||||
return WEEK_CACHE[key] ??= (() => {
|
||||
const fmt = new Intl.DateTimeFormat(lang, { weekday: "short" });
|
||||
const all = [1, 2, 3, 4, 5, 6, 7].map((d) => fmt.format(new Date(2001, 0, d)));
|
||||
const offset = firstDay - 1;
|
||||
return [...all.slice(offset), ...all.slice(0, offset)];
|
||||
})();
|
||||
};
|
||||
|
||||
const firstDay = (lang) => FIRSTDAY_CACHE[lang] ??= (() => {
|
||||
try {
|
||||
const info = new Intl.Locale(lang).getWeekInfo();
|
||||
if (info && typeof info.firstDay === "number") return info.firstDay;
|
||||
} catch (error) {}
|
||||
const mondayFirst = ["cs", "sk", "sl", "hr", "sr", "bs", "de", "fr", "es",
|
||||
"it", "nl", "pl", "pt", "ru", "uk", "sv", "da"];
|
||||
return mondayFirst.includes(lang) ? 1 : 7;
|
||||
})();
|
||||
|
||||
const formatDate = (lang, d) => new Intl.DateTimeFormat(lang, {
|
||||
day: "numeric", month: "long", year: "numeric",
|
||||
}).format(d);
|
||||
|
||||
const parseISO = (s) => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s || "")) return null;
|
||||
const [y, m, d] = s.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
const toISO = (d) =>
|
||||
d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0")
|
||||
+ "-" + String(d.getDate()).padStart(2, "0");
|
||||
|
||||
const sameDay = (a, b) => !!a && !!b
|
||||
&& a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
|
||||
const todayMidnight = () => {
|
||||
const n = new Date();
|
||||
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
|
||||
};
|
||||
|
||||
const svgIcon = (name) => '<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#'
|
||||
+ name + '"></use></svg>';
|
||||
|
||||
const ICONS = {
|
||||
chevronLeft: "chevron-left",
|
||||
chevronRight: "chevron-right",
|
||||
calendar: "calendar",
|
||||
};
|
||||
|
||||
function buildPicker(input) {
|
||||
if (input.dataset.volumenPicker === "1") return;
|
||||
input.dataset.volumenPicker = "1";
|
||||
|
||||
const lang = langFor(input);
|
||||
const weekdayStart = firstDay(lang);
|
||||
let selected = parseISO(input.value || "");
|
||||
let viewYear, viewMonth;
|
||||
|
||||
/* Two pickers can share one page, so every control name carries
|
||||
the field's own label: a screen reader then hears which date
|
||||
the calendar belongs to. */
|
||||
const fieldLabel = input.closest(".field")
|
||||
?.querySelector(".field__label")?.textContent.trim() ?? "";
|
||||
const labelled = (id) => fieldLabel === ""
|
||||
? window.__(id)
|
||||
: window.__(id) + " (" + fieldLabel + ")";
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "date-input";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "date-input__btn";
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "date-input__label";
|
||||
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.className = "date-input__icon";
|
||||
iconEl.innerHTML = svgIcon(ICONS.calendar);
|
||||
button.append(labelEl, iconEl);
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "date-input__popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", labelled("Pick a date"));
|
||||
pop.hidden = true;
|
||||
pop.innerHTML =
|
||||
'<div class="date-input__nav">'
|
||||
+ '<button type="button" class="date-input__nav-btn" data-prev aria-label="'
|
||||
+ labelled("datepicker.prev") + '">' + svgIcon(ICONS.chevronLeft) + "</button>"
|
||||
+ '<div class="date-input__nav-title"></div>'
|
||||
+ '<button type="button" class="date-input__nav-btn" data-next aria-label="'
|
||||
+ labelled("datepicker.next") + '">' + svgIcon(ICONS.chevronRight) + "</button>"
|
||||
+ "</div>"
|
||||
+ '<div class="date-input__weekdays"></div>'
|
||||
+ '<div class="date-input__days"></div>'
|
||||
+ '<div class="date-input__footer">'
|
||||
+ '<button type="button" class="date-input__footer-btn" data-clear>'
|
||||
+ labelled("Clear") + "</button>"
|
||||
+ '<button type="button" class="date-input__footer-btn date-input__footer-btn--primary" data-today>'
|
||||
+ labelled("Today") + "</button>"
|
||||
+ "</div>";
|
||||
|
||||
const weekdaysEl = pop.querySelector(".date-input__weekdays");
|
||||
const daysEl = pop.querySelector(".date-input__days");
|
||||
const titleEl = pop.querySelector(".date-input__nav-title");
|
||||
|
||||
const resetView = () => {
|
||||
const ref = selected || todayMidnight();
|
||||
viewYear = ref.getFullYear();
|
||||
viewMonth = ref.getMonth();
|
||||
};
|
||||
|
||||
function renderLabel() {
|
||||
if (selected) {
|
||||
labelEl.textContent = formatDate(lang, selected);
|
||||
button.dataset.empty = "0";
|
||||
} else {
|
||||
labelEl.textContent = input.placeholder || window.__("Pick a date");
|
||||
button.dataset.empty = "1";
|
||||
}
|
||||
}
|
||||
|
||||
function renderMonth() {
|
||||
titleEl.textContent = monthNames(lang)[viewMonth] + " " + viewYear;
|
||||
weekdaysEl.innerHTML = weekdayNames(lang, weekdayStart)
|
||||
.map((w) => '<div class="date-input__weekday">' + w + "</div>")
|
||||
.join("");
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
const firstWd = ((first.getDay() - weekdayStart + 7) % 7) + 1;
|
||||
const start = new Date(viewYear, viewMonth, 1 - (firstWd - 1));
|
||||
const today = todayMidnight();
|
||||
const cells = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
let attrs = ' class="date-input__day" data-iso="' + toISO(d) + '"';
|
||||
if (d.getMonth() !== viewMonth) attrs += ' data-other-month="1"';
|
||||
if (sameDay(d, today)) attrs += ' data-today="1"';
|
||||
if (sameDay(d, selected)) attrs += ' data-selected="1"';
|
||||
cells.push("<button" + attrs + ' tabindex="-1" type="button">' + d.getDate() + "</button>");
|
||||
}
|
||||
daysEl.innerHTML = cells.join("");
|
||||
}
|
||||
|
||||
const commit = (d) => {
|
||||
selected = d;
|
||||
input.value = toISO(d);
|
||||
renderLabel();
|
||||
renderMonth();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!pop.hidden) return;
|
||||
resetView();
|
||||
renderMonth();
|
||||
pop.hidden = false;
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (pop.hidden) return;
|
||||
pop.hidden = true;
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
pop.hidden ? open() : close();
|
||||
});
|
||||
|
||||
pop.addEventListener("click", (event) => {
|
||||
const day = event.target.closest(".date-input__day");
|
||||
if (day?.dataset.iso) {
|
||||
const d = parseISO(day.dataset.iso);
|
||||
if (d) {
|
||||
commit(d);
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-prev]")) {
|
||||
if (--viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
renderMonth();
|
||||
} else if (event.target.closest("[data-next]")) {
|
||||
if (++viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
renderMonth();
|
||||
} else if (event.target.closest("[data-clear]")) {
|
||||
selected = null;
|
||||
input.value = "";
|
||||
renderLabel();
|
||||
renderMonth();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
close();
|
||||
} else if (event.target.closest("[data-today]")) {
|
||||
commit(todayMidnight());
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", (event) => {
|
||||
if (!wrap.contains(event.target)) close();
|
||||
}, true);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !pop.hidden) {
|
||||
close();
|
||||
button.focus();
|
||||
}
|
||||
}, true);
|
||||
|
||||
input.parentNode.insertBefore(wrap, input);
|
||||
input.classList.add("date-input__native");
|
||||
wrap.append(input, button, pop);
|
||||
renderLabel();
|
||||
}
|
||||
|
||||
const enhanceAll = () => document
|
||||
.querySelectorAll('input.control[type="date"], input[type="date"]')
|
||||
.forEach(buildPicker);
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", enhanceAll);
|
||||
} else {
|
||||
enhanceAll();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "scheme-toggle"}}
|
||||
<button class="icon-btn theme-toggle" type="button" data-scheme-toggle
|
||||
aria-label="{{ tr "Colour mode" }}"
|
||||
title="{{ tr "Follow system" }} / {{ tr "Light" }} / {{ tr "Dark" }}">
|
||||
<svg class="icon--system" aria-hidden="true"><use href="/admin/assets/graphis.svg#monitor"></use></svg>
|
||||
<svg class="icon--sun" aria-hidden="true"><use href="/admin/assets/graphis.svg#sun"></use></svg>
|
||||
<svg class="icon--moon" aria-hidden="true"><use href="/admin/assets/graphis.svg#moon"></use></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
{{define "confirm-dialog"}}
|
||||
<dialog id="confirm-dialog">
|
||||
<form method="dialog">
|
||||
<div class="dialog__head">
|
||||
<h2 id="confirm-title">{{ tr "Are you sure?" }}</h2>
|
||||
</div>
|
||||
<div class="dialog__body">
|
||||
<p id="confirm-message"></p>
|
||||
</div>
|
||||
<div class="dialog__foot">
|
||||
<button type="submit" value="cancel" class="btn btn--ghost">{{ tr "Cancel" }}</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">{{ tr "Continue" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="prompt-dialog">
|
||||
<form method="dialog">
|
||||
<div class="dialog__head">
|
||||
<h2 id="prompt-label"></h2>
|
||||
</div>
|
||||
<div class="dialog__body">
|
||||
<div class="field">
|
||||
<label class="field__label" for="prompt-field">{{ tr "URL" }}</label>
|
||||
<input id="prompt-field" class="control" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog__foot">
|
||||
<button type="submit" value="cancel" class="btn btn--ghost">{{ tr "Cancel" }}</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">{{ tr "OK" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{{end}}
|
||||
@@ -0,0 +1,462 @@
|
||||
{{define "title"}}{{ tr "Posts" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
{{if .Notice}}
|
||||
<div class="status status--ok" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check-circle"></use></svg>
|
||||
<div>{{ .Notice }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Stats.Total}}
|
||||
<dl class="stats">
|
||||
<div><dt>{{ tr "Published" }}</dt><dd>{{ .Stats.Published }}</dd></div>
|
||||
<div><dt>{{ tr "Drafts" }}</dt><dd>{{ .Stats.Drafts }}</dd></div>
|
||||
<div><dt>{{ tr "Scheduled" }}</dt><dd>{{ .Stats.Scheduled }}</dd></div>
|
||||
<div><dt>{{ tr "Total" }}</dt><dd>{{ .Stats.Total }}</dd></div>
|
||||
</dl>
|
||||
|
||||
{{if or .Stats.Recent .Stats.Upcoming}}
|
||||
<div class="grid--fit">
|
||||
{{if .Stats.Recent}}
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Recent published" }}</h2>
|
||||
</div>
|
||||
<div class="quick">
|
||||
{{range .Stats.Recent}}
|
||||
<a class="quick__row" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<span class="quick__title">{{ .Title }}</span>
|
||||
<span class="quick__when">{{ .DateString }}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .Stats.Upcoming}}
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Upcoming" }}</h2>
|
||||
</div>
|
||||
<div class="quick">
|
||||
{{range .Stats.Upcoming}}
|
||||
<a class="quick__row" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<span class="quick__title">{{ .Title }}</span>
|
||||
<span class="quick__when">{{ .When }}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Posts" }}</h1>
|
||||
<p class="page-head__lede">{{ trn (len .Posts) "posts.total" }}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="/admin/posts/import">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
{{ tr "Import" }}
|
||||
</a>
|
||||
<a class="btn btn--primary" href="/admin/posts/new">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus"></use></svg>
|
||||
{{ tr "New post" }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="bulk-all" id="bulk-select-wrap">
|
||||
<input type="checkbox" id="bulk-all" aria-label="{{ tr "Select all posts" }}">
|
||||
</label>
|
||||
<div class="search">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<label class="visually-hidden" for="post-search">{{ tr "Search title, slug, tag..." }}</label>
|
||||
<input id="post-search" class="control" type="search"
|
||||
placeholder="{{ tr "Search title, slug, tag..." }}" value="{{ .Q }}">
|
||||
</div>
|
||||
<div class="filters" id="post-filters" role="group" aria-label="{{ tr "Filter by status" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#filter"></use></svg>
|
||||
<button type="button" class="chip" data-filter="all" aria-pressed="true">{{ tr "All" }}</button>
|
||||
<button type="button" class="chip" data-filter="published" aria-pressed="false">{{ tr "Published" }}</button>
|
||||
<button type="button" class="chip" data-filter="draft" aria-pressed="false">{{ tr "Drafts" }}</button>
|
||||
<button type="button" class="chip" data-filter="scheduled" aria-pressed="false">{{ tr "Scheduled" }}</button>
|
||||
</div>
|
||||
<span class="toolbar__count" id="post-count" aria-live="polite"></span>
|
||||
{{if .TagCounts}}
|
||||
<div class="filters" id="post-tag-filters" role="group" aria-label="{{ tr "Filter by tag" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#tag"></use></svg>
|
||||
{{range .TagCounts}}
|
||||
<button type="button" class="chip" data-tag="{{ .Name }}" aria-pressed="false"
|
||||
title="{{ trn .Count "posts.count" }}">{{ .Name }} <span class="chip__count">{{ .Count }}</span></button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/posts/bulk" id="bulk-form" class="bulk-bar" hidden
|
||||
data-confirm="{{ tr "Delete the selected posts?" }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="slugs" id="bulk-slugs" value="">
|
||||
<span class="bulk-bar__label"><span id="bulk-count">0</span></span>
|
||||
<button type="submit" name="action" value="publish" class="btn btn--sm">{{ tr "Publish" }}</button>
|
||||
<button type="submit" name="action" value="draft" class="btn btn--sm">{{ tr "Move to draft" }}</button>
|
||||
<button type="submit" name="action" value="delete" class="btn btn--sm btn--danger">{{ tr "Delete" }}</button>
|
||||
<span class="bulk-bar__spacer"></span>
|
||||
<button type="button" class="btn btn--ghost btn--sm" id="bulk-cancel">{{ tr "Cancel" }}</button>
|
||||
</form>
|
||||
|
||||
{{if not .Posts}}
|
||||
<div class="empty">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file"></use></svg>
|
||||
<h2>{{ tr "No posts yet" }}</h2>
|
||||
<p class="empty__note">{{ tr "Create your first post to get started." }}</p>
|
||||
<a class="btn btn--primary" href="/admin/posts/new">{{ tr "Create post" }}</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="post-grid" id="post-grid" role="region" aria-label="{{ tr "Posts" }}">
|
||||
<div class="empty empty--span" id="post-no-results" hidden>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<h2>{{ tr "No posts match your search" }}</h2>
|
||||
<p class="empty__note">{{ tr "Try a different search term or status filter." }}</p>
|
||||
<button type="button" class="btn btn--ghost" id="post-clear-filters">{{ tr "Clear filters" }}</button>
|
||||
</div>
|
||||
{{range .Posts}}
|
||||
<article class="post"
|
||||
data-title="{{ lower .Title }}"
|
||||
data-slug="{{ .Slug }}"
|
||||
data-tags="|{{ lower (join .Tags "|") }}|"
|
||||
data-excerpt="{{ lower .Excerpt }}"
|
||||
data-status="{{ .Status }}"
|
||||
{{if .VariantsJS}}data-variants="{{ .VariantsJS }}"{{end}}
|
||||
{{if .Timestamp}}data-timestamp="{{ .Timestamp }}"{{end}}>
|
||||
<label class="post__check">
|
||||
<input type="checkbox" class="bulk-one" value="{{ .GroupSlugs }}" aria-label="{{ trf "Select %s" .Title }}">
|
||||
</label>
|
||||
<a class="post__link" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<div class="post__cover"{{if .Cover}} data-cover="{{ .Cover }}"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
</div>
|
||||
<div class="post__body">
|
||||
<h2 class="post__title">{{ .Title }}</h2>
|
||||
<p class="post__excerpt"{{if not .Excerpt}} hidden{{end}}>{{ .Excerpt }}</p>
|
||||
<div class="post__meta">
|
||||
{{if not .Variants}}<span class="tag">{{ .Lang }}</span>{{end}}
|
||||
<span class="tag tag--warn badge-draft" {{if not .Draft}}hidden{{end}}>{{ tr "Draft" }}</span>
|
||||
<span class="tag tag--accent badge-scheduled" {{if not .Scheduled}}hidden{{end}}>{{ tr "Scheduled" }}</span>
|
||||
{{if .Series}}<span class="tag tag--accent badge-series">{{ .Series }}{{if .HasOrder}} · {{ .SeriesOrder }}{{end}}</span>{{else}}<span class="tag tag--accent badge-series" hidden></span>{{end}}
|
||||
{{if .DateString}}<span class="post__date" data-date="{{ .DateString }}" title="{{ .DateString }}">{{ .DateString }}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="post__foot">
|
||||
<a class="btn btn--ghost btn--sm" href="/admin/posts/{{ .Slug }}/edit">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#pencil"></use></svg>
|
||||
{{ tr "Edit" }}
|
||||
</a>
|
||||
{{if gt (len .Variants) 1}}
|
||||
<span class="post__langs" role="group" aria-label="{{ tr "Language version" }}">
|
||||
{{range .Variants}}<button type="button" class="chip chip--lang" data-lang="{{ .Lang }}" lang="{{ .Lang }}"
|
||||
aria-pressed="{{if eq .Lang $.Lang}}true{{else}}false{{end}}">{{ .Lang }}</button>{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="flex"></span>
|
||||
<form method="post" action="/admin/posts/{{ .Slug }}/delete"
|
||||
data-confirm="{{ tr "Delete this post?" }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="icon-btn icon-btn--danger" aria-label="{{ tr "Delete post" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#trash"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const search = document.getElementById("post-search");
|
||||
const filterButtons = document.querySelectorAll("#post-filters .chip");
|
||||
const tagButtons = document.querySelectorAll("#post-tag-filters .chip");
|
||||
const cards = document.querySelectorAll("#post-grid .post");
|
||||
const countEl = document.getElementById("post-count");
|
||||
const noResultsEl = document.getElementById("post-no-results");
|
||||
const clearBtn = document.getElementById("post-clear-filters");
|
||||
const bulkAll = document.getElementById("bulk-all");
|
||||
const bulkForm = document.getElementById("bulk-form");
|
||||
const bulkCount = document.getElementById("bulk-count");
|
||||
const bulkSlugs = document.getElementById("bulk-slugs");
|
||||
const bulkCancel = document.getElementById("bulk-cancel");
|
||||
const checkboxes = document.querySelectorAll(".bulk-one");
|
||||
let currentFilter = "all";
|
||||
let currentTag = "";
|
||||
const total = cards.length;
|
||||
|
||||
function applyFilters() {
|
||||
const q = (search.value || "").toLowerCase().trim();
|
||||
const tagQ = currentTag.toLowerCase();
|
||||
let visible = 0;
|
||||
cards.forEach((card) => {
|
||||
const tags = card.dataset.tags || "";
|
||||
const haystack = [card.dataset.title, card.dataset.slug, tags, card.dataset.excerpt]
|
||||
.join(" ");
|
||||
const hit = (!q || haystack.includes(q))
|
||||
&& (currentFilter === "all" || card.dataset.status === currentFilter)
|
||||
// Tags are matched with delimiters around each one, so a tag
|
||||
// containing a space still matches exactly.
|
||||
&& (!tagQ || tags.includes("|" + tagQ + "|"));
|
||||
card.classList.toggle("is-hidden", !hit);
|
||||
if (hit) visible += 1;
|
||||
});
|
||||
if (countEl) {
|
||||
const hasFilter = q || currentFilter !== "all" || currentTag !== "";
|
||||
countEl.textContent = hasFilter
|
||||
? window.__("filter.of").replace("%d", String(visible)).replace("%d", String(total))
|
||||
: window.__n("posts.count", total);
|
||||
countEl.classList.toggle("is-empty", visible === 0);
|
||||
}
|
||||
// The no-results panel lives inside the post grid, which an empty
|
||||
// instance does not render at all.
|
||||
if (noResultsEl) noResultsEl.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
function visibleChecked() {
|
||||
return Array.from(checkboxes)
|
||||
.filter((cb) => cb.closest(".post") && !cb.closest(".post").classList.contains("is-hidden") && cb.checked);
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const selected = visibleChecked();
|
||||
const visibleCards = Array.from(cards).filter((c) => !c.classList.contains("is-hidden"));
|
||||
const visCbs = visibleCards.map((c) => c.querySelector(".bulk-one")).filter(Boolean);
|
||||
const allChecked = visCbs.length > 0 && visCbs.every((cb) => cb.checked);
|
||||
bulkAll.checked = allChecked;
|
||||
bulkAll.indeterminate = !allChecked && selected.length > 0;
|
||||
bulkForm.hidden = selected.length === 0;
|
||||
bulkCount.textContent = window.__n("bulk.selected", selected.length);
|
||||
bulkSlugs.value = selected.map((cb) => cb.value).join(",");
|
||||
}
|
||||
|
||||
const syncUrl = () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const value = search.value.trim();
|
||||
if (value) params.set("q", value);
|
||||
else params.delete("q");
|
||||
const rest = params.toString();
|
||||
history.replaceState(null, "", location.pathname + (rest ? "?" + rest : "") + location.hash);
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => { applyFilters(); updateBulkBar(); syncUrl(); });
|
||||
search.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && search.value !== "") {
|
||||
search.value = "";
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
syncUrl();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
filterButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
filterButtons.forEach((b) => { b.setAttribute("aria-pressed", String(b === btn)); });
|
||||
currentFilter = btn.dataset.filter;
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
tagButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tag = btn.dataset.tag || "";
|
||||
currentTag = currentTag === tag ? "" : tag;
|
||||
tagButtons.forEach((b) => { b.setAttribute("aria-pressed", String(currentTag === b.dataset.tag)); });
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
// The clear button rides the no-results panel, so an instance with no
|
||||
// posts at all never sees it.
|
||||
if (clearBtn) clearBtn.addEventListener("click", () => {
|
||||
search.value = "";
|
||||
currentFilter = "all";
|
||||
currentTag = "";
|
||||
filterButtons.forEach((b) => { b.setAttribute("aria-pressed", String(b.dataset.filter === "all")); });
|
||||
tagButtons.forEach((b) => { b.setAttribute("aria-pressed", "false"); });
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
search.focus();
|
||||
syncUrl();
|
||||
});
|
||||
checkboxes.forEach((cb) => cb.addEventListener("change", updateBulkBar));
|
||||
bulkAll.addEventListener("change", () => {
|
||||
cards.forEach((card) => {
|
||||
if (card.classList.contains("is-hidden")) return;
|
||||
const cb = card.querySelector(".bulk-one");
|
||||
if (cb) cb.checked = bulkAll.checked;
|
||||
});
|
||||
updateBulkBar();
|
||||
});
|
||||
bulkCancel.addEventListener("click", () => {
|
||||
checkboxes.forEach((cb) => { cb.checked = false; });
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
// --- Language variants ----------------------------------------------------
|
||||
/* A publication with language versions shows one card; the chips move
|
||||
it to another version: the name, the summary, the state, the date
|
||||
and the links of the card all follow the picked language file. */
|
||||
for (const card of document.querySelectorAll(".post[data-variants]")) {
|
||||
let variants;
|
||||
try { variants = JSON.parse(card.dataset.variants); } catch (error) { continue; }
|
||||
if (!Array.isArray(variants) || variants.length < 2) continue;
|
||||
const byLang = Object.groupBy(variants, (v) => v.lang);
|
||||
card.querySelectorAll("[data-lang]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = byLang[btn.dataset.lang]?.[0];
|
||||
if (!v) return;
|
||||
card.querySelectorAll("[data-lang]").forEach((b) => {
|
||||
b.setAttribute("aria-pressed", String(b === btn));
|
||||
});
|
||||
card.querySelector(".post__title").textContent = v.title;
|
||||
card.dataset.title = (v.title || "").toLowerCase();
|
||||
const excerpt = card.querySelector(".post__excerpt");
|
||||
excerpt.textContent = v.excerpt || "";
|
||||
excerpt.hidden = !v.excerpt;
|
||||
card.dataset.excerpt = (v.excerpt || "").toLowerCase();
|
||||
const dateEl = card.querySelector(".post__date");
|
||||
if (dateEl) {
|
||||
dateEl.textContent = v.date;
|
||||
dateEl.title = v.date;
|
||||
dateEl.dataset.date = v.date;
|
||||
}
|
||||
card.dataset.timestamp = v.timestamp || "";
|
||||
card.dataset.status = v.status;
|
||||
card.dataset.slug = v.slug;
|
||||
card.querySelector(".badge-draft").hidden = !v.draft;
|
||||
card.querySelector(".badge-scheduled").hidden = !v.scheduled;
|
||||
const series = card.querySelector(".badge-series");
|
||||
series.hidden = !v.series;
|
||||
series.textContent = v.series
|
||||
? v.series + (v.hasOrder ? " · " + v.seriesOrder : "") : "";
|
||||
card.querySelector(".post__link").href =
|
||||
"/admin/posts/" + encodeURIComponent(v.slug) + "/edit";
|
||||
card.querySelector(".post__foot form").action =
|
||||
"/admin/posts/" + encodeURIComponent(v.slug) + "/delete";
|
||||
const cover = card.querySelector(".post__cover");
|
||||
if (v.cover) {
|
||||
cover.dataset.cover = v.cover;
|
||||
cover.style.setProperty("--cover-url", "url('" + v.cover + "')");
|
||||
} else {
|
||||
delete cover.dataset.cover;
|
||||
cover.style.removeProperty("--cover-url");
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Pick up an initial ?q=… so a deep link or reload filters at once.
|
||||
{
|
||||
const urlQ = new URLSearchParams(location.search).get("q");
|
||||
if (urlQ && search.value === "") search.value = urlQ;
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkBar();
|
||||
|
||||
// --- Relative time ----------------------------------------------------
|
||||
const rtf = Intl.RelativeTimeFormat
|
||||
? new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
|
||||
: null;
|
||||
const UNITS = [
|
||||
["year", 31536000000], ["month", 2592000000], ["week", 604800000],
|
||||
["day", 86400000], ["hour", 3600000], ["minute", 60000], ["second", 1000],
|
||||
];
|
||||
function applyRelativeTimes() {
|
||||
if (!rtf) return;
|
||||
const now = Date.now();
|
||||
cards.forEach((card) => {
|
||||
const ms = Number.parseInt(card.dataset.timestamp || "", 10);
|
||||
if (Number.isNaN(ms)) return;
|
||||
const diff = ms - now;
|
||||
const abs = Math.abs(diff);
|
||||
let unit = "second";
|
||||
let value = Math.round(diff / 1000);
|
||||
for (const [name, span] of UNITS) {
|
||||
if (abs >= span) {
|
||||
unit = name;
|
||||
value = Math.round(diff / span);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const dateEl = card.querySelector(".post__date");
|
||||
if (dateEl) dateEl.textContent = rtf.format(value, unit);
|
||||
});
|
||||
}
|
||||
applyRelativeTimes();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) applyRelativeTimes();
|
||||
});
|
||||
|
||||
// --- Save toast ---------------------------------------------------------
|
||||
const SAVED_MESSAGES = {
|
||||
created: window.__("post.created"),
|
||||
updated: window.__("post.updated"),
|
||||
deleted: window.__("post.deleted"),
|
||||
duplicated: window.__("post.duplicated"),
|
||||
undone: window.__("post.undone"),
|
||||
undelete_failed: window.__("post.undelete_failed"),
|
||||
duplicate_failed: window.__("post.duplicate_failed"),
|
||||
};
|
||||
(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const saved = params.get("saved");
|
||||
if (!saved || !SAVED_MESSAGES[saved]) return;
|
||||
const drop = () => {
|
||||
params.delete("saved");
|
||||
params.delete("undo");
|
||||
const rest = params.toString();
|
||||
history.replaceState(null, "", location.pathname + (rest ? "?" + rest : "") + location.hash);
|
||||
};
|
||||
const undoSlug = params.get("undo");
|
||||
if (saved === "deleted" && undoSlug) {
|
||||
const csrf = document.querySelector("input[name='_csrf']")?.value || "";
|
||||
let consumed = false;
|
||||
window.__volumen.toast(SAVED_MESSAGES.deleted, {
|
||||
kind: "ok",
|
||||
ttl: 10000,
|
||||
action: {
|
||||
label: window.__("undo"),
|
||||
onClick: async () => {
|
||||
if (consumed) return;
|
||||
consumed = true;
|
||||
drop();
|
||||
try {
|
||||
const response = await fetch("/admin/posts/" + encodeURIComponent(undoSlug) + "/undelete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ _csrf: csrf }).toString(),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.ok || response.redirected) window.location.reload();
|
||||
else window.__volumen.toast(window.__("undo.failed"), { kind: "error" });
|
||||
} catch (error) {
|
||||
window.__volumen.toast(window.__("undo.failed"), { kind: "error" });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// A failure reports as an error whatever its wording; the rest are
|
||||
// confirmations.
|
||||
const kind = saved.endsWith("_failed") ? "error" : "ok";
|
||||
window.__volumen.toast(SAVED_MESSAGES[saved], { kind });
|
||||
drop();
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{define "title"}}{{ tr "Sign in" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "login_content"}}
|
||||
<div class="login__card">
|
||||
<h1 class="login__title">{{ tr "Sign in" }}</h1>
|
||||
<p class="login__pitch">{{ tr "Welcome back. Sign in to manage your posts." }}</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert"
|
||||
{{if .RetryAfter}}id="login-lockout"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>
|
||||
<span class="lockout-msg">{{ .Error }}</span>
|
||||
{{if .RetryAfter}}<span class="lockout-count" id="lockout-countdown" data-seconds="{{ .RetryAfter }}"></span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/login" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">{{ tr "Username" }}</label>
|
||||
<input id="username" class="control" type="text" name="username"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">{{ tr "Password" }}</label>
|
||||
<div class="pw">
|
||||
<input id="password" class="control" type="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
<button type="button" class="pw__toggle" id="pw-toggle" aria-label="{{ tr "Show password" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#eye"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary login__submit">
|
||||
{{ tr "Sign in" }}
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const btn = document.getElementById("pw-toggle");
|
||||
const input = document.getElementById("password");
|
||||
btn.addEventListener("click", () => {
|
||||
const show = input.type === "password";
|
||||
input.type = show ? "text" : "password";
|
||||
btn.setAttribute("aria-label", window.__(show ? "Hide password" : "Show password"));
|
||||
});
|
||||
|
||||
/* The lockout counts down to the minute, and the form waits with
|
||||
it: an honest disabled state, not a submit that fails. */
|
||||
const countdown = document.getElementById("lockout-countdown");
|
||||
if (!countdown) return;
|
||||
let seconds = Number.parseInt(countdown.dataset.seconds || "0", 10);
|
||||
const form = document.querySelector("form[action='/admin/login']");
|
||||
if (form) {
|
||||
form.querySelectorAll("input, button").forEach((el) => { el.disabled = true; });
|
||||
}
|
||||
const tick = () => {
|
||||
if (seconds <= 0) {
|
||||
countdown.textContent = window.__("login.retry");
|
||||
if (form) {
|
||||
form.querySelectorAll("input, button").forEach((el) => { el.disabled = false; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
countdown.textContent = window.__("login.unlock").replace("{}", String(seconds));
|
||||
seconds -= 1;
|
||||
setTimeout(tick, 1000);
|
||||
};
|
||||
tick();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,196 @@
|
||||
{{define "title"}}{{ tr "Media" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Media" }}</h1>
|
||||
<p class="page-head__lede">{{ trn (len .MediaItems) "media.count" }}{{if .MediaTotal}} · {{ .MediaTotal }}{{end}}</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button type="button" class="btn btn--primary" id="media-upload-btn">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus"></use></svg>
|
||||
{{ tr "Upload images" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<label class="dropzone dropzone--row" id="media-drop">
|
||||
<input id="media-file" type="file" accept="image/webp,image/avif,image/svg+xml" multiple class="dropzone__input"
|
||||
data-csrf="{{ .CSRFToken }}">
|
||||
<span class="dropzone__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#upload"></use></svg>
|
||||
</span>
|
||||
<span>
|
||||
<span class="dropzone__title">{{ tr "Drop images here, or click to browse" }}</span>
|
||||
<span class="dropzone__hint">{{ tr "WebP, AVIF or SVG, up to 10 MB each." }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{{if not .MediaItems}}
|
||||
<div class="empty gap-top">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
|
||||
<h2>{{ tr "No media yet" }}</h2>
|
||||
<p class="empty__note">{{ tr "Upload an image to use as a cover or inline content. WebP, AVIF and SVG are supported." }}</p>
|
||||
<p class="empty__note">{{ tr "You can also paste or drop an image directly into the editor body." }}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<label class="visually-hidden" for="media-search">{{ tr "Search files..." }}</label>
|
||||
<input id="media-search" class="control" type="search"
|
||||
placeholder="{{ tr "Search files..." }}" autocomplete="off">
|
||||
</div>
|
||||
<span class="toolbar__count" id="media-count" aria-live="polite" hidden></span>
|
||||
</div>
|
||||
|
||||
<div class="media-grid" id="media-grid">
|
||||
{{range .MediaItems}}
|
||||
<div class="card media-tile" data-name="{{ lower .Name }}">
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener" class="media-tile__thumb">
|
||||
<img src="{{ .URL }}" alt="{{ .Name }}" loading="lazy" decoding="async">
|
||||
</a>
|
||||
<div class="media-tile__name" title="{{ .Name }}">{{ .Name }}</div>
|
||||
<div class="media-tile__meta">{{ .SizeKB }} kB{{if .Dimensions}} · {{ .Dimensions }}{{end}}</div>
|
||||
<div class="media-tile__actions">
|
||||
<button type="button" class="icon-btn" data-copy="{{ .URL }}" title="{{ tr "Copy link" }}" aria-label="{{ tr "Copy link" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#copy"></use></svg>
|
||||
</button>
|
||||
<a class="icon-btn" href="{{ .URL }}" target="_blank" rel="noopener"
|
||||
title="{{ tr "Open in new tab" }}" aria-label="{{ tr "Open in new tab" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#external-link"></use></svg>
|
||||
</a>
|
||||
<span class="flex"></span>
|
||||
<form method="post" action="/admin/media/{{ .Name }}/delete"
|
||||
data-confirm="{{ trf "Delete %s?" .Name }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="icon-btn icon-btn--danger" title="{{ tr "Delete" }}" aria-label="{{ tr "Delete" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#trash"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="empty empty--span gap-top" id="media-no-results" hidden>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
|
||||
<h2>{{ tr "No files match your search." }}</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const drop = document.getElementById("media-drop");
|
||||
const input = document.getElementById("media-file");
|
||||
const headBtn = document.getElementById("media-upload-btn");
|
||||
const search = document.getElementById("media-search");
|
||||
const countEl = document.getElementById("media-count");
|
||||
const noResults = document.getElementById("media-no-results");
|
||||
const grid = document.getElementById("media-grid");
|
||||
let depth = 0;
|
||||
|
||||
headBtn.addEventListener("click", () => input.click());
|
||||
|
||||
/* The whole window tracks the drag so the strip answers anywhere. */
|
||||
["dragover", "drop"].forEach((evt) =>
|
||||
window.addEventListener(evt, (event) => event.preventDefault()));
|
||||
window.addEventListener("dragenter", (event) => {
|
||||
if (!event.dataTransfer?.types.includes("Files")) return;
|
||||
depth += 1;
|
||||
drop.classList.add("is-over");
|
||||
});
|
||||
window.addEventListener("dragleave", () => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) drop.classList.remove("is-over");
|
||||
});
|
||||
drop.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
depth = 0;
|
||||
drop.classList.remove("is-over");
|
||||
const files = Array.from(event.dataTransfer?.files || []);
|
||||
if (files.length) uploadAll(files);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length) uploadAll(files);
|
||||
input.value = "";
|
||||
});
|
||||
|
||||
const isUploadable = (file) => /\.(webp|avif|svg)$/i.test(file.name)
|
||||
|| file.type === "image/webp" || file.type === "image/avif" || file.type === "image/svg+xml";
|
||||
|
||||
async function uploadOne(file) {
|
||||
window.__volumen.toast(window.__f("media.uploading", file.name), { kind: "ok", ttl: 1500 });
|
||||
const data = new FormData();
|
||||
data.append("file", file);
|
||||
data.append("_csrf", input.dataset.csrf);
|
||||
try {
|
||||
const response = await fetch("/admin/uploads", { method: "POST", body: data, credentials: "same-origin" });
|
||||
const json = await response.json();
|
||||
if (response.ok && json.url) return true;
|
||||
window.__volumen.toast(
|
||||
window.__f("media.upload_failed", json.message || json.error || file.name),
|
||||
{ kind: "error" });
|
||||
} catch (error) {
|
||||
window.__volumen.toast(window.__f("media.upload_failed", file.name), { kind: "error" });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function uploadAll(files) {
|
||||
let done = 0;
|
||||
for (const file of files) {
|
||||
if (!isUploadable(file)) {
|
||||
window.__volumen.toast(window.__f("upload.rejected", file.name), { kind: "error" });
|
||||
continue;
|
||||
}
|
||||
if (await uploadOne(file)) done += 1;
|
||||
}
|
||||
if (done > 0) {
|
||||
window.__volumen.toast(window.__("upload.complete"), { kind: "ok" });
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copy link -----------------------------------------------------------
|
||||
grid?.addEventListener("click", async (event) => {
|
||||
const btn = event.target.closest("[data-copy]");
|
||||
if (!btn) return;
|
||||
const url = btn.dataset.copy;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
window.__volumen.toast(window.__("media.copied"), { kind: "ok" });
|
||||
} catch (error) {
|
||||
const shown = await window.__volumen.prompt(window.__("media.copy_prompt"), url);
|
||||
if (shown) try { await navigator.clipboard.writeText(shown); } catch (inner) {}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Search ----------------------------------------------------------------
|
||||
search?.addEventListener("input", () => {
|
||||
const q = search.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
grid.querySelectorAll(".media-tile").forEach((tile) => {
|
||||
const hit = !q || (tile.dataset.name || "").includes(q);
|
||||
tile.classList.toggle("is-hidden", !hit);
|
||||
if (hit) visible += 1;
|
||||
});
|
||||
const total = grid.querySelectorAll(".media-tile").length;
|
||||
countEl.hidden = !q;
|
||||
if (q) {
|
||||
countEl.textContent = window.__("filter.of")
|
||||
.replace("%d", String(visible)).replace("%d", String(total));
|
||||
}
|
||||
noResults.hidden = visible !== 0;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "title"}}{{ tr "Page not found" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="empty">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#compass"></use></svg>
|
||||
<h1>{{ tr "Page not found" }}</h1>
|
||||
<p class="empty__note">{{ tr "The address does not exist; it may have been moved or deleted." }}</p>
|
||||
<a class="btn btn--primary" href="/admin/">{{ tr "Back to dashboard" }}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,753 @@
|
||||
{{define "title"}}{{ tr "Settings" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-head">
|
||||
<div class="page-head__text">
|
||||
<h1>{{ tr "Settings" }}</h1>
|
||||
<p class="page-head__lede">{{ tr "Manage your account and users" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Notice}}
|
||||
<div class="status status--ok" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check"></use></svg>
|
||||
<div>{{ .Notice }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="settings">
|
||||
<nav class="settings__nav" aria-label="{{ tr "Settings" }}">
|
||||
<a href="#account" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#user"></use></svg>
|
||||
{{ tr "Account" }}
|
||||
</a>
|
||||
{{if .IsAdmin}}
|
||||
<a href="#users" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#users"></use></svg>
|
||||
{{ tr "Users" }}
|
||||
</a>
|
||||
<a href="#templates" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
||||
{{ tr "Templates" }}
|
||||
</a>
|
||||
<a href="#backup" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#download"></use></svg>
|
||||
{{ tr "Backup" }}
|
||||
</a>
|
||||
<a href="#version" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#repeat"></use></svg>
|
||||
{{ tr "Version" }}
|
||||
</a>
|
||||
<a href="#webhooks" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#compare"></use></svg>
|
||||
{{ tr "Webhooks" }}
|
||||
</a>
|
||||
<a href="#tokens" class="settings__link">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#key"></use></svg>
|
||||
{{ tr "API tokens" }}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<div class="settings__panels">
|
||||
<section id="account" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Account" }}</h2>
|
||||
<p class="card__note">{{ tr "Signed in as" }} <strong>{{ .CurrentUser }}</strong> · {{ tr .CurrentRole }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#user"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Profile photo" }}</h3>
|
||||
<p class="setting__desc">{{ tr "WebP, AVIF or SVG, max 10 MB. Shown in the sidebar." }}</p>
|
||||
<form method="post" action="/admin/settings/photo" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="photo-row">
|
||||
{{if .CurrentUserRecord.Photo}}
|
||||
<img src="{{ .CurrentUserRecord.Photo }}" alt="" class="photo-row__preview">
|
||||
{{else}}
|
||||
<span class="photo-row__preview photo-row__placeholder">{{ .UserInitial }}</span>
|
||||
{{end}}
|
||||
<div class="photo-row__fields">
|
||||
<input type="file" name="photo" accept="image/webp,image/avif,image/svg+xml" required>
|
||||
<div class="photo-row__buttons">
|
||||
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Upload" }}</button>
|
||||
{{if .CurrentUserRecord.Photo}}
|
||||
<button type="submit" form="remove-photo-form" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{if .CurrentUserRecord.Photo}}
|
||||
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" hidden>
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#target"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Language" }}</h3>
|
||||
<p class="setting__desc">{{ tr "The interface language of your account. The login screen follows your last choice." }}</p>
|
||||
<form method="post" action="/admin/settings/language" class="pill-group">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" name="language" value="en" class="chip" aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}" lang="en">English</button>
|
||||
<button type="submit" name="language" value="cs" class="chip" aria-pressed="{{if eq .Lang "cs"}}true{{else}}false{{end}}" lang="cs">Čeština</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#droplet"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Colour scheme" }}</h3>
|
||||
<p class="setting__desc">{{ tr "The colour scheme of your account. The login screen follows your last choice." }}</p>
|
||||
<form method="post" action="/admin/settings/theme" class="pill-group">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" name="theme" value="viridis" class="chip" aria-pressed="{{if eq .Theme "viridis"}}true{{else}}false{{end}}">Viridis</button>
|
||||
<button type="submit" name="theme" value="plasma" class="chip" aria-pressed="{{if eq .Theme "plasma"}}true{{else}}false{{end}}">Plasma</button>
|
||||
<button type="submit" name="theme" value="magma" class="chip" aria-pressed="{{if eq .Theme "magma"}}true{{else}}false{{end}}">Magma</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#type"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Identity" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Display name pre-fills the author field. Fediverse handle pre-fills the creator, and the ORCID pre-fills the author identifier of new publications." }}</p>
|
||||
<form method="post" action="/admin/settings/name" class="setting__row">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">{{ tr "Display name" }}</label>
|
||||
<input id="name" class="control" type="text" name="name" value="{{ .CurrentUserRecord.Name }}"
|
||||
placeholder="{{ tr "Your real name" }}">
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Save name" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/fediverse" class="setting__row gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="fediverse_creator">{{ tr "Fediverse handle" }}</label>
|
||||
<input id="fediverse_creator" class="control" type="text" name="fediverse_creator"
|
||||
value="{{ .CurrentUserRecord.FediverseCreator }}" placeholder="{{ tr "@user@instance.tld" }}">
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Save handle" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/orcid" class="setting__row gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="orcid">{{ tr "ORCID iD" }}</label>
|
||||
<input id="orcid" class="control" type="text" name="orcid"
|
||||
value="{{ .CurrentUserRecord.Orcid }}" placeholder="{{ tr "0000-0002-1825-0097" }}">
|
||||
<p class="field__hint">{{ tr "Your Open Researcher and Contributor ID; it pre-fills the author field of new publications." }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Save ORCID" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#lock"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Security" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Change your password and username. You can also lock yourself out." }}</p>
|
||||
<form method="post" action="/admin/settings/password">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="current_password">{{ tr "Current password" }}</label>
|
||||
<input id="current_password" class="control" type="password" name="current_password"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new_password">{{ tr "New password" }}</label>
|
||||
<input id="new_password" class="control" type="password" name="new_password"
|
||||
autocomplete="new-password" data-pw-meter
|
||||
data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="new-password-strength">
|
||||
<div class="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="new-password-strength" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn gap-top">{{ tr "Update password" }}</button>
|
||||
</form>
|
||||
|
||||
<div class="totp gap-top" id="twofactor">
|
||||
<h4 class="totp__title">{{ tr "Two-factor authentication" }}</h4>
|
||||
{{if .RecoveryCodes}}
|
||||
<div class="status status--info" role="status">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#key"></use></svg>
|
||||
<div>{{ .RecoveryNotice }}</div>
|
||||
</div>
|
||||
<div class="recovery">
|
||||
{{range .RecoveryCodes}}<code class="recovery__code">{{ . }}</code>{{end}}
|
||||
</div>
|
||||
{{else if .TotpEnabled}}
|
||||
<p class="setting__desc">{{ tr "Your sign-in asks for a code from your application after the password." }}</p>
|
||||
<div class="totp__forms">
|
||||
<form method="post" action="/admin/settings/twofactor/codes" class="totp__form">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="codes-code">{{ tr "Current code" }}</label>
|
||||
<input id="codes-code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Regenerate recovery codes" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/twofactor/disable" class="totp__form"
|
||||
data-confirm="{{ tr "Turn two-factor off? Your application will no longer be asked." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="off-code">{{ tr "Current code" }}</label>
|
||||
<input id="off-code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--danger">{{ tr "Turn off" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else if .TotpPending}}
|
||||
<p class="setting__desc">{{ tr "Scan the code with your authenticator application, or enter the secret by hand, then confirm with the code it shows." }}</p>
|
||||
<div class="totp__setup">
|
||||
<div class="totp__qr">{{ .TotpSVG }}</div>
|
||||
<div class="totp__setup-side">
|
||||
<p class="totp__secret-label">{{ tr "Secret" }}</p>
|
||||
<code class="totp__secret">{{ .TotpSecret }}</code>
|
||||
<form method="post" action="/admin/settings/twofactor/verify" class="totp__form">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="verify-code">{{ tr "Verification code" }}</label>
|
||||
<input id="verify-code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Verify and enable" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/twofactor/cancel">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Cancel" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "Ask for a code from your authenticator application after the password. Voluntary: nothing changes until you finish the setup." }}</p>
|
||||
<form method="post" action="/admin/settings/twofactor/start">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn">{{ tr "Set up two-factor" }}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/settings/username" class="setting__row gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">{{ tr "Username" }}</label>
|
||||
<input id="username" class="control mono" type="text" name="username" value="{{ .CurrentUser }}">
|
||||
</div>
|
||||
<button type="submit" class="btn">{{ tr "Update username" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<section id="users" class="card card--flush">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Users" }}</h2>
|
||||
<p class="card__note">{{ tr "Add or remove admin and editor accounts" }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{range .UserRows}}
|
||||
<div class="user-row">
|
||||
{{if .Photo}}
|
||||
<span class="avatar"><img src="{{ .Photo }}" alt=""></span>
|
||||
{{else}}
|
||||
<span class="avatar">{{ .Initial }}</span>
|
||||
{{end}}
|
||||
<span class="user-row__info">
|
||||
<span class="user-row__name">
|
||||
{{ .Display }}
|
||||
{{if .IsSelf}} <span class="tag tag--accent">{{ tr "you" }}</span>{{end}}
|
||||
</span>
|
||||
<span class="user-row__username">@{{ .Username }}</span>
|
||||
</span>
|
||||
<div class="user-row__actions">
|
||||
{{if .IsSelf}}
|
||||
<span class="tag">{{ tr .Role }}</span>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/settings/users/{{ .Username }}/role" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<select class="control control--sm" name="role" data-submit-on-change>
|
||||
{{range .RoleOptions}}
|
||||
<option value="{{ .Value }}" {{if .Selected}}selected{{end}}>{{ tr .Value }}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
<details class="user-row__reset">
|
||||
<summary class="btn btn--ghost btn--sm">{{ tr "Reset password" }}</summary>
|
||||
<form method="post" action="/admin/settings/users/{{ .Username }}/password"
|
||||
class="user-row__reset-form"
|
||||
data-confirm="{{ tr "Reset this user's password? Their sessions will be signed out." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input class="control control--sm" type="password" name="password"
|
||||
autocomplete="new-password" placeholder="{{ tr "New password" }}" required
|
||||
data-pw-meter data-min-length="{{ $.Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="pw-strength-{{ .Username }}">
|
||||
<div class="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="pw-strength-{{ .Username }}" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Reset password" }}</button>
|
||||
</form>
|
||||
</details>
|
||||
<form method="post" action="/admin/settings/users/{{ .Username }}/delete"
|
||||
data-confirm="{{ tr "Remove this user?" }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/settings/users" class="user-add">
|
||||
<h3>{{ tr "Add user" }}</h3>
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-username">{{ tr "Username" }}</label>
|
||||
<input id="new-username" class="control mono" type="text" name="username" placeholder="{{ tr "jane-doe" }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-password">{{ tr "Password" }}</label>
|
||||
<input id="new-password" class="control" type="password" name="password" autocomplete="new-password"
|
||||
data-pw-meter data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="new-user-password-strength">
|
||||
<div class="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="new-user-password-strength" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-role">{{ tr "Role" }}</label>
|
||||
<select id="new-role" class="control" name="role">
|
||||
{{range .Roles}}
|
||||
<option value="{{ . }}" {{if eq . $.DefaultRole}}selected{{end}}>{{ tr . }}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Add user" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="templates" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Post templates" }}</h2>
|
||||
<p class="card__note">{{ tr "Pre-fill new posts with a reusable structure" }}</p>
|
||||
</div>
|
||||
|
||||
{{if .TemplatesList}}
|
||||
<ul class="kv-stack">
|
||||
{{range .TemplatesList}}
|
||||
<li class="kv-row">
|
||||
<span><strong>{{ .Name }}</strong>{{if .Tags}}<span class="muted">: {{ join .Tags ", " }}</span>{{end}}{{if .FieldsText}}<span class="muted mono"> · {{ .FieldsText }}</span>{{end}}</span>
|
||||
<form method="post" action="/admin/settings/templates/{{ .Name }}/delete"
|
||||
data-confirm="{{ trf "Delete %s?" .Name }}" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Delete" }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "No templates yet. Templates pre-fill the new-post form so you can keep your favourite structure on hand." }}</p>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/settings/templates" class="stack gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-name">{{ tr "Name" }}</label>
|
||||
<input id="tpl-name" class="control" type="text" name="name" placeholder="{{ tr "e.g. Review" }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-title">{{ tr "Default title" }}</label>
|
||||
<input id="tpl-title" class="control" type="text" name="title" placeholder="{{ tr "Optional" }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-slug">{{ tr "Default slug" }}</label>
|
||||
<input id="tpl-slug" class="control mono" type="text" name="slug" placeholder="{{ tr "Optional" }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-tags">{{ tr "Tags (comma-separated)" }}</label>
|
||||
<input id="tpl-tags" class="control" type="text" name="tags" placeholder="review, opinion">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-body">{{ tr "Body template (Markdown)" }}</label>
|
||||
<textarea id="tpl-body" class="control control--mono" name="body" rows="5"
|
||||
placeholder="## Summary ## Verdict "></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="tpl-fields">{{ tr "Fields to pre-fill" }}</label>
|
||||
<textarea id="tpl-fields" class="control control--mono" name="fields" rows="4"
|
||||
placeholder="series = "tds" doi = "10.5281/…" series_order = 3"></textarea>
|
||||
<p class="field__hint">{{ tr "Extra editor inputs as TOML key = value lines, one per line, strings quoted: author, lang, doi, orcid, series, series_order, cover, excerpt." }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Add template" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="backup" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Backup & restore" }}</h2>
|
||||
<p class="card__note">{{ tr "Export or import all posts, media, users, and templates" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="stack">
|
||||
<h3>{{ tr "Export" }}</h3>
|
||||
<p class="setting__desc">{{ trh "Download a <code>.tar.gz</code> archive of your entire site." }}</p>
|
||||
<a href="/admin/settings/export" class="btn btn--primary">{{ tr "Download backup" }}</a>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<h3>{{ tr "Import" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Restore from a previously exported archive. Overwrites existing data." }}</p>
|
||||
<form method="post" action="/admin/settings/import" enctype="multipart/form-data"
|
||||
data-confirm="{{ tr "This will overwrite existing posts and users. Continue?" }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="visually-hidden" for="backup-file">{{ tr "Restore backup" }}</label>
|
||||
<input id="backup-file" type="file" name="backup" accept=".tar.gz,.tgz" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--danger gap-top">{{ tr "Restore backup" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="version" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Version" }}</h2>
|
||||
<p class="card__note">{{ tr "Keep Volumen up to date" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Installed version" }}</h3>
|
||||
<p class="setting__desc">
|
||||
{{ tr "Running" }} <strong class="mono">{{ .Version }}</strong>
|
||||
{{if .UpdateAvailable}}
|
||||
· {{ tr "latest" }} <strong class="mono">{{ .UpdateAvailable }}</strong>
|
||||
{{end}}
|
||||
</p>
|
||||
{{if .UpdateAvailable}}
|
||||
<div class="status status--warn">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>
|
||||
<strong>Volumen {{ .UpdateAvailable }}</strong> {{ tr "is available; you are running" }} {{ .Version }}.
|
||||
{{ tr "The server will restart." }}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/settings/update"
|
||||
data-confirm="{{ trf "Update to %s now?" .UpdateAvailable }} {{ tr "The server will restart." }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--primary">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
{{ trf "Update to %s" .UpdateAvailable }}
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/settings/check-update">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--ghost">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#repeat"></use></svg>
|
||||
{{ tr "Check for updates" }}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="webhooks" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "Webhooks" }}</h2>
|
||||
<p class="card__note">{{ tr "Notify external services when posts change" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#compare"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Endpoints" }}</h3>
|
||||
{{if not .WebhookRows}}
|
||||
<p class="setting__desc">
|
||||
{{ trh "No webhooks yet. Add an endpoint below, or declare <code>[[webhooks]]</code> blocks in <code>config.toml</code> (those are read-only here):" }}
|
||||
</p>
|
||||
<pre class="webhook-example"><code>[[webhooks]]
|
||||
url = "https://example.com/hooks/rebuild"
|
||||
secret = "a-long-random-string" # HMAC-SHA256 signing key
|
||||
events = ["post.created", "post.updated", "post.deleted", "post.published"]</code></pre>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "Signed JSON payloads are POSTed on post changes. Changes apply without a restart." }}</p>
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>URL</th><th>{{ tr "Events" }}</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .WebhookRows}}
|
||||
<tr>
|
||||
<td class="mono break-all">
|
||||
{{ .URL }}
|
||||
{{if not .Enabled}}<span class="tag tag--warn">{{ tr "disabled" }}</span>{{end}}
|
||||
{{if .Signed}}<span class="tag">{{ tr "signed" }}</span>{{end}}
|
||||
{{if not .Managed}}<span class="tag">{{ tr "from config" }}</span>{{end}}
|
||||
</td>
|
||||
<td>{{ .EventsText }}</td>
|
||||
<td class="table__actions">
|
||||
<form method="post" action="/admin/settings/webhooks/{{ .Index }}/test" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Send test" }}</button>
|
||||
</form>
|
||||
{{if .Managed}}
|
||||
<form method="post" action="/admin/settings/webhooks/toggle" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="url" value="{{ .URL }}">
|
||||
{{if .Enabled}}<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Disable" }}</button>
|
||||
{{else}}<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Enable" }}</button>{{end}}
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/webhooks/delete" class="form-inline"
|
||||
data-confirm="{{ tr "Remove this webhook?" }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="url" value="{{ .URL }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<details>
|
||||
<summary class="btn btn--ghost btn--sm">{{ tr "Add webhook" }}</summary>
|
||||
<form method="post" action="/admin/settings/webhooks" class="stack gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="hook-url">{{ tr "Endpoint URL" }}</label>
|
||||
<input id="hook-url" class="control mono" type="url" name="url" required
|
||||
placeholder="https://example.com/hooks/rebuild">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="hook-secret">{{ tr "Signing secret (optional)" }}</label>
|
||||
<input id="hook-secret" class="control" type="text" name="secret" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="hook-events">{{ tr "Events (comma-separated, empty for all)" }}</label>
|
||||
<input id="hook-events" class="control mono" type="text" name="events"
|
||||
placeholder="post.created, post.updated, post.deleted, post.published">
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="enabled" checked>
|
||||
<span>{{ tr "Enabled" }}</span>
|
||||
</label>
|
||||
<button type="submit" class="btn">{{ tr "Add" }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .WebhookDeliveries}}
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#activity"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Recent deliveries" }}</h3>
|
||||
<p class="setting__desc">{{ tr "Kept in memory; cleared on restart." }}</p>
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>{{ tr "When" }}</th><th>{{ tr "Event" }}</th><th>{{ tr "Endpoint" }}</th><th>{{ tr "Result" }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .WebhookDeliveries}}
|
||||
<tr>
|
||||
<td class="mono">{{ .Timestamp }}</td>
|
||||
<td><span class="tag">{{ .Event }}</span></td>
|
||||
<td class="mono break-all">{{ .HookURL }}</td>
|
||||
<td>
|
||||
{{if .OK}}
|
||||
<span class="ok-text mono">HTTP {{ .StatusCode }}</span>
|
||||
{{else}}
|
||||
<span class="danger-text" title="{{ .Error }}">{{ .Result }}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section id="tokens" class="card">
|
||||
<div class="card__head">
|
||||
<h2>{{ tr "API tokens" }}</h2>
|
||||
<p class="card__note">{{ tr "Programmatic write access to the public API" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="card__icon" aria-hidden="true">
|
||||
<svg><use href="/admin/assets/graphis.svg#key"></use></svg>
|
||||
</span>
|
||||
<div class="setting__body">
|
||||
<h3 class="setting__title">{{ tr "Access tokens" }}</h3>
|
||||
<p class="setting__desc">{{ trh "Send a token as <code>Authorization: Bearer <token></code> on <code>POST/PUT/DELETE /api/volumen/posts</code>." }}</p>
|
||||
{{if .NewToken}}
|
||||
<div class="status status--warn">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-triangle"></use></svg>
|
||||
<div>
|
||||
<strong>{{ tr "Copy this token now; it will never be shown again." }}</strong>
|
||||
<div class="gap-top"><code class="select-all">{{ .NewToken }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .TokenRows}}
|
||||
<div class="card card--flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>{{ tr "Name" }}</th><th>{{ tr "Created" }}</th><th>{{ tr "Last used" }}</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TokenRows}}
|
||||
<tr>
|
||||
<td><strong>{{ .Name }}</strong></td>
|
||||
<td class="mono">{{ .CreatedDay }}</td>
|
||||
<td class="mono">{{ .LastUsedDay }}</td>
|
||||
<td class="table__actions">
|
||||
<form method="post" action="/admin/settings/tokens/{{ .Name }}/delete" class="form-inline"
|
||||
data-confirm="{{ trf "Revoke token “%s”? Scripts using it will stop working." .Name }}">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Revoke" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="setting__desc">{{ tr "No tokens yet. Create one to publish posts from scripts or CI." }}</p>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/settings/tokens" class="stack gap-top">
|
||||
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
||||
<div class="setting__row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="token-name">{{ tr "Token name" }}</label>
|
||||
<input id="token-name" class="control" type="text" name="name"
|
||||
placeholder="{{ tr "e.g. deploy-script" }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary">{{ tr "Create token" }}</button>
|
||||
</div>
|
||||
<fieldset class="field">
|
||||
<legend class="field__label">{{ tr "Scopes" }}</legend>
|
||||
<div class="check-row">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="scope" value="write" checked>
|
||||
<span>{{ tr "Write" }}</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="scope" value="delete">
|
||||
<span>{{ tr "Delete" }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint">{{ tr "Write creates and edits posts; delete removes them. Read endpoints are public, so no scope is needed for them. Selecting neither grants full access." }}</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* The settings panels switch one at a time: the URL keeps the section,
|
||||
and a form POST that lands without a fragment restores the section
|
||||
that was last open. */
|
||||
(() => {
|
||||
const sections = document.querySelectorAll(".settings__panels section[id]");
|
||||
const navLinks = document.querySelectorAll(".settings__nav a[href^='#']");
|
||||
if (!sections.length || !navLinks.length) return;
|
||||
|
||||
const KEY = "volumen.settings.section";
|
||||
const byId = (id) => Array.from(sections).find((s) => s.id === id);
|
||||
|
||||
function showSection(id) {
|
||||
sections.forEach((s) => { s.hidden = s.id !== id; });
|
||||
navLinks.forEach((a) => {
|
||||
a.setAttribute("aria-current", String(a.getAttribute("href") === "#" + id));
|
||||
});
|
||||
try {
|
||||
history.replaceState(null, "", "/admin/settings#" + id);
|
||||
sessionStorage.setItem(KEY, id);
|
||||
} catch (error) {}
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
navLinks.forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
showSection(link.getAttribute("href").slice(1));
|
||||
});
|
||||
});
|
||||
window.addEventListener("hashchange", () => {
|
||||
const target = byId(window.location.hash.slice(1));
|
||||
if (target) showSection(target.id);
|
||||
});
|
||||
|
||||
let initial = byId(window.location.hash.slice(1));
|
||||
if (!initial) {
|
||||
let remembered = "";
|
||||
try { remembered = sessionStorage.getItem(KEY) || ""; } catch (error) {}
|
||||
initial = byId(remembered) || sections[0];
|
||||
}
|
||||
showSection(initial.id);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,145 @@
|
||||
{{define "title"}}{{ tr "Set up Volumen" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "login_content"}}
|
||||
<div class="login__card setup">
|
||||
<h1 class="login__title" data-i18n="Welcome to Volumen">{{ tr "Welcome to Volumen" }}</h1>
|
||||
<p class="login__pitch" data-i18n="Set up the administrator account to open this installation.">{{ tr "Set up the administrator account to open this installation." }}</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>{{ .Error }}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/setup" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<input type="hidden" name="language" id="setup-language" value="{{ .Lang }}">
|
||||
|
||||
<fieldset class="setup__group">
|
||||
<legend data-i18n="Account">{{ tr "Account" }}</legend>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username" data-i18n="Username">{{ tr "Username" }}</label>
|
||||
<input id="username" class="control mono" type="text" name="username" value="admin"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="name" data-i18n="Display name">{{ tr "Display name" }}</label>
|
||||
<input id="name" class="control" type="text" name="name" autocomplete="name"
|
||||
placeholder="{{ tr "Your real name" }}" data-i18n-placeholder="Your real name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password" data-i18n="Password">{{ tr "Password" }}</label>
|
||||
<div class="pw">
|
||||
<input id="password" class="control" type="password" name="password"
|
||||
autocomplete="new-password" required data-pw-meter
|
||||
data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
||||
aria-describedby="pw-strength-text">
|
||||
<button type="button" class="pw__toggle" id="pw-toggle" aria-label="{{ tr "Show password" }}">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#eye"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pw-level" id="pw-level" data-level="0">
|
||||
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<span class="pw-level__label" id="pw-strength-text" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<p class="field__hint" data-i18n="password.hint">{{ trn .Config.Admin.MinPasswordLength "password.min" }}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="setup__group">
|
||||
<legend data-i18n="Language">{{ tr "Language" }}</legend>
|
||||
<div class="pill-group">
|
||||
<a class="chip chip--switch" lang="en" href="/admin/setup?lang=en" data-lang-chip="en"
|
||||
{{if eq .Lang "en"}}aria-current="true"{{end}}>English</a>
|
||||
<a class="chip chip--switch" lang="cs" href="/admin/setup?lang=cs" data-lang-chip="cs"
|
||||
{{if eq .Lang "cs"}}aria-current="true"{{end}}>Čeština</a>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="setup__group">
|
||||
<legend data-i18n="Colour scheme">{{ tr "Colour scheme" }}</legend>
|
||||
<div class="pill-group pill-group--themes">
|
||||
<label class="chip chip--radio chip--theme">
|
||||
<input type="radio" name="theme" value="viridis" {{if eq .Theme "viridis"}}checked{{end}}>
|
||||
<span class="chip__swatch chip__swatch--viridis" aria-hidden="true"></span><span>Viridis</span>
|
||||
</label>
|
||||
<label class="chip chip--radio chip--theme">
|
||||
<input type="radio" name="theme" value="plasma" {{if eq .Theme "plasma"}}checked{{end}}>
|
||||
<span class="chip__swatch chip__swatch--plasma" aria-hidden="true"></span><span>Plasma</span>
|
||||
</label>
|
||||
<label class="chip chip--radio chip--theme">
|
||||
<input type="radio" name="theme" value="magma" {{if eq .Theme "magma"}}checked{{end}}>
|
||||
<span class="chip__swatch chip__swatch--magma" aria-hidden="true"></span><span>Magma</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field__hint" data-i18n="The page takes the colours as you choose.">{{ tr "The page takes the colours as you choose." }}</p>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn--primary login__submit">
|
||||
<span data-i18n="Create account">{{ tr "Create account" }}</span>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
(() => {
|
||||
const btn = document.getElementById("pw-toggle");
|
||||
const input = document.getElementById("password");
|
||||
btn.addEventListener("click", () => {
|
||||
const show = input.type === "password";
|
||||
input.type = show ? "text" : "password";
|
||||
btn.setAttribute("aria-label", window.__(show ? "Hide password" : "Show password"));
|
||||
});
|
||||
|
||||
/* The wizard previews its own choices: the palette rides the same
|
||||
html attribute the sheets read, and the language swaps the page
|
||||
text from the bundle the server carried. The chips are real
|
||||
links to the same page with ?lang=, so both choices survive a
|
||||
browser without JavaScript: the link reloads, the theme radios
|
||||
submit. */
|
||||
const BUNDLE = {{ .SetupI18n }};
|
||||
const apply = (lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const pair = BUNDLE.ui[el.dataset.i18n];
|
||||
if (pair && pair[lang]) el.textContent = pair[lang];
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
const pair = BUNDLE.ui[el.dataset.i18nPlaceholder];
|
||||
if (pair && pair[lang]) el.placeholder = pair[lang];
|
||||
});
|
||||
window.__volumen.i18n = BUNDLE.js[lang];
|
||||
btn.setAttribute("aria-label",
|
||||
window.__(input.type === "password" ? "Show password" : "Hide password"));
|
||||
/* The meter's label comes from the shared catalogue: re-score
|
||||
the field so the word follows the language swap. */
|
||||
input.dispatchEvent(new Event("input"));
|
||||
document.getElementById("setup-language").value = lang;
|
||||
document.querySelectorAll("[data-lang-chip]").forEach((chip) => {
|
||||
if (chip.dataset.langChip === lang) chip.setAttribute("aria-current", "true");
|
||||
else chip.removeAttribute("aria-current");
|
||||
});
|
||||
};
|
||||
document.querySelectorAll("[data-lang-chip]").forEach((chip) => {
|
||||
chip.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
apply(chip.dataset.langChip);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="theme"]').forEach((radio) => {
|
||||
radio.addEventListener("change", () => {
|
||||
document.documentElement.dataset.palette = radio.value;
|
||||
});
|
||||
});
|
||||
|
||||
/* The strength meter itself is the shared one the layout wires
|
||||
for every input[data-pw-meter]; only the label refresh on a
|
||||
language switch is local to this page. */
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{define "title"}}{{ tr "Two-factor code" }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "login_content"}}
|
||||
<div class="login__card">
|
||||
<h1 class="login__title">{{ tr "Two-factor code" }}</h1>
|
||||
<p class="login__pitch">{{ tr "The password is accepted; answer the second question." }}</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="status status--error" role="alert"
|
||||
{{if .RetryAfter}}id="login-lockout"{{end}}>
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
||||
<div>
|
||||
<span class="lockout-msg">{{ .Error }}</span>
|
||||
{{if .RetryAfter}}<span class="lockout-count" id="lockout-countdown" data-seconds="{{ .RetryAfter }}"></span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/twofactor" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="code">{{ tr "Verification code" }}</label>
|
||||
<input id="code" class="control mono" type="text" name="code"
|
||||
inputmode="numeric" autocomplete="one-time-code"
|
||||
placeholder="{{ tr "six digits, or a recovery code" }}" required autofocus>
|
||||
<p class="field__hint">{{ tr "Six digits from your application, or one of your recovery codes." }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary login__submit">
|
||||
{{ tr "Continue" }}
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login__alt"><a href="/admin/login">{{ tr "Back to sign in" }}</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "title"}}{{ tr "Updated to" }} {{ .Target }} · {{ tr "Volumen admin" }}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="empty narrow">
|
||||
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
||||
<h1>{{ tr "Updated to" }} {{ .Target }}</h1>
|
||||
<p class="empty__note" id="update-status" role="status">{{ tr "The server is restarting. This page will reload automatically." }}</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ .CSPNonce }}">
|
||||
/* Wait for the engine to come back by polling its own health endpoint,
|
||||
never by a guessed delay. */
|
||||
(() => {
|
||||
const status = document.getElementById("update-status");
|
||||
let attempts = 0;
|
||||
const timer = setInterval(async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const response = await fetch("/healthz", { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
clearInterval(timer);
|
||||
status.textContent = window.__("update.reload");
|
||||
setTimeout(() => { window.location.href = "/admin/"; }, 500);
|
||||
return;
|
||||
}
|
||||
} catch (error) { /* still down */ }
|
||||
if (attempts > 60) {
|
||||
clearInterval(timer);
|
||||
status.textContent = window.__("update.gaveup");
|
||||
}
|
||||
}, 3000);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import "slices"
|
||||
|
||||
// Themes are the colour schemes the admin UI ships, in the order the
|
||||
// settings switch lists them. Each scheme takes its values from the
|
||||
// stops of the matplotlib colour map of the same name.
|
||||
var Themes = []string{"viridis", "plasma", "magma"}
|
||||
|
||||
// DefaultTheme is the scheme an account without a choice renders in.
|
||||
const DefaultTheme = "viridis"
|
||||
|
||||
// ThemeCookie carries the colour scheme across the login screen, where
|
||||
// no account is known yet.
|
||||
const ThemeCookie = "volumen_admin_theme"
|
||||
|
||||
// ValidTheme reports whether the value is a shipped colour scheme.
|
||||
func ValidTheme(theme string) bool {
|
||||
return slices.Contains(Themes, theme)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package web provides the HTTP middleware chain shared by the public
|
||||
// API and the admin UI: gzip, security headers with per-request CSP
|
||||
// nonces, and client-IP resolution.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type nonceKey struct{}
|
||||
|
||||
// PermissionsPolicy is the Permissions-Policy header sent on every
|
||||
// response: every listed feature is denied.
|
||||
const PermissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
|
||||
|
||||
var baseCSPDirectives = []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
}
|
||||
|
||||
// ClientIP resolves the client address.
|
||||
//
|
||||
// X-Forwarded-For is honoured only when the deployment is behind a proxy,
|
||||
// and only for a peer the configuration trusts: with trusted prefixes set,
|
||||
// a request that did not arrive from one of them is answered with its own
|
||||
// address, so a client that can reach the listener directly cannot choose
|
||||
// the key it is rate-limited by. The last entry of the header is used,
|
||||
// because a proxy appends the address it accepted the connection from;
|
||||
// everything to its left is client-supplied.
|
||||
func ClientIP(r *http.Request, trusted []netip.Prefix) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
if len(trusted) == 0 {
|
||||
return host
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !inPrefixes(peer, trusted) {
|
||||
return host
|
||||
}
|
||||
forwarded := r.Header.Get("X-Forwarded-For")
|
||||
if forwarded == "" {
|
||||
return host
|
||||
}
|
||||
_, after, ok := strings.CutLast(forwarded, ",")
|
||||
if !ok {
|
||||
return strings.TrimSpace(forwarded)
|
||||
}
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
|
||||
func inPrefixes(addr netip.Addr, prefixes []netip.Prefix) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Nonce returns the CSP nonce generated for this request, if any.
|
||||
func Nonce(ctx context.Context) string {
|
||||
if nonce, ok := ctx.Value(nonceKey{}).(string); ok {
|
||||
return nonce
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SecurityHeaders sets the baseline security headers on every
|
||||
// response. Admin paths additionally get a per-request CSP nonce and
|
||||
// no-store caching.
|
||||
func SecurityHeaders(cookieSecure bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Headers are set before the handler runs: net/http
|
||||
// snapshots the header map at the first WriteHeader.
|
||||
// The packaged assets under /admin/assets/ are the
|
||||
// exception to the admin's no-store: they are static,
|
||||
// revalidated by their content ETag instead.
|
||||
ctx := r.Context()
|
||||
path := r.URL.Path
|
||||
isAdmin := strings.HasPrefix(path, "/admin") && !strings.HasPrefix(path, "/admin/assets/")
|
||||
if isAdmin {
|
||||
ctx = context.WithValue(ctx, nonceKey{}, newNonce())
|
||||
}
|
||||
|
||||
header := w.Header()
|
||||
setDefault(header, "X-Content-Type-Options", "nosniff")
|
||||
setDefault(header, "Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
setDefault(header, "X-Frame-Options", "DENY")
|
||||
setDefault(header, "Permissions-Policy", PermissionsPolicy)
|
||||
|
||||
csp := baseCSPDirectives
|
||||
if isAdmin {
|
||||
nonce := Nonce(ctx)
|
||||
directives := make([]string, 0, len(baseCSPDirectives)+2)
|
||||
for _, d := range baseCSPDirectives {
|
||||
if strings.HasPrefix(d, "script-src") || strings.HasPrefix(d, "style-src") {
|
||||
continue
|
||||
}
|
||||
directives = append(directives, d)
|
||||
}
|
||||
directives = append(directives,
|
||||
"script-src 'self' 'nonce-"+nonce+"'",
|
||||
"style-src 'self' 'nonce-"+nonce+"'",
|
||||
)
|
||||
csp = directives
|
||||
setDefault(header, "Cache-Control", "no-store")
|
||||
}
|
||||
header.Set("Content-Security-Policy", strings.Join(csp, "; "))
|
||||
if cookieSecure {
|
||||
setDefault(header, "Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setDefault(header http.Header, key, value string) {
|
||||
if header.Get(key) == "" {
|
||||
header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func newNonce() string {
|
||||
// 128 bits of randomness in a URL-safe alphabet; crypto/rand.Text
|
||||
// panics on a system failure rather than returning a weak nonce.
|
||||
return rand.Text()
|
||||
}
|
||||
|
||||
// gzipResponse buffers the handler output and compresses it when the
|
||||
// client asked for gzip and the body is large enough.
|
||||
type gzipResponse struct {
|
||||
http.ResponseWriter
|
||||
buf bytes.Buffer
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (g *gzipResponse) WriteHeader(code int) {
|
||||
if !g.wroteHeader {
|
||||
g.status = code
|
||||
g.wroteHeader = true
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gzipResponse) Write(b []byte) (int, error) {
|
||||
g.wroteHeader = true
|
||||
return g.buf.Write(b)
|
||||
}
|
||||
|
||||
// acceptsGzip reports whether the Accept-Encoding header names gzip with
|
||||
// a non-zero quality. It is a token list, not a substring test:
|
||||
// "gzip;q=0" is an explicit refusal, and answering it with a compressed
|
||||
// body would hand the client something it cannot decode.
|
||||
func acceptsGzip(header string) bool {
|
||||
for part := range strings.SplitSeq(header, ",") {
|
||||
token, params, _ := strings.Cut(part, ";")
|
||||
name := strings.TrimSpace(token)
|
||||
if name != "gzip" && name != "x-gzip" {
|
||||
continue
|
||||
}
|
||||
q := 1.0
|
||||
if key, value, ok := strings.Cut(params, "="); ok && strings.TrimSpace(key) == "q" {
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil {
|
||||
q = parsed
|
||||
}
|
||||
}
|
||||
if q > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Gzip compresses response bodies of at least minSize bytes when the
|
||||
// client supports it.
|
||||
func Gzip(minSize int) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !acceptsGzip(r.Header.Get("Accept-Encoding")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
g := &gzipResponse{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(g, r)
|
||||
|
||||
body := g.buf.Bytes()
|
||||
header := w.Header()
|
||||
header.Del("Content-Length")
|
||||
// Compressed or not, the body depends on the request's
|
||||
// Accept-Encoding, so a shared cache must be told.
|
||||
header.Add("Vary", "Accept-Encoding")
|
||||
if g.status == http.StatusNotModified || len(body) < minSize {
|
||||
w.WriteHeader(g.status)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
return
|
||||
}
|
||||
header.Set("Content-Encoding", "gzip")
|
||||
w.WriteHeader(g.status)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
zw := gzip.NewWriter(w)
|
||||
_, _ = zw.Write(body)
|
||||
_ = zw.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientIPDirect(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "203.0.113.9:5555"
|
||||
if got := ClientIP(req, nil); got != "203.0.113.9" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustProxy(t *testing.T) {
|
||||
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:5555"
|
||||
// The proxy appends the address it accepted the connection from, so
|
||||
// the last entry is the client and everything to its left is
|
||||
// supplied by the caller. The forged 203.0.113.9 must not become the
|
||||
// rate-limit key.
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.7")
|
||||
if got := ClientIP(req, trusted); got != "198.51.100.7" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
// A single entry is the client's own address as the proxy saw it.
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.7")
|
||||
if got := ClientIP(req, trusted); got != "198.51.100.7" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
// Without a trusted prefix the forwarded header is ignored.
|
||||
if got := ClientIP(req, nil); got != "10.0.0.1" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A client that can reach the listener directly cannot choose its own
|
||||
// rate-limit key by sending the header, because its peer address is not
|
||||
// one of the trusted proxies.
|
||||
func TestClientIPIgnoresAnUntrustedPeer(t *testing.T) {
|
||||
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "203.0.113.9:5555"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.7")
|
||||
if got := ClientIP(req, trusted); got != "203.0.113.9" {
|
||||
t.Fatalf("ClientIP = %q, want the peer's own address", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPIPv6(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "[2001:db8::1]:4711"
|
||||
if got := ClientIP(req, nil); got != "2001:db8::1" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersPublic(t *testing.T) {
|
||||
handler := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
|
||||
header := rec.Header()
|
||||
if header.Get("X-Content-Type-Options") != "nosniff" ||
|
||||
header.Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatalf("headers = %v", header)
|
||||
}
|
||||
csp := header.Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "default-src 'self'") || strings.Contains(csp, "nonce-") {
|
||||
t.Fatalf("csp = %q", csp)
|
||||
}
|
||||
if header.Get("Cache-Control") != "" {
|
||||
t.Fatal("public responses must not be no-store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAdmin(t *testing.T) {
|
||||
var nonce string
|
||||
handler := SecurityHeaders(true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nonce = Nonce(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
|
||||
if nonce == "" {
|
||||
t.Fatal("nonce not generated")
|
||||
}
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "script-src 'self' 'nonce-"+nonce+"'") {
|
||||
t.Fatalf("csp = %q", csp)
|
||||
}
|
||||
if strings.Contains(csp, "script-src 'self';") {
|
||||
t.Fatalf("base script-src not replaced: %q", csp)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("admin responses must be no-store")
|
||||
}
|
||||
if rec.Header().Get("Strict-Transport-Security") == "" {
|
||||
t.Fatal("HSTS missing for secure deployments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipCompressesLargeBodies(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("hello world "), 100)
|
||||
handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("content-encoding = %q", rec.Header().Get("Content-Encoding"))
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Fatal("compressed body differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipSkipsSmallBodies(t *testing.T) {
|
||||
handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("small"))
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Header().Get("Content-Encoding") != "" {
|
||||
t.Fatal("small body compressed")
|
||||
}
|
||||
if rec.Body.String() != "small" {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipSkippedWithoutAcceptEncoding(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("x"), 2000)
|
||||
handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Header().Get("Content-Encoding") != "" {
|
||||
t.Fatal("compressed without Accept-Encoding")
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), body) {
|
||||
t.Fatal("body altered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipPreserves304(t *testing.T) {
|
||||
handler := Gzip(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
||||
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A state-changing request a browser sent from another origin is refused
|
||||
// before any handler runs, and a request with no browser headers at all
|
||||
// (a CLI client) is allowed through.
|
||||
func TestCrossOriginRefusesForeignBrowserRequests(t *testing.T) {
|
||||
handler := CrossOrigin()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
foreign := httptest.NewRequest(http.MethodPost, "/admin/login", nil)
|
||||
foreign.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
foreign.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, foreign)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-site POST = %d, want 403", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "cross_origin") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
foreign.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, foreign)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("same-origin POST = %d, want 204", rec.Code)
|
||||
}
|
||||
|
||||
cli := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, cli)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("a request with no browser headers = %d, want 204", rec.Code)
|
||||
}
|
||||
|
||||
read := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
read.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, read)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("cross-site GET = %d, want 204: safe methods are not refused", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// "gzip;q=0" is an explicit refusal: the response must stay plain even
|
||||
// though the header contains the substring "gzip".
|
||||
func TestGzipHonoursZeroQuality(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("hello world "), 100)
|
||||
handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
for _, header := range []string{"gzip;q=0", "br, gzip;q=0.000", "identity, gzip;q=0"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", header)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if enc := rec.Header().Get("Content-Encoding"); enc == "gzip" {
|
||||
t.Fatalf("Accept-Encoding %q was answered with gzip", header)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), body) {
|
||||
t.Fatalf("Accept-Encoding %q: body was transformed", header)
|
||||
}
|
||||
}
|
||||
// A positive quality still compresses.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", "br, gzip;q=0.5")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatal("gzip;q=0.5 was not answered with gzip")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user