tracker: single-binary project tracker with auth, api keys, and json api

Go + SQLite web app, server-rendered, hackerman theme. Projects with
descriptions, searchable notes, todo/doing/done task tracking. Bcrypt
login sessions, hashed api keys, full /api/v1 crud. Deploys on straylight
via configs/tracker.nix (prebuilt binary).
This commit is contained in:
2026-09-18 21:28:40 -07:00
commit c9e74b236d
20 changed files with 2489 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// edit: {{.Project.Name}}</h1>
<form method="post" action="/projects/{{.Project.ID}}/update" class="stack">
<label>name
<input type="text" name="name" value="{{.Project.Name}}" required maxlength="120">
</label>
<label>description
<textarea name="description" rows="6">{{.Project.Description}}</textarea>
</label>
<div class="row">
<button type="submit" class="btn primary">save ▸</button>
<a class="btn" href="/projects/{{.Project.ID}}">cancel</a>
</div>
</form>
</section>
{{template "footer" .}}
+9
View File
@@ -0,0 +1,9 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// error {{.Status}}</h1>
<p class="description">{{.Message}}</p>
<p><a class="btn" href="/">◂ back to base</a></p>
</section>
{{template "footer" .}}
+35
View File
@@ -0,0 +1,35 @@
{{template "header" .}}
<section class="stats">
<div class="stat"><span class="num">{{.Stats.Projects}}</span><span class="label">projects</span></div>
<div class="stat todo"><span class="num">{{.Stats.Tasks.Todo}}</span><span class="label">todo</span></div>
<div class="stat doing"><span class="num">{{.Stats.Tasks.Doing}}</span><span class="label">doing</span></div>
<div class="stat done"><span class="num">{{.Stats.Tasks.Done}}</span><span class="label">done</span></div>
<div class="stat"><span class="num">{{.Stats.Notes}}</span><span class="label">notes</span></div>
</section>
<section class="panel">
<h1 class="glow">// new project</h1>
<form method="post" action="/projects" class="stack">
<input type="text" name="name" placeholder="project name" required maxlength="120">
<textarea name="description" placeholder="description…" rows="3"></textarea>
<button type="submit" class="btn primary">init ▸</button>
</form>
</section>
<section class="panel">
<h1 class="glow">// projects</h1>
{{if not .Projects}}
<p class="muted">no projects on the grid. create one above ▲</p>
{{end}}
<ul class="project-list">
{{range .Projects}}
<li>
<a class="project-link" href="/projects/{{.ID}}">{{.Name}}<span class="arrow"></span></a>
{{if .Description}}<p class="muted">{{.Description}}</p>{{end}}
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+33
View File
@@ -0,0 +1,33 @@
{{template "header" .}}
{{if .NewKey}}
<section class="panel">
<h1 class="glow">// key generated</h1>
<p>copy it now — it is stored hashed and will not be shown again:</p>
<pre class="keybox">{{.NewKey}}</pre>
</section>
{{end}}
<section class="panel">
<h1 class="glow">// api keys</h1>
<p class="muted">keys grant full API access via <code>X-API-Key</code> or <code>Authorization: Bearer</code>.</p>
<form method="post" action="/keys/create" class="inline-form">
<input type="text" name="name" placeholder="key name (e.g. laptop-cli)" required maxlength="60">
<button type="submit" class="btn primary">generate ▸</button>
</form>
{{if not .Keys}}<p class="muted">no keys issued.</p>{{end}}
<ul class="key-list">
{{range .Keys}}
<li class="key-row">
<span class="key-name">{{.Name}}</span>
<span class="muted">created {{dateFmt .CreatedAt}}</span>
<span class="muted push">{{if .LastUsed.Valid}}last used {{dateFmt .LastUsed.String}}{{else}}never used{{end}}</span>
<form method="post" action="/keys/{{.ID}}/delete" onsubmit="return confirm('revoke this key?')">
<button type="submit" class="btn ghost">revoke ✕</button>
</form>
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+17
View File
@@ -0,0 +1,17 @@
{{template "header" .}}
<section class="panel login-panel">
<h1 class="glow">// access</h1>
{{if .Error}}<p class="error-msg">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="stack">
<label>handle
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label>passphrase
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn primary">jack in ▸</button>
</form>
</section>
{{template "footer" .}}
+29
View File
@@ -0,0 +1,29 @@
{{define "header"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} ∷ TRACKER</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<nav class="topbar">
<a class="logo" href="/"> tracker<span class="cursor"></span></a>
<form class="searchbar" action="/search" method="get">
<input type="search" name="q" placeholder="grep the grid…" value="" autocomplete="off">
</form>
<div class="nav">
<a href="/keys">keys</a>
<form method="post" action="/logout"><button type="submit" class="btn ghost">logout</button></form>
</div>
</nav>
<main class="wrap">
{{end}}
{{define "footer"}}
</main>
<footer class="foot">// signal intact · {{.Title}}</footer>
</body>
</html>
{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{template "header" .}}
<section class="panel">
<div class="row spread">
<h1 class="glow">{{.Project.Name}}</h1>
<div class="row">
<a class="btn" href="/projects/{{.Project.ID}}/edit">edit</a>
<form method="post" action="/projects/{{.Project.ID}}/delete" onsubmit="return confirm('delete project and all its data?')">
<button type="submit" class="btn danger">rm -rf</button>
</form>
</div>
</div>
{{if .Project.Description}}<p class="description">{{.Project.Description}}</p>
{{else}}<p class="muted">no description.</p>{{end}}
</section>
<section class="panel">
<h2 class="glow">// tasks</h2>
<form method="post" action="/projects/{{.Project.ID}}/tasks" class="inline-form">
<input type="text" name="title" placeholder="new task…" required maxlength="240">
<button type="submit" class="btn primary">add ▸</button>
</form>
{{if not .Tasks}}<p class="muted">no tasks queued.</p>{{end}}
<ul class="task-list">
{{range .Tasks}}
<li class="task">
<form method="post" action="/tasks/{{.ID}}/cycle">
<button type="submit" class="status {{.Status}}" title="cycle status">{{statusLabel .Status}}</button>
</form>
<span class="task-title {{.Status}}">{{.Title}}</span>
<form method="post" action="/tasks/{{.ID}}/delete" class="push">
<button type="submit" class="btn ghost" title="delete"></button>
</form>
</li>
{{end}}
</ul>
</section>
<section class="panel">
<h2 class="glow">// notes</h2>
<form method="post" action="/projects/{{.Project.ID}}/notes" class="stack">
<textarea name="body" placeholder="log an entry…" rows="3" required></textarea>
<div class="row"><button type="submit" class="btn primary">log ▸</button></div>
</form>
{{if not .Notes}}<p class="muted">no entries logged.</p>{{end}}
<ul class="note-list">
{{range .Notes}}
<li class="note">
<div class="row spread">
<span class="muted">[{{dateFmt .CreatedAt}}]</span>
<form method="post" action="/notes/{{.ID}}/delete">
<button type="submit" class="btn ghost" title="delete"></button>
</form>
</div>
<p class="note-body">{{.Body}}</p>
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+27
View File
@@ -0,0 +1,27 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// search</h1>
<form action="/search" method="get" class="inline-form big-search">
<input type="search" name="q" value="{{.Query}}" placeholder="grep the grid…" autofocus autocomplete="off">
<button type="submit" class="btn primary">run ▸</button>
</form>
</section>
{{if .Query}}
<section class="panel">
<h2 class="muted">{{len .Hits}} hit(s) for “{{.Query}}”</h2>
{{if not .Hits}}<p class="muted">nothing found in the grid.</p>{{end}}
<ul class="hit-list">
{{range .Hits}}
<li class="hit">
<span class="badge {{.Kind}}">{{.Kind}}</span>
<a href="/projects/{{.ProjectID}}" class="hit-title">{{.Title}}</a>
<p class="muted snippet">{{.Snippet}}</p>
</li>
{{end}}
</ul>
</section>
{{end}}
{{template "footer" .}}