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:
37
web/templates/classify.tmpl
Normal file
37
web/templates/classify.tmpl
Normal 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
71
web/templates/detail.tmpl
Normal 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
5
web/templates/error.tmpl
Normal 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
20
web/templates/layout.tmpl
Normal 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
28
web/templates/new.tmpl
Normal 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
52
web/templates/tree.tmpl
Normal 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}}
|
||||
Reference in New Issue
Block a user