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

@@ -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).