main.html 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <pstm-name>main</pstm-name>
  7. <pstm-title>ClankerSaddle</pstm-title>
  8. <script pstm-res-src="statum.js"></script>
  9. <link rel="stylesheet" pstm-res-href="app.css">
  10. <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
  11. <link rel="apple-touch-icon" pstm-res-href="clanker-saddle.png">
  12. <link rel="stm-guard" require="auth != null || location.pathname == '/login'" href="/login">
  13. <script>
  14. // Shared page plumbing. showToast: slide-in notifications (used in
  15. // place of alert). initQuoteComments: registers a container matcher
  16. // for the text-highlight -> comment popover - selecting text inside
  17. // a matched container shows a floating button, then a popover that
  18. // posts the comment with the quoted text via the action the page
  19. // supplies.
  20. // Shared UI engines (parameterised per page):
  21. //
  22. // showToast - every page's toast corner.
  23. //
  24. // initQuoteComments - the text-selection quote popover + dialog
  25. // posting a comment with the quoted text; pages pass a `matches`
  26. // node predicate and the action accessor.
  27. //
  28. // initPlanReviews (plan 16 R3, arch 15 - the ~180-line pattern
  29. // that lived duplicated between the task and goal pages, now
  30. // goal-only after the task-page plan review died with the poll
  31. // era): the plan/completion REVIEW remark queue. "Add remark"
  32. // appends to a per-block queue rendered at the bottom of the
  33. // review body, persists across statum re-renders (the maps live
  34. // outside the render cycle), rides the rejection NUMBERED (each
  35. // remark with its selection quote prefixed like a comment), and
  36. // clears on accept. Remark-less rejection is allowed when the
  37. // feedback textarea carries the substance (plan 16 S2). The
  38. // rejection body rides `feedback`; the SERVER wraps the per-kind
  39. // stable prefix (R4 - templates carry no verdict constants). The
  40. // page supplies `resolveBlock(id)` - how a [data-plan-review]
  41. // container's block row resolves from page state.
  42. window.initPlanReviews = function (config) {
  43. if (!window.statum || !statum.afterEvaluate) return;
  44. var resolveBlock = (config && config.resolveBlock)
  45. || function () { return null; };
  46. var remarksByBlock = {};
  47. var rejectOpenByBlock = {};
  48. var remarkPopover = null;
  49. statum.afterEvaluate(syncPlanRemarks);
  50. document.addEventListener('click', function (event) {
  51. if (!event.target.closest) return;
  52. var accept = event.target.closest('[data-plan-accept]');
  53. if (accept) {
  54. // Accept ignores/clears the queued remarks.
  55. var accepted = accept.closest('[data-plan-review]');
  56. if (accepted) {
  57. var acceptId = accepted.getAttribute('data-block-id');
  58. remarksByBlock[acceptId] = [];
  59. rejectOpenByBlock[acceptId] = false;
  60. }
  61. return;
  62. }
  63. var toggle = event.target.closest('[data-plan-toggle]');
  64. if (toggle) {
  65. hideRemarkPopover();
  66. var review = toggle.closest('[data-plan-review]');
  67. var id = review && review.getAttribute('data-block-id');
  68. if (id) rejectOpenByBlock[id] = !rejectOpenByBlock[id];
  69. syncPlanRemarks();
  70. return;
  71. }
  72. var submit = event.target.closest('[data-plan-submit]');
  73. if (submit) {
  74. submitPlanRemarks(submit.closest('[data-plan-review]'));
  75. return;
  76. }
  77. var remove = event.target.closest('[data-plan-remove]');
  78. if (remove) {
  79. var entry = remove.closest('[data-plan-remark-id]');
  80. var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id');
  81. var list = remarksByBlock[blockId] || [];
  82. var index = parseInt(entry.getAttribute('data-plan-remark-id'), 10);
  83. if (!isNaN(index) && list[index]) list.splice(index, 1);
  84. remarksByBlock[blockId] = list;
  85. syncPlanRemarks();
  86. }
  87. });
  88. document.addEventListener('mouseup', function (event) {
  89. if (!event.target.closest) return;
  90. if (remarkPopover && remarkPopover.contains(event.target)) return;
  91. var body = event.target.closest('[data-plan-body]');
  92. if (!body) {
  93. hideRemarkPopover();
  94. return;
  95. }
  96. var review = body.closest('[data-plan-review]');
  97. if (!review || review.getAttribute('data-plan-open') !== '1') return;
  98. var selection = window.getSelection();
  99. var text = selection ? String(selection.toString()).trim() : '';
  100. if (text.length === 0) {
  101. hideRemarkPopover();
  102. return;
  103. }
  104. showRemarkPopover(event.pageX, event.pageY, text, review);
  105. });
  106. document.addEventListener('mousedown', function (event) {
  107. if (remarkPopover && event.target.closest
  108. && event.target.closest('.plan-popover')) return;
  109. hideRemarkPopover();
  110. });
  111. function showRemarkPopover(x, y, quote, review) {
  112. hideRemarkPopover();
  113. remarkPopover = document.createElement('div');
  114. remarkPopover.className = 'plan-popover';
  115. remarkPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px';
  116. remarkPopover.style.top = y + 'px';
  117. var quoted = document.createElement('blockquote');
  118. quoted.textContent = quote;
  119. remarkPopover.appendChild(quoted);
  120. var body = document.createElement('textarea');
  121. body.placeholder = 'Your remark on the selected plan text';
  122. remarkPopover.appendChild(body);
  123. var actions = document.createElement('div');
  124. actions.className = 'actions';
  125. var add = document.createElement('button');
  126. add.type = 'button';
  127. add.className = 'primary';
  128. add.textContent = 'Add remark';
  129. add.addEventListener('click', function () {
  130. if (body.value.trim().length === 0) return;
  131. var id = review.getAttribute('data-block-id');
  132. var list = remarksByBlock[id] || [];
  133. list.push({ quote: quote, body: body.value.trim() });
  134. remarksByBlock[id] = list;
  135. hideRemarkPopover();
  136. syncPlanRemarks();
  137. });
  138. var cancel = document.createElement('button');
  139. cancel.type = 'button';
  140. cancel.textContent = 'Cancel';
  141. cancel.addEventListener('click', hideRemarkPopover);
  142. actions.appendChild(add);
  143. actions.appendChild(cancel);
  144. remarkPopover.appendChild(actions);
  145. document.body.appendChild(remarkPopover);
  146. body.focus();
  147. }
  148. function hideRemarkPopover() {
  149. if (remarkPopover) {
  150. remarkPopover.remove();
  151. remarkPopover = null;
  152. }
  153. }
  154. function submitPlanRemarks(review) {
  155. if (!review) return;
  156. var id = review.getAttribute('data-block-id');
  157. var list = remarksByBlock[id] || [];
  158. var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
  159. var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : '';
  160. // Remark-less rejection is allowed when the feedback
  161. // textarea carries the review's substance - one of the
  162. // two must be non-empty (plan 16 S2).
  163. if (list.length === 0 && extraText.length === 0) {
  164. if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error');
  165. return;
  166. }
  167. var block = resolveBlock(id);
  168. if (!block || !block.answer_action) return;
  169. // The rejection body keeps the historical shape: every
  170. // queued remark NUMBERED in queue order, each with its
  171. // selection quote prefixed like a comment, then the
  172. // free-text feedback.
  173. var feedback = list.map(function (item, index) {
  174. return (item.quote
  175. ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n'
  176. : '') + (index + 1) + '. ' + item.body;
  177. }).join('\n\n');
  178. if (extraText.length > 0) {
  179. feedback += (feedback.length > 0 ? '\n\n' : '') + extraText;
  180. }
  181. remarksByBlock[id] = [];
  182. rejectOpenByBlock[id] = false;
  183. statum.act(block.answer_action, { feedback: feedback }).catch(function () { });
  184. }
  185. function syncPlanRemarks() {
  186. document.querySelectorAll('[data-plan-review]').forEach(function (review) {
  187. var id = review.getAttribute('data-block-id');
  188. var open = rejectOpenByBlock[id] ? '1' : '0';
  189. review.setAttribute('data-plan-open', open);
  190. var feedback = review.querySelector('[data-plan-feedback]');
  191. if (feedback) {
  192. if (rejectOpenByBlock[id]) feedback.removeAttribute('hidden');
  193. else feedback.setAttribute('hidden', '');
  194. }
  195. var list = review.querySelector('[data-plan-remarks]');
  196. if (!list) return;
  197. while (list.firstChild) list.removeChild(list.firstChild);
  198. (remarksByBlock[id] || []).forEach(function (item, index) {
  199. var entry = document.createElement('li');
  200. entry.setAttribute('data-plan-remark-id', String(index));
  201. if (item.quote) {
  202. var quoted = document.createElement('blockquote');
  203. quoted.textContent = item.quote;
  204. entry.appendChild(quoted);
  205. }
  206. var body = document.createElement('div');
  207. body.textContent = item.body;
  208. entry.appendChild(body);
  209. var remove = document.createElement('button');
  210. remove.type = 'button';
  211. remove.className = 'inline';
  212. remove.setAttribute('data-plan-remove', '');
  213. remove.textContent = 'Remove';
  214. entry.appendChild(remove);
  215. list.appendChild(entry);
  216. });
  217. });
  218. }
  219. };
  220. //
  221. // initLongFormEditors (plan QoL task 12): in-place editors for the
  222. // long-form card fields (task mandate, goal brief). The rendered
  223. // markdown view and the editor <form> BOTH live in the page markup;
  224. // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
  225. // plan-remarks pattern) toggles which side shows. Why JS-applied
  226. // values and not stm-attribute.value: statum's attribute binding
  227. // sets the CONTENT attribute, which the browser ignores on
  228. // <textarea> (its value lives in the text node) - so the textarea
  229. // is re-prefilled from the card's data-value payload after every
  230. // render cycle, while the page state carries the source of truth.
  231. // The card contract:
  232. // [data-long-form] the card root; data-field = slot key
  233. // whose object carries the payload
  234. // [data-lf-view] the rendered markdown half
  235. // [data-lf-edit] the editor half (form)
  236. // textarea/input[data-lf-src] a state path ("task.description")
  237. // whose current value prefills the
  238. // control on every render
  239. // [data-lf-toggle] the Edit / Cancel button
  240. // Buttons INSIDE the form (Save) submit through the form's
  241. // stm-action as usual; toggling is purely presentational, so no
  242. // state round-trip and no flicker.
  243. //
  244. window.initLongFormEditors = function () {
  245. if (!window.statum || !statum.afterEvaluate) return;
  246. // Resolve a "type.field" state path: statum.read returns the
  247. // SLOT's public object, so the first segment is the slot and
  248. // the rest walks into it.
  249. function readPath(path) {
  250. if (!path) return '';
  251. var parts = path.split('.');
  252. var value = statum.read(parts[0]);
  253. for (var i = 1; i < parts.length && value != null; i++) {
  254. value = value[parts[i]];
  255. }
  256. return value === undefined || value === null ? '' : String(value);
  257. }
  258. function syncCard(card) {
  259. var open = card.getAttribute('data-lf-open') === '1';
  260. var view = card.querySelector('[data-lf-view]');
  261. var editor = card.querySelector('[data-lf-edit]');
  262. if (view) {
  263. if (open) view.setAttribute('hidden', '');
  264. else view.removeAttribute('hidden');
  265. }
  266. if (editor) {
  267. if (open) editor.removeAttribute('hidden');
  268. else editor.setAttribute('hidden', '');
  269. }
  270. }
  271. // Prefill bookkeeping: applied ONCE per open - a statum
  272. // re-render (pulse refresh, action response) rebinds
  273. // attributes but never textarea VALUES, so re-applying on
  274. // every cycle would clobber the user's in-progress typing.
  275. // Closing the card clears the flag; the next open re-prefills
  276. // from the (possibly updated) state.
  277. var prefilled = {};
  278. function applyPrefills() {
  279. var cards = document.querySelectorAll('[data-long-form]');
  280. Array.prototype.forEach.call(cards, function (card) {
  281. syncCard(card);
  282. var key = card.getAttribute('data-field') || '';
  283. var open = card.getAttribute('data-lf-open') === '1';
  284. if (open && !prefilled[key]) {
  285. var controls = card.querySelectorAll('[data-lf-src]');
  286. Array.prototype.forEach.call(controls, function (control) {
  287. var value = readPath(control.getAttribute('data-lf-src'));
  288. if (document.activeElement !== control) {
  289. control.value = value;
  290. }
  291. });
  292. prefilled[key] = true;
  293. }
  294. if (!open) delete prefilled[key];
  295. });
  296. }
  297. document.addEventListener('click', function (event) {
  298. if (!event.target.closest) return;
  299. var button = event.target.closest('[data-lf-toggle]');
  300. if (!button) return;
  301. var card = button.closest('[data-long-form]');
  302. if (!card) return;
  303. event.preventDefault();
  304. var open = card.getAttribute('data-lf-open') === '1';
  305. if (open) {
  306. card.setAttribute('data-lf-open', '0');
  307. delete prefilled[card.getAttribute('data-field') || ''];
  308. } else {
  309. card.setAttribute('data-lf-open', '1');
  310. }
  311. syncCard(card);
  312. // Prefill immediately (the render cycle applies the state
  313. // bindings; the textarea's value needs this direct pass).
  314. applyPrefills();
  315. if (!open) {
  316. var area = card.querySelector('textarea');
  317. if (area && area.focus) area.focus();
  318. }
  319. });
  320. statum.afterEvaluate(applyPrefills);
  321. };
  322. if (window.statum) {
  323. (function () {
  324. 'use strict';
  325. window.showToast = function (message, kind) {
  326. var stack = document.querySelector('.toast-stack');
  327. if (!stack) {
  328. stack = document.createElement('div');
  329. stack.className = 'toast-stack';
  330. document.body.appendChild(stack);
  331. }
  332. var toast = document.createElement('div');
  333. toast.className = 'toast ' + (kind || '');
  334. toast.textContent = message;
  335. stack.appendChild(toast);
  336. window.setTimeout(function () {
  337. toast.classList.add('leaving');
  338. window.setTimeout(function () { toast.remove(); }, 300);
  339. }, 5000);
  340. };
  341. statum.onNotifyHandler = function (kind, message) {
  342. showToast(message || kind, kind);
  343. };
  344. var quoteHandlers = [];
  345. var quoteButton = null;
  346. var quotePopover = null;
  347. var quotedText = '';
  348. var activeHandler = null;
  349. window.initQuoteComments = function (handler) {
  350. quoteHandlers.push(handler);
  351. };
  352. document.addEventListener('mouseup', function (event) {
  353. if (quoteButton && quoteButton.contains(event.target)) return;
  354. var selection = window.getSelection();
  355. var text = selection ? String(selection.toString()).trim() : '';
  356. activeHandler = matchHandler(event.target);
  357. if (text.length === 0 || !activeHandler) {
  358. hideQuoteUi();
  359. return;
  360. }
  361. quotedText = text;
  362. showQuoteButton(event.pageX, event.pageY);
  363. });
  364. document.addEventListener('mousedown', function (event) {
  365. if (quoteButton && quoteButton.contains(event.target)) return;
  366. if (quotePopover && quotePopover.contains(event.target)) return;
  367. hideQuoteUi();
  368. });
  369. function matchHandler(node) {
  370. for (var i = 0; i < quoteHandlers.length; i++) {
  371. if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
  372. }
  373. return null;
  374. }
  375. function showQuoteButton(x, y) {
  376. hideQuoteUi();
  377. quoteButton = document.createElement('button');
  378. quoteButton.type = 'button';
  379. quoteButton.className = 'quote-button';
  380. quoteButton.textContent = 'Comment on this';
  381. quoteButton.style.left = Math.max(x - 60, 8) + 'px';
  382. quoteButton.style.top = (y - 36) + 'px';
  383. quoteButton.addEventListener('click', function (event) {
  384. event.preventDefault();
  385. openQuoteDialog();
  386. });
  387. document.body.appendChild(quoteButton);
  388. }
  389. function openQuoteDialog() {
  390. if (quoteButton) { quoteButton.remove(); quoteButton = null; }
  391. var dialog = document.getElementById('quote-comment-dialog');
  392. if (!dialog) return;
  393. var quote = dialog.querySelector('#quote-text');
  394. var field = dialog.querySelector('input[name="quote"]');
  395. if (quote) quote.textContent = quotedText;
  396. if (field) field.value = quotedText;
  397. var returnField = dialog.querySelector('input[name="return_to"]');
  398. if (returnField) returnField.value = location.pathname + location.search;
  399. if (typeof dialog.showModal === 'function') {
  400. dialog.showModal();
  401. } else {
  402. dialog.setAttribute('open', '');
  403. }
  404. }
  405. function hideQuoteUi() {
  406. if (quoteButton) { quoteButton.remove(); quoteButton = null; }
  407. if (quotePopover) { quotePopover.remove(); quotePopover = null; }
  408. activeHandler = null;
  409. }
  410. })();
  411. }
  412. </script>
  413. </head>
  414. <body>
  415. <header>
  416. <h1><img class="brand-emblem" alt="" pstm-res-src="clanker-saddle-emblem.png">ClankerSaddle</h1>
  417. <nav>
  418. <a href="/">Dashboard</a>
  419. <!-- spry:nav-begin -->
  420. <a href="/projects">Projects</a>
  421. <a href="/tasks">Tasks</a>
  422. <a href="/goals">Goals</a>
  423. <a href="/saddles">Saddles</a>
  424. <a href="/rules">Rules</a>
  425. <a href="/stacks">Stacks</a>
  426. <!-- The users link is display-guarded (the page's entrypoint
  427. guard is authoritative); auth.permissions rides the
  428. signed auth slot, so an admin sees it everywhere. -->
  429. <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
  430. <!-- spry:nav-end -->
  431. <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
  432. <a href="/profile" title="Your profile">Profile</a>
  433. <button type="button" class="inline" stm-action="auth.logout">Log out</button>
  434. <!-- spry:nav-auth-begin -->
  435. <!-- spry:nav-auth-end -->
  436. </span>
  437. <a stm-if="auth == null" href="/login">Log in</a>
  438. </nav>
  439. </header>
  440. <main>
  441. <div stm-preloader>Loading…</div>
  442. <div stm-content hidden>
  443. <pstm-content></pstm-content>
  444. </div>
  445. </main>
  446. <footer><p>ClankerSaddle — the agent works while you sleep.</p></footer>
  447. </body>
  448. </html>