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:
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
@@ -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}}
|
||||
@@ -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" .}}
|
||||
@@ -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" .}}
|
||||
Reference in New Issue
Block a user