feat: Go HTTP server with tree / detail / new / classify

cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).

store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.

web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
  GET  /                   tree of items_unified
  GET  /i/{path}           detail (editable for projax, read-only +
                           promote form for mai.projects)
  POST /i/{path}           update projax-native item
  POST /i/{path}/promote   one-page promote (HTMX-aware fragment for
                           inline classify)
  GET  /new?parent={path}  create form
  POST /new                create projax-native item
  GET  /admin/classify     orphan list with inline HTMX promote
  GET  /healthz            DB ping
  GET  /static/*           embedded assets

Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).

Tests (skip when DB env is unset):
  TestTreeRenders, TestHealthz,
  TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
  TestClassifyListsOrphans, TestPromoteRoundTrip.
This commit is contained in:
mAi
2026-05-15 13:24:44 +02:00
parent c0466ade36
commit 9f905de461
11 changed files with 1184 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
{{define "content"}}
<h1>Classify mai.projects orphans</h1>
<p>{{len .Orphans}} unclassified rows. Pick an area (or any projax item) per row and click Promote — keeps the original mai.projects row untouched and links back via item_links.</p>
<table class="classify">
<thead>
<tr><th>Mai ID / Title</th><th>Status</th><th>Parent</th><th>Slug</th><th>Title</th><th></th></tr>
</thead>
<tbody>
{{range .Orphans}}
<tr id="row-{{.SourceRefDeref}}">
<td>
<a href="/i/{{.Path}}">{{.Title}}</a>
<br><small class="slug">{{.SourceRefDeref}}</small>
</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
<td>
<form
hx-post="/i/{{.Path}}/promote"
hx-target="#row-{{.SourceRefDeref}}"
hx-swap="outerHTML"
class="inline-promote">
<select name="parent_id" required>
<option value="">— pick parent —</option>
{{range $.ParentOptions}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
</select>
</td>
<td><input name="slug" value="{{.Slug}}" required pattern="[^.]+"></td>
<td><input name="title" value="{{.Title}}" required></td>
<td><button type="submit">Promote</button></form></td>
</tr>
{{else}}
<tr><td colspan="6"><em>No orphans. Everything is classified.</em></td></tr>
{{end}}
</tbody>
</table>
{{end}}

71
web/templates/detail.tmpl Normal file
View File

@@ -0,0 +1,71 @@
{{define "content"}}
<h1>{{.Item.Title}}</h1>
<p class="meta">
<span class="source source-{{.Item.Source}}">{{.Item.Source}}</span>
<span class="slug">{{.Item.Path}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
</p>
{{if .Item.Editable}}
<form method="post" action="/i/{{.Item.Path}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parent
<select name="parent_id">
{{if .Item.IsArea}}
<option value="" selected>(root area)</option>
{{else}}
{{range .ParentOptions}}
<option value="{{.ID}}" {{if and $.Item.ParentID (eq .ID (deref $.Item.ParentID))}}selected{{end}}>{{.Path}}</option>
{{end}}
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
{{else}}
<div class="readonly">
<p><em>Read-only: this row is sourced from {{.Item.Source}}.</em></p>
<pre class="content">{{.Item.ContentMD}}</pre>
<h2>Promote to projax</h2>
<p>Pick the area or project this should live under. mai.projects row stays untouched; the projax item links back to it via item_links.</p>
<form method="post" action="/i/{{.Item.Path}}/promote" class="promote">
<label>Parent
<select name="parent_id" required>
{{range .ParentOptions}}
<option value="{{.ID}}">{{.Path}}</option>
{{end}}
</select>
</label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<div class="actions">
<button type="submit">Promote</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
</div>
{{end}}
{{end}}

5
web/templates/error.tmpl Normal file
View File

@@ -0,0 +1,5 @@
{{define "content"}}
<h1>Error</h1>
<p class="error">{{.Message}}</p>
<p><a href="/">Back to tree</a></p>
{{end}}

20
web/templates/layout.tmpl Normal file
View File

@@ -0,0 +1,20 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}} — projax</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous"></script>
</head>
<body>
<header>
<nav>
<a href="/" class="brand">projax</a>
<a href="/admin/classify">classify orphans</a>
</nav>
</header>
<main>
{{template "content" .}}
</main>
</body>
</html>{{end}}

28
web/templates/new.tmpl Normal file
View File

@@ -0,0 +1,28 @@
{{define "content"}}
<h1>New item</h1>
<p class="meta">Parent: <strong>{{if .Parent}}{{.Parent.Path}}{{else}}(root area){{end}}</strong></p>
<form method="post" action="/new" class="edit">
{{if .Parent}}<input type="hidden" name="parent_id" value="{{.Parent.ID}}">{{end}}
<label>Kind
<select name="kind">
{{if not .Parent}}<option value="area" selected>area</option>{{end}}
<option value="project" {{if .Parent}}selected{{end}}>project</option>
</select>
</label>
<label>Title <input name="title" required></label>
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}<option value="{{$opt}}">{{$opt}}</option>{{end}}
</select>
</label>
<label>Content
<textarea name="content_md" rows="10"></textarea>
</label>
<div class="actions">
<button type="submit">Create</button>
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.Path}}{{else}}/{{end}}">Cancel</a>
</div>
</form>
{{end}}

52
web/templates/tree.tmpl Normal file
View File

@@ -0,0 +1,52 @@
{{define "content"}}
<h1>Tree</h1>
<p class="counts">
<strong>{{.ProjaxCount}}</strong> projax · <strong>{{.MaiCount}}</strong> mai.projects orphans
{{if .MaiCount}}<a href="/admin/classify">→ classify</a>{{end}}
</p>
<section class="tree">
<h2>Areas + projax items</h2>
<ul class="forest">
{{range .Areas}}
<li class="node area">
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.Path}}</span>
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
{{template "children" .}}
</li>
{{end}}
</ul>
</section>
{{if .Orphans}}
<section class="orphans">
<h2>mai.projects orphans <small>(unclassified)</small></h2>
<ul class="flat">
{{range .Orphans}}
<li class="node orphan">
<a href="/i/{{.Path}}">{{.Title}}</a>
<span class="slug">{{.Path}}</span>
<span class="status status-{{.Status}}">{{.Status}}</span>
</li>
{{end}}
</ul>
</section>
{{end}}
{{end}}
{{define "children"}}
{{if .Children}}
<ul>
{{range .Children}}
<li class="node project">
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.Path}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
{{template "children" .}}
</li>
{{end}}
</ul>
{{end}}
{{end}}