Files
projax/web/templates/detail.tmpl
mAi 1af0990108 feat(detail): reorder fields general→specific, divider before auxiliary
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.
2026-05-26 13:15:39 +02:00

245 lines
12 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 "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}}