feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth

Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.

## Schema (migration 0014)
- public               boolean       default false (partial index when true)
- public_description   text          default ''
- public_live_url      text          default ''
- public_source_url    text          default ''
- public_screenshots   text[]        default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx     PARTIAL INDEX WHERE public = true (5% of rows)

## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter

## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
  can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB

## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
  inputs + screenshot list editor with add/remove rows + inline JS for
  the editor. Values persist when public is off so toggling never
  destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
  select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
  Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
  round-trip, detail-page affordances, tree-filter narrowing

## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).

## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
This commit is contained in:
mAi
2026-05-17 19:11:26 +02:00
parent 9abe8da71c
commit f6cf050c3f
12 changed files with 816 additions and 53 deletions

View File

@@ -144,6 +144,7 @@ type bulkAction struct {
RemoveTag string
SetMgmt string // "mai" / "self" / "external" / "clear"
SetStatus string // "active" / "done" / "archived"
SetPublic string // "" / "make_public" / "make_private" — Phase 4d
}
func parseBulkAction(r *http.Request) bulkAction {
@@ -153,6 +154,7 @@ func parseBulkAction(r *http.Request) bulkAction {
RemoveTag: strings.ToLower(get("remove_tag")),
SetMgmt: get("set_mgmt"),
SetStatus: get("set_status"),
SetPublic: get("set_public"),
}
}
@@ -166,6 +168,10 @@ func (a bulkAction) describe() string {
return "set management " + a.SetMgmt
case a.SetStatus != "":
return "set status " + a.SetStatus
case a.SetPublic == "make_public":
return "make public"
case a.SetPublic == "make_private":
return "make private"
}
return ""
}
@@ -261,6 +267,16 @@ func (s *Server) applyBulk(ctx context.Context, ids []string, a bulkAction) erro
update projax.items set status = $2
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.SetStatus)
case a.SetPublic == "make_public":
_, err = tx.Exec(ctx, `
update projax.items set public = true
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.SetPublic == "make_private":
_, err = tx.Exec(ctx, `
update projax.items set public = false
where id = any($1::uuid[]) and deleted_at is null`,
ids)
default:
return errors.New("bulk: empty action")
}

255
web/public_listing_test.go Normal file
View File

@@ -0,0 +1,255 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestPublicListingMigrationLanded asserts the five new columns are
// queryable. The migration runs as part of mustServer setup; if it
// silently failed (CREATE INDEX clash etc.) every subsequent query against
// the new columns would 500 — surface that loudly here.
func TestPublicListingMigrationLanded(t *testing.T) {
_, pool := mustServer(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var cols []string
rows, err := pool.Query(ctx, `
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'projax' AND table_name = 'items'
AND column_name IN ('public','public_description','public_live_url','public_source_url','public_screenshots')
ORDER BY column_name`)
if err != nil {
t.Fatalf("information_schema query: %v", err)
}
defer rows.Close()
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatalf("scan: %v", err)
}
cols = append(cols, c)
}
want := []string{"public", "public_description", "public_live_url", "public_screenshots", "public_source_url"}
if len(cols) != len(want) {
t.Fatalf("expected 5 public_* columns on projax.items, got %v", cols)
}
for i, w := range want {
if cols[i] != w {
t.Errorf("column[%d] = %q, want %q", i, cols[i], w)
}
}
}
// TestPublicListingFormRoundTrip seeds an item, POSTs the detail form with
// public=1 + the four field values + two screenshots, then asserts the
// stored row matches what was submitted.
func TestPublicListingFormRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "pub-rt-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'Pub RT', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
form := url.Values{}
form.Set("title", "Pub RT")
form.Set("slug", slug)
form.Set("status", "active")
form.Set("public", "1")
form.Set("public_description", "A test public listing.")
form.Set("public_live_url", "https://example.com/live")
form.Set("public_source_url", "https://mgit.msbls.de/m/example")
form.Add("public_screenshots", "https://example.com/a.png")
form.Add("public_screenshots", "https://example.com/b.png")
form.Add("public_screenshots", "") // empty row — parseScreenshotList must drop it
form.Set("parent_id", dev)
req := httptest.NewRequest(http.MethodPost, "/i/dev."+slug, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusSeeOther {
t.Fatalf("POST /i/dev.%s → %d", slug, w.Result().StatusCode)
}
var pub bool
var desc, live, src string
var shots []string
if err := pool.QueryRow(ctx,
`select public, public_description, public_live_url, public_source_url, public_screenshots
from projax.items where id = $1`, id,
).Scan(&pub, &desc, &live, &src, &shots); err != nil {
t.Fatalf("re-read: %v", err)
}
if !pub {
t.Errorf("public flag did not flip on")
}
if desc != "A test public listing." {
t.Errorf("description round-trip lost the value, got %q", desc)
}
if live != "https://example.com/live" {
t.Errorf("live_url round-trip broken: %q", live)
}
if src != "https://mgit.msbls.de/m/example" {
t.Errorf("source_url round-trip broken: %q", src)
}
if len(shots) != 2 || shots[0] != "https://example.com/a.png" || shots[1] != "https://example.com/b.png" {
t.Errorf("screenshots round-trip lost order/values: %v", shots)
}
}
// TestPublicListingBulkActionMakesPublic seeds an item private, POSTs the
// bulk apply with set_public=make_public, then verifies the flag flipped.
func TestPublicListingBulkActionMakesPublic(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "pub-bk-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'Pub Bulk', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
form := url.Values{}
form.Add("ids", id)
form.Set("set_public", "make_public")
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// Either HTMX 200 with fragment or 303 non-HTMX redirect; both fine.
if c := w.Result().StatusCode; c != http.StatusOK && c != http.StatusSeeOther {
t.Fatalf("bulk apply → %d", c)
}
var pub bool
if err := pool.QueryRow(ctx, `select public from projax.items where id=$1`, id).Scan(&pub); err != nil {
t.Fatalf("re-read: %v", err)
}
if !pub {
t.Errorf("make_public bulk action did not flip the flag")
}
// Round-trip make_private.
form2 := url.Values{}
form2.Add("ids", id)
form2.Set("set_public", "make_private")
req2 := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form2.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, req2)
if err := pool.QueryRow(ctx, `select public from projax.items where id=$1`, id).Scan(&pub); err != nil {
t.Fatalf("re-read after make_private: %v", err)
}
if pub {
t.Errorf("make_private bulk action did not flip the flag back")
}
}
// TestPublicListingDetailFormShipsAffordances proves the detail page
// renders the public-listing fieldset with all five inputs and the
// screenshot list editor.
func TestPublicListingDetailFormShipsAffordances(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/i/dev")
if code != 200 {
t.Fatalf("GET /i/dev → %d", code)
}
for _, want := range []string{
`<fieldset class="public-listing">`,
`name="public"`,
`name="public_description"`,
`name="public_live_url"`,
`name="public_source_url"`,
`name="public_screenshots"`,
`id="public-screenshot-add"`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail page missing public-listing element %q", want)
}
}
}
// TestTreeFilterPublicNarrows seeds two items, sets one to public, then
// asserts ?public=1 narrows to the public one and ?public=0 to the private.
func TestTreeFilterPublicNarrows(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
var dev string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
var pubID, prvID string
pubSlug := "pub-filt-yes-" + stamp
prvSlug := "pub-filt-no-" + stamp
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, public)
values (array['project']::text[], 'Pub Yes', $1, ARRAY[$2]::uuid[], true)
returning id`, pubSlug, dev).Scan(&pubID); err != nil {
t.Fatalf("seed public: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, public)
values (array['project']::text[], 'Pub No', $1, ARRAY[$2]::uuid[], false)
returning id`, prvSlug, dev).Scan(&prvID); err != nil {
t.Fatalf("seed private: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, pubID, prvID)
_, yesBody := get(t, h, "/?public=1")
if !strings.Contains(yesBody, pubSlug) {
t.Errorf("?public=1 should show pub-filt-yes")
}
if strings.Contains(yesBody, prvSlug) {
t.Errorf("?public=1 should hide pub-filt-no")
}
_, noBody := get(t, h, "/?public=0")
if strings.Contains(noBody, pubSlug) {
t.Errorf("?public=0 should hide pub-filt-yes")
}
if !strings.Contains(noBody, prvSlug) {
t.Errorf("?public=0 should show pub-filt-no")
}
}

View File

@@ -502,6 +502,15 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
Archived: r.FormValue("archived") == "1",
Tags: parseCSV(r.FormValue("tags")),
Management: parseCSV(r.FormValue("management")),
// Phase 4d public-listing fields. The form includes the toggle + four
// inputs whenever the user has edit access; missing fields fall through
// to zero (false / "" / empty array), which matches "make private +
// clear values" semantics — by design.
Public: r.FormValue("public") == "1",
PublicDescription: r.FormValue("public_description"),
PublicLiveURL: strings.TrimSpace(r.FormValue("public_live_url")),
PublicSourceURL: strings.TrimSpace(r.FormValue("public_source_url")),
PublicScreenshots: parseScreenshotList(r.Form["public_screenshots"]),
}
updated, err := s.Store.Update(r.Context(), it.ID, in)
if err != nil {
@@ -566,6 +575,22 @@ func dedupeStrings(in []string) []string {
return out
}
// parseScreenshotList trims each entry and drops empties, preserving order.
// Used by the Public-listing form whose list editor submits one URL per
// repeated `public_screenshots` field. Order matters — the public renderer
// shows them top-down — so no deduping or sorting here.
func parseScreenshotList(raw []string) []string {
out := make([]string, 0, len(raw))
for _, v := range raw {
s := strings.TrimSpace(v)
if s == "" {
continue
}
out = append(out, s)
}
return out
}
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
// JSON/SQL writes get an explicit empty array).

View File

@@ -664,3 +664,39 @@ header .theme-toggle .theme-icon {
@media (max-width: 480px) {
header .theme-toggle { padding: 6px 10px; font-size: 1em; min-height: 36px; }
}
/* --- Public listing (Phase 4d) --- */
fieldset.public-listing {
margin: 20px 0 8px;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-alt);
}
fieldset.public-listing legend {
font-weight: 600;
font-size: 0.95em;
padding: 0 6px;
color: var(--accent);
}
fieldset.public-listing > p.muted {
margin: 4px 0 12px;
font-size: 0.85em;
}
fieldset.public-listing label { margin-top: 8px; }
.public-screenshots { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; }
.public-screenshot-row { display: flex; gap: 6px; align-items: center; }
.public-screenshot-row input[type=url] { flex: 1; }
.public-screenshot-remove {
background: var(--surface); color: var(--muted);
border: 1px solid var(--border); border-radius: 3px;
padding: 2px 8px; font-size: 1em; line-height: 1; cursor: pointer; min-height: 0;
}
.public-screenshot-remove:hover { color: var(--bad); border-color: var(--bad); }
.public-screenshot-add {
margin-top: 6px;
background: transparent; color: var(--accent);
border: 1px solid var(--border); border-radius: 4px;
padding: 4px 10px; font-size: 0.9em; cursor: pointer; min-height: 0;
}
.public-screenshot-add:hover { background: var(--accent); color: var(--accent-fg); }

View File

@@ -77,6 +77,13 @@
<option value="archived">archived</option>
</select>
</label>
<label>public-listing
<select name="set_public">
<option value="">—</option>
<option value="make_public">Make public</option>
<option value="make_private">Make private</option>
</select>
</label>
<button type="submit">Apply</button>
</div>
</fieldset>

View File

@@ -55,9 +55,76 @@
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<fieldset class="public-listing">
<legend>Public listing</legend>
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
consumer) can pull these fields via the projax MCP. The values are
preserved when public is off — toggling never destroys them.</p>
<label class="checkbox">
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
</label>
<label>Public description
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
</label>
<label>Live URL
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
</label>
<label>Source URL
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
</label>
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
<div class="public-screenshots" id="public-screenshots">
{{range .Item.PublicScreenshots}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
{{end}}
<div class="public-screenshot-row">
<input name="public_screenshots" type="url" value="" placeholder="https://…">
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
</div>
</div>
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
</label>
</fieldset>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
<script>
// Phase 4d screenshot list editor. Small inline JS — no framework. Rows
// are simple <input name="public_screenshots"> entries; the server's
// parseScreenshotList drops empties and preserves order. Removing the
// last row leaves one blank input so the user can always type in one.
(function() {
var box = document.getElementById("public-screenshots");
var add = document.getElementById("public-screenshot-add");
if (!box || !add) return;
function blankRow() {
var row = document.createElement("div");
row.className = "public-screenshot-row";
row.innerHTML = '<input name="public_screenshots" type="url" value="" placeholder="https://…"><button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>';
return row;
}
add.addEventListener("click", function() {
box.appendChild(blankRow());
var inp = box.lastElementChild && box.lastElementChild.querySelector("input");
if (inp) inp.focus();
});
box.addEventListener("click", function(e) {
var t = e.target;
if (!(t instanceof HTMLElement)) return;
if (!t.classList.contains("public-screenshot-remove")) return;
var row = t.closest(".public-screenshot-row");
if (!row) return;
row.remove();
// Ensure there's always at least one blank row to type into.
if (!box.querySelector(".public-screenshot-row")) box.appendChild(blankRow());
});
})();
</script>
{{end}}

View File

@@ -17,12 +17,13 @@ type TreeFilter struct {
Status []string // ANY of these matches (default = ["active"])
HasLinks []string // ANY of these ref_types must be linked to the item ("caldav-list", "gitea-repo")
ShowArchived bool // when false, hide items with archived=true even if Status matches
Public *bool // Phase 4d — nil = no filter; true = public only; false = private only
}
// Active reports whether any filter dimension is set to something other than
// the implicit default. Used for the "clear filters" link visibility.
func (f TreeFilter) Active() bool {
if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived {
if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived || f.Public != nil {
return true
}
// Status is the only dimension with a default; treat it as "active" if it
@@ -44,6 +45,17 @@ func ParseTreeFilter(q url.Values) TreeFilter {
HasLinks: parseCSV(q.Get("has")),
ShowArchived: q.Get("show-archived") == "1",
}
if v := strings.TrimSpace(q.Get("public")); v != "" {
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
b := true
f.Public = &b
case "0", "false", "no", "off":
b := false
f.Public = &b
}
}
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
@@ -73,9 +85,32 @@ func (f TreeFilter) QueryString() string {
if f.ShowArchived {
v.Set("show-archived", "1")
}
if f.Public != nil {
if *f.Public {
v.Set("public", "1")
} else {
v.Set("public", "0")
}
}
return v.Encode()
}
// TogglePublic flips through the three states: nil → public-only → private-only → nil.
func (f TreeFilter) TogglePublic() TreeFilter {
next := f
switch {
case f.Public == nil:
t := true
next.Public = &t
case *f.Public:
t := false
next.Public = &t
default:
next.Public = nil
}
return next
}
// URL builds a `/?…` URL for this filter. Empty filter → "/".
func (f TreeFilter) URL() string {
q := f.QueryString()
@@ -183,6 +218,10 @@ func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) b
return false
}
}
// Public (Phase 4d): when set, must match the item's flag.
if f.Public != nil && *f.Public != it.Public {
return false
}
// q substring match.
if f.Q != "" {
q := strings.ToLower(f.Q)