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:
16
web/bulk.go
16
web/bulk.go
@@ -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
255
web/public_listing_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user