m's report: detail page (/i/{path}) shows Tasks / Issues / Documents
above the edit form, and the form's 9 flat fields read as a wall of
labels rather than a flow. He wants the form first, fields grouped, then
auxiliary read-only sections below a clear visual break.
Reordered top-to-bottom flow:
h1 + meta
▸ form
General — Title → Slug → Parents → Status
Classification — Tags → Management
Flags — pinned + archived (inline pair)
Content — markdown textarea
Public listing <details> (stays inside form: save coherence)
Timeline behaviour <details> (stays inside form: save coherence)
Save / Cancel actions
◂ /form
<hr class="aux-divider">
▸ section.aux-sections "Related"
Tasks <details> (was above form)
Issues <details> (was above form)
Documents <details> (was above form)
reset section state link
web/templates/detail.tmpl:
- Three <section class="form-group"> blocks each with a <h2
class="form-group-heading"> ID-anchored for aria-labelledby + the
ordering test. The headings render as small uppercase muted labels —
visual hierarchy without screaming "FORM".
- Form-bound collapsibles (Public Listing + Timeline behaviour) stay
inside the form; moving them out would require a separate POST
endpoint, which the brief explicitly puts out of scope.
- Tasks / Issues / Documents collapsibles moved out of the form, into a
new <section class="aux-sections"> after a thematic <hr>.
- Reset-section-state link relocated to .aux-reset under the auxiliary
section since that's where most collapsible state lives now.
- All data-section / data-item-id / proj-section class hooks preserved
exactly — Phase 4e smart-default + localStorage state semantics
unchanged.
web/static/style.css:
- .detail-form: column flex, gap 20px between groups for breathing room.
- .form-group-heading: 0.78em uppercase muted with dotted-border-bottom
separator — looks like an admin-form group header without being
shouty.
- .form-group-flags: row-flex so pinned + archived sit inline.
- .aux-divider: full-width 1px solid border-top with 32px margin above,
16px below — the explicit "this is where editable ends" break.
- .aux-sections + .aux-heading + .aux-reset: matched flex layout +
small "Related" header so the change-of-mode reads without
squinting.
Tests:
- TestDetailFieldsRenderInOrder (new) — strict-greater index walk
through every documented anchor: General → Title → Slug → Parents →
Status → Classification → Tags → Management → Flags → pinned →
archived → Content → content_md → Save → aux-divider → Related →
Documents. Catches any future regression that re-tangles the order.
- TestDetailFormGroupHeadings (new) — pins the five visible group
headings (General / Classification / Flags / Content / Related) so
a string-cleanup pass can't silently strip them.
- TestDetailAuxSectionsAfterForm (new) — Documents <details> lives
AFTER the detail form's </form>, while Public listing stays INSIDE
the form for save-coherence. Skips the sidebar's logout-form </form>
by anchoring on the detail-form's action="/i/dev" start tag.
- TestDetailIncludesSectionToggleScript / TestDetailSectionsWrappedInDetails /
TestDetailDocumentsClosedDefaultsWhenManyItems still pass — the
Phase 4e collapsible semantics are untouched.
Net: +298 / -92.
245 lines
12 KiB
Cheetah
245 lines
12 KiB
Cheetah
{{define "content"}}
|
||
<h1>{{.Item.Title}}</h1>
|
||
<p class="meta">
|
||
<span class="slug">{{.Item.PrimaryPath}}</span>
|
||
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
|
||
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
|
||
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
|
||
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
|
||
{{if .Item.SourceRefDeref}}<span class="muted">mai id: {{.Item.SourceRefDeref}}</span>{{end}}
|
||
</p>
|
||
{{if .Item.OtherPaths}}
|
||
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
|
||
{{end}}
|
||
|
||
{{$itemID := .Item.ID}}
|
||
|
||
{{/*
|
||
Phase 5i: reordered general → specific. Form first (Title → Slug →
|
||
Parents → Status → Classification → Flags → Content → form-bound
|
||
collapsibles) so the always-edit fields sit at the top, then a divider
|
||
before the auxiliary read-only collapsibles (Tasks / Issues /
|
||
Documents). Field grouping (General / Classification / Flags) reads as
|
||
three groups instead of nine flat labels per m's "pimped a bit" ask.
|
||
|
||
Public listing + Timeline behaviour stay INSIDE the form so they save
|
||
with the main Save button — moving them out would require a separate
|
||
POST endpoint, which is out of scope for this pass.
|
||
|
||
Phase 4e collapsibles smart-default + localStorage state preserved
|
||
exactly: same data-section keys, same proj-section CSS class, same
|
||
inline JS.
|
||
*/}}
|
||
|
||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit detail-form">
|
||
|
||
<section class="form-group" aria-labelledby="hdr-general">
|
||
<h2 id="hdr-general" class="form-group-heading">General</h2>
|
||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
|
||
<select name="parent_ids" multiple size="6">
|
||
{{range .ParentOptions}}
|
||
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
|
||
{{end}}
|
||
</select>
|
||
</label>
|
||
<label>Status
|
||
<select name="status">
|
||
{{range $opt := .StatusOptions}}
|
||
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
|
||
{{end}}
|
||
</select>
|
||
</label>
|
||
</section>
|
||
|
||
<section class="form-group" aria-labelledby="hdr-classification">
|
||
<h2 id="hdr-classification" class="form-group-heading">Classification</h2>
|
||
<label>Tags
|
||
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
|
||
</label>
|
||
<label>Management
|
||
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
|
||
</label>
|
||
</section>
|
||
|
||
<section class="form-group form-group-flags" aria-labelledby="hdr-flags">
|
||
<h2 id="hdr-flags" class="form-group-heading">Flags</h2>
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
|
||
</label>
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||
</label>
|
||
</section>
|
||
|
||
<section class="form-group" aria-labelledby="hdr-content">
|
||
<h2 id="hdr-content" class="form-group-heading">Content</h2>
|
||
<label class="form-group-content-label">
|
||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||
</label>
|
||
</section>
|
||
|
||
<details class="proj-section" data-section="public" data-item-id="{{$itemID}}">
|
||
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
|
||
<fieldset class="public-listing">
|
||
<legend class="visually-hidden">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>
|
||
</details>
|
||
|
||
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
|
||
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
|
||
<fieldset class="timeline-exclude">
|
||
<legend class="visually-hidden">Timeline behaviour</legend>
|
||
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
|
||
{{$ex := .Item.TimelineExclude}}
|
||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
|
||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
|
||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="docs" {{if contains $ex "docs"}}checked{{end}}> exclude docs (dated item_links)</label>
|
||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="creation" {{if contains $ex "creation"}}checked{{end}}> exclude creation marker (this item's "added to projax" row)</label>
|
||
</fieldset>
|
||
</details>
|
||
|
||
<div class="actions">
|
||
<button type="submit">Save</button>
|
||
<a class="cancel" href="/">Cancel</a>
|
||
</div>
|
||
</form>
|
||
|
||
<hr class="aux-divider" aria-hidden="true">
|
||
|
||
<section class="aux-sections" aria-labelledby="hdr-aux">
|
||
<h2 id="hdr-aux" class="aux-heading">Related</h2>
|
||
|
||
{{if .CalDAVOn}}
|
||
{{/* Tasks section opens by default when any linked calendar has at least
|
||
one open VTODO. */}}
|
||
{{$tasksOpen := false}}
|
||
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
|
||
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
|
||
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
|
||
{{template "tasks-section" .}}
|
||
</details>
|
||
{{end}}
|
||
|
||
{{if and .GiteaOn .Issues}}
|
||
{{$open := le .IssuesOpenTotal 10}}
|
||
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
|
||
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
|
||
{{template "issues-section" .}}
|
||
</details>
|
||
{{end}}
|
||
|
||
{{$docOpen := le (len .Documents) 5}}
|
||
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
|
||
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
|
||
{{template "documents-section" .}}
|
||
</details>
|
||
|
||
<p class="aux-reset">
|
||
<a class="proj-section-reset muted" href="#" data-item-id="{{$itemID}}">reset section state</a>
|
||
</p>
|
||
</section>
|
||
|
||
<script>
|
||
// Phase 4e collapsible-section persistence. Each <details data-section data-item-id>
|
||
// reads its open state from localStorage on boot (user choice wins over the
|
||
// server-rendered default), writes back on toggle. The reset link wipes
|
||
// every projax.section.{item}.* key so smart defaults take over again.
|
||
(function() {
|
||
var details = document.querySelectorAll("details.proj-section[data-section][data-item-id]");
|
||
function keyFor(d) {
|
||
return "projax.section." + d.getAttribute("data-item-id") + "." + d.getAttribute("data-section");
|
||
}
|
||
details.forEach(function(d) {
|
||
try {
|
||
var saved = localStorage.getItem(keyFor(d));
|
||
if (saved === "open") d.setAttribute("open", "");
|
||
else if (saved === "closed") d.removeAttribute("open");
|
||
} catch (e) { /* localStorage blocked — fall through to default */ }
|
||
d.addEventListener("toggle", function() {
|
||
try { localStorage.setItem(keyFor(d), d.open ? "open" : "closed"); } catch (e) {}
|
||
});
|
||
});
|
||
var reset = document.querySelector(".proj-section-reset");
|
||
if (reset) reset.addEventListener("click", function(e) {
|
||
e.preventDefault();
|
||
var itemID = reset.getAttribute("data-item-id");
|
||
if (!itemID) return;
|
||
try {
|
||
var prefix = "projax.section." + itemID + ".";
|
||
for (var i = localStorage.length - 1; i >= 0; i--) {
|
||
var k = localStorage.key(i);
|
||
if (k && k.indexOf(prefix) === 0) localStorage.removeItem(k);
|
||
}
|
||
} catch (e) {}
|
||
// Reload so the server's smart defaults take effect again.
|
||
location.reload();
|
||
});
|
||
})();
|
||
</script>
|
||
<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}}
|