Add lightweight self-hosted Hugo CMS with GitHub API backend

Implements a Go-based CMS for editing Hugo posts and uploading images
via the GitHub REST API, with no database and Git as the source of truth.

Features:
- Single admin auth with bcrypt password and signed session cookies
- Dashboard, post listing with search, create/edit with EasyMDE editor
- Media library for static/uploads/ with drag-drop and clipboard paste
- Client-side autosave, unsaved changes warning, dark mode, responsive UI
- Modular packages for auth, github, posts, media, session, and handlers

Co-authored-by: codegirl007 <s.raide@gmail.com>
This commit is contained in:
Cursor Agent
2026-07-06 18:56:17 +00:00
co-authored by codegirl007
parent 68dfdfa33b
commit ce15f54a52
28 changed files with 2945 additions and 0 deletions
@@ -0,0 +1,53 @@
{{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}}
@@ -0,0 +1,9 @@
{{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}}
@@ -0,0 +1,37 @@
{{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}}
@@ -0,0 +1,23 @@
{{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}}
@@ -0,0 +1,36 @@
{{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}}
@@ -0,0 +1,70 @@
{{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}}
@@ -0,0 +1,45 @@
{{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}}