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)
164 lines
6.2 KiB
Cheetah
164 lines
6.2 KiB
Cheetah
{{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
|
||
<select name="tag" multiple size="3">
|
||
{{$selTags := .Filter.Tags}}
|
||
{{range .AllTags}}<option value="{{.}}" {{if contains $selTags .}}selected{{end}}>{{.}}</option>{{end}}
|
||
</select>
|
||
</label>
|
||
|
||
<label>mgmt
|
||
<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
|
||
<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}}
|