Files
projax/web/templates/bulk_section.tmpl
mAi f6cf050c3f 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)
2026-05-17 19:11:26 +02:00

164 lines
6.2 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "bulk-section"}}
<section id="bulk-section" class="bulk-section">
<section class="tagbar" id="bulk-filterbar">
<form id="bulk-filter" class="search"
hx-get="/admin/bulk"
hx-target="#bulk-section"
hx-swap="outerHTML"
hx-trigger="keyup changed delay:200ms from:input[name=q], change from:select, change from:input[type=hidden], change from:input[name=show-archived]"
hx-push-url="true">
<input type="search" name="q" value="{{.Filter.Q}}" placeholder="search title, slug, content…" autocomplete="off">
<label>tag&nbsp;
<select name="tag" multiple size="3">
{{$selTags := .Filter.Tags}}
{{range .AllTags}}<option value="{{.}}" {{if contains $selTags .}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label>mgmt&nbsp;
<select name="mgmt" multiple size="4">
{{$selM := .Filter.Management}}
<option value="mai" {{if contains $selM "mai"}}selected{{end}}>mai</option>
<option value="self" {{if contains $selM "self"}}selected{{end}}>self</option>
<option value="external" {{if contains $selM "external"}}selected{{end}}>external</option>
<option value="unmanaged" {{if contains $selM "unmanaged"}}selected{{end}}>unmanaged</option>
</select>
</label>
<label>status&nbsp;
<select name="status" multiple size="3">
{{$selS := .Filter.Status}}
<option value="active" {{if contains $selS "active"}}selected{{end}}>active</option>
<option value="done" {{if contains $selS "done"}}selected{{end}}>done</option>
<option value="archived" {{if contains $selS "archived"}}selected{{end}}>archived</option>
</select>
</label>
<label class="checkbox">
<input type="checkbox" name="show-archived" value="1" {{if .Filter.ShowArchived}}checked{{end}}>
show archived
</label>
</form>
<p class="counts"><strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match</p>
</section>
{{with .Banner}}<p class="banner warn">{{.}}</p>{{end}}
<form id="bulk-actions"
method="post"
action="/admin/bulk/apply"
hx-post="/admin/bulk/apply"
hx-target="#bulk-section"
hx-swap="outerHTML"
hx-include="#bulk-filter">
<fieldset class="actions">
<legend>Bulk action <small>(applies to all checked rows)</small></legend>
<div class="action-row">
<label>+ tag <input name="add_tag" placeholder="tag-slug"></label>
<label> tag <input name="remove_tag" placeholder="tag-slug"></label>
<label>set management
<select name="set_mgmt">
<option value="">—</option>
<option value="mai">mai</option>
<option value="self">self</option>
<option value="external">external</option>
<option value="clear">clear</option>
</select>
</label>
<label>set status
<select name="set_status">
<option value="">—</option>
<option value="active">active</option>
<option value="done">done</option>
<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>
<table class="bulk">
<thead>
<tr>
<th><input type="checkbox" id="bulk-all" onclick="document.querySelectorAll('input[name=ids]').forEach(c=>c.checked=this.checked)"></th>
<th>slug</th>
<th>primary path</th>
<th>tags</th>
<th>mgmt</th>
<th>status</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr id="bulk-row-{{.ID}}">
<td><input type="checkbox" name="ids" value="{{.ID}}"></td>
<td><a href="/i/{{.PrimaryPath}}">{{.Slug}}</a></td>
<td><small class="muted">{{.PrimaryPath}}</small></td>
<td class="cell-tags" id="cell-tags-{{.ID}}">
{{template "bulk-chip-tags" .}}
</td>
<td class="cell-mgmt" id="cell-mgmt-{{.ID}}">
{{template "bulk-chip-mgmt" .}}
</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
</tr>
{{else}}
<tr><td colspan="6"><em>No items match. Loosen the filters.</em></td></tr>
{{end}}
</tbody>
</table>
</form>
</section>
{{end}}
{{define "bulk-chip-tags"}}
{{range .Tags}}
<span class="tag">{{.}}
<button class="chip-x"
hx-post="/admin/bulk/chip"
hx-vals='{"id":"{{$.ID}}","op":"remove","kind":"tag","value":"{{.}}"}'
hx-target="#cell-tags-{{$.ID}}"
hx-swap="innerHTML"
type="button"
title="remove tag">×</button>
</span>
{{end}}
{{/* No nested <form> — HTML forbids it. The input fires hx-post on
Enter; the +-button is a click trigger on the same endpoint. Both
carry id/op/kind via hx-vals; the input's own value field rides
along as name="value". */}}
<span class="chip-add">
<input name="value" placeholder="+tag" size="6"
id="chip-add-value-{{.ID}}"
hx-post="/admin/bulk/chip"
hx-target="#cell-tags-{{.ID}}"
hx-swap="innerHTML"
hx-trigger="keyup[key=='Enter']"
hx-vals='{"id":"{{.ID}}","op":"add","kind":"tag"}'>
<button type="button"
class="chip-add-btn"
hx-post="/admin/bulk/chip"
hx-target="#cell-tags-{{.ID}}"
hx-swap="innerHTML"
hx-include="#chip-add-value-{{.ID}}"
hx-vals='{"id":"{{.ID}}","op":"add","kind":"tag"}'
title="add tag">+</button>
</span>
{{end}}
{{define "bulk-chip-mgmt"}}
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{if not .Management}}<span class="muted">unmanaged</span>{{end}}
{{end}}