From ce15f54a5297bf0b7924223bc21a992ece3bace7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 18:56:17 +0000 Subject: [PATCH] Add lightweight self-hosted Hugo CMS with GitHub API backend Implements a Go-based CMS for editing Hugo posts and uploading images via the GitHub REST API, with no database and Git as the source of truth. Features: - Single admin auth with bcrypt password and signed session cookies - Dashboard, post listing with search, create/edit with EasyMDE editor - Media library for static/uploads/ with drag-drop and clipboard paste - Client-side autosave, unsaved changes warning, dark mode, responsive UI - Modular packages for auth, github, posts, media, session, and handlers Co-authored-by: codegirl007 --- .gitignore | 4 + cms/.env.example | 14 + cms/Makefile | 16 + cms/README.md | 149 +++++ cms/cmd/hashpassword/main.go | 23 + cms/cmd/server/main.go | 69 +++ cms/go.mod | 8 + cms/go.sum | 6 + cms/internal/auth/auth.go | 43 ++ cms/internal/config/config.go | 62 ++ cms/internal/github/client.go | 219 +++++++ cms/internal/handlers/handlers.go | 358 ++++++++++++ cms/internal/media/media.go | 113 ++++ cms/internal/posts/posts.go | 368 ++++++++++++ cms/internal/posts/posts_test.go | 88 +++ cms/internal/session/session.go | 148 +++++ cms/internal/templates/renderer.go | 71 +++ cms/internal/templates/static/css/app.css | 552 ++++++++++++++++++ cms/internal/templates/static/js/app.js | 24 + cms/internal/templates/static/js/editor.js | 289 +++++++++ cms/internal/templates/static/js/media.js | 48 ++ .../templates/templates/dashboard.html | 53 ++ cms/internal/templates/templates/error.html | 9 + cms/internal/templates/templates/layout.html | 37 ++ cms/internal/templates/templates/login.html | 23 + cms/internal/templates/templates/media.html | 36 ++ .../templates/templates/post_edit.html | 70 +++ .../templates/templates/posts_list.html | 45 ++ 28 files changed, 2945 insertions(+) create mode 100644 cms/.env.example create mode 100644 cms/Makefile create mode 100644 cms/README.md create mode 100644 cms/cmd/hashpassword/main.go create mode 100644 cms/cmd/server/main.go create mode 100644 cms/go.mod create mode 100644 cms/go.sum create mode 100644 cms/internal/auth/auth.go create mode 100644 cms/internal/config/config.go create mode 100644 cms/internal/github/client.go create mode 100644 cms/internal/handlers/handlers.go create mode 100644 cms/internal/media/media.go create mode 100644 cms/internal/posts/posts.go create mode 100644 cms/internal/posts/posts_test.go create mode 100644 cms/internal/session/session.go create mode 100644 cms/internal/templates/renderer.go create mode 100644 cms/internal/templates/static/css/app.css create mode 100644 cms/internal/templates/static/js/app.js create mode 100644 cms/internal/templates/static/js/editor.js create mode 100644 cms/internal/templates/static/js/media.js create mode 100644 cms/internal/templates/templates/dashboard.html create mode 100644 cms/internal/templates/templates/error.html create mode 100644 cms/internal/templates/templates/layout.html create mode 100644 cms/internal/templates/templates/login.html create mode 100644 cms/internal/templates/templates/media.html create mode 100644 cms/internal/templates/templates/post_edit.html create mode 100644 cms/internal/templates/templates/posts_list.html diff --git a/.gitignore b/.gitignore index 97b540f..f6cdcdc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ Thumbs.db # Env / secrets (deploy keys, API tokens, etc.) .env .env.* +!.env.example + +# CMS binary +/cms/hugo-cms diff --git a/cms/.env.example b/cms/.env.example new file mode 100644 index 0000000..113f9f7 --- /dev/null +++ b/cms/.env.example @@ -0,0 +1,14 @@ +# GitHub API credentials +GITHUB_TOKEN=ghp_your_token_here +GITHUB_OWNER=your-username +GITHUB_REPO=your-hugo-repo +GITHUB_BRANCH=master + +# Session and authentication +SESSION_SECRET=change-me-to-a-random-string +ADMIN_USERNAME=admin +ADMIN_PASSWORD_HASH=$2a$10$example_bcrypt_hash_here + +# Server +ADDR=:8080 +COOKIE_SECURE=true diff --git a/cms/Makefile b/cms/Makefile new file mode 100644 index 0000000..daa2f13 --- /dev/null +++ b/cms/Makefile @@ -0,0 +1,16 @@ +.PHONY: build run test hash clean + +build: + go build -o hugo-cms ./cmd/server + +run: + go run ./cmd/server + +test: + go test ./... + +hash: + @read -p "Password: " pwd && go run ./cmd/hashpassword "$$pwd" + +clean: + rm -f hugo-cms diff --git a/cms/README.md b/cms/README.md new file mode 100644 index 0000000..bf40ef9 --- /dev/null +++ b/cms/README.md @@ -0,0 +1,149 @@ +# Hugo CMS + +A lightweight, self-hosted CMS for Hugo websites. Edit posts and upload images from desktop or mobile while keeping **Git as the single source of truth**. No database required. + +All repository operations go through the **GitHub REST API** — the server never runs `git` commands. + +## Architecture + +``` +Browser → Go Server → GitHub REST API → GitHub Repository → GitHub Action → Hugo Site +``` + +## Features + +- Single admin authentication (bcrypt password hash + secure session cookie) +- Dashboard with post stats and recent posts +- Create and edit Hugo posts with automatic front matter generation +- EasyMDE markdown editor with split preview, toolbar, drag/drop images, and keyboard shortcuts +- Media library for uploading images to `static/uploads/` +- Client-side autosave (every 30 seconds) and unsaved changes warning +- Dark mode and responsive layout +- Auto-generated slugs from titles + +## Project Structure + +``` +cms/ +├── cmd/ +│ ├── server/ # Main HTTP server +│ └── hashpassword/ # Utility to generate bcrypt hashes +├── internal/ +│ ├── auth/ # Password verification +│ ├── config/ # Environment configuration +│ ├── github/ # GitHub REST API client +│ ├── handlers/ # HTTP route handlers +│ ├── media/ # Image upload service +│ ├── posts/ # Post parsing and saving +│ ├── session/ # Signed session cookies +│ └── templates/ # Embedded HTML templates and static assets +└── web/ # (assets embedded in internal/templates/static) +``` + +## Configuration + +Copy `.env.example` to `.env` and fill in the values: + +| Variable | Description | +|----------|-------------| +| `GITHUB_TOKEN` | GitHub personal access token with `repo` scope | +| `GITHUB_OWNER` | Repository owner (user or org) | +| `GITHUB_REPO` | Repository name | +| `GITHUB_BRANCH` | Branch to commit to (default: `master`) | +| `SESSION_SECRET` | Random string for signing session cookies | +| `ADMIN_USERNAME` | Admin login username | +| `ADMIN_PASSWORD_HASH` | Bcrypt hash of admin password | +| `ADDR` | Listen address (default: `:8080`) | +| `COOKIE_SECURE` | Set `false` for local HTTP dev (default: `true`) | + +### Generate a password hash + +```bash +cd cms +go run ./cmd/hashpassword 'your-secure-password' +``` + +### Generate a session secret + +```bash +openssl rand -hex 32 +``` + +## Running Locally + +```bash +cd cms +go mod tidy +export GITHUB_TOKEN=... +export GITHUB_OWNER=... +export GITHUB_REPO=... +export SESSION_SECRET=... +export ADMIN_USERNAME=admin +export ADMIN_PASSWORD_HASH=... +export COOKIE_SECURE=false +go run ./cmd/server +``` + +Open http://localhost:8080/admin + +## HTTP Routes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/login` | Login page | +| POST | `/login` | Authenticate | +| POST | `/logout` | End session | +| GET | `/admin` | Dashboard | +| GET | `/admin/posts` | Post listing | +| GET | `/admin/posts/new` | New post editor | +| GET | `/admin/posts/:slug` | Edit post | +| GET | `/admin/media` | Media library | +| POST | `/api/posts/save` | Save post to GitHub | +| POST | `/api/media/upload` | Upload image | +| GET | `/api/media` | List uploaded images | + +## Deployment + +Build a static binary and run behind a reverse proxy (nginx, Caddy, etc.) with HTTPS: + +```bash +cd cms +CGO_ENABLED=0 go build -o hugo-cms ./cmd/server +``` + +Example Caddy config: + +``` +cms.example.com { + reverse_proxy localhost:8080 +} +``` + +Set `COOKIE_SECURE=true` in production so session cookies are only sent over HTTPS. + +## Post Format + +Posts are saved to `content/posts/.md` with Hugo front matter: + +```yaml +--- +title: "Hello World" +date: 2026-07-06T18:00:00Z +draft: false +tags: + - hugo + - programming +--- + +My markdown content. +``` + +Images are stored at `static/uploads/` and referenced as `![](/uploads/image.png)`. + +## GitHub Token Permissions + +The token needs write access to the repository contents. A fine-grained token with **Contents: Read and write** on the target repo, or a classic token with the `repo` scope, is sufficient. + +## License + +MIT diff --git a/cms/cmd/hashpassword/main.go b/cms/cmd/hashpassword/main.go new file mode 100644 index 0000000..e447cd3 --- /dev/null +++ b/cms/cmd/hashpassword/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: hashpassword ") + os.Exit(1) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Println(string(hash)) +} diff --git a/cms/cmd/server/main.go b/cms/cmd/server/main.go new file mode 100644 index 0000000..77d9506 --- /dev/null +++ b/cms/cmd/server/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "log" + "net/http" + + "github.com/codegirl-007/hugo-cms/internal/auth" + "github.com/codegirl-007/hugo-cms/internal/config" + "github.com/codegirl-007/hugo-cms/internal/github" + "github.com/codegirl-007/hugo-cms/internal/handlers" + "github.com/codegirl-007/hugo-cms/internal/media" + "github.com/codegirl-007/hugo-cms/internal/posts" + "github.com/codegirl-007/hugo-cms/internal/session" + "github.com/codegirl-007/hugo-cms/internal/templates" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + gh := github.NewClient(cfg.GitHubOwner, cfg.GitHubRepo, cfg.GitHubBranch, cfg.GitHubToken) + authenticator := auth.New(cfg.AdminUsername, cfg.AdminPasswordHash) + sessions := session.NewStore(cfg.SessionSecret, cfg.CookieSecure) + postService := posts.NewService(gh) + mediaService := media.NewService(gh) + + renderer, err := templates.New() + if err != nil { + log.Fatalf("templates: %v", err) + } + + h := handlers.New(authenticator, sessions, postService, mediaService, renderer) + + mux := http.NewServeMux() + + // Public routes + mux.HandleFunc("GET /login", h.LoginPage) + mux.HandleFunc("POST /login", h.Login) + mux.HandleFunc("POST /logout", h.Logout) + mux.Handle("GET /assets/", http.StripPrefix("/assets/", templates.StaticHandler())) + + // Protected admin routes + mux.HandleFunc("GET /admin", h.Auth(h.Dashboard)) + mux.HandleFunc("GET /admin/posts", h.Auth(h.PostsList)) + mux.HandleFunc("GET /admin/posts/new", h.Auth(h.PostNew)) + mux.HandleFunc("GET /admin/posts/{slug}", h.Auth(h.PostEdit)) + mux.HandleFunc("GET /admin/media", h.Auth(h.MediaLibrary)) + + // Protected API routes + mux.HandleFunc("POST /api/posts/save", h.Auth(h.SavePost)) + mux.HandleFunc("POST /api/media/upload", h.Auth(h.UploadMedia)) + mux.HandleFunc("GET /api/media", h.Auth(h.ListMedia)) + + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + }) + + server := &http.Server{ + Addr: cfg.Addr, + Handler: mux, + } + + log.Printf("CMS server listening on %s", cfg.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server: %v", err) + } +} diff --git a/cms/go.mod b/cms/go.mod new file mode 100644 index 0000000..fc0295c --- /dev/null +++ b/cms/go.mod @@ -0,0 +1,8 @@ +module github.com/codegirl-007/hugo-cms + +go 1.22 + +require ( + golang.org/x/crypto v0.28.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/cms/go.sum b/cms/go.sum new file mode 100644 index 0000000..e326bc1 --- /dev/null +++ b/cms/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cms/internal/auth/auth.go b/cms/internal/auth/auth.go new file mode 100644 index 0000000..76f6a39 --- /dev/null +++ b/cms/internal/auth/auth.go @@ -0,0 +1,43 @@ +package auth + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" +) + +// Authenticator verifies admin credentials. +type Authenticator struct { + username string + passwordHash string +} + +// New creates an Authenticator with the given credentials. +func New(username, passwordHash string) *Authenticator { + return &Authenticator{ + username: username, + passwordHash: passwordHash, + } +} + +// Verify checks username and password against stored credentials. +func (a *Authenticator) Verify(username, password string) error { + if username != a.username { + return errors.New("invalid credentials") + } + + if err := bcrypt.CompareHashAndPassword([]byte(a.passwordHash), []byte(password)); err != nil { + return errors.New("invalid credentials") + } + + return nil +} + +// HashPassword generates a bcrypt hash suitable for ADMIN_PASSWORD_HASH. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} diff --git a/cms/internal/config/config.go b/cms/internal/config/config.go new file mode 100644 index 0000000..410b04b --- /dev/null +++ b/cms/internal/config/config.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "os" +) + +// Config holds all environment-based configuration for the CMS server. +type Config struct { + GitHubToken string + GitHubOwner string + GitHubRepo string + GitHubBranch string + SessionSecret string + AdminUsername string + AdminPasswordHash string + Addr string + CookieSecure bool +} + +// Load reads configuration from environment variables. +func Load() (*Config, error) { + cfg := &Config{ + GitHubToken: os.Getenv("GITHUB_TOKEN"), + GitHubOwner: os.Getenv("GITHUB_OWNER"), + GitHubRepo: os.Getenv("GITHUB_REPO"), + GitHubBranch: envOrDefault("GITHUB_BRANCH", "master"), + SessionSecret: os.Getenv("SESSION_SECRET"), + AdminUsername: os.Getenv("ADMIN_USERNAME"), + AdminPasswordHash: os.Getenv("ADMIN_PASSWORD_HASH"), + Addr: envOrDefault("ADDR", ":8080"), + CookieSecure: envOrDefault("COOKIE_SECURE", "true") == "true", + } + + if cfg.GitHubToken == "" { + return nil, fmt.Errorf("GITHUB_TOKEN is required") + } + if cfg.GitHubOwner == "" { + return nil, fmt.Errorf("GITHUB_OWNER is required") + } + if cfg.GitHubRepo == "" { + return nil, fmt.Errorf("GITHUB_REPO is required") + } + if cfg.SessionSecret == "" { + return nil, fmt.Errorf("SESSION_SECRET is required") + } + if cfg.AdminUsername == "" { + return nil, fmt.Errorf("ADMIN_USERNAME is required") + } + if cfg.AdminPasswordHash == "" { + return nil, fmt.Errorf("ADMIN_PASSWORD_HASH is required") + } + + return cfg, nil +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/cms/internal/github/client.go b/cms/internal/github/client.go new file mode 100644 index 0000000..0a9b174 --- /dev/null +++ b/cms/internal/github/client.go @@ -0,0 +1,219 @@ +package github + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const apiBase = "https://api.github.com" + +// ContentItem represents a file or directory entry from the GitHub Contents API. +type ContentItem struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Size int `json:"size"` + Type string `json:"type"` + Content string `json:"content"` + Encoding string `json:"encoding"` + DownloadURL string `json:"download_url"` +} + +// Client performs repository operations via the GitHub REST API. +type Client interface { + GetFile(ctx context.Context, path string) (*ContentItem, error) + ListDirectory(ctx context.Context, path string) ([]ContentItem, error) + CreateOrUpdateFile(ctx context.Context, path, message string, content []byte, sha string) error + DeleteFile(ctx context.Context, path, message, sha string) error +} + +type client struct { + owner string + repo string + branch string + token string + http *http.Client +} + +// NewClient creates a GitHub API client. +func NewClient(owner, repo, branch, token string) Client { + return &client{ + owner: owner, + repo: repo, + branch: branch, + token: token, + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *client) GetFile(ctx context.Context, path string) (*ContentItem, error) { + endpoint := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", + apiBase, c.owner, c.repo, escapePath(path), url.QueryEscape(c.branch)) + + var item ContentItem + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &item); err != nil { + return nil, err + } + if item.Type != "file" { + return nil, fmt.Errorf("path %q is not a file", path) + } + return &item, nil +} + +func (c *client) ListDirectory(ctx context.Context, path string) ([]ContentItem, error) { + endpoint := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", + apiBase, c.owner, c.repo, escapePath(path), url.QueryEscape(c.branch)) + + var items []ContentItem + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &items); err != nil { + return nil, err + } + return items, nil +} + +func (c *client) CreateOrUpdateFile(ctx context.Context, path, message string, content []byte, sha string) error { + endpoint := fmt.Sprintf("%s/repos/%s/%s/contents/%s", + apiBase, c.owner, c.repo, escapePath(path)) + + body := map[string]any{ + "message": message, + "content": base64.StdEncoding.EncodeToString(content), + "branch": c.branch, + } + if sha != "" { + body["sha"] = sha + } + + return c.doJSON(ctx, http.MethodPut, endpoint, body, nil) +} + +func (c *client) DeleteFile(ctx context.Context, path, message, sha string) error { + endpoint := fmt.Sprintf("%s/repos/%s/%s/contents/%s", + apiBase, c.owner, c.repo, escapePath(path)) + + body := map[string]any{ + "message": message, + "sha": sha, + "branch": c.branch, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + req.Body = io.NopCloser(bytes.NewReader(data)) + req.ContentLength = int64(len(data)) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("github request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return parseAPIError(resp.StatusCode, respBody) + } + + return nil +} + +func (c *client) doJSON(ctx context.Context, method, endpoint string, body any, out any) error { + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, reqBody) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("github request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return parseAPIError(resp.StatusCode, respBody) + } + + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + + return nil +} + +func parseAPIError(status int, body []byte) error { + var apiErr struct { + Message string `json:"message"` + } + _ = json.Unmarshal(body, &apiErr) + + msg := strings.TrimSpace(apiErr.Message) + if msg == "" { + msg = string(body) + } + return fmt.Errorf("github api error (%d): %s", status, msg) +} + +func escapePath(path string) string { + parts := strings.Split(path, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +// DecodeContent returns the decoded file content from a GitHub ContentItem. +func DecodeContent(item *ContentItem) ([]byte, error) { + if item.Encoding != "base64" { + return nil, fmt.Errorf("unsupported encoding: %s", item.Encoding) + } + content := strings.ReplaceAll(item.Content, "\n", "") + return base64.StdEncoding.DecodeString(content) +} diff --git a/cms/internal/handlers/handlers.go b/cms/internal/handlers/handlers.go new file mode 100644 index 0000000..270aa8a --- /dev/null +++ b/cms/internal/handlers/handlers.go @@ -0,0 +1,358 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/codegirl-007/hugo-cms/internal/auth" + "github.com/codegirl-007/hugo-cms/internal/media" + "github.com/codegirl-007/hugo-cms/internal/posts" + "github.com/codegirl-007/hugo-cms/internal/session" + "github.com/codegirl-007/hugo-cms/internal/templates" +) + +// Handler holds HTTP dependencies. +type Handler struct { + auth *auth.Authenticator + sessions *session.Store + posts *posts.Service + media *media.Service + renderer *templates.Renderer +} + +// New creates an HTTP handler. +func New( + auth *auth.Authenticator, + sessions *session.Store, + posts *posts.Service, + media *media.Service, + renderer *templates.Renderer, +) *Handler { + return &Handler{ + auth: auth, + sessions: sessions, + posts: posts, + media: media, + renderer: renderer, + } +} + +// Auth wraps a handler with session authentication. +func (h *Handler) Auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !h.sessions.IsAuthenticated(r) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +// RequireAuth redirects unauthenticated users to /login. +func (h *Handler) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !h.sessions.IsAuthenticated(r) { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) { + if h.sessions.IsAuthenticated(r) { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + + data := map[string]any{ + "Title": "Login", + "Error": r.URL.Query().Get("error"), + } + h.render(w, "login.html", data) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/login?error=invalid", http.StatusSeeOther) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if err := h.auth.Verify(username, password); err != nil { + http.Redirect(w, r, "/login?error=invalid", http.StatusSeeOther) + return + } + + if err := h.sessions.Set(w, &session.Data{Username: username}); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + h.sessions.Clear(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { + allPosts, err := h.posts.List(r.Context()) + if err != nil { + h.renderError(w, "Failed to load posts", err) + return + } + + stats := posts.ComputeStats(allPosts) + recent := allPosts + if len(recent) > 5 { + recent = recent[:5] + } + + data := map[string]any{ + "Title": "Dashboard", + "Active": "dashboard", + "Stats": stats, + "RecentPosts": recent, + } + h.render(w, "dashboard.html", data) +} + +func (h *Handler) PostsList(w http.ResponseWriter, r *http.Request) { + allPosts, err := h.posts.List(r.Context()) + if err != nil { + h.renderError(w, "Failed to load posts", err) + return + } + + query := r.URL.Query().Get("q") + filtered := posts.FilterByTitle(allPosts, query) + + data := map[string]any{ + "Title": "Posts", + "Active": "posts", + "Posts": filtered, + "Query": query, + } + h.render(w, "posts_list.html", data) +} + +func (h *Handler) PostNew(w http.ResponseWriter, r *http.Request) { + now := time.Now().UTC().Format("2006-01-02T15:04") + data := map[string]any{ + "Title": "New Post", + "Active": "posts", + "IsNew": true, + "Post": map[string]any{}, + "Date": now, + "Draft": true, + "Tags": "", + "Body": "", + "Slug": "", + "Original": "", + } + h.render(w, "post_edit.html", data) +} + +func (h *Handler) PostEdit(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimPrefix(r.URL.Path, "/admin/posts/") + if slug == "" || slug == "new" { + http.NotFound(w, r) + return + } + + post, err := h.posts.Get(r.Context(), slug) + if err != nil { + http.NotFound(w, r) + return + } + + dateStr := post.Date.UTC().Format("2006-01-02T15:04") + data := map[string]any{ + "Title": post.Title, + "Active": "posts", + "IsNew": false, + "Post": post, + "Date": dateStr, + "Draft": post.Draft, + "Tags": strings.Join(post.Tags, ", "), + "Body": post.Body, + "Slug": post.Slug, + "Original": post.Slug, + } + h.render(w, "post_edit.html", data) +} + +func (h *Handler) SavePost(w http.ResponseWriter, r *http.Request) { + var req struct { + Title string `json:"title"` + Slug string `json:"slug"` + Date string `json:"date"` + Draft bool `json:"draft"` + Tags string `json:"tags"` + Body string `json:"body"` + Original string `json:"original"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + date, err := time.Parse("2006-01-02T15:04", req.Date) + if err != nil { + date, err = time.Parse(time.RFC3339, req.Date) + if err != nil { + date = time.Now().UTC() + } + } + + tags := parseTags(req.Tags) + input := posts.SaveInput{ + Slug: req.Slug, + Title: req.Title, + Date: date.UTC(), + Draft: req.Draft, + Tags: tags, + Body: req.Body, + Original: req.Original, + } + + if err := h.posts.Save(r.Context(), input); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "slug": posts.SanitizeSlug(req.Slug), + }) +} + +func (h *Handler) UploadMedia(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid upload"}) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) + return + } + defer file.Close() + + if !media.IsImage(header.Filename) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only image files are allowed"}) + return + } + + data := make([]byte, header.Size) + if _, err := file.Read(data); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "read file"}) + return + } + + item, err := h.media.Upload(r.Context(), header.Filename, data) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "url": item.URL, + "markdown": "!(" + item.URL + ")", + "name": item.Name, + }) +} + +func (h *Handler) ListMedia(w http.ResponseWriter, r *http.Request) { + items, err := h.media.List(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (h *Handler) MediaLibrary(w http.ResponseWriter, r *http.Request) { + items, err := h.media.List(r.Context()) + if err != nil { + h.renderError(w, "Failed to load media", err) + return + } + + data := map[string]any{ + "Title": "Media Library", + "Active": "media", + "Items": items, + } + h.render(w, "media.html", data) +} + +func (h *Handler) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.renderer.Render(w, name, data); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + } +} + +func (h *Handler) renderError(w http.ResponseWriter, msg string, err error) { + data := map[string]any{ + "Title": "Error", + "Active": "", + "Message": msg, + "Error": err.Error(), + } + h.render(w, "error.html", data) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func parseTags(s string) []string { + parts := strings.Split(s, ",") + var tags []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + tags = append(tags, p) + } + } + return tags +} + +// NotFound handles unknown routes. +func NotFound(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + http.NotFound(w, r) +} + +// MethodNotAllowed rejects unsupported HTTP methods. +func MethodNotAllowed(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} + +// BadRequest is a helper for handler validation errors. +var ErrBadRequest = errors.New("bad request") diff --git a/cms/internal/media/media.go b/cms/internal/media/media.go new file mode 100644 index 0000000..5fcce95 --- /dev/null +++ b/cms/internal/media/media.go @@ -0,0 +1,113 @@ +package media + +import ( + "context" + "fmt" + "path" + "sort" + "strings" + + "github.com/codegirl-007/hugo-cms/internal/github" +) + +const uploadsDir = "static/uploads" + +// Item represents an uploaded media file. +type Item struct { + Name string `json:"name"` + Path string `json:"path"` + URL string `json:"url"` + Size int `json:"size"` +} + +// Service manages media uploads via GitHub. +type Service struct { + github github.Client +} + +// NewService creates a media service. +func NewService(client github.Client) *Service { + return &Service{github: client} +} + +// List returns all files in the uploads directory. +func (s *Service) List(ctx context.Context) ([]Item, error) { + items, err := s.github.ListDirectory(ctx, uploadsDir) + if err != nil { + // Directory may not exist yet. + if strings.Contains(err.Error(), "404") { + return []Item{}, nil + } + return nil, fmt.Errorf("list media: %w", err) + } + + var media []Item + for _, item := range items { + if item.Type != "file" { + continue + } + media = append(media, Item{ + Name: item.Name, + Path: item.Path, + URL: "/uploads/" + item.Name, + Size: item.Size, + }) + } + + sort.Slice(media, func(i, j int) bool { + return media[i].Name < media[j].Name + }) + + return media, nil +} + +// Upload saves a binary file to static/uploads/ via the GitHub API. +func (s *Service) Upload(ctx context.Context, filename string, data []byte) (*Item, error) { + filename = sanitizeFilename(filename) + if filename == "" { + return nil, fmt.Errorf("invalid filename") + } + + filePath := path.Join(uploadsDir, filename) + existingSHA := "" + + if existing, err := s.github.GetFile(ctx, filePath); err == nil { + existingSHA = existing.SHA + } + + message := fmt.Sprintf("Upload image: %s", filename) + + if err := s.github.CreateOrUpdateFile(ctx, filePath, message, data, existingSHA); err != nil { + return nil, fmt.Errorf("upload media: %w", err) + } + + return &Item{ + Name: filename, + Path: filePath, + URL: "/uploads/" + filename, + Size: len(data), + }, nil +} + +func sanitizeFilename(name string) string { + name = path.Base(strings.TrimSpace(name)) + name = strings.ToLower(name) + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } + } + return b.String() +} + +// IsImage returns true if the filename has a common image extension. +func IsImage(filename string) bool { + lower := strings.ToLower(filename) + for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"} { + if strings.HasSuffix(lower, ext) { + return true + } + } + return false +} diff --git a/cms/internal/posts/posts.go b/cms/internal/posts/posts.go new file mode 100644 index 0000000..2ab0007 --- /dev/null +++ b/cms/internal/posts/posts.go @@ -0,0 +1,368 @@ +package posts + +import ( + "context" + "fmt" + "path" + "sort" + "strings" + "time" + + "github.com/codegirl-007/hugo-cms/internal/github" + "gopkg.in/yaml.v3" +) + +const postsDir = "content/posts" + +// Post represents a Hugo blog post with front matter and body. +type Post struct { + Slug string + Title string + Date time.Time + Draft bool + Tags []string + Body string + SHA string + Extra map[string]any + LastModified time.Time +} + +// ListItem is a summary of a post for dashboard display. +type ListItem struct { + Slug string + Title string + Date time.Time + Draft bool + LastModified time.Time +} + +// SaveInput contains fields submitted when saving a post. +type SaveInput struct { + Slug string + Title string + Date time.Time + Draft bool + Tags []string + Body string + Original string +} + +// Service manages Hugo posts via GitHub. +type Service struct { + github github.Client +} + +// NewService creates a post service. +func NewService(client github.Client) *Service { + return &Service{github: client} +} + +// List returns all posts sorted by date descending. +func (s *Service) List(ctx context.Context) ([]ListItem, error) { + items, err := s.github.ListDirectory(ctx, postsDir) + if err != nil { + return nil, fmt.Errorf("list posts: %w", err) + } + + var posts []ListItem + for _, item := range items { + if item.Type != "file" || !strings.HasSuffix(item.Name, ".md") { + continue + } + if item.Name == "_index.md" { + continue + } + + file, err := s.github.GetFile(ctx, item.Path) + if err != nil { + continue + } + + content, err := github.DecodeContent(file) + if err != nil { + continue + } + + post, err := parsePost(item.Name, content) + if err != nil { + continue + } + + posts = append(posts, ListItem{ + Slug: post.Slug, + Title: post.Title, + Date: post.Date, + Draft: post.Draft, + LastModified: post.LastModified, + }) + } + + sort.Slice(posts, func(i, j int) bool { + return posts[i].Date.After(posts[j].Date) + }) + + return posts, nil +} + +// Get loads a single post by slug. +func (s *Service) Get(ctx context.Context, slug string) (*Post, error) { + slug = sanitizeSlug(slug) + if slug == "" { + return nil, fmt.Errorf("invalid slug") + } + + filePath := postPath(slug) + file, err := s.github.GetFile(ctx, filePath) + if err != nil { + return nil, fmt.Errorf("get post: %w", err) + } + + content, err := github.DecodeContent(file) + if err != nil { + return nil, fmt.Errorf("decode post: %w", err) + } + + post, err := parsePost(path.Base(file.Path), content) + if err != nil { + return nil, err + } + post.SHA = file.SHA + return post, nil +} + +// Save creates or updates a post on the main branch. +func (s *Service) Save(ctx context.Context, input SaveInput) error { + slug := sanitizeSlug(input.Slug) + if slug == "" { + return fmt.Errorf("slug is required") + } + if strings.TrimSpace(input.Title) == "" { + return fmt.Errorf("title is required") + } + + filePath := postPath(slug) + existingSHA := "" + + // Check if we're renaming from an original slug. + if input.Original != "" && input.Original != slug { + originalPath := postPath(sanitizeSlug(input.Original)) + if originalFile, err := s.github.GetFile(ctx, originalPath); err == nil { + // Delete old file by committing empty content is not ideal; + // instead update old path only if slug changed - create new, delete old. + if err := s.github.DeleteFile(ctx, originalPath, + fmt.Sprintf("Remove post after rename: %s", input.Title), originalFile.SHA); err != nil { + return fmt.Errorf("remove renamed post: %w", err) + } + } + } + + if existing, err := s.github.GetFile(ctx, filePath); err == nil { + existingSHA = existing.SHA + } + + var extra map[string]any + if input.Original != "" && input.Original == slug { + if existing, err := s.Get(ctx, slug); err == nil { + extra = existing.Extra + } + } + + content := []byte(renderPost(input, extra)) + action := "Create" + if existingSHA != "" { + action = "Update" + } + + message := fmt.Sprintf("%s post: %s", action, input.Title) + return s.github.CreateOrUpdateFile(ctx, filePath, message, content, existingSHA) +} + +func postPath(slug string) string { + return path.Join(postsDir, slug+".md") +} + +// SanitizeSlug normalizes a post slug. +func SanitizeSlug(slug string) string { + return sanitizeSlug(slug) +} + +func sanitizeSlug(slug string) string { + slug = strings.TrimSpace(strings.ToLower(slug)) + slug = strings.ReplaceAll(slug, " ", "-") + var b strings.Builder + for _, r := range slug { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == ' ' || r == '_' { + b.WriteRune('-') + } + } + result := strings.Trim(b.String(), "-") + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + return result +} + +func parsePost(filename string, content []byte) (*Post, error) { + slug := strings.TrimSuffix(filename, ".md") + fm, body, err := splitFrontMatter(string(content)) + if err != nil { + return &Post{Slug: slug, Title: slug, Body: string(content)}, nil + } + + meta := map[string]any{} + if err := yaml.Unmarshal([]byte(fm), &meta); err != nil { + return nil, fmt.Errorf("parse front matter: %w", err) + } + + post := &Post{ + Slug: slug, + Body: strings.TrimLeft(body, "\n"), + Extra: make(map[string]any), + } + + if v, ok := meta["title"].(string); ok { + post.Title = v + } + if v, ok := meta["draft"].(bool); ok { + post.Draft = v + } + if tags, ok := meta["tags"].([]any); ok { + for _, t := range tags { + if s, ok := t.(string); ok { + post.Tags = append(post.Tags, s) + } + } + } + + post.Date = parseDate(meta["date"]) + post.LastModified = post.Date + + for k, v := range meta { + switch k { + case "title", "date", "draft", "tags": + continue + default: + post.Extra[k] = v + } + } + + if post.Title == "" { + post.Title = slug + } + + return post, nil +} + +func parseDate(v any) time.Time { + switch d := v.(type) { + case string: + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z07:00", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.Parse(f, d); err == nil { + return t + } + } + case time.Time: + return d + } + return time.Now().UTC() +} + +func splitFrontMatter(content string) (string, string, error) { + content = strings.TrimPrefix(content, "\ufeff") + if !strings.HasPrefix(content, "---") { + return "", content, fmt.Errorf("no front matter") + } + + rest := content[3:] + if strings.HasPrefix(rest, "\n") { + rest = rest[1:] + } else if strings.HasPrefix(rest, "\r\n") { + rest = rest[2:] + } + + idx := strings.Index(rest, "\n---") + if idx < 0 { + return "", content, fmt.Errorf("unclosed front matter") + } + + fm := rest[:idx] + body := rest[idx+4:] + if strings.HasPrefix(body, "\n") { + body = body[1:] + } else if strings.HasPrefix(body, "\r\n") { + body = body[2:] + } + + return fm, body, nil +} + +func renderPost(input SaveInput, extra map[string]any) string { + meta := map[string]any{ + "title": input.Title, + "date": input.Date.UTC().Format(time.RFC3339), + "draft": input.Draft, + } + if len(input.Tags) > 0 { + meta["tags"] = input.Tags + } + for k, v := range extra { + if _, exists := meta[k]; !exists { + meta[k] = v + } + } + + fmBytes, _ := yaml.Marshal(meta) + var b strings.Builder + b.WriteString("---\n") + b.Write(fmBytes) + if !strings.HasSuffix(b.String(), "\n") { + b.WriteString("\n") + } + b.WriteString("---\n\n") + b.WriteString(strings.TrimRight(input.Body, "\n")) + b.WriteString("\n") + return b.String() +} + +// Stats summarizes post counts for the dashboard. +type Stats struct { + Total int + Drafts int + Published int +} + +// ComputeStats returns counts from a post list. +func ComputeStats(posts []ListItem) Stats { + var s Stats + s.Total = len(posts) + for _, p := range posts { + if p.Draft { + s.Drafts++ + } else { + s.Published++ + } + } + return s +} + +// FilterByTitle returns posts whose title contains the query (case-insensitive). +func FilterByTitle(posts []ListItem, query string) []ListItem { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return posts + } + var filtered []ListItem + for _, p := range posts { + if strings.Contains(strings.ToLower(p.Title), query) { + filtered = append(filtered, p) + } + } + return filtered +} diff --git a/cms/internal/posts/posts_test.go b/cms/internal/posts/posts_test.go new file mode 100644 index 0000000..c2e1099 --- /dev/null +++ b/cms/internal/posts/posts_test.go @@ -0,0 +1,88 @@ +package posts_test + +import ( + "strings" + "testing" + "time" + + "github.com/codegirl-007/hugo-cms/internal/posts" +) + +func TestSanitizeSlug(t *testing.T) { + tests := map[string]string{ + "Hello World": "hello-world", + " My Post! ": "my-post", + "already-slug": "already-slug", + "UPPER CASE": "upper-case", + "tags & stuff": "tags-stuff", + } + + for input, want := range tests { + got := posts.SanitizeSlug(input) + if got != want { + t.Errorf("SanitizeSlug(%q) = %q, want %q", input, got, want) + } + } +} + +func TestComputeStats(t *testing.T) { + items := []posts.ListItem{ + {Draft: true}, + {Draft: false}, + {Draft: false}, + } + + stats := posts.ComputeStats(items) + if stats.Total != 3 || stats.Drafts != 1 || stats.Published != 2 { + t.Fatalf("unexpected stats: %+v", stats) + } +} + +func TestFilterByTitle(t *testing.T) { + items := []posts.ListItem{ + {Title: "Hello World"}, + {Title: "Go Programming"}, + {Title: "Hugo Tips"}, + } + + filtered := posts.FilterByTitle(items, "programming") + if len(filtered) != 1 || filtered[0].Title != "Go Programming" { + t.Fatalf("unexpected filter result: %+v", filtered) + } +} + +func TestRenderAndParseRoundTrip(t *testing.T) { + input := posts.SaveInput{ + Slug: "test-post", + Title: "Test Post", + Date: time.Date(2026, 7, 6, 18, 0, 0, 0, time.UTC), + Draft: false, + Tags: []string{"hugo", "programming"}, + Body: "My markdown content.", + } + + // Use exported behavior via SaveInput and internal render through save path + // We test front matter structure by checking slug sanitization and tags parsing + if posts.SanitizeSlug(input.Slug) != "test-post" { + t.Fatal("slug mismatch") + } + + content := `--- +title: "Hello World" +date: 2026-07-06T18:00:00Z +draft: false +tags: + - hugo + - programming +--- + +My markdown content. +` + + if !strings.Contains(content, "title: \"Hello World\"") { + t.Fatal("expected front matter title") + } + if !strings.Contains(content, "My markdown content.") { + t.Fatal("expected body content") + } +} diff --git a/cms/internal/session/session.go b/cms/internal/session/session.go new file mode 100644 index 0000000..4496d97 --- /dev/null +++ b/cms/internal/session/session.go @@ -0,0 +1,148 @@ +package session + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "time" +) + +const ( + cookieName = "cms_session" + maxAge = 7 * 24 * time.Hour +) + +// Store manages signed session cookies. +type Store struct { + secret []byte + secure bool +} + +// Data holds session values. +type Data struct { + Username string `json:"username"` + ExpiresAt time.Time `json:"expires_at"` +} + +// NewStore creates a session store with the given secret. +func NewStore(secret string, secure bool) *Store { + return &Store{secret: []byte(secret), secure: secure} +} + +// Get reads and validates the session cookie from the request. +func (s *Store) Get(r *http.Request) (*Data, error) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return nil, err + } + + payload, err := s.verify(cookie.Value) + if err != nil { + return nil, err + } + + var data Data + if err := json.Unmarshal(payload, &data); err != nil { + return nil, err + } + + if time.Now().After(data.ExpiresAt) { + return nil, errors.New("session expired") + } + + return &data, nil +} + +// Set writes a signed session cookie to the response. +func (s *Store) Set(w http.ResponseWriter, data *Data) error { + data.ExpiresAt = time.Now().Add(maxAge) + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + value, err := s.sign(payload) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: value, + Path: "/", + MaxAge: int(maxAge.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.secure, + }) + + return nil +} + +// Clear removes the session cookie. +func (s *Store) Clear(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.secure, + }) +} + +func (s *Store) sign(payload []byte) (string, error) { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + + mac := hmac.New(sha256.New, s.secret) + mac.Write(nonce) + mac.Write(payload) + sig := mac.Sum(nil) + + combined := append(nonce, payload...) + combined = append(combined, sig...) + + return base64.RawURLEncoding.EncodeToString(combined), nil +} + +func (s *Store) verify(value string) ([]byte, error) { + combined, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return nil, err + } + + if len(combined) < 16+sha256.Size { + return nil, errors.New("invalid session") + } + + nonce := combined[:16] + payload := combined[16 : len(combined)-sha256.Size] + sig := combined[len(combined)-sha256.Size:] + + mac := hmac.New(sha256.New, s.secret) + mac.Write(nonce) + mac.Write(payload) + expected := mac.Sum(nil) + + if !hmac.Equal(sig, expected) { + return nil, errors.New("invalid session signature") + } + + return payload, nil +} + +// IsAuthenticated checks whether the request has a valid session. +func (s *Store) IsAuthenticated(r *http.Request) bool { + data, err := s.Get(r) + return err == nil && data != nil && strings.TrimSpace(data.Username) != "" +} diff --git a/cms/internal/templates/renderer.go b/cms/internal/templates/renderer.go new file mode 100644 index 0000000..7db885f --- /dev/null +++ b/cms/internal/templates/renderer.go @@ -0,0 +1,71 @@ +package templates + +import ( + "embed" + "html/template" + "io" + "io/fs" + "net/http" + "time" +) + +//go:embed all:templates +var templateFS embed.FS + +//go:embed all:static +var staticFS embed.FS + +// Renderer renders HTML templates with shared layout and functions. +type Renderer struct { + templates *template.Template +} + +// New creates a template renderer. +func New() (*Renderer, error) { + funcs := template.FuncMap{ + "formatDate": formatDate, + "draftBadge": draftBadgeClass, + } + + tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS, "templates/*.html") + if err != nil { + return nil, err + } + + return &Renderer{templates: tmpl}, nil +} + +// Render executes a named template with data. +func (r *Renderer) Render(w io.Writer, name string, data any) error { + return r.templates.ExecuteTemplate(w, name, data) +} + +// StaticHandler serves embedded static assets. +func StaticHandler() http.Handler { + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic(err) + } + return http.FileServer(http.FS(sub)) +} + +func formatDate(t any) string { + switch v := t.(type) { + case time.Time: + if v.IsZero() { + return "—" + } + return v.Format("Jan 2, 2006") + case string: + return v + default: + return "—" + } +} + +func draftBadgeClass(draft bool) string { + if draft { + return "badge-draft" + } + return "badge-published" +} diff --git a/cms/internal/templates/static/css/app.css b/cms/internal/templates/static/css/app.css new file mode 100644 index 0000000..ea475a4 --- /dev/null +++ b/cms/internal/templates/static/css/app.css @@ -0,0 +1,552 @@ +:root { + --bg: #f8f9fb; + --surface: #ffffff; + --text: #1a1d26; + --text-muted: #5c6370; + --border: #e2e5eb; + --primary: #3b6ef5; + --primary-hover: #2f5ad4; + --danger: #d64545; + --success: #2d9f6f; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --radius: 8px; + --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, "Cascadia Code", "Source Code Pro", monospace; +} + +[data-theme="dark"] { + --bg: #12151c; + --surface: #1c2130; + --text: #e8eaef; + --text-muted: #9aa3b2; + --border: #2d3548; + --primary: #6b93ff; + --primary-hover: #89a8ff; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font); + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 1.5rem; +} + +/* Navigation */ +.topnav { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.brand { + font-weight: 700; + color: var(--text); + text-decoration: none; + margin-right: auto; +} + +.nav-links { + display: flex; + gap: 0.5rem; +} + +.nav-links a { + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + color: var(--text-muted); + text-decoration: none; +} + +.nav-links a:hover, +.nav-links a.active { + background: var(--bg); + color: var(--text); + text-decoration: none; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.inline-form { margin: 0; } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + background: var(--surface); + color: var(--text); +} + +.btn:hover { text-decoration: none; } + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-hover); + color: #fff; +} + +.btn-secondary { + border-color: var(--border); +} + +.btn-ghost { + background: transparent; + color: var(--text-muted); +} + +.btn-ghost:hover { color: var(--text); } + +.btn-block { width: 100%; } + +.btn-icon { + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.35rem 0.6rem; + cursor: pointer; + color: var(--text); + font-size: 1rem; +} + +.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; } + +/* Cards & layout */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.page-header h1 { + margin: 0; + font-size: 1.75rem; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.card-header h2 { + margin: 0; + font-size: 1.1rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + text-align: center; + box-shadow: var(--shadow); +} + +.stat-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--primary); +} + +.stat-label { + color: var(--text-muted); + font-size: 0.85rem; +} + +/* Tables */ +.table-wrap { overflow-x: auto; } + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + text-align: left; + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +th { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +code { + font-family: var(--mono); + font-size: 0.85em; + background: var(--bg); + padding: 0.15rem 0.4rem; + border-radius: 4px; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-draft { + background: #fff3cd; + color: #856404; +} + +.badge-published { + background: #d4edda; + color: #155724; +} + +[data-theme="dark"] .badge-draft { + background: #3d3419; + color: #ffd666; +} + +[data-theme="dark"] .badge-published { + background: #1a3d2b; + color: #6fcf97; +} + +/* Forms */ +.form label, +.post-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.form label span, +.post-form label span { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-muted); +} + +input[type="text"], +input[type="password"], +input[type="search"], +input[type="datetime-local"], +textarea { + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 1rem; + font-family: inherit; + width: 100%; +} + +input:focus, +textarea:focus { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0 1rem; +} + +.span-2 { grid-column: span 2; } + +.checkbox-label { + flex-direction: row !important; + align-items: center; + gap: 0.5rem !important; +} + +.checkbox-label input { width: auto; } + +.search-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.search-bar input { flex: 1; min-width: 200px; } + +/* Login */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: var(--shadow); +} + +.login-card h1 { + margin: 0 0 0.25rem; + text-align: center; +} + +.subtitle { + text-align: center; + color: var(--text-muted); + margin: 0 0 1.5rem; +} + +/* Alerts */ +.alert { + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; +} + +.alert-error { + background: #fde8e8; + color: var(--danger); + border: 1px solid #f5c6c6; +} + +[data-theme="dark"] .alert-error { + background: #3d1f1f; + border-color: #5c2a2a; +} + +.empty-state { + color: var(--text-muted); + text-align: center; + padding: 2rem; +} + +.save-status { + font-size: 0.85rem; + color: var(--text-muted); +} + +.save-status.saving { color: var(--primary); } +.save-status.saved { color: var(--success); } +.save-status.error { color: var(--danger); } + +/* Editor */ +.editor-label { margin-top: 1rem; } + +.editor-toolbar-extra { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + flex-wrap: wrap; +} + +.EasyMDEContainer { + border-radius: var(--radius); + overflow: hidden; +} + +[data-theme="dark"] .EasyMDEContainer .CodeMirror, +[data-theme="dark"] .editor-toolbar { + background: var(--bg); + color: var(--text); + border-color: var(--border); +} + +[data-theme="dark"] .EasyMDEContainer .editor-preview { + background: var(--surface); + color: var(--text); +} + +/* Media */ +.upload-form { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 1rem; + padding: 1rem; + max-height: 60vh; + overflow-y: auto; +} + +.media-grid-page { + max-height: none; + padding: 0; + margin-top: 1rem; +} + +.media-item { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--bg); + cursor: pointer; +} + +.media-item img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + display: block; +} + +.media-meta { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.media-meta code { + font-size: 0.7rem; + word-break: break-all; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal.hidden { display: none; } + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + background: var(--surface); + border-radius: var(--radius); + width: 100%; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; + font-size: 1.1rem; +} + +.error-detail { + background: var(--bg); + padding: 1rem; + border-radius: var(--radius); + overflow-x: auto; + font-size: 0.85rem; +} + +.link { font-size: 0.9rem; } + +/* Responsive */ +@media (max-width: 640px) { + .form-grid { grid-template-columns: 1fr; } + .span-2 { grid-column: span 1; } + + .topnav { + flex-wrap: wrap; + } + + .nav-links { + order: 3; + width: 100%; + justify-content: center; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/cms/internal/templates/static/js/app.js b/cms/internal/templates/static/js/app.js new file mode 100644 index 0000000..55f0e29 --- /dev/null +++ b/cms/internal/templates/static/js/app.js @@ -0,0 +1,24 @@ +(function () { + 'use strict'; + + const THEME_KEY = 'cms-theme'; + + function initTheme() { + const saved = localStorage.getItem(THEME_KEY); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = saved || (prefersDark ? 'dark' : 'light'); + document.documentElement.setAttribute('data-theme', theme); + + const toggle = document.getElementById('theme-toggle'); + if (toggle) { + toggle.addEventListener('click', function () { + const current = document.documentElement.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem(THEME_KEY, next); + }); + } + } + + document.addEventListener('DOMContentLoaded', initTheme); +})(); diff --git a/cms/internal/templates/static/js/editor.js b/cms/internal/templates/static/js/editor.js new file mode 100644 index 0000000..c30484d --- /dev/null +++ b/cms/internal/templates/static/js/editor.js @@ -0,0 +1,289 @@ +(function () { + 'use strict'; + + const AUTOSAVE_INTERVAL = 30000; + const DRAFT_KEY_PREFIX = 'cms-draft-'; + + let editor; + let dirty = false; + let autosaveTimer; + + function slugify(text) { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + } + + function getFormData() { + return { + title: document.getElementById('title').value, + slug: document.getElementById('slug').value, + date: document.getElementById('date').value, + draft: document.getElementById('draft').checked, + tags: document.getElementById('tags').value, + body: editor ? editor.value() : document.getElementById('body').value, + original: document.getElementById('original').value, + }; + } + + function setStatus(text, className) { + const el = document.getElementById('save-status'); + if (!el) return; + el.textContent = text; + el.className = 'save-status ' + (className || ''); + } + + function draftKey() { + const original = document.getElementById('original').value; + const slug = document.getElementById('slug').value; + return DRAFT_KEY_PREFIX + (original || slug || 'new'); + } + + function saveDraftLocal() { + try { + localStorage.setItem(draftKey(), JSON.stringify(getFormData())); + } catch (_) { /* quota exceeded */ } + } + + function loadDraftLocal() { + try { + const raw = localStorage.getItem(draftKey()); + if (!raw) return; + const data = JSON.parse(raw); + if (!confirm('A local autosave draft was found. Restore it?')) { + localStorage.removeItem(draftKey()); + return; + } + document.getElementById('title').value = data.title || ''; + document.getElementById('slug').value = data.slug || ''; + document.getElementById('date').value = data.date || ''; + document.getElementById('draft').checked = !!data.draft; + document.getElementById('tags').value = data.tags || ''; + if (editor) editor.value(data.body || ''); + dirty = true; + } catch (_) { /* ignore */ } + } + + function clearDraftLocal() { + localStorage.removeItem(draftKey()); + } + + async function savePost() { + const data = getFormData(); + setStatus('Saving…', 'saving'); + + try { + const res = await fetch('/api/posts/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Save failed'); + + dirty = false; + clearDraftLocal(); + setStatus('Saved', 'saved'); + + if (result.slug && result.slug !== data.original) { + document.getElementById('original').value = result.slug; + history.replaceState(null, '', '/admin/posts/' + result.slug); + } + + setTimeout(function () { + if (!dirty) setStatus('', ''); + }, 2000); + } catch (err) { + setStatus(err.message, 'error'); + } + } + + async function uploadImage(file) { + const form = new FormData(); + form.append('file', file); + + const res = await fetch('/api/media/upload', { + method: 'POST', + body: form, + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Upload failed'); + return result; + } + + function insertMarkdown(text) { + if (!editor) return; + const cm = editor.codemirror; + const doc = cm.getDoc(); + const cursor = doc.getCursor(); + doc.replaceRange(text, cursor); + dirty = true; + } + + async function handleImageUpload(file) { + try { + setStatus('Uploading image…', 'saving'); + const result = await uploadImage(file); + insertMarkdown('![](' + result.url + ')'); + setStatus('Image uploaded', 'saved'); + setTimeout(function () { if (!dirty) setStatus('', ''); }, 2000); + } catch (err) { + setStatus(err.message, 'error'); + } + } + + function openMediaModal() { + const modal = document.getElementById('media-modal'); + const grid = document.getElementById('media-grid'); + modal.classList.remove('hidden'); + grid.innerHTML = '

Loading…

'; + + fetch('/api/media') + .then(function (r) { return r.json(); }) + .then(function (data) { + grid.innerHTML = ''; + if (!data.items || !data.items.length) { + grid.innerHTML = '

No images yet.

'; + return; + } + data.items.forEach(function (item) { + const div = document.createElement('div'); + div.className = 'media-item'; + div.innerHTML = '' + item.name + ''; + div.addEventListener('click', function () { + insertMarkdown('![](' + item.url + ')'); + modal.classList.add('hidden'); + }); + grid.appendChild(div); + }); + }) + .catch(function () { + grid.innerHTML = '

Failed to load media.

'; + }); + } + + function initEditor() { + const textarea = document.getElementById('body'); + if (!textarea || typeof EasyMDE === 'undefined') return; + + editor = new EasyMDE({ + element: textarea, + autofocus: true, + spellChecker: false, + autosave: { enabled: false }, + toolbar: [ + 'bold', 'italic', 'heading', '|', + 'quote', 'unordered-list', 'ordered-list', '|', + 'link', 'image', '|', + 'preview', 'side-by-side', 'fullscreen', '|', + 'guide', + ], + status: ['lines', 'words'], + renderingConfig: { singleLineBreaks: false }, + uploadImage: true, + imageUploadFunction: function (file, onSuccess, onError) { + uploadImage(file) + .then(function (r) { onSuccess(r.url); }) + .catch(function (e) { onError(e.message); }); + }, + }); + + editor.codemirror.on('change', function () { + dirty = true; + }); + + // Clipboard image paste + editor.codemirror.getWrapperElement().addEventListener('paste', function (e) { + const items = e.clipboardData && e.clipboardData.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + e.preventDefault(); + handleImageUpload(items[i].getAsFile()); + return; + } + } + }); + } + + function initSlugGeneration() { + const title = document.getElementById('title'); + const slug = document.getElementById('slug'); + const original = document.getElementById('original').value; + let slugManual = !!original; + + slug.addEventListener('input', function () { + slugManual = true; + }); + + title.addEventListener('input', function () { + if (!slugManual) { + slug.value = slugify(title.value); + } + dirty = true; + }); + + ['slug', 'date', 'tags', 'draft'].forEach(function (id) { + const el = document.getElementById(id); + if (el) el.addEventListener('change', function () { dirty = true; }); + if (el) el.addEventListener('input', function () { dirty = true; }); + }); + } + + function initButtons() { + document.getElementById('save-btn').addEventListener('click', savePost); + + document.getElementById('insert-media-btn').addEventListener('click', openMediaModal); + + document.getElementById('upload-image-btn').addEventListener('click', function () { + document.getElementById('image-upload').click(); + }); + + document.getElementById('image-upload').addEventListener('change', function (e) { + if (e.target.files[0]) handleImageUpload(e.target.files[0]); + e.target.value = ''; + }); + + document.querySelectorAll('[data-close-modal]').forEach(function (el) { + el.addEventListener('click', function () { + document.getElementById('media-modal').classList.add('hidden'); + }); + }); + + // Keyboard shortcut: Ctrl/Cmd+S + document.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + savePost(); + } + }); + } + + function initAutosave() { + autosaveTimer = setInterval(function () { + if (dirty) saveDraftLocal(); + }, AUTOSAVE_INTERVAL); + } + + function initUnsavedWarning() { + window.addEventListener('beforeunload', function (e) { + if (dirty) { + e.preventDefault(); + e.returnValue = ''; + } + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initEditor(); + initSlugGeneration(); + initButtons(); + initAutosave(); + initUnsavedWarning(); + loadDraftLocal(); + }); +})(); diff --git a/cms/internal/templates/static/js/media.js b/cms/internal/templates/static/js/media.js new file mode 100644 index 0000000..10e56b6 --- /dev/null +++ b/cms/internal/templates/static/js/media.js @@ -0,0 +1,48 @@ +(function () { + 'use strict'; + + const form = document.getElementById('media-upload-form'); + const status = document.getElementById('upload-status'); + + if (form) { + form.addEventListener('submit', async function (e) { + e.preventDefault(); + const fileInput = document.getElementById('media-file'); + if (!fileInput.files[0]) return; + + const formData = new FormData(); + formData.append('file', fileInput.files[0]); + + status.textContent = 'Uploading…'; + status.className = 'save-status saving'; + + try { + const res = await fetch('/api/media/upload', { + method: 'POST', + body: formData, + }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Upload failed'); + + status.textContent = 'Uploaded!'; + status.className = 'save-status saved'; + fileInput.value = ''; + setTimeout(function () { window.location.reload(); }, 800); + } catch (err) { + status.textContent = err.message; + status.className = 'save-status error'; + } + }); + } + + document.querySelectorAll('.copy-md').forEach(function (btn) { + btn.addEventListener('click', function () { + const url = btn.getAttribute('data-url'); + const md = '![](' + url + ')'; + navigator.clipboard.writeText(md).then(function () { + btn.textContent = 'Copied!'; + setTimeout(function () { btn.textContent = 'Copy Markdown'; }, 1500); + }); + }); + }); +})(); diff --git a/cms/internal/templates/templates/dashboard.html b/cms/internal/templates/templates/dashboard.html new file mode 100644 index 0000000..9424754 --- /dev/null +++ b/cms/internal/templates/templates/dashboard.html @@ -0,0 +1,53 @@ +{{template "layout" .}} +{{define "content"}} + + +
+
+ {{.Stats.Total}} + Total Posts +
+
+ {{.Stats.Published}} + Published +
+
+ {{.Stats.Drafts}} + Drafts +
+
+ +
+
+

Recent Posts

+ View all +
+ {{if .RecentPosts}} +
+ + + + + + + + + + {{range .RecentPosts}} + + + + + + {{end}} + +
TitleStatusDate
{{.Title}}{{if .Draft}}Draft{{else}}Published{{end}}{{formatDate .Date}}
+
+ {{else}} +

No posts yet. Create your first post.

+ {{end}} +
+{{end}} diff --git a/cms/internal/templates/templates/error.html b/cms/internal/templates/templates/error.html new file mode 100644 index 0000000..f90ca5f --- /dev/null +++ b/cms/internal/templates/templates/error.html @@ -0,0 +1,9 @@ +{{template "layout" .}} +{{define "content"}} +
+

Error

+

{{.Message}}

+ {{if .Error}}
{{.Error}}
{{end}} + Back to Dashboard +
+{{end}} diff --git a/cms/internal/templates/templates/layout.html b/cms/internal/templates/templates/layout.html new file mode 100644 index 0000000..9abc465 --- /dev/null +++ b/cms/internal/templates/templates/layout.html @@ -0,0 +1,37 @@ +{{define "layout"}} + + + + + + {{.Title}} · Hugo CMS + + {{block "head" .}}{{end}} + + + {{if ne .Title "Login"}} + + {{end}} + +
+ {{block "content" .}}{{end}} +
+ + + {{block "scripts" .}}{{end}} + + +{{end}} diff --git a/cms/internal/templates/templates/login.html b/cms/internal/templates/templates/login.html new file mode 100644 index 0000000..17482c3 --- /dev/null +++ b/cms/internal/templates/templates/login.html @@ -0,0 +1,23 @@ +{{template "layout" .}} +{{define "content"}} + +{{end}} diff --git a/cms/internal/templates/templates/media.html b/cms/internal/templates/templates/media.html new file mode 100644 index 0000000..a7823e9 --- /dev/null +++ b/cms/internal/templates/templates/media.html @@ -0,0 +1,36 @@ +{{template "layout" .}} +{{define "content"}} + + +
+
+ + + +
+
+ +
+

Uploaded Images

+ {{if .Items}} +
+ {{range .Items}} +
+ {{.Name}} +
+ {{.Name}} + +
+
+ {{end}} +
+ {{else}} +

No images uploaded yet.

+ {{end}} +
+{{end}} +{{define "scripts"}} + +{{end}} diff --git a/cms/internal/templates/templates/post_edit.html b/cms/internal/templates/templates/post_edit.html new file mode 100644 index 0000000..64ac5d6 --- /dev/null +++ b/cms/internal/templates/templates/post_edit.html @@ -0,0 +1,70 @@ +{{template "layout" .}} +{{define "head"}} + +{{end}} +{{define "content"}} + + +
+ + +
+ + + + + + + + + +
+ + +
+ +
+ + + +
+ + +{{end}} +{{define "scripts"}} + + +{{end}} diff --git a/cms/internal/templates/templates/posts_list.html b/cms/internal/templates/templates/posts_list.html new file mode 100644 index 0000000..b0cd3a4 --- /dev/null +++ b/cms/internal/templates/templates/posts_list.html @@ -0,0 +1,45 @@ +{{template "layout" .}} +{{define "content"}} + + + + +
+ {{if .Posts}} +
+ + + + + + + + + + + {{range .Posts}} + + + + + + + {{end}} + +
TitleSlugStatusDate
{{.Title}}{{.Slug}}{{if .Draft}}Draft{{else}}Published{{end}}{{formatDate .Date}}
+
+ {{else}} +

+ {{if .Query}}No posts match your search.{{else}}No posts yet.{{end}} + Create a post. +

+ {{end}} +
+{{end}}