summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/server.go268
1 files changed, 85 insertions, 183 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go
index bad7e66..948e97e 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -13,7 +13,9 @@ import (
"strings"
"time"
- "nebbet.no/internal/auth"
+ "github.com/gin-gonic/gin"
+
+ "nebbet.no/internal/admin/auth"
"nebbet.no/internal/builder"
)
@@ -28,11 +30,12 @@ type Server struct {
// Builder is used to rebuild pages after create/edit/delete operations.
Builder *builder.Builder
- tmpl *template.Template
+ engine *gin.Engine
+ tmpl *template.Template
}
-// post holds the metadata and content of a single post.
-type post struct {
+// Post holds the metadata and content of a single Post.
+type Post struct {
Slug string
Title string
Date string
@@ -40,6 +43,46 @@ type post struct {
Content string // raw markdown body
}
+// NewServer creates a new admin server with Gin routing and auth middleware.
+func NewServer(postsDir, authFile string, builder *builder.Builder) *Server {
+ s := &Server{
+ PostsDir: postsDir,
+ AuthFile: authFile,
+ Builder: builder,
+ }
+
+ // Initialize Gin engine
+ s.engine = gin.Default()
+
+ // Load templates
+ s.tmpl = mustParseTemplates()
+
+ // Apply auth middleware to all routes
+ s.engine.Use(s.authMiddleware())
+
+ // Register routes under /admin prefix
+ admin := s.engine.Group("/admin")
+ {
+ // List posts
+ admin.GET("/", s.handleList)
+ // Create form and create post
+ admin.GET("/new", s.handleNew)
+ admin.POST("/", s.handleNewPost)
+ // Edit form and update post
+ admin.GET("/:slug", s.handleEdit)
+ admin.POST("/:slug", s.handleEdit)
+ // Delete post
+ admin.DELETE("/:slug", s.handleDelete)
+ }
+
+ return s
+}
+
+// Engine returns the Gin engine for this server.
+func (s *Server) Engine() *gin.Engine {
+ return s.engine
+}
+
// ServeHTTP implements http.Handler. Expected to be mounted with a stripped
// prefix, e.g.: http.StripPrefix("/admin", srv)
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -69,8 +112,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
-// checkAuth performs HTTP Basic authentication against the passwords file.
-// Returns true if the request is authorised (or if auth is disabled).
func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool {
if s.AuthFile == "" {
return false
@@ -98,7 +139,11 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to list posts: "+err.Error(), http.StatusInternalServerError)
return
}
- s.render(w, "list", map[string]any{"Posts": posts})
+ s.render(w, "base", map[string]any{
+ "Title": "Posts",
+ "ContentTemplate": "list-content",
+ "Posts": posts,
+ })
}
func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) {
@@ -106,11 +151,12 @@ func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) {
s.handleNewPost(w, r)
return
}
- s.render(w, "form", map[string]any{
- "Title": "New Post",
- "Action": "/admin/new",
- "Post": post{Date: time.Now().Format("2006-01-02")},
- "IsNew": true,
+ s.render(w, "base", map[string]any{
+ "Title": "New Post",
+ "ContentTemplate": "form-content",
+ "Action": "/admin/new",
+ "Post": Post{Date: time.Now().Format("2006-01-02")},
+ "IsNew": true,
})
}
@@ -163,11 +209,13 @@ func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request, slug string)
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
- s.render(w, "form", map[string]any{
- "Title": "Edit Post",
- "Action": "/admin/" + slug + "/edit",
- "Post": p,
- "IsNew": false,
+
+ s.render(w, "base", map[string]any{
+ "Title": "Edit Post",
+ "ContentTemplate": "form-content",
+ "Action": "/admin/" + slug + "/edit",
+ "Post": p,
+ "IsNew": false,
})
}
@@ -187,8 +235,6 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, slug strin
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
func (s *Server) rebuild(mdPath string) {
if s.Builder == nil {
return
@@ -197,7 +243,7 @@ func (s *Server) rebuild(mdPath string) {
_ = s.Builder.BuildFile(mdPath, importMap)
}
-func (s *Server) listPosts() ([]post, error) {
+func (s *Server) listPosts() ([]Post, error) {
if err := os.MkdirAll(s.PostsDir, 0755); err != nil {
return nil, err
}
@@ -205,7 +251,7 @@ func (s *Server) listPosts() ([]post, error) {
if err != nil {
return nil, err
}
- var posts []post
+ var posts []Post
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
@@ -229,13 +275,17 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
func (s *Server) renderError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
- _ = s.tmpl.ExecuteTemplate(w, "error", map[string]any{"Message": msg})
+ _ = s.tmpl.ExecuteTemplate(w, "base", map[string]any{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": msg,
+ })
}
// postFromForm reads a post from an HTTP form submission.
-func postFromForm(r *http.Request) post {
+func postFromForm(r *http.Request) Post {
_ = r.ParseForm()
- return post{
+ return Post{
Title: strings.TrimSpace(r.FormValue("title")),
Date: strings.TrimSpace(r.FormValue("date")),
Tags: strings.TrimSpace(r.FormValue("tags")),
@@ -244,12 +294,12 @@ func postFromForm(r *http.Request) post {
}
// readPostFile reads and parses a markdown file into a post struct.
-func readPostFile(path, slug string) (post, error) {
+func readPostFile(path, slug string) (Post, error) {
data, err := os.ReadFile(path)
if err != nil {
- return post{}, err
+ return Post{}, err
}
- p := post{Slug: slug}
+ p := Post{Slug: slug}
body := string(data)
// Parse frontmatter manually — keep it simple.
@@ -282,7 +332,7 @@ func readPostFile(path, slug string) (post, error) {
}
// writePostFile writes a post to disk as a markdown file with frontmatter.
-func writePostFile(path string, p post) error {
+func writePostFile(path string, p Post) error {
date := p.Date
if date == "" {
date = time.Now().Format("2006-01-02")
@@ -305,156 +355,7 @@ func slugify(title string) string {
return s
}
-// ── Templates ─────────────────────────────────────────────────────────────────
-
-const adminCSS = `
-* { box-sizing: border-box; margin: 0; padding: 0; }
-body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; }
-.layout { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; }
-nav { display: flex; align-items: center; gap: 1.5rem; padding: 0.75rem 0;
- border-bottom: 1px solid #ddd; margin-bottom: 2rem; }
-nav a { text-decoration: none; color: #555; font-size: 0.9rem; }
-nav a:hover { color: #000; }
-nav .brand { font-weight: 700; color: #000; font-size: 1rem; }
-h1 { font-size: 1.4rem; margin-bottom: 1.25rem; }
-h2 { font-size: 1.1rem; margin-bottom: 0.75rem; }
-table { width: 100%; border-collapse: collapse; background: #fff;
- border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
-th { background: #f0f0f0; font-size: 0.8rem; text-transform: uppercase;
- letter-spacing: .04em; color: #666; padding: 0.6rem 0.9rem; text-align: left; }
-td { padding: 0.7rem 0.9rem; border-top: 1px solid #eee; vertical-align: middle; }
-tr:hover td { background: #fafafa; }
-.tags { display: flex; flex-wrap: wrap; gap: 0.3rem; }
-.tag { background: #e8f0fe; color: #2d5fc4; font-size: 0.75rem;
- padding: 0.1rem 0.45rem; border-radius: 3px; }
-.actions { display: flex; gap: 0.5rem; }
-a.btn, button.btn { display: inline-block; padding: 0.35rem 0.75rem; font-size: 0.82rem;
- border: none; border-radius: 4px; cursor: pointer; text-decoration: none;
- font-family: inherit; }
-.btn-primary { background: #2d5fc4; color: #fff; }
-.btn-primary:hover { background: #1e4bad; }
-.btn-secondary { background: #e0e0e0; color: #333; }
-.btn-secondary:hover { background: #ccc; }
-.btn-danger { background: #dc2626; color: #fff; }
-.btn-danger:hover { background: #b91c1c; }
-.empty { text-align: center; padding: 3rem; color: #999; font-size: 0.9rem; }
-form { background: #fff; padding: 1.5rem; border-radius: 6px;
- box-shadow: 0 1px 3px rgba(0,0,0,.08); max-width: 760px; }
-label { display: block; font-size: 0.85rem; font-weight: 600;
- color: #444; margin-bottom: 0.3rem; margin-top: 1rem; }
-label:first-child { margin-top: 0; }
-input[type=text], input[type=date], textarea {
- width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #ccc;
- border-radius: 4px; font-size: 0.9rem; font-family: inherit; }
-input[type=text]:focus, input[type=date]:focus, textarea:focus {
- outline: none; border-color: #2d5fc4; box-shadow: 0 0 0 2px rgba(45,95,196,.15); }
-textarea { font-family: 'Menlo', 'Consolas', monospace; font-size: 0.85rem;
- height: 340px; resize: vertical; line-height: 1.5; }
-.hint { font-size: 0.75rem; color: #888; margin-top: 0.25rem; }
-.form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
-.alert { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b;
- padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
-`
-
-const baseTemplate = `<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Admin — {{.Title}}</title>
- <style>` + adminCSS + `</style>
-</head>
-<body>
-<div class="layout">
- <nav>
- <span class="brand">Admin</span>
- <a href="/admin/">Posts</a>
- <a href="/admin/new">New Post</a>
- </nav>
- {{block "content" .}}{{end}}
-</div>
-</body>
-</html>`
-
-const listTemplate = `{{define "list"}}` + baseTemplate + `{{end}}
-{{define "content"}}
- <h1>Posts</h1>
- {{if .Posts}}
- <table>
- <thead>
- <tr>
- <th>Title</th>
- <th>Date</th>
- <th>Tags</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- {{range .Posts}}
- <tr>
- <td>{{.Title}}</td>
- <td>{{.Date}}</td>
- <td>
- {{if .Tags}}
- <div class="tags">
- {{range (splitTags .Tags)}}<span class="tag">{{.}}</span>{{end}}
- </div>
- {{end}}
- </td>
- <td>
- <div class="actions">
- <a href="/admin/{{.Slug}}/edit" class="btn btn-secondary">Edit</a>
- <form method="POST" action="/admin/{{.Slug}}/delete"
- onsubmit="return confirm('Delete {{.Title}}?')">
- <button type="submit" class="btn btn-danger">Delete</button>
- </form>
- </div>
- </td>
- </tr>
- {{end}}
- </tbody>
- </table>
- {{else}}
- <div class="empty">No posts yet. <a href="/admin/new">Create one.</a></div>
- {{end}}
-{{end}}`
-
-const formTemplate = `{{define "form"}}` + baseTemplate + `{{end}}
-{{define "content"}}
- <h1>{{.Title}}</h1>
- <form method="POST" action="{{.Action}}">
- <label for="title">Title</label>
- <input type="text" id="title" name="title" value="{{.Post.Title}}" required autofocus>
-
- <label for="date">Date</label>
- <input type="date" id="date" name="date" value="{{.Post.Date}}">
-
- <label for="tags">Tags</label>
- <input type="text" id="tags" name="tags" value="{{.Post.Tags}}"
- placeholder="tag1, tag2, tag3">
- <p class="hint">Comma-separated list of tags.</p>
-
- <label for="content">Content (Markdown)</label>
- <textarea id="content" name="content">{{.Post.Content}}</textarea>
-
- <div class="form-actions">
- <button type="submit" class="btn btn-primary">
- {{if .IsNew}}Create Post{{else}}Save Changes{{end}}
- </button>
- <a href="/admin/" class="btn btn-secondary">Cancel</a>
- </div>
- </form>
-{{end}}`
-
-const errorTemplate = `{{define "error"}}<!DOCTYPE html>
-<html><head><meta charset="UTF-8"><title>Error</title>
-<style>body{font-family:system-ui;max-width:600px;margin:3rem auto;padding:0 1rem}</style>
-</head><body>
-<h2>Error</h2>
-<p>{{.Message}}</p>
-<p><a href="javascript:history.back()">Go back</a></p>
-</body></html>{{end}}`
-
+// mustParseTemplates loads admin templates from the templates/admin/ directory.
func mustParseTemplates() *template.Template {
funcs := template.FuncMap{
"splitTags": func(s string) []string {
@@ -468,9 +369,10 @@ func mustParseTemplates() *template.Template {
return tags
},
}
- return template.Must(
- template.New("admin").Funcs(funcs).Parse(
- listTemplate + formTemplate + errorTemplate,
- ),
- )
+ // Load all .html files from templates/admin/ directory
+ tmpl, err := template.New("admin").Funcs(funcs).ParseGlob("templates/admin/*.html")
+ if err != nil {
+ panic(fmt.Sprintf("failed to parse admin templates: %v", err))
+ }
+ return tmpl
}