diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/server.go | 268 |
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 } |
