Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d43f5259b |
@@ -17,7 +17,3 @@ Thumbs.db
|
||||
# Env / secrets (deploy keys, API tokens, etc.)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# CMS binary
|
||||
/cms/hugo-cms
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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
|
||||
@@ -1,16 +0,0 @@
|
||||
.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
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
# 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/<slug>.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/<filename>` and referenced as ``.
|
||||
|
||||
## 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
|
||||
@@ -1,23 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: hashpassword <password>")
|
||||
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))
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
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=
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
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")
|
||||
@@ -1,113 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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) != ""
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
(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);
|
||||
})();
|
||||
@@ -1,289 +0,0 @@
|
||||
(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('');
|
||||
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 = '<p>Loading…</p>';
|
||||
|
||||
fetch('/api/media')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
grid.innerHTML = '';
|
||||
if (!data.items || !data.items.length) {
|
||||
grid.innerHTML = '<p class="empty-state">No images yet.</p>';
|
||||
return;
|
||||
}
|
||||
data.items.forEach(function (item) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'media-item';
|
||||
div.innerHTML = '<img src="' + item.url + '" alt="' + item.name + '">';
|
||||
div.addEventListener('click', function () {
|
||||
insertMarkdown('');
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
grid.appendChild(div);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
grid.innerHTML = '<p class="empty-state">Failed to load media.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
})();
|
||||
@@ -1,48 +0,0 @@
|
||||
(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 = '';
|
||||
navigator.clipboard.writeText(md).then(function () {
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function () { btn.textContent = 'Copy Markdown'; }, 1500);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,53 +0,0 @@
|
||||
{{template "layout" .}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<a href="/admin/posts/new" class="btn btn-primary">New Post</a>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{.Stats.Total}}</span>
|
||||
<span class="stat-label">Total Posts</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{.Stats.Published}}</span>
|
||||
<span class="stat-label">Published</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{.Stats.Drafts}}</span>
|
||||
<span class="stat-label">Drafts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Recent Posts</h2>
|
||||
<a href="/admin/posts" class="link">View all</a>
|
||||
</div>
|
||||
{{if .RecentPosts}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentPosts}}
|
||||
<tr>
|
||||
<td><a href="/admin/posts/{{.Slug}}">{{.Title}}</a></td>
|
||||
<td><span class="badge {{draftBadge .Draft}}">{{if .Draft}}Draft{{else}}Published{{end}}</span></td>
|
||||
<td>{{formatDate .Date}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-state">No posts yet. <a href="/admin/posts/new">Create your first post</a>.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,9 +0,0 @@
|
||||
{{template "layout" .}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h1>Error</h1>
|
||||
<p>{{.Message}}</p>
|
||||
{{if .Error}}<pre class="error-detail">{{.Error}}</pre>{{end}}
|
||||
<a href="/admin" class="btn btn-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,37 +0,0 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} · Hugo CMS</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{if ne .Title "Login"}}
|
||||
<nav class="topnav">
|
||||
<a href="/admin" class="brand">Hugo CMS</a>
|
||||
<div class="nav-links">
|
||||
<a href="/admin" class="{{if eq .Active "dashboard"}}active{{end}}">Dashboard</a>
|
||||
<a href="/admin/posts" class="{{if eq .Active "posts"}}active{{end}}">Posts</a>
|
||||
<a href="/admin/media" class="{{if eq .Active "media"}}active{{end}}">Media</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<button type="button" id="theme-toggle" class="btn-icon" title="Toggle dark mode" aria-label="Toggle dark mode">◐</button>
|
||||
<form method="POST" action="/logout" class="inline-form">
|
||||
<button type="submit" class="btn btn-ghost">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
<main class="container">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<script src="/assets/js/app.js"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,23 +0,0 @@
|
||||
{{template "layout" .}}
|
||||
{{define "content"}}
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h1>Hugo CMS</h1>
|
||||
<p class="subtitle">Sign in to manage your site</p>
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">Invalid username or password</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/login" class="form">
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" required autocomplete="username" autofocus>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,36 +0,0 @@
|
||||
{{template "layout" .}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Media Library</h1>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form id="media-upload-form" class="upload-form" enctype="multipart/form-data">
|
||||
<input type="file" id="media-file" name="file" accept="image/*" required>
|
||||
<button type="submit" class="btn btn-primary">Upload Image</button>
|
||||
<span id="upload-status" class="save-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Uploaded Images</h2>
|
||||
{{if .Items}}
|
||||
<div class="media-grid media-grid-page">
|
||||
{{range .Items}}
|
||||
<div class="media-item">
|
||||
<img src="{{.URL}}" alt="{{.Name}}" loading="lazy">
|
||||
<div class="media-meta">
|
||||
<code>{{.Name}}</code>
|
||||
<button type="button" class="btn btn-ghost btn-sm copy-md" data-url="{{.URL}}">Copy Markdown</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-state">No images uploaded yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script src="/assets/js/media.js"></script>
|
||||
{{end}}
|
||||
@@ -1,70 +0,0 @@
|
||||
{{template "layout" .}}
|
||||
{{define "head"}}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css">
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>{{if .IsNew}}New Post{{else}}Edit Post{{end}}</h1>
|
||||
<div class="header-actions">
|
||||
<span id="save-status" class="save-status"></span>
|
||||
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="post-form" class="post-form">
|
||||
<input type="hidden" id="original" value="{{.Original}}">
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="span-2">
|
||||
<span>Title</span>
|
||||
<input type="text" id="title" value="{{.Title}}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Slug</span>
|
||||
<input type="text" id="slug" value="{{.Slug}}" required pattern="[a-z0-9-]+" title="Lowercase letters, numbers, and hyphens only">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Date</span>
|
||||
<input type="datetime-local" id="date" value="{{.Date}}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Tags</span>
|
||||
<input type="text" id="tags" value="{{.Tags}}" placeholder="hugo, programming">
|
||||
</label>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="draft" {{if .Draft}}checked{{end}}>
|
||||
<span>Draft</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="editor-label">
|
||||
<span>Body</span>
|
||||
<textarea id="body">{{.Body}}</textarea>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div class="editor-toolbar-extra">
|
||||
<button type="button" id="insert-media-btn" class="btn btn-secondary">Insert from Media Library</button>
|
||||
<input type="file" id="image-upload" accept="image/*" hidden>
|
||||
<button type="button" id="upload-image-btn" class="btn btn-secondary">Upload Image</button>
|
||||
</div>
|
||||
|
||||
<div id="media-modal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Media library">
|
||||
<div class="modal-backdrop" data-close-modal></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Media Library</h2>
|
||||
<button type="button" class="btn-icon" data-close-modal aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="media-grid" class="media-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js"></script>
|
||||
<script src="/assets/js/editor.js"></script>
|
||||
{{end}}
|
||||
@@ -1,45 +0,0 @@
|
||||
{{template "layout" .}}
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Posts</h1>
|
||||
<a href="/admin/posts/new" class="btn btn-primary">New Post</a>
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/admin/posts" class="search-bar">
|
||||
<input type="search" name="q" value="{{.Query}}" placeholder="Search by title…" aria-label="Search posts">
|
||||
<button type="submit" class="btn btn-secondary">Search</button>
|
||||
{{if .Query}}<a href="/admin/posts" class="btn btn-ghost">Clear</a>{{end}}
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
{{if .Posts}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Posts}}
|
||||
<tr>
|
||||
<td><a href="/admin/posts/{{.Slug}}">{{.Title}}</a></td>
|
||||
<td><code>{{.Slug}}</code></td>
|
||||
<td><span class="badge {{draftBadge .Draft}}">{{if .Draft}}Draft{{else}}Published{{end}}</span></td>
|
||||
<td>{{formatDate .Date}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-state">
|
||||
{{if .Query}}No posts match your search.{{else}}No posts yet.{{end}}
|
||||
<a href="/admin/posts/new">Create a post</a>.
|
||||
</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
+1
-1
@@ -3,4 +3,4 @@ title: codegirl.games
|
||||
description: I build games and show the craft along the way, documenting every step in public.
|
||||
---
|
||||
|
||||
I aim to build games and show things I learn along the way by documenting my journey.
|
||||
I document prototypes while I build them: what worked, what broke, what I’d do next.
|
||||
|
||||
+4
-5
@@ -1,10 +1,9 @@
|
||||
---
|
||||
title: About
|
||||
description: A place where I build games and share the development process in public.
|
||||
description: Games in progress. Notes in public. No finished portfolio cosplay.
|
||||
layout: about
|
||||
---
|
||||
|
||||
**Codegirl Games** is a place where I build games and share how they're made: devlogs, experiments, and lessons from real projects.
|
||||
**Codegirl Games** is a workbench, not a highlight reel.
|
||||
|
||||
I'm not pretending to have all the answers. I'm learning in public: comparing languages on the same project, applying patterns from books like *Game Programming Patterns*, and building through trial and error.
|
||||
|
||||
Whether you're starting out, leveling up, or just curious how games get made, you're in the right place.
|
||||
I build prototypes and write down what the systems taught me — language comparisons, pattern experiments, broken paths, and the next attempt. The art stays honest. The learning stays public.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Posts
|
||||
description: Devlogs, lessons, language comparisons, and book reviews from my game development work.
|
||||
description: The build log — every note from prototypes in progress.
|
||||
---
|
||||
|
||||
Devlogs, lessons, and experiments from my game development journey.
|
||||
Dated entries from games I’m actually building.
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
title: "Starting a colony sim, lessons I'm carrying forward"
|
||||
description: "Kicking off a new Odin prototype and the grid-game patterns I already trust: cameras, tilemaps, and how to lay out entity data."
|
||||
date: 2026-07-03
|
||||
type: lesson
|
||||
series: colony-sim-prototype
|
||||
series_order: 1
|
||||
languages: ["odin"]
|
||||
tags: ["odin", "colony-sim", "game-dev"]
|
||||
---
|
||||
|
||||
I'm starting a second prototype, a colony simulation, in [Odin](https://odin-lang.org/) and Raylib. It's early days and there's not much game there yet. This post isn't a feature tour. It's the stuff I already know works because I learned it building the [tower defense prototype](https://github.com/Codegirl-Games/tower-defense-prototype) first.
|
||||
|
||||
If you're starting a top-down grid game, these are the foundations I keep reaching for.
|
||||
|
||||
## The 2D camera is just coordinate math
|
||||
|
||||
A 2D camera doesn't need a library. It's two numbers and two functions:
|
||||
|
||||
- **Offset**: which world point sits at the center of the screen
|
||||
- **Zoom**: how many world pixels map to one screen pixel
|
||||
|
||||
Convert world → screen:
|
||||
|
||||
```
|
||||
screen = (world - offset) * zoom + screen_center
|
||||
```
|
||||
|
||||
Convert screen → world (for mouse input):
|
||||
|
||||
```
|
||||
world = (screen - screen_center) / zoom + offset
|
||||
```
|
||||
|
||||
That's it. Panning moves the offset. The scroll wheel clamps zoom between sensible min/max values. Pan speed gets divided by zoom so movement feels consistent when you're zoomed in.
|
||||
|
||||
The lesson I keep re-learning: **every mouse click must go through `screen_to_world` before you do anything useful.** Selection, movement commands, building placement: all of it. Forget this once and your clicks drift when the camera moves.
|
||||
|
||||
## Tilemaps are flat arrays with helpers
|
||||
|
||||
A tilemap is a width, a height, and a flat buffer. Tile `(x, y)` lives at index `y * width + x`. Wrap access in two helpers and never think about the math again:
|
||||
|
||||
- `tile_index(map, x, y)`: buffer lookup
|
||||
- `tile_in_bounds(map, x, y)`: guard every read and write
|
||||
|
||||
World position to tile coordinate is just `floor(world / tile_size)` on each axis. I used the same pattern in both prototypes. The colony sim added a comment in `tile_in_bounds`, *"learning from another game, this will become handy"*, because I skipped it early in the tower defense project and paid for it later.
|
||||
|
||||
Terrain type can start as a `u8` per tile. A switch or lookup table maps type → color. Don't over-engineer biomes on day one; get the grid drawing and the coordinate conversions right first.
|
||||
|
||||
When drawing, multiply tile size by zoom and cull tiles that fall off-screen. An 80×60 map is 4,800 rectangles, fine for a prototype, but the cull pass is free and keeps the pattern honest for bigger maps.
|
||||
|
||||
## Logical tiles vs visual position
|
||||
|
||||
Grid games have two positions whether you plan for it or not:
|
||||
|
||||
- **Logical position**: which tile the entity occupies (`Tile_Coord{3, 7}`)
|
||||
- **Visual position**: where the sprite actually renders (smoothly interpolated between tile centers)
|
||||
|
||||
The colonist's grid cell updates one step at a time. The circle on screen lerps toward the next tile center each frame. Gameplay stays discrete; motion looks continuous. Mix these up and pathfinding, collision, and selection all get harder.
|
||||
|
||||
I didn't need this in the tower defense game: enemies moved in continuous world space along a path. Colony sims live on tiles. Separate the two early.
|
||||
|
||||
## Struct of arrays vs array of structs
|
||||
|
||||
Both layouts show up in my code. Neither is always wrong.
|
||||
|
||||
**Array of structs (AoS)**, what the tower defense prototype uses:
|
||||
|
||||
```odin
|
||||
enemies: [MAX_ENEMIES]Enemy,
|
||||
```
|
||||
|
||||
Each slot is a full `Enemy` struct: position, health, speed, active flag, all together. Natural to read: `enemy.health -= damage`. Good when you often touch most fields on one entity at once.
|
||||
|
||||
**Struct of arrays (SoA)**, what the colony sim uses:
|
||||
|
||||
```odin
|
||||
Entity_World :: struct {
|
||||
active: [MAX_ENTITIES]bool,
|
||||
position: [MAX_ENTITIES]Tile_Coord,
|
||||
move_state: [MAX_ENTITIES]Move_State,
|
||||
visual_position: [MAX_ENTITIES]rl.Vector2,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Each field is a parallel array across all entities. Good when you update one system at a time (move every entity, then draw every entity) and when entities are sparse (lots of inactive slots in a fixed pool).
|
||||
|
||||
My rule of thumb so far:
|
||||
|
||||
| Reach for AoS when… | Reach for SoA when… |
|
||||
|---|---|
|
||||
| Entities are few and always accessed whole | You iterate one component across many entities |
|
||||
| Struct fits in cache and you're touching most of it | Many slots are inactive (object pool) |
|
||||
| Code clarity matters more than layout | Systems are split (movement, render, AI) |
|
||||
|
||||
Both prototypes use **fixed pools** with an `active` flag, no allocate/free per spawn. That pattern transferred directly from tower defense to colony sim regardless of AoS vs SoA.
|
||||
|
||||
## Entity handles, not raw indices
|
||||
|
||||
The colony sim returns `Entity`, a `distinct u32`, instead of passing array indices around. Internally it's `index + 1`, with `0` meaning invalid. Small thing, but it stops you from accidentally passing a tile coordinate or a mouse value where an entity ID goes.
|
||||
|
||||
## Update and draw stay separate
|
||||
|
||||
Both games follow the same loop shape:
|
||||
|
||||
1. Read input
|
||||
2. Update simulation (`world_update`, `entity_update_movement`)
|
||||
3. Draw (`tilemap_draw`, `entity_draw`)
|
||||
|
||||
Simulation code never calls draw functions. Draw code never changes game state. Obvious, but worth stating because it's the seam that keeps things readable as files multiply.
|
||||
|
||||
## What I'm not writing about yet
|
||||
|
||||
Pathfinding, job queues, resources, building: none of that exists in the repo yet. When there's a full week of work to show, I'll write a proper devlog. For now, the [repo](https://github.com/Codegirl-Games/colony-sim-prototype) is a camera, a tilemap, one colonist, and a right-click move command.
|
||||
|
||||
The game will come. These patterns are the part I'm confident in.
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: "Game Programming Patterns, first impressions"
|
||||
description: "A book review of Robert Nystrom's Game Programming Patterns and how I use it in my tower defense prototype."
|
||||
date: 2026-06-28
|
||||
type: books
|
||||
book: "Game Programming Patterns"
|
||||
tags: ["patterns", "books"]
|
||||
---
|
||||
|
||||
*Game Programming Patterns* by Robert Nystrom is the reference I'm using as I build. The patterns aren't abstract; I'm already applying several of them in my [Odin tower defense prototype](https://github.com/Codegirl-Games/tower-defense-prototype).
|
||||
|
||||
Here is where the book shows up in real code so far.
|
||||
|
||||
## Patterns in the tower defense game
|
||||
|
||||
### Object Pool
|
||||
|
||||
Enemies and projectiles are never allocated per spawn. Both use fixed arrays (`[MAX_ENEMIES]Enemy`, `[MAX_PROJECTILES]Projectile`) with `acquire_enemy` and `acquire_projectile` scanning for inactive slots. A spawned unit resets its fields and sets `active = true`; on death or impact, `active = false` returns the slot to the pool.
|
||||
|
||||
This was the first pattern I reached for, in week one. Every wave can spawn dozens of enemies and towers can fire many projectiles per second. Avoiding allocate/free in the hot path keeps the update loop predictable.
|
||||
|
||||
### Command
|
||||
|
||||
Player input goes through a command layer, not straight into game logic. `gather_commands` and `poll_controls_command` read keyboard and mouse state and return a `Command` value: `Place_Tower`, `Start_Wave`, `Upgrade_Tower`, or `None`. `execute_command` dispatches to the right handler.
|
||||
|
||||
UI buttons, hotkeys, and mouse clicks all produce the same command type. Adding a new input source does not mean rewriting placement or wave logic.
|
||||
|
||||
### Event Queue
|
||||
|
||||
Gold changes do not happen inside combat code directly. When an enemy dies or a wave is cleared, systems call `push_event` with `Enemy_Killed` or `Wave_Survived`. At the end of `update_world`, `process_events` drains the queue and applies gold rewards.
|
||||
|
||||
Combat systems announce what happened; the economy system decides what it costs. That separation kept the update loop readable as towers, waves, and enemy types piled on.
|
||||
|
||||
### State
|
||||
|
||||
The game runs in explicit phases: `Build`, `Combat`, `Game_Over`, and `Victory`. Phase controls what input is accepted (you cannot place towers during combat), when waves can start, and when the simulation stops updating.
|
||||
|
||||
This is a straightforward state machine, not a deep AI behavior tree, but the same idea: one variable drives which rules apply this frame.
|
||||
|
||||
### Update Method
|
||||
|
||||
Each system owns an update function called once per frame from `update_world`: `update_wave`, `update_enemies`, `update_projectiles`, `update_towers`, `update_phase`. No single giant function walks every entity type inline.
|
||||
|
||||
The book's "one game loop, many systems" structure maps cleanly onto separate `.odin` files as the project grew.
|
||||
|
||||
### Game Loop
|
||||
|
||||
`app.odin` runs the classic loop: read input, call `update_world`, render the map, entities, overlay, and controls. Update and draw stay separate; simulation code never calls draw functions.
|
||||
|
||||
### Data Locality
|
||||
|
||||
Enemies and projectiles live in contiguous fixed arrays rather than scattered heap allocations. I iterate the full pool each frame but skip inactive slots. Not as aggressive as a struct-of-arrays layout, but the fixed-buffer approach gives similar benefits: no pointer chasing, no allocator pressure during combat.
|
||||
|
||||
### Type Object (archetype tables)
|
||||
|
||||
Tower and enemy stats live in shared lookup tables (`TOWER_ARCHETYPES`, `ENEMY_DEF`), not duplicated on every instance. A placed tower stores its kind, position, cooldown, and upgrade level; range, damage, cost, and footprint come from the table row.
|
||||
|
||||
Adding a cannon or a tank enemy is mostly a new table entry plus a behavior branch, not a new class hierarchy.
|
||||
|
||||
### Subclass Sandbox
|
||||
|
||||
Tower types differ by `switch t.kind` in `update_towers`: archers spawn homing projectiles, cannons fire ballistic shots with splash, ice towers apply slow on hit. I skipped inheritance trees in favor of enum variants and explicit branches. Nystrom argues this is the right call when you only have a handful of types and the differences are behavioral, not structural.
|
||||
|
||||
### Spatial partition (grid)
|
||||
|
||||
The map is a 2D tile grid (`Blocked`, `Path`, `Build`). Tower placement queries `can_build_at` and footprint overlap against grid cells, not against every entity on the map. A full spatial hash would be overkill at this scale; the grid already gives O(1) tile lookups for build rules.
|
||||
|
||||
## What I have not used yet
|
||||
|
||||
Patterns I expect to need later but have not implemented in this prototype:
|
||||
|
||||
- **Pathfinding** for dynamic routes when tower placement blocks the path
|
||||
- **Behavior trees** or a deeper **State** machine per enemy for complex AI
|
||||
- **Observer** beyond the simple event queue (e.g. UI reacting to stat changes)
|
||||
- **Component** or **Entity-Component-System** if entity types multiply significantly
|
||||
|
||||
## Why I keep the book nearby
|
||||
|
||||
Nystrom names the patterns, explains the tradeoffs, and shows when *not* to use them. That matches how I work: reach for Object Pool and Command early because the problem is obvious; hold off on ECS until the entity count justifies the complexity.
|
||||
|
||||
Expect follow-up posts that go deeper on individual chapters as I apply more patterns to the tower defense game and the colony sim.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
title: "Week 1: Tower defense in Odin, project kickoff"
|
||||
description: "First week building my Odin tower defense prototype: grid map, object pool, commands, events, and a playable build/combat loop."
|
||||
date: 2026-06-12
|
||||
type: devlog
|
||||
series: tower-defense-prototype
|
||||
series_order: 2
|
||||
languages: ["odin"]
|
||||
tags: ["odin", "tower-defense", "devlog"]
|
||||
---
|
||||
|
||||
First devlog in the tower defense series. This week I kicked off the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype) and went from a blank `main.odin` to a playable loop: build towers, start a wave, watch enemies walk the path, lose base health when they leak through.
|
||||
|
||||

|
||||
|
||||
## What I built
|
||||
|
||||
**Day 1: map and loop.** Switched the project to Odin + Raylib. Added a fixed 30×20 tile grid with three tile kinds: blocked grass, a build zone, and a hand-authored L-shaped path. The game window is exactly map-sized (`MAP_W * TILE_SIZE`), and the main loop separates `update_world` from `render_world`.
|
||||
|
||||
**Enemies that move.** Enemies spawn at the path start and step toward waypoints extracted from path tiles on the grid. Movement is simple vector math, no steering, no physics engine.
|
||||
|
||||
**Object pool from day one.** Enemies live in a fixed `[MAX_ENEMIES]Enemy` array. `acquire_enemy` reuses inactive slots instead of allocating every spawn. This is the first [Game Programming Patterns](https://gameprogrammingpatterns.com/object-pool.html) idea I reached for, and it fit Odin naturally: explicit memory, no hidden allocations.
|
||||
|
||||
**Commands and events.** Input goes through a small command layer (`Place_Tower`, `Start_Wave`). Gold changes go through an event queue (`Enemy_Killed`, `Wave_Survived`) so combat logic does not touch the economy directly. That separation made the update loop easier to read even before I had much gameplay.
|
||||
|
||||
**Towers, waves, and phases.** By the end of the week I had one tower type (Archer) backed by a `Tower_Archetype` table: range, damage, fire rate, cost in one place. The game alternates between **Build** and **Combat** phases. Press `N` to start a wave; a spawner drips out enemies with scaling health and speed. Towers pick the nearest target in range and apply damage directly. Survive the wave, earn gold, place more towers.
|
||||
|
||||
## What worked
|
||||
|
||||
- Odin's struct enums and fixed arrays made the object pool straightforward, no fighting the language
|
||||
- Data-driven tower archetypes: adding stats later did not require rewriting placement logic
|
||||
- Command + event split kept `update_world` readable as systems piled on
|
||||
- Raylib got something on screen fast; I spent the week on game logic, not boilerplate
|
||||
|
||||
## What broke
|
||||
|
||||
- **No projectiles yet.** Towers subtract health instantly. It plays, but it does not look or feel like a tower defense game yet
|
||||
- **Path is not pathfinding.** `build_path` scans the grid for path tiles in row order. Fine for a hand-drawn map, useless once I want procedural levels or tower placement that reroutes enemies
|
||||
- **HUD lives in the renderer.** Gold, base HP, wave count, and phase hints are drawn inline in `render_world`. Works for now, will get messy
|
||||
- **Tower placement during combat.** The command gatherer had a duplicate mouse handler that let you place towers mid-fight. Small bug, easy miss
|
||||
|
||||
## Repo snapshot
|
||||
|
||||
By June 12 the `game/` package had separate files for map, path, enemies, towers, waves, events, commands, and rendering: about 450 lines added across the week. Not pretty, but the skeleton for every port (C, C++) is visible: fixed pools, explicit phases, data tables instead of inheritance trees.
|
||||
|
||||
Next: projectiles, split out the HUD, and more tower types.
|
||||
|
||||
<figure class="post__video">
|
||||
<iframe
|
||||
class="post__iframe"
|
||||
src="https://www.youtube-nocookie.com/embed/MKBSuTkClys"
|
||||
title="Week 1: Tower defense in Odin, project kickoff"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</figure>
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: "Week 2: Projectiles, footprints, and a UI detour"
|
||||
description: "Second week on the Odin tower defense prototype: homing projectiles, multi-tile towers, render split, and learning to stick with Raylib for UI."
|
||||
date: 2026-06-19
|
||||
type: devlog
|
||||
series: tower-defense-prototype
|
||||
series_order: 3
|
||||
languages: ["odin"]
|
||||
tags: ["odin", "tower-defense", "devlog"]
|
||||
---
|
||||
|
||||
Week two on the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype). Last week ended with instant-hit archers and a keyboard-driven HUD. This week the game started to look like a tower defense: arrows fly, towers occupy real space on the grid, and I got my first proper UI, after a brief and expensive detour through third-party UI libraries.
|
||||
|
||||
## What I built
|
||||
|
||||
**Projectiles.** Archers no longer subtract health on the frame they fire. Towers spawn homing projectiles from a second object pool (`[MAX_PROJECTILES]Projectile`), track the target enemy by slot index, and deal damage on impact. Same pattern as the enemy pool from week one: acquire slot, reset fields, mark inactive when done.
|
||||
|
||||

|
||||
|
||||
**Render split.** `render_world` became a thin orchestrator: `render_map`, `render_enemies`, `render_towers`, `render_projectiles`. Each system owns its draw calls. Small refactor, big readability win as files grew.
|
||||
|
||||
**Multi-tile towers.** Tower archetypes gained `footprint_w` and `footprint_h`. The archer is 1×2 tiles, taller than a single cell. Placement now checks whether the full rectangle fits on build tiles and does not overlap other towers. Rendering draws a gold rectangle sized to the footprint instead of a fixed 28×28 square.
|
||||
|
||||
**Memory cleanup.** Added explicit `delete` calls for dynamic arrays (`path`, `towers`, `events`) when the game loop exits. Odin will not save you from leaking if you allocated with `append`.
|
||||
|
||||
**Overlay bar.** Moved gold and base health out of scattered `DrawText` calls into `overlay.odin`, a top bar with consistent padding and colors. Wave count and combat hints stayed in `hud.odin` for now.
|
||||
|
||||
**Control panel.** Bottom bar with clickable buttons: select Archer, start wave. `poll_controls_command` feeds into the existing command layer so UI clicks and keyboard shortcuts share one path. Added `utils.odin` with a reusable `draw_button` helper for centered labels.
|
||||
|
||||
## What worked
|
||||
|
||||
- Projectile pool mirrored the enemy pool: copy the pattern, ship faster
|
||||
- Footprint-based placement forced me to think in grid coordinates early; multi-tower-type layouts will need this anyway
|
||||
- Command layer absorbed UI input cleanly: buttons return `Command` values just like keyboard handlers
|
||||
- Rip-and-replace on Clay was painful but left me with a simpler codebase than I started with
|
||||
|
||||
## What broke
|
||||
|
||||
- **UI library detour.** Tried ImGui bindings, then [Clay](https://github.com/nicklockwood/clay) for two days. Gold moved to Clay, start-wave became a Clay button, then I deleted all of it and rewrote the overlay in pure Raylib. Lesson: for a small game HUD, immediate-mode Raylib is enough. Do not import a layout engine until you have a layout problem.
|
||||
- **Tower selection half-wired.** The Archer button sets `world.selected_tower`, but `execute_command` still hardcodes `.Archer` on placement. UI looks done; logic is not.
|
||||
- **Split HUD.** Gold and health live in `overlay.odin`, wave/enemy count in `hud.odin`, controls in `controls.odin`. Three files for one screen; next cleanup pass needed.
|
||||
- **Range bug (later fix).** A typo in the distance function made archer range longer than intended. Caught at the end of the week; fix landed June 26.
|
||||
|
||||
## Repo snapshot
|
||||
|
||||
June 13–19 added about 470 net lines across 18 files. New modules: `projectile.odin`, `overlay.odin`, `controls.odin`, `utils.odin`. Still one tower type, but the archer now shoots, occupies space, and has a shop button.
|
||||
|
||||
Next: end screens, fullscreen, more tower types, and enemy variety.
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
title: "Week 3: Fullscreen, variety, and splash damage"
|
||||
description: "Third week on the Odin tower defense prototype: end screens, render-to-texture scaling, three tower types, enemy archetypes, wave recipes, upgrades, and cannon splash."
|
||||
date: 2026-06-26
|
||||
type: devlog
|
||||
series: tower-defense-prototype
|
||||
series_order: 4
|
||||
languages: ["odin"]
|
||||
tags: ["odin", "tower-defense", "devlog"]
|
||||
---
|
||||
|
||||
Week three on the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype). Week two left me with one tower, one enemy, and a HUD split across three files. This week the prototype started feeling like a game: win/lose screens, resizable fullscreen, three tower types, three enemy types, wave recipes, and cannons that splash.
|
||||
|
||||
## What I built
|
||||
|
||||
**End screens and unified overlay.** Added `endscreen.odin` for game over and victory overlays. Moved wave count and enemy totals into `overlay.odin` and deleted the leftover `hud.odin` split. One top bar for economy and combat status; end screens dim the world and show the result.
|
||||
|
||||
**Fullscreen via render texture.** New `display.odin` renders the fixed 960×640 game world into a `RenderTexture`, then scales it to whatever window size the player uses. `F11` toggles fullscreen. Mouse coordinates go through `game_mouse()` so clicks still map to grid tiles when the viewport letterboxes. The game logic stays pixel-fixed; only presentation scales.
|
||||
|
||||
**Three tower types.** Archer (homing arrows), Cannon (2×2 footprint, ballistic arc), Ice (slow effect, smaller footprint). Each has its own archetype row: cost, range, fire rate, footprint, upgrade caps. Shop buttons in `controls.odin` grew to match: select tower, click the map, place.
|
||||
|
||||
**Tower preview.** Hovering the build grid while a tower is selected draws a ghost footprint before you commit gold. Small UX win that made placement feel less like guessing.
|
||||
|
||||
**Enemy variety.** Replaced the single grunt with archetypes: Grunt, Runner (fast, fragile), Tank (slow, thick). Each scales health and speed with wave number and gets its own color.
|
||||
|
||||
**Wave recipes.** Waves are no longer `5 + wave * 2` of the same enemy. `build_wave_recipe` returns an ordered list of `(kind, count)` entries: wave 1 is six grunts, wave 2 mixes grunts and runners, later waves add tanks. The spawner drips through the recipe entry by entry.
|
||||
|
||||
**Tower upgrades.** Towers track `upgrade_level`. Stats scale via `tower_stats_at_level`: extra damage and range per level, paid from gold during build phase.
|
||||
|
||||
**Ballistic projectiles and splash.** Projectiles gained a `Projectile_Mode`: homing for archers, ballistic for cannons. Cannon shots fly in a fixed direction; on impact they call `apply_splash_damage` in a radius defined on the archetype. First time area damage changed how I thought about placement: kill zones, not just single targets.
|
||||
|
||||
**World refactor.** Split `World` into nested structs for economy, combat, and wave state. Gold and enemies moved out of flat fields. Cleaner ownership before more systems land.
|
||||
|
||||
## What worked
|
||||
|
||||
- Render-to-texture scaling solved fullscreen without rewriting every coordinate
|
||||
- Enemy and tower archetype tables made adding Runner/Tank/Cannon mostly data changes
|
||||
- Wave recipes are easy to read and tweak; no code change to reshuffle wave 3
|
||||
- Projectile modes reused the same pool; ballistic and homing share acquire/update/render paths
|
||||
|
||||
## What broke
|
||||
|
||||
- **Half-finished tower rollout.** Cannon and Ice landed before projectiles and shop buttons caught up; several commits of "no projectiles, no button" in the log
|
||||
- **Wave recipe memory leak.** Forgetting to `delete` the old recipe before building a new spawner leaked dynamic arrays every wave start. Fixed same day
|
||||
- **Path length check inside the enemy loop.** A `return` on short paths aborted the entire update instead of skipping one enemy. Moved the guard outside the loop
|
||||
- **Range typo.** A bug in `distance` made archer range longer than intended, caught and fixed at the end of the week along with a range buff
|
||||
|
||||
## Repo snapshot
|
||||
|
||||
June 20–26 added about 650 net lines across 16 files. New modules: `display.odin`, `endscreen.odin`. Deleted: `hud.odin`. Three towers, three enemies, two projectile modes, and a win/lose loop.
|
||||
|
||||
Next: tests, more refactors, and cleaning up the control panel as tower count grows.
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
title: "Week 4: Tests, refactors, and data tables"
|
||||
description: "Fourth week on the Odin tower defense prototype: splitting tower placement, table-driven defs, first tests, and undoing a nested world refactor."
|
||||
date: 2026-06-29
|
||||
type: devlog
|
||||
series: tower-defense-prototype
|
||||
series_order: 5
|
||||
languages: ["odin"]
|
||||
tags: ["odin", "tower-defense", "devlog"]
|
||||
---
|
||||
|
||||
Week four on the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype). Week three shipped fullscreen, three tower types, enemy variety, and splash damage. This week was quieter on features and heavier on structure: splitting files, pushing logic into data tables, and writing my first tests.
|
||||
|
||||
## What I built
|
||||
|
||||
**Split tower logic.** `tower.odin` had grown to handle combat, stats, rendering, and placement. I pulled placement into `tower_placement.odin`: footprint checks, overlap tests, `try_place_tower`, ghost preview. Combat and archetypes stay in `tower.odin`. Placement is geometry; combat is timing and targeting. Keeping them separate made both files easier to navigate.
|
||||
|
||||
**Data tables over switch statements.** Several `get_*` functions became lookup tables:
|
||||
|
||||
- `TILE_COLORS` replaced a `tile_color` switch
|
||||
- `ENEMY_DEF` replaced per-kind switch logic with base stats, per-wave scaling, draw radius, and gold reward in one row per enemy
|
||||
- Tower archetypes dropped a redundant `kind` field and gained a `color` column for rendering
|
||||
|
||||
Tweaking tank gold from 5 to 10 or archer cost became a one-line edit instead of hunting through switch arms.
|
||||
|
||||
**Constants consolidation.** UI layout lived in helper functions that recalculated button rectangles every frame. Moved to `constants.odin`: `ARCHER_BUTTON_RECT`, `START_WAVE_BUTTON_RECT`, `STARTING_GOLD`, `STARTING_BASE_HEALTH`, overlay colors, control bar dimensions. `controls.odin` got shorter and the layout stopped drifting.
|
||||
|
||||
**First tests.** Added `math2d_test.odin` (distance, `move_toward`) and `wave_test.odin` (start wave, clear wave, recipe progression). Small suite, but it caught a wave recipe memory leak during gold rebalancing: the kind of bug I'd fixed once by hand and reintroduced during a refactor.
|
||||
|
||||
**Economy pass.** Gold rewards moved into enemy definitions. Tank kills pay more than grunts. Tower costs live in archetype rows. Balanced enough to play-test without obvious snowball or stall.
|
||||
|
||||
**World struct simplified.** Flattened `base_health` and map init into cleaner helpers. Started the week by reverting the nested `Economy` / `Combat` / `Wave` sub-structs from week three, back to a flat `World` with direct field access.
|
||||
|
||||
## What worked
|
||||
|
||||
- Splitting placement from combat scaled better as tower logic grew
|
||||
- Table-driven defs made adding and tuning enemy/tower stats mostly data changes
|
||||
- Tests paid for themselves immediately on wave spawner edge cases
|
||||
- Flat `World` struct was easier to reason about than nested sub-structs for a game this size
|
||||
|
||||
## What broke
|
||||
|
||||
- **Nested world refactor, reverted.** Week three split `World` into `Economy`, `Combat`, and `Wave` sub-structs. Looked clean on paper. Every system suddenly needed `world.combat.enemies` instead of `world.enemies`, accessors multiplied, and nothing got simpler. Reverted on June 27. Don't refactor structure until the current shape is actually hurting you, and wait until you have tests.
|
||||
|
||||
- **Test cleanup is manual.** Odin tests that allocate dynamic arrays need explicit `defer delete`. Forgot once; leak showed up in the test runner, not the game.
|
||||
|
||||
## Repo snapshot
|
||||
|
||||
June 27–29 touched 14 files, ~490 net lines. New modules: `tower_placement.odin`, `math2d_test.odin`, `wave_test.odin`. Three towers, three enemies, wave recipes, upgrades, splash damage, fullscreen, end screens, plus a test suite to build on.
|
||||
|
||||
Next: pathfinding, more tower shop wiring, ice slow polish, and whatever breaks once I add a fourth tower type.
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
title: "Building a browser deckbuilder in vanilla JavaScript"
|
||||
description: "How a birthday gift became a 6,000-line roguelike deckbuilder: state machines, commands, data tables, and what the commit history looks like when you ship anyway."
|
||||
date: 2026-07-04
|
||||
type: lesson
|
||||
languages: ["javascript"]
|
||||
tags: ["javascript", "patterns", "deckbuilder", "web"]
|
||||
---
|
||||
|
||||
Last year I built a parody *Slay the Spire* game in the browser as a birthday gift for [ThePrimeagen](https://www.twitch.tv/theprimeagen). It started as a joke. The [repo](https://github.com/codegirl-007/theprimeagen-spire) ended up at ~6,400 lines of JavaScript across 60 files, with two acts, dozens of cards, community-written birthday messages, and a full roguelike loop you can play without a build step.
|
||||
|
||||
You can play it at <a href="https://theprimeagenbirthday.com" target="_blank" rel="noopener noreferrer">theprimeagenbirthday.com</a>.
|
||||
|
||||
This post is not a feature tour. It is how the project was built, what the git history actually shows, and where *Game Programming Patterns* shows up outside my Odin prototypes.
|
||||
|
||||
## What shipped
|
||||
|
||||
The game is a static site: `index.html`, ES modules, CSS split by screen. No React, no bundler in production (npm was added briefly for tests, then removed).
|
||||
|
||||
The loop matches StS closely enough to feel familiar:
|
||||
|
||||
- Branching map with battles, elites, shops, rest sites, and events
|
||||
- Turn-based combat with energy, block, weak/vulnerable, and intents
|
||||
- Deck building: strike/defend staples plus dev-themed cards (`Terminal Coffee Rush`, `Production Deploy`, `Code Review`)
|
||||
- Relics with hook functions (`onTurnStart`, `onDamageTaken`, etc.)
|
||||
- Two acts with different enemy rosters and map layouts
|
||||
- Win/lose screens, mid-run saves to `localStorage`, and a pre-launch countdown that blocked play until September 9, 2025
|
||||
|
||||
The flavor is extremely online. Enemies are stream/community in-jokes. Events quote Lewis and Tolkien. The victory screen unlocks birthday messages from people who sent notes for Prime. That part is personal. The architecture underneath is reusable.
|
||||
|
||||
## What the commits look like
|
||||
|
||||
There are 88 commits from August 30, 2025 to March 11, 2026. Rough phases:
|
||||
|
||||
**Week one (Aug 30–Sep 2): gameplay exists.** The initial commit already added ~7,600 lines: map, battle engine, card UI, styling. After that it was iteration: card costs, enemy tuning, keyboard vs mouse fixes, double-tap to play cards, swipe sounds, acts, and a commit literally titled `think this is the final gameplay commit`.
|
||||
|
||||
**Week two (Sep 3–Sep 10): content and polish.** Birthday messages landed in batches (`Birthday Messages`, `phpeepee!`, `casey!`, `DHH!`, `ken wheeler and AOP`). Bug fixes for deck initialization, event HP/energy leaks, block reset between turns. UI passes on the battle screen and welcome message. Balance commits: `nerf act 2`, `nerf dax`, `Un-nerf act 2w`.
|
||||
|
||||
**Week two, structure (Sep 8):** `implement state machine`. Gameplay was already there; this commit extracted map, battle, shop, rest, event, victory, defeat, and relic selection into discrete states. That refactor made the rest of the project maintainable.
|
||||
|
||||
**Quiet period, then March 2027 prep.** From September to March the repo sat mostly idle. Then a concentrated refactor week: split client vs shared code, moved data files, serialized shop/reward state for future networking, WebP assets, save behavior fixes (`fix block leak between enemy turn`), and a 1,700-line Cloudflare co-op design doc (`tutorial.md`) for authoritative multiplayer later.
|
||||
|
||||
The history is not a clean agile epic. It is burst development, meme commit messages, and a second pass when you already know the game works.
|
||||
|
||||
## Architecture that held up
|
||||
|
||||
### State machine for screens
|
||||
|
||||
`GameStateMachine` registers one class per screen: `MAP`, `BATTLE`, `REWARD`, `SHOP`, `REST`, `EVENT`, `VICTORY`, `DEFEAT`, `RELIC_SELECTION`. Each state implements `enter`, `exit`, `render`, and optional save/restore hooks.
|
||||
|
||||
Battle mid-run resume works because `BattleState.getSaveData()` persists the enemy, flags, and `battleInProgress`. On load, bootstrap checks whether you were mid-fight and routes back into combat instead of dropping you on the map with orphaned state.
|
||||
|
||||
This is the same *State* pattern I use for Build/Combat phases in the [tower defense prototype](https://github.com/Codegirl-Games/tower-defense-prototype), applied to UI flow instead of simulation phases.
|
||||
|
||||
### Command pattern for input
|
||||
|
||||
Player actions go through command objects (`PlayCardCommand`, `EndTurnCommand`, `MapMoveCommand`, etc.) executed by a `CommandInvoker`. Keyboard shortcuts, mouse clicks, and shop buttons all funnel into the same paths.
|
||||
|
||||
That separation mattered when input got fancy: single number press raises a card, double press plays it. InputManager handles code review picks, shop purchases, and map navigation without duplicating game rules in event listeners.
|
||||
|
||||
### Data tables for content
|
||||
|
||||
Cards, enemies, relics, and map nodes live in plain JS objects:
|
||||
|
||||
```javascript
|
||||
coffee_rush: {
|
||||
id: "coffee_rush",
|
||||
name: "Terminal Coffee Rush",
|
||||
cost: 0,
|
||||
type: "skill",
|
||||
effect: (ctx) => { /* ... */ },
|
||||
}
|
||||
```
|
||||
|
||||
Adding content means adding rows, not subclass trees. Enemy AI is mostly `(turn) => ({ type, value })` functions. Relics use optional hook objects. The `Code Review` card sets `pendingCodeReview` on the root; battle render and InputManager know how to show the pick-one-of-three overlay.
|
||||
|
||||
Same idea as `TOWER_ARCHETYPES` and `ENEMY_DEF` in Odin: behavior stays in code, stats and identity stay in tables.
|
||||
|
||||
### Shared vs client split (March refactor)
|
||||
|
||||
Late refactors moved simulation-ish code under `src/shared/` (`engine/`, `data/`, `game/`) and kept DOM/render/input under `src/client/`. The goal was a future where a Cloudflare Durable Object owns authoritative state while the browser keeps rendering.
|
||||
|
||||
Multiplayer never shipped. The split still made the codebase easier to reason about: battle math does not live beside `innerHTML` templates.
|
||||
|
||||
## Bugs the commits keep fixing
|
||||
|
||||
Roguelikes hide nasty state bugs. This repo is no exception:
|
||||
|
||||
- **Block leaking between turns.** Fixed in separate commits for player and enemy turn boundaries. Block must reset at turn start; missing one side means silent damage inflation.
|
||||
- **Mid-battle saves.** Saving `_battleInProgress`, enemy HP, and hand state to `localStorage`, then restoring on reload. Easy to get wrong when most testing happens in one sitting.
|
||||
- **Event modifiers vs combat.** `fix health bug and energy bug inside events` shows how one-shot screens can corrupt run state if they touch player stats outside the battle engine's expectations.
|
||||
|
||||
The `?screen=battle` and `?screen=shop` URL params plus mock player data in `bootstrap.js` were added so individual screens could be tested without playing to them every time. Worth copying for any UI-heavy browser game.
|
||||
|
||||
## Performance passes
|
||||
|
||||
Late commits focused on load and layout cost: WebP conversion, dropping aggressive image preload, caching swipe sound, reducing layout churn during battle animations. For a static birthday game, this was optional polish. For a public deploy on slow mobile networks, it matters.
|
||||
|
||||
## How this connects to my other work
|
||||
|
||||
I keep [*Game Programming Patterns*](https://gameprogrammingpatterns.com/) nearby while building the Odin prototypes. This JavaScript project applies several of the same ideas in a different shape:
|
||||
|
||||
| Pattern | Here | Tower defense (Odin) |
|
||||
|---|---|---|
|
||||
| State | Screen flow (map, battle, shop) | Build / Combat / Game Over |
|
||||
| Command | PlayCard, EndTurn, MapMove | Place_Tower, Start_Wave |
|
||||
| Update method | Per-state `render()` + battle engine steps | `update_enemies`, `update_towers`, etc. |
|
||||
| Data locality | Plain objects, shuffle/draw on arrays | Fixed pools, archetype tables |
|
||||
|
||||
Different language, same instinct: separate input from rules, separate screens from simulation, push content into data.
|
||||
|
||||
## What I would do differently
|
||||
|
||||
- **State machine earlier.** Gameplay landed first; the refactor on Sep 8 touched 15 files. Starting with states would have hurt day-one momentum but saved mid-project pain.
|
||||
- **Smaller render files from the start.** `render.js` was enormous before feature folders (`battleRender`, `mapRender`, etc.) split it up.
|
||||
- **Link the live site in the README.** The game runs at <a href="https://theprimeagenbirthday.com" target="_blank" rel="noopener noreferrer">theprimeagenbirthday.com</a>; the repo should say that up front next to the clone instructions.
|
||||
- **Move the repo under the studio org.** It still lives at `codegirl-007/theprimeagen-spire`; my prototypes now live under [Codegirl-Games](https://github.com/Codegirl-Games).
|
||||
|
||||
## Worth a post?
|
||||
|
||||
Yes, but as a lesson, not a weekly devlog. There is no neat week-by-week timeline after launch week. The value is architectural: how far vanilla JS gets you, what patterns transfer to Odin, and an honest commit log that includes `Ligma balls` next to `implement state machine`.
|
||||
|
||||
Play at <a href="https://theprimeagenbirthday.com" target="_blank" rel="noopener noreferrer">theprimeagenbirthday.com</a>. To explore the code, start at `src/client/app/bootstrap.js` for the state registration, `src/shared/engine/battle.js` for combat rules, and `src/shared/data/cards.js` for content shape. Multiplayer design notes are in `tutorial.md` if you want to see the planned next step that never left the design doc.
|
||||
|
||||
The game was a gift. The structure is the part worth stealing for the next project.
|
||||
@@ -1,14 +1,26 @@
|
||||
baseURL = 'https://codegirl.games/'
|
||||
languageCode = 'en-me'
|
||||
title = 'codegirl.games'
|
||||
theme = 'codegirl'
|
||||
defaultContentLanguage = 'en'
|
||||
|
||||
[languages]
|
||||
[languages.en]
|
||||
locale = 'en-US'
|
||||
label = 'English'
|
||||
weight = 1
|
||||
|
||||
[params]
|
||||
logo = 'codegirl.games'
|
||||
logo_image = '/images/logo.png'
|
||||
logo_image = '/images/logo-header.png'
|
||||
logo_image_full = '/images/logo.png'
|
||||
description = 'I build games and show the craft along the way, documenting every step in public.'
|
||||
tagline = 'Building games and showing what I learn'
|
||||
author = 'Codegirl Games'
|
||||
og_image = '/images/og-default.png'
|
||||
|
||||
[taxonomies]
|
||||
tag = 'tags'
|
||||
series = 'series'
|
||||
|
||||
[markup]
|
||||
[markup.goldmark]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,28 @@
|
||||
{{ define "main" }}
|
||||
<article class="about">
|
||||
<div class="about__banner">
|
||||
<div class="about__banner-inner container">
|
||||
{{ with .Site.Params.logo_image_full }}
|
||||
<img
|
||||
class="about__logo"
|
||||
src="{{ . | relURL }}"
|
||||
alt="{{ $.Site.Params.logo | default $.Site.Title }}"
|
||||
width="512"
|
||||
height="512"
|
||||
decoding="async"
|
||||
>
|
||||
{{ end }}
|
||||
<div class="about__intro">
|
||||
<h1 class="about__title">{{ .Title }}</h1>
|
||||
{{ with .Description }}
|
||||
<p class="about__lead">{{ . }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about__body container container--narrow content">{{ .Content }}</div>
|
||||
<div class="about__cta-wrap container container--narrow">
|
||||
<a class="stage__cta" href="{{ "/posts/" | relURL }}">Open the build log</a>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
@@ -1,11 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.LanguageCode }}">
|
||||
<html lang="{{ with .Site.Language.Locale }}{{ . }}{{ else }}en{{ end }}">
|
||||
<head>
|
||||
{{ partial "head.html" . }}
|
||||
</head>
|
||||
<body class="page">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
{{ partial "header.html" . }}
|
||||
<main class="page__main">
|
||||
<main id="main" class="page__main">
|
||||
{{ block "main" . }}{{ end }}
|
||||
</main>
|
||||
{{ partial "footer.html" . }}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
{{ define "main" }}
|
||||
<section class="section container">
|
||||
<header class="section__header">
|
||||
<p class="section__eyebrow">Index</p>
|
||||
<h1 class="section__title">{{ .Title }}</h1>
|
||||
{{ with .Content }}
|
||||
<div class="section__intro content">{{ . }}</div>
|
||||
{{ end }}
|
||||
</header>
|
||||
<ul class="post-list">
|
||||
<ol class="log__list">
|
||||
{{ range .Pages.ByDate.Reverse }}
|
||||
<li class="post-list__item">
|
||||
{{ partial "post-card.html" . }}
|
||||
<li class="log__item">
|
||||
<a class="log__row" href="{{ .RelPermalink }}">
|
||||
<time class="log__date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "060102" }}</time>
|
||||
<span class="log__type">{{ with .Params.type }}{{ . }}{{ else }}note{{ end }}</span>
|
||||
<span class="log__name">{{ .Title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</ol>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{ define "main" }}
|
||||
<article class="post container">
|
||||
<article class="post container container--narrow">
|
||||
<header class="post__header">
|
||||
<p class="post__meta">
|
||||
{{ with .Params.type }}<span class="post__type">{{ humanize . }}</span>{{ end }}
|
||||
@@ -10,8 +10,14 @@
|
||||
{{ with .Params.languages }}
|
||||
<p class="post__tags">{{ delimit . ", " }}</p>
|
||||
{{ end }}
|
||||
{{ with .Params.series }}
|
||||
<p class="post__series">Part of {{ . }}</p>
|
||||
{{ with .GetTerms "series" }}
|
||||
{{ range . }}
|
||||
<p class="post__series">Part of <a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></p>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{ with .Params.series }}
|
||||
<p class="post__series">Part of <a href="{{ printf "/series/%s/" . | relURL }}">{{ replace . "-" " " | title }}</a></p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ with .Params.video_url }}
|
||||
<figure class="post__video">
|
||||
@@ -29,5 +35,27 @@
|
||||
{{ end }}
|
||||
</header>
|
||||
<div class="post__body content">{{ .Content }}</div>
|
||||
|
||||
{{ with .Params.series }}
|
||||
{{ $seriesName := . }}
|
||||
{{ $siblings := where (where $.Site.RegularPages "Section" "eq" "posts") "Params.series" "eq" $seriesName }}
|
||||
{{ $sorted := $siblings.ByDate }}
|
||||
{{ if gt (len $sorted) 1 }}
|
||||
<nav class="series-nav" aria-label="More in this series">
|
||||
<p class="series-nav__label">In this series</p>
|
||||
<ol class="series-nav__list">
|
||||
{{ range $sorted }}
|
||||
<li class="series-nav__item{{ if eq $.RelPermalink .RelPermalink }} series-nav__item--current{{ end }}">
|
||||
{{ if eq $.RelPermalink .RelPermalink }}
|
||||
<span class="series-nav__current">{{ .Title }}</span>
|
||||
{{ else }}
|
||||
<a class="series-nav__link" href="{{ .RelPermalink }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ol>
|
||||
</nav>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{{ define "main" }}
|
||||
<section class="section container">
|
||||
<header class="section__header">
|
||||
<p class="section__eyebrow">Series</p>
|
||||
<h1 class="section__title">Active builds</h1>
|
||||
<p class="section__intro">Prototype threads I’m writing while I build them.</p>
|
||||
</header>
|
||||
<div class="workbench__grid">
|
||||
{{ range .Pages }}
|
||||
<a class="workbench__cell" href="{{ .RelPermalink }}">
|
||||
<span class="workbench__count">{{ len .Pages }} {{ if eq (len .Pages) 1 }}log{{ else }}logs{{ end }}</span>
|
||||
<span class="workbench__name">{{ .LinkTitle }}</span>
|
||||
{{ with .Description }}
|
||||
<span class="workbench__desc">{{ . }}</span>
|
||||
{{ end }}
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{ define "main" }}
|
||||
<section class="section container">
|
||||
<header class="section__header">
|
||||
<p class="section__eyebrow">{{ if eq .Data.Singular "series" }}Series{{ else if eq .Data.Singular "tag" }}Tag{{ else }}{{ .Data.Singular | humanize }}{{ end }}</p>
|
||||
<h1 class="section__title">{{ .Title }}</h1>
|
||||
{{ with .Content }}
|
||||
<div class="section__intro content">{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ with .Description }}
|
||||
{{ if not $.Content }}
|
||||
<p class="section__intro">{{ . }}</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</header>
|
||||
<ul class="post-list">
|
||||
{{ range .Pages.ByDate.Reverse }}
|
||||
<li class="post-list__item">
|
||||
{{ partial "post-card.html" . }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -1,22 +1,86 @@
|
||||
{{ define "main" }}
|
||||
<section class="hero container">
|
||||
<p class="hero__eyebrow">{{ .Site.Params.tagline }}</p>
|
||||
<h1 class="hero__title">{{ .Title }}</h1>
|
||||
{{ with .Content }}
|
||||
<div class="hero__body content">{{ . }}</div>
|
||||
{{ $featuredHref := "/posts/" | relURL }}
|
||||
{{ with .Params.featured_series }}
|
||||
{{ $featuredHref = printf "/series/%s/" . | relURL }}
|
||||
{{ end }}
|
||||
{{ $hasSeries := gt (len .Site.Taxonomies.series) 0 }}
|
||||
{{ $posts := where .Site.RegularPages "Section" "eq" "posts" }}
|
||||
|
||||
<section class="stage{{ if not .Params.featured_image }} stage--plain{{ end }}">
|
||||
{{ with .Params.featured_image }}
|
||||
<div class="stage__plane" aria-hidden="true">
|
||||
<img
|
||||
class="stage__image"
|
||||
src="{{ . | relURL }}"
|
||||
alt=""
|
||||
width="960"
|
||||
height="639"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
<div class="stage__veil" aria-hidden="true"></div>
|
||||
{{ end }}
|
||||
</section>
|
||||
<section class="latest container">
|
||||
<header class="latest__header">
|
||||
<h2 class="latest__title">Latest</h2>
|
||||
<a class="latest__link" href="{{ "/posts/" | relURL }}">All posts →</a>
|
||||
</header>
|
||||
<ul class="post-list">
|
||||
{{ range where .Site.RegularPages "Section" "eq" "posts" | first 12 }}
|
||||
<li class="post-list__item">
|
||||
{{ partial "post-card.html" . }}
|
||||
</li>
|
||||
<div class="stage__slab container">
|
||||
<p class="stage__eyebrow">{{ .Site.Params.tagline }}</p>
|
||||
<h1 class="stage__title">{{ .Title }}</h1>
|
||||
{{ with .Content }}
|
||||
<div class="stage__body">{{ . }}</div>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<div class="stage__actions">
|
||||
{{ if or .Params.featured_series .Params.featured_label }}
|
||||
<a class="stage__cta" href="{{ $featuredHref }}">
|
||||
{{ with .Params.featured_label }}{{ . }}{{ else }}Enter series{{ end }}
|
||||
</a>
|
||||
{{ end }}
|
||||
<a class="stage__{{ if or $.Params.featured_series $.Params.featured_label }}ghost{{ else }}cta{{ end }}" href="{{ "/posts/" | relURL }}">Read the log</a>
|
||||
<a class="stage__ghost" href="{{ "/about/" | relURL }}">About</a>
|
||||
</div>
|
||||
{{ with .Params.featured_caption }}
|
||||
<p class="stage__caption">{{ . }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ if $hasSeries }}
|
||||
<section class="workbench container" aria-label="Active series">
|
||||
<header class="workbench__header">
|
||||
<h2 class="workbench__title">Active builds</h2>
|
||||
</header>
|
||||
<div class="workbench__grid">
|
||||
{{ range $term, $pages := .Site.Taxonomies.series }}
|
||||
{{ $termPage := $.Site.GetPage (printf "/series/%s" $term) }}
|
||||
<a class="workbench__cell" href="{{ with $termPage }}{{ .RelPermalink }}{{ else }}{{ printf "/series/%s/" $term | relURL }}{{ end }}">
|
||||
<span class="workbench__count">{{ len $pages }} {{ if eq (len $pages) 1 }}log{{ else }}logs{{ end }}</span>
|
||||
<span class="workbench__name">{{ with $termPage }}{{ .LinkTitle }}{{ else }}{{ replace $term "-" " " | title }}{{ end }}</span>
|
||||
{{ with $termPage }}
|
||||
{{ with .Description }}
|
||||
<span class="workbench__desc">{{ . }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ if gt (len $posts) 0 }}
|
||||
<section class="log container">
|
||||
<header class="log__header">
|
||||
<h2 class="log__title">Build log</h2>
|
||||
<a class="log__all" href="{{ "/posts/" | relURL }}">All entries →</a>
|
||||
</header>
|
||||
<ol class="log__list">
|
||||
{{ range first 12 $posts }}
|
||||
<li class="log__item">
|
||||
<a class="log__row" href="{{ .RelPermalink }}">
|
||||
<time class="log__date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "060102" }}</time>
|
||||
<span class="log__type">{{ with .Params.type }}{{ . }}{{ else }}note{{ end }}</span>
|
||||
<span class="log__name">{{ .Title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ol>
|
||||
</section>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=Pixelify+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
@@ -1,9 +1,14 @@
|
||||
<footer class="site-footer container">
|
||||
<div class="site-footer__inner">
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer__inner container">
|
||||
<p class="site-footer__mark">{{ .Site.Params.logo | default .Site.Title }}</p>
|
||||
<p class="site-footer__tagline">{{ .Site.Params.tagline }}</p>
|
||||
<nav class="site-footer__nav" aria-label="Footer">
|
||||
<a class="site-footer__link" href="{{ "/posts/" | relURL }}">Posts</a>
|
||||
<a class="site-footer__link" href="{{ "/series/" | relURL }}">Series</a>
|
||||
<a class="site-footer__link" href="{{ "/about/" | relURL }}">About</a>
|
||||
{{ with .Site.Home.OutputFormats.Get "RSS" }}
|
||||
<a class="site-footer__link" href="{{ .RelPermalink }}">RSS</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
<p class="site-footer__copy">© {{ now.Year }} {{ .Site.Params.author }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }}</title>
|
||||
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
|
||||
{{ $desc := .Description | default .Site.Params.description }}
|
||||
<meta name="description" content="{{ $desc }}">
|
||||
<link rel="canonical" href="{{ .Permalink }}">
|
||||
|
||||
{{ $ogImage := .Site.Params.og_image | default "/images/og-default.png" }}
|
||||
{{ with .Params.featured_image }}{{ $ogImage = . }}{{ end }}
|
||||
{{ with .Params.images }}{{ with index . 0 }}{{ $ogImage = . }}{{ end }}{{ end }}
|
||||
<meta property="og:site_name" content="{{ .Site.Title }}">
|
||||
<meta property="og:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
|
||||
<meta property="og:description" content="{{ $desc }}">
|
||||
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
|
||||
<meta property="og:url" content="{{ .Permalink }}">
|
||||
<meta property="og:image" content="{{ $ogImage | absURL }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
|
||||
<meta name="twitter:description" content="{{ $desc }}">
|
||||
<meta name="twitter:image" content="{{ $ogImage | absURL }}">
|
||||
|
||||
<link rel="icon" href="{{ "images/favicon.ico" | relURL }}" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ "images/favicon-32.png" | relURL }}">
|
||||
<link rel="apple-touch-icon" href="{{ "images/apple-touch-icon.png" | relURL }}">
|
||||
{{ with .OutputFormats.Get "RSS" }}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ $.Site.Title }}" href="{{ .RelPermalink }}">
|
||||
{{ end }}
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
{{ partial "fonts.html" . }}
|
||||
<link rel="stylesheet" href="{{ "css/style.css" | relURL }}">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<header class="site-header container">
|
||||
<a class="site-header__logo" href="{{ "/" | relURL }}">
|
||||
{{ with .Site.Params.logo_image }}
|
||||
<img class="site-header__logo-image" src="{{ . | relURL }}" alt="{{ $.Site.Params.logo | default $.Site.Title }}">
|
||||
{{ else }}
|
||||
{{ $.Site.Params.logo }}
|
||||
{{ end }}
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="Main">
|
||||
<a class="site-nav__link{{ if eq .Section "posts" }} site-nav__link--active{{ end }}" href="{{ "/posts/" | relURL }}">Posts</a>
|
||||
<a class="site-nav__link{{ if and .IsPage (eq .File.BaseFileName "about") }} site-nav__link--active{{ end }}" href="{{ "/about/" | relURL }}">About</a>
|
||||
</nav>
|
||||
<header class="site-header">
|
||||
<div class="site-header__bar container">
|
||||
<a class="site-header__brand" href="{{ "/" | relURL }}">
|
||||
{{ with .Site.Params.logo_image }}
|
||||
<img class="site-header__logo-image" src="{{ . | relURL }}" alt="">
|
||||
{{ end }}
|
||||
<span class="site-header__wordmark">{{ .Site.Params.logo | default .Site.Title }}</span>
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="Main">
|
||||
<a class="site-nav__link{{ if eq .Section "posts" }} site-nav__link--active{{ end }}" href="{{ "/posts/" | relURL }}">Posts</a>
|
||||
<a class="site-nav__link{{ if eq .Section "series" }} site-nav__link--active{{ end }}" href="{{ "/series/" | relURL }}">Series</a>
|
||||
<a class="site-nav__link{{ if and .IsPage (eq .File.BaseFileName "about") }} site-nav__link--active{{ end }}" href="{{ "/about/" | relURL }}">About</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -5,5 +5,16 @@
|
||||
</p>
|
||||
<h3 class="post-list__title">{{ .Title }}</h3>
|
||||
{{ with .Description }}<p class="post-list__excerpt">{{ . }}</p>{{ end }}
|
||||
{{ with .Params.series }}<p class="post-list__series">{{ . }}</p>{{ end }}
|
||||
</a>
|
||||
{{ $seriesTerms := .GetTerms "series" }}
|
||||
{{ if $seriesTerms }}
|
||||
{{ range $seriesTerms }}
|
||||
<p class="post-list__series">
|
||||
<a class="post-list__series-link" href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ else if .Params.series }}
|
||||
<p class="post-list__series">
|
||||
<a class="post-list__series-link" href="{{ printf "/series/%s/" .Params.series | relURL }}">{{ replace .Params.series "-" " " | title }}</a>
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user