From 6797cafba0059558da475426f9ca69299dd956b5 Mon Sep 17 00:00:00 2001
From: ivar
Date: Fri, 3 Apr 2026 14:18:30 +0200
Subject: refactor: add Gin routing infrastructure with NewServer constructor
---
internal/admin/server.go | 268 +++++++++++++++--------------------------------
1 file changed, 85 insertions(+), 183 deletions(-)
(limited to 'internal/admin')
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 = `
-
-
-
-
- Posts
- {{if .Posts}}
-
-
-
- {{else}}
-
-
-
-
- {{range .Posts}}
- Title
- Date
- Tags
-
-
-
- {{end}}
-
- {{.Title}}
- {{.Date}}
-
- {{if .Tags}}
-
- {{end}}
-
-
-
- {{.Title}}
-
-{{end}}`
-
-const errorTemplate = `{{define "error"}}
-Error
-
{{.Message}}
- -{{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 } -- cgit v1.3