summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:34:46 +0200
committerivar <i@oiee.no>2026-04-04 16:34:46 +0200
commita6355e7a6530af3335c4cd8af05f1e9c8b978169 (patch)
treec9d920d1e996ef1c42d3455825731598df6b56c2 /docs
parent8a093aacd162d3fd9f142b53aab9edfa061fd66a (diff)
downloadnebbet.no-a6355e7a6530af3335c4cd8af05f1e9c8b978169.tar.xz
nebbet.no-a6355e7a6530af3335c4cd8af05f1e9c8b978169.zip
.
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-04-03-gin-migration-plan.md739
-rw-r--r--docs/superpowers/specs/2026-04-03-gin-migration-design.md198
2 files changed, 0 insertions, 937 deletions
diff --git a/docs/superpowers/plans/2026-04-03-gin-migration-plan.md b/docs/superpowers/plans/2026-04-03-gin-migration-plan.md
deleted file mode 100644
index 6b95cac..0000000
--- a/docs/superpowers/plans/2026-04-03-gin-migration-plan.md
+++ /dev/null
@@ -1,739 +0,0 @@
-# Gin Migration Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Migrate the nebbet admin server from `net/http` to Gin with RESTful routes and middleware-based auth.
-
-**Architecture:** Replace the manual `ServeHTTP` handler with Gin's declarative routing. The `Server` struct gains a `*gin.Engine` field. Auth becomes reusable middleware. Route handlers update their signatures to use `*gin.Context`. Routes move under `/admin` namespace with RESTful patterns (GET/POST for forms, DELETE for removal).
-
-**Tech Stack:** Go, Gin, existing auth/template system (unchanged)
-
----
-
-## File Structure
-
-**Files to modify:**
-- `go.mod` — Add Gin dependency
-- `internal/admin/server.go` — Core changes: NewServer(), middleware, handler rewrites
-- `cmd/nebbet/main.go` — Update cmdServe() to use NewServer()
-
----
-
-### Task 1: Add Gin Dependency
-
-**Files:**
-- Modify: `go.mod`
-
-- [ ] **Step 1: Add Gin to go.mod**
-
-Run:
-```bash
-cd /Users/ivarlovlie/p/nebbet.no && go get github.com/gin-gonic/gin
-```
-
-Expected output: Gin module downloaded and added to go.mod
-
-- [ ] **Step 2: Verify go.mod was updated**
-
-Run:
-```bash
-grep "github.com/gin-gonic/gin" go.mod
-```
-
-Expected output: A line like `github.com/gin-gonic/gin v1.X.X` (version may vary)
-
-- [ ] **Step 3: Run go mod tidy**
-
-Run:
-```bash
-go mod tidy
-```
-
-Expected: No errors, go.sum updated
-
----
-
-### Task 2: Refactor server.go — Add NewServer() Constructor
-
-**Files:**
-- Modify: `internal/admin/server.go` (top section with Server struct)
-
-- [ ] **Step 1: Update Server struct to include Gin engine**
-
-Replace the struct definition:
-
-```go
-// Server is an HTTP handler for the admin post-management UI.
-type Server struct {
- // PostsDir is the directory where post markdown files are stored,
- // e.g. "content/posts". It is created on first use if it doesn't exist.
- PostsDir string
- // AuthFile is the path to the htpasswd-compatible passwords file.
- // Authentication is skipped when AuthFile is empty or the file doesn't exist.
- AuthFile string
- // Builder is used to rebuild pages after create/edit/delete operations.
- Builder *builder.Builder
-
- engine *gin.Engine
- tmpl *template.Template
-}
-```
-
-- [ ] **Step 2: Add imports for Gin**
-
-Add to the imports section at the top of server.go:
-
-```go
-import (
- "fmt"
- "html/template"
- "net/http"
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
-
- "nebbet.no/internal/admin/auth"
- "nebbet.no/internal/builder"
-)
-```
-
-- [ ] **Step 3: Add NewServer() constructor function**
-
-Add this after the Server struct definition (before any methods):
-
-```go
-// 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
-}
-```
-
----
-
-### Task 3: Refactor server.go — Add Auth Middleware
-
-**Files:**
-- Modify: `internal/admin/server.go` (replace checkAuth method)
-
-- [ ] **Step 1: Replace checkAuth with middleware function**
-
-Remove the old `checkAuth()` method and add this in its place:
-
-```go
-// authMiddleware returns a Gin middleware that validates Basic Auth credentials.
-func (s *Server) authMiddleware() gin.HandlerFunc {
- return func(c *gin.Context) {
- // Skip auth if no auth file is configured
- if s.AuthFile == "" {
- c.Next()
- return
- }
-
- // Skip auth if auth file doesn't exist
- if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) {
- c.Next()
- return
- }
-
- // Extract Basic Auth credentials
- username, password, ok := c.Request.BasicAuth()
- if !ok {
- c.Header("WWW-Authenticate", `Basic realm="Admin"`)
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- // Verify credentials
- a := auth.New(s.AuthFile)
- valid, err := a.Verify(username, password)
- if err != nil || !valid {
- c.Header("WWW-Authenticate", `Basic realm="Admin"`)
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- // Auth succeeded, continue
- c.Next()
- }
-}
-```
-
----
-
-### Task 4: Refactor server.go — Remove ServeHTTP and Update Handlers
-
-**Files:**
-- Modify: `internal/admin/server.go` (remove ServeHTTP, update handler signatures)
-
-- [ ] **Step 1: Remove the ServeHTTP method**
-
-Delete the entire `func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)` method (lines 45-70 in original).
-
-- [ ] **Step 2: Update handleList signature and implementation**
-
-Replace:
-```go
-func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
- posts, err := s.listPosts()
- if err != nil {
- http.Error(w, "Failed to list posts: "+err.Error(), http.StatusInternalServerError)
- return
- }
- s.render(w, "base", map[string]any{
- "Title": "Posts",
- "ContentTemplate": "list-content",
- "Posts": posts,
- })
-}
-```
-
-With:
-```go
-func (s *Server) handleList(c *gin.Context) {
- posts, err := s.listPosts()
- if err != nil {
- c.HTML(http.StatusInternalServerError, "base", gin.H{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": "Failed to list posts: " + err.Error(),
- })
- return
- }
- c.HTML(http.StatusOK, "base", gin.H{
- "Title": "Posts",
- "ContentTemplate": "list-content",
- "Posts": posts,
- })
-}
-```
-
-- [ ] **Step 3: Update handleNew signature and implementation**
-
-Replace:
-```go
-func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- s.handleNewPost(w, r)
- return
- }
- 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,
- })
-}
-```
-
-With:
-```go
-func (s *Server) handleNew(c *gin.Context) {
- c.HTML(http.StatusOK, "base", gin.H{
- "Title": "New Post",
- "ContentTemplate": "form-content",
- "Action": "/admin/new",
- "Post": Post{Date: time.Now().Format("2006-01-02")},
- "IsNew": true,
- })
-}
-```
-
-- [ ] **Step 4: Update handleNewPost signature and implementation**
-
-Replace:
-```go
-func (s *Server) handleNewPost(w http.ResponseWriter, r *http.Request) {
- p := postFromForm(r)
- if p.Title == "" {
- s.renderError(w, "Title is required")
- return
- }
- if p.Slug == "" {
- p.Slug = slugify(p.Title)
- }
-
- mdPath := filepath.Join(s.PostsDir, p.Slug+".md")
- if err := os.MkdirAll(s.PostsDir, 0755); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if _, err := os.Stat(mdPath); err == nil {
- s.renderError(w, fmt.Sprintf("Post %q already exists", p.Slug))
- return
- }
- if err := writePostFile(mdPath, p); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- s.rebuild(mdPath)
- http.Redirect(w, r, "/admin/", http.StatusSeeOther)
-}
-```
-
-With:
-```go
-func (s *Server) handleNewPost(c *gin.Context) {
- p := postFromForm(c)
- if p.Title == "" {
- s.renderError(c, "Title is required")
- return
- }
- if p.Slug == "" {
- p.Slug = slugify(p.Title)
- }
-
- mdPath := filepath.Join(s.PostsDir, p.Slug+".md")
- if err := os.MkdirAll(s.PostsDir, 0755); err != nil {
- c.HTML(http.StatusInternalServerError, "base", gin.H{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": err.Error(),
- })
- return
- }
- if _, err := os.Stat(mdPath); err == nil {
- s.renderError(c, fmt.Sprintf("Post %q already exists", p.Slug))
- return
- }
- if err := writePostFile(mdPath, p); err != nil {
- c.HTML(http.StatusInternalServerError, "base", gin.H{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": err.Error(),
- })
- return
- }
- s.rebuild(mdPath)
- c.Redirect(http.StatusSeeOther, "/admin/")
-}
-```
-
-- [ ] **Step 5: Update handleEdit signature and implementation**
-
-Replace:
-```go
-func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request, slug string) {
- mdPath := filepath.Join(s.PostsDir, slug+".md")
- p, err := readPostFile(mdPath, slug)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- if r.Method == http.MethodPost {
- updated := postFromForm(r)
- updated.Slug = slug // slug is immutable after creation
- if updated.Title == "" {
- s.renderError(w, "Title is required")
- return
- }
- if err := writePostFile(mdPath, updated); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- s.rebuild(mdPath)
- http.Redirect(w, r, "/admin/", http.StatusSeeOther)
- return
- }
-
- s.render(w, "base", map[string]any{
- "Title": "Edit Post",
- "ContentTemplate": "form-content",
- "Action": "/admin/" + slug + "/edit",
- "Post": p,
- "IsNew": false,
- })
-}
-```
-
-With:
-```go
-func (s *Server) handleEdit(c *gin.Context) {
- slug := c.Param("slug")
- mdPath := filepath.Join(s.PostsDir, slug+".md")
- p, err := readPostFile(mdPath, slug)
- if err != nil {
- c.HTML(http.StatusNotFound, "base", gin.H{
- "Title": "Not Found",
- "ContentTemplate": "error-content",
- "Message": "Post not found",
- })
- return
- }
-
- if c.Request.Method == http.MethodPost {
- updated := postFromForm(c)
- updated.Slug = slug // slug is immutable after creation
- if updated.Title == "" {
- s.renderError(c, "Title is required")
- return
- }
- if err := writePostFile(mdPath, updated); err != nil {
- c.HTML(http.StatusInternalServerError, "base", gin.H{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": err.Error(),
- })
- return
- }
- s.rebuild(mdPath)
- c.Redirect(http.StatusSeeOther, "/admin/")
- return
- }
-
- c.HTML(http.StatusOK, "base", gin.H{
- "Title": "Edit Post",
- "ContentTemplate": "form-content",
- "Action": "/admin/" + slug,
- "Post": p,
- "IsNew": false,
- })
-}
-```
-
-- [ ] **Step 6: Update handleDelete signature and implementation**
-
-Replace:
-```go
-func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, slug string) {
- if r.Method != http.MethodPost {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
- mdPath := filepath.Join(s.PostsDir, slug+".md")
- if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if s.Builder != nil {
- _ = s.Builder.RemovePage(mdPath)
- }
- http.Redirect(w, r, "/admin/", http.StatusSeeOther)
-}
-```
-
-With:
-```go
-func (s *Server) handleDelete(c *gin.Context) {
- slug := c.Param("slug")
- mdPath := filepath.Join(s.PostsDir, slug+".md")
- if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) {
- c.HTML(http.StatusInternalServerError, "base", gin.H{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": err.Error(),
- })
- return
- }
- if s.Builder != nil {
- _ = s.Builder.RemovePage(mdPath)
- }
- c.Redirect(http.StatusSeeOther, "/admin/")
-}
-```
-
----
-
-### Task 5: Refactor server.go — Update Helper Functions
-
-**Files:**
-- Modify: `internal/admin/server.go` (helper functions section)
-
-- [ ] **Step 1: Update render() helper**
-
-Replace:
-```go
-func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
-}
-```
-
-With:
-```go
-func (s *Server) render(c *gin.Context, name string, data gin.H) {
- if err := s.tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- }
-}
-```
-
-- [ ] **Step 2: Update renderError() helper**
-
-Replace:
-```go
-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, "base", map[string]any{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": msg,
- })
-}
-```
-
-With:
-```go
-func (s *Server) renderError(c *gin.Context, msg string) {
- c.HTML(http.StatusBadRequest, "base", gin.H{
- "Title": "Error",
- "ContentTemplate": "error-content",
- "Message": msg,
- })
-}
-```
-
-- [ ] **Step 3: Update postFromForm() to accept *gin.Context**
-
-Replace:
-```go
-// postFromForm reads a post from an HTTP form submission.
-func postFromForm(r *http.Request) Post {
- _ = r.ParseForm()
- return Post{
- Title: strings.TrimSpace(r.FormValue("title")),
- Date: strings.TrimSpace(r.FormValue("date")),
- Tags: strings.TrimSpace(r.FormValue("tags")),
- Content: r.FormValue("content"),
- }
-}
-```
-
-With:
-```go
-// postFromForm reads a post from an HTTP form submission.
-func postFromForm(c *gin.Context) Post {
- return Post{
- Title: strings.TrimSpace(c.PostForm("title")),
- Date: strings.TrimSpace(c.PostForm("date")),
- Tags: strings.TrimSpace(c.PostForm("tags")),
- Content: c.PostForm("content"),
- }
-}
-```
-
----
-
-### Task 6: Update cmd/nebbet/main.go
-
-**Files:**
-- Modify: `cmd/nebbet/main.go` (cmdServe function)
-
-- [ ] **Step 1: Update import to include Gin**
-
-Add `"github.com/gin-gonic/gin"` to the imports section:
-
-```go
-import (
- "flag"
- "fmt"
- "net/http"
- "os"
-
- "github.com/gin-gonic/gin"
-
- "nebbet.no/internal/admin"
- "nebbet.no/internal/admin/auth"
- "nebbet.no/internal/builder"
- "nebbet.no/internal/db"
-)
-```
-
-- [ ] **Step 2: Rewrite cmdServe() function**
-
-Replace the entire `cmdServe()` function with:
-
-```go
-func cmdServe(args []string) {
- fs := flag.NewFlagSet("serve", flag.ExitOnError)
- port := fs.String("port", "8080", "port to listen on")
- _ = fs.Parse(args)
-
- b := mustBuilder()
- defer b.MetaDB.Close()
- defer b.SearchDB.Close()
-
- // Initial build.
- if err := b.BuildAll(); err != nil {
- fmt.Fprintln(os.Stderr, "build error:", err)
- os.Exit(1)
- }
-
- // Watch in background.
- go func() {
- if err := b.Watch(); err != nil {
- fmt.Fprintln(os.Stderr, "watch error:", err)
- }
- }()
-
- // Create admin server with Gin
- adminSrv := admin.NewServer(postsDir, passwordFile, b)
- engine := adminSrv.Engine()
-
- // Serve dependencies (lib/)
- engine.Static("/lib", "lib")
-
- // Serve static site from public/
- engine.Static("/", outputDir)
-
- addr := ":" + *port
- fmt.Printf("listening on http://localhost%s\n", addr)
- fmt.Printf(" public site: http://localhost%s/\n", addr)
- fmt.Printf(" admin UI: http://localhost%s/admin/\n", addr)
- if err := engine.Run(addr); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
-}
-```
-
----
-
-### Task 7: Test the Migration
-
-**Files:**
-- Test: Manual testing (no automated tests needed for this migration)
-
-- [ ] **Step 1: Build the project**
-
-Run:
-```bash
-cd /Users/ivarlovlie/p/nebbet.no && go build ./cmd/nebbet
-```
-
-Expected: Binary builds without errors
-
-- [ ] **Step 2: Start the server**
-
-Run:
-```bash
-go run ./cmd/nebbet serve --port 8080
-```
-
-Expected output:
-```
-listening on http://localhost:8080
- public site: http://localhost:8080/
- admin UI: http://localhost:8080/admin/
-```
-
-- [ ] **Step 3: Test list posts endpoint**
-
-Open browser: `http://localhost:8080/admin/`
-
-Expected: Posts list page loads (may show empty if no posts)
-
-- [ ] **Step 4: Create a new post**
-
-- Navigate to `http://localhost:8080/admin/new`
-- Fill in: Title, Date, Tags, Content
-- Click "Create" (or submit button)
-
-Expected: Redirects to `/admin/`, new post appears in list
-
-- [ ] **Step 5: Edit a post**
-
-- Click edit button on a post in the list
-- Modify a field (e.g., title)
-- Submit
-
-Expected: Redirects to `/admin/`, post shows updated content
-
-- [ ] **Step 6: Delete a post**
-
-- On the edit page, click delete button
-- Confirm deletion
-
-Expected: Redirects to `/admin/`, post no longer in list
-
-- [ ] **Step 7: Test static file serving**
-
-- Navigate to `http://localhost:8080/`
-- Check that public site loads
-- Navigate to `http://localhost:8080/lib/` to verify lib/ is served
-
-Expected: Both static file paths work
-
-- [ ] **Step 8: Test auth**
-
-- Stop the server (Ctrl+C)
-- In another terminal, create a user if needed:
-
-```bash
-go run ./cmd/nebbet user add testuser
-# Enter a password when prompted
-```
-
-- Start the server again
-- Visit `http://localhost:8080/admin/`
-- When prompted for auth, enter invalid credentials
-
-Expected: 401 Unauthorized error
-
-- Enter valid credentials (testuser + password)
-
-Expected: Admin page loads
-
----
-
-## Self-Review Checklist
-
-✓ **Spec coverage:**
-- Route migration (Task 2 NewServer) ✓
-- Auth middleware (Task 3) ✓
-- Handler refactoring (Task 4) ✓
-- Helper functions (Task 5) ✓
-- CLI integration (Task 6) ✓
-- Testing (Task 7) ✓
-
-✓ **Placeholder scan:** No TBDs, all code is complete and specific
-
-✓ **Type consistency:**
-- Handler signatures consistently `(c *gin.Context)`
-- Form helpers use `postFromForm(c)`
-- Template rendering uses `c.HTML()`
-- No mismatched method names
-
-✓ **Function signatures match:** All handlers in NewServer() route definitions match their actual signatures
diff --git a/docs/superpowers/specs/2026-04-03-gin-migration-design.md b/docs/superpowers/specs/2026-04-03-gin-migration-design.md
deleted file mode 100644
index 41163a3..0000000
--- a/docs/superpowers/specs/2026-04-03-gin-migration-design.md
+++ /dev/null
@@ -1,198 +0,0 @@
----
-name: Gin Migration Design
-description: Migrate admin server from net/http to Gin framework with RESTful routes and middleware-based auth
-type: implementation
-date: 2026-04-03
----
-
-# Gin Migration Design
-
-## Overview
-
-Migrate the nebbet admin server from Go's `net/http` package to the Gin web framework. The migration will:
-- Replace manual route handling with Gin's declarative routing
-- Reorganize routes to follow RESTful conventions
-- Extract auth into reusable middleware
-- Preserve all existing functionality (auth, template rendering, post management)
-- Keep UI behavior identical from the user's perspective
-
-## Goals
-
-1. **Cleaner routing:** Replace manual `ServeHTTP` switch with Gin's route declarations
-2. **Better framework:** Leverage Gin for middleware, error handling, and future extensibility
-3. **RESTful design:** Modernize route structure while keeping templates server-rendered
-
-## Non-Goals
-
-- Rewriting the frontend to a JS framework
-- Adding new features beyond the migration
-- Changing auth format or password file structure
-- Changing how posts are stored or built
-
-## Architecture
-
-### Current State
-
-The `admin.Server` implements `http.Handler` with manual routing in `ServeHTTP()`:
-```
-GET/POST /admin → list posts
-GET/POST /admin/new → create form / create post
-GET/POST /admin/{slug}/edit → edit form / update post
-POST /admin/{slug}/delete → delete post
-```
-
-Auth is checked manually via `checkAuth()` for every request.
-
-### Target State
-
-Replace with Gin routing and middleware:
-- `Server` struct holds a `*gin.Engine`
-- `NewServer()` initializes Gin with routes and middleware
-- Auth middleware wraps all admin routes
-- Handlers call `gin.Context` instead of `http.ResponseWriter`
-
-**New route structure (RESTful, under `/admin` namespace):**
-```
-GET /admin/ → list posts
-GET /admin/new → create form
-POST /admin/ → create post
-GET /admin/:slug → edit form
-POST /admin/:slug → update post
-DELETE /admin/:slug → delete post
-```
-
-## Implementation Details
-
-### File: `internal/admin/server.go`
-
-#### Server Struct
-- Add field: `engine *gin.Engine`
-- Keep existing fields: `PostsDir`, `AuthFile`, `Builder`, `tmpl`
-
-#### Constructor: `NewServer(postsDir, authFile string, builder *builder.Builder) *Server`
-- Create and configure Gin engine
-- Register middleware (auth)
-- Register all routes
-- Return `*Server`
-
-#### Auth Middleware
-- Extracted from `checkAuth()` into a middleware function
-- Signature: `func (s *Server) authMiddleware() gin.HandlerFunc`
-- Logic:
- - Skip auth if `AuthFile` is empty or doesn't exist
- - Extract Basic Auth credentials
- - Call `auth.Verify(username, password)`
- - Send 401 + `WWW-Authenticate` header on failure
- - Call `c.Next()` to proceed on success
-
-#### Route Handlers
-Keep existing handler functions, update signatures:
-- `handleList(c *gin.Context)` — render post list
-- `handleNew(c *gin.Context)` — GET shows form, POST creates post
-- `handleNewPost(c *gin.Context)` — handle POST /new (merge with `handleNew` or keep separate)
-- `handleEdit(c *gin.Context)` — GET shows form, POST updates post
-- `handleDelete(c *gin.Context)` — DELETE removes post
-
-For GET requests that show forms, continue using `c.HTML()` with the template and data map.
-For POST requests, validate form data, write to disk, rebuild, and redirect to `/`.
-
-#### Helper Functions
-Keep existing:
-- `listPosts()` — read `.md` files from PostsDir
-- `render(c *gin.Context, name string, data map[string]any)` — render template (adapt to use `c.HTML()`)
-- `renderError(c *gin.Context, msg string)` — render error template
-- `postFromForm(c *gin.Context) Post` — extract form values from `c.PostForm()`
-- `readPostFile()`, `writePostFile()`, `slugify()` — unchanged
-- `mustParseTemplates()` — unchanged
-
-### File: `cmd/nebbet/main.go`
-
-#### Changes in `cmdServe()`
-Instead of:
-```go
-adminSrv := &admin.Server{...}
-mux := http.NewServeMux()
-mux.Handle("/admin/", http.StripPrefix("/admin", adminSrv))
-mux.Handle("/lib/", ...)
-mux.Handle("/", ...)
-http.ListenAndServe(addr, mux)
-```
-
-Change to:
-```go
-adminSrv := admin.NewServer(postsDir, passwordFile, b)
-engine := adminSrv.Engine() // or keep internal, add to main router
-
-// Either:
-// 1. Use adminSrv.Engine directly (if it handles all routes)
-// 2. Create a main Gin router and nest adminSrv's routes
-
-// Handle /lib/ and static files as Gin routes or separate handlers
-```
-
-Exact approach depends on whether we keep `/lib` and public site serving separate or consolidate into one Gin router. **Recommended:** single Gin router for consistency.
-
-## Route Mapping
-
-| Old Route | New Route | Method | Action |
-|-----------|-----------|--------|--------|
-| `/admin/` | `/admin/` | GET | List posts |
-| `/admin/new` | `/admin/new` | GET | Show create form |
-| `/admin/new` | `/admin/` | POST | Create post |
-| `/admin/{slug}/edit` | `/admin/{slug}` | GET | Show edit form |
-| `/admin/{slug}/edit` | `/admin/{slug}` | POST | Update post |
-| `/admin/{slug}/delete` | `/admin/{slug}` | DELETE | Delete post |
-
-**HTML Form Handling:** Standard HTML forms only support GET/POST. For DELETE:
-- **Option A:** Keep POST with custom handling (check method override or hidden field)
-- **Option B:** Use POST `/admin/{slug}?method=delete` and parse in handler
-
-Recommended: keep as POST for form compatibility, or use POST with Gin's `c.PostForm("_method")` check.
-
-## Dependencies
-
-- Add `github.com/gin-gonic/gin` to `go.mod`
-- No changes to other dependencies
-
-## Backwards Compatibility
-
-- **URLs:** Admin routes change (listed above). Bookmarks will break, but it's an internal admin interface.
-- **Auth:** No changes. Password file format and Basic Auth remain the same.
-- **Posts:** No changes. Markdown files, frontmatter, build process unchanged.
-- **Public site:** No changes. Static files served the same way.
-
-## Testing Strategy
-
-- **Manual:** Create/edit/delete posts via the admin UI, verify rebuilds work
-- **Auth:** Test Basic Auth with valid/invalid credentials
-- **Forms:** Verify all form fields (title, date, tags, content) are captured and saved correctly
-- **Errors:** Verify error handling for missing posts, invalid input, file write failures
-
-## Implementation Order
-
-1. Add Gin dependency to `go.mod`
-2. Create `NewServer()` constructor and auth middleware in `server.go`
-3. Register routes in `NewServer()`
-4. Convert handler signatures to `*gin.Context`
-5. Update `render()` and helper functions to work with Gin
-6. Update `cmd/nebbet/main.go` to use `NewServer()`
-7. Verify all routes work locally
-8. Test admin UI end-to-end
-
-## Risks & Mitigation
-
-| Risk | Mitigation |
-|------|-----------|
-| Breaking the admin UI during migration | Test each route in a browser after updating |
-| Form submission issues (e.g., multipart/form-data) | Use `c.PostForm()` and test file uploads if used |
-| Auth middleware interfering with static files | Apply middleware only to admin routes, not `/lib` or `/` |
-| Template rendering errors | Keep template loading and execution the same |
-
-## Success Criteria
-
-- ✓ All admin routes work with Gin
-- ✓ Auth middleware blocks unauthorized access
-- ✓ Create/edit/delete posts works end-to-end
-- ✓ Posts rebuild after create/edit/delete
-- ✓ No changes to password file format or post storage
-- ✓ Static file serving (`/lib`, public site) unchanged