| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <pstm-name>main</pstm-name>
- <pstm-title>ClankerSaddle</pstm-title>
- <script pstm-res-src="statum.js"></script>
- <link rel="stylesheet" pstm-res-href="app.css">
- <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
- <link rel="apple-touch-icon" pstm-res-href="clanker-saddle.png">
- <link rel="stm-guard" require="auth != null || location.pathname == '/login'" href="/login">
- <script>
- // Shared page plumbing. showToast: slide-in notifications (used in
- // place of alert). initQuoteComments: registers a container matcher
- // for the text-highlight -> comment popover — selecting text inside
- // a matched container shows a floating button, then a popover that
- // posts the comment with the quoted text via the action the page
- // supplies.
- // Shared UI engines (parameterised per page):
- //
- // showToast — every page's toast corner.
- //
- // initQuoteComments — the text-selection quote popover + dialog
- // posting a comment with the quoted text; pages pass a `matches`
- // node predicate and the action accessor.
- //
- // initPlanReviews (plan 16 R3, arch 15 — the ~180-line pattern
- // that lived duplicated between the task and goal pages, now
- // goal-only after the task-page plan review died with the poll
- // era): the plan/completion REVIEW remark queue. "Add remark"
- // appends to a per-block queue rendered at the bottom of the
- // review body, persists across statum re-renders (the maps live
- // outside the render cycle), rides the rejection NUMBERED (each
- // remark with its selection quote prefixed like a comment), and
- // clears on accept. Remark-less rejection is allowed when the
- // feedback textarea carries the substance (plan 16 S2). The
- // rejection body rides `feedback`; the SERVER wraps the per-kind
- // stable prefix (R4 — templates carry no verdict constants). The
- // page supplies `resolveBlock(id)` — how a [data-plan-review]
- // container's block row resolves from page state.
- window.initPlanReviews = function (config) {
- if (!window.statum || !statum.afterEvaluate) return;
- var resolveBlock = (config && config.resolveBlock)
- || function () { return null; };
- var remarksByBlock = {};
- var rejectOpenByBlock = {};
- var remarkPopover = null;
- statum.afterEvaluate(syncPlanRemarks);
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var accept = event.target.closest('[data-plan-accept]');
- if (accept) {
- // Accept ignores/clears the queued remarks.
- var accepted = accept.closest('[data-plan-review]');
- if (accepted) {
- var acceptId = accepted.getAttribute('data-block-id');
- remarksByBlock[acceptId] = [];
- rejectOpenByBlock[acceptId] = false;
- }
- return;
- }
- var toggle = event.target.closest('[data-plan-toggle]');
- if (toggle) {
- hideRemarkPopover();
- var review = toggle.closest('[data-plan-review]');
- var id = review && review.getAttribute('data-block-id');
- if (id) rejectOpenByBlock[id] = !rejectOpenByBlock[id];
- syncPlanRemarks();
- return;
- }
- var submit = event.target.closest('[data-plan-submit]');
- if (submit) {
- submitPlanRemarks(submit.closest('[data-plan-review]'));
- return;
- }
- var remove = event.target.closest('[data-plan-remove]');
- if (remove) {
- var entry = remove.closest('[data-plan-remark-id]');
- var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id');
- var list = remarksByBlock[blockId] || [];
- var index = parseInt(entry.getAttribute('data-plan-remark-id'), 10);
- if (!isNaN(index) && list[index]) list.splice(index, 1);
- remarksByBlock[blockId] = list;
- syncPlanRemarks();
- }
- });
- document.addEventListener('mouseup', function (event) {
- if (!event.target.closest) return;
- if (remarkPopover && remarkPopover.contains(event.target)) return;
- var body = event.target.closest('[data-plan-body]');
- if (!body) {
- hideRemarkPopover();
- return;
- }
- var review = body.closest('[data-plan-review]');
- if (!review || review.getAttribute('data-plan-open') !== '1') return;
- var selection = window.getSelection();
- var text = selection ? String(selection.toString()).trim() : '';
- if (text.length === 0) {
- hideRemarkPopover();
- return;
- }
- showRemarkPopover(event.pageX, event.pageY, text, review);
- });
- document.addEventListener('mousedown', function (event) {
- if (remarkPopover && event.target.closest
- && event.target.closest('.plan-popover')) return;
- hideRemarkPopover();
- });
- function showRemarkPopover(x, y, quote, review) {
- hideRemarkPopover();
- remarkPopover = document.createElement('div');
- remarkPopover.className = 'plan-popover';
- remarkPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px';
- remarkPopover.style.top = y + 'px';
- var quoted = document.createElement('blockquote');
- quoted.textContent = quote;
- remarkPopover.appendChild(quoted);
- var body = document.createElement('textarea');
- body.placeholder = 'Your remark on the selected plan text';
- remarkPopover.appendChild(body);
- var actions = document.createElement('div');
- actions.className = 'actions';
- var add = document.createElement('button');
- add.type = 'button';
- add.className = 'primary';
- add.textContent = 'Add remark';
- add.addEventListener('click', function () {
- if (body.value.trim().length === 0) return;
- var id = review.getAttribute('data-block-id');
- var list = remarksByBlock[id] || [];
- list.push({ quote: quote, body: body.value.trim() });
- remarksByBlock[id] = list;
- hideRemarkPopover();
- syncPlanRemarks();
- });
- var cancel = document.createElement('button');
- cancel.type = 'button';
- cancel.textContent = 'Cancel';
- cancel.addEventListener('click', hideRemarkPopover);
- actions.appendChild(add);
- actions.appendChild(cancel);
- remarkPopover.appendChild(actions);
- document.body.appendChild(remarkPopover);
- body.focus();
- }
- function hideRemarkPopover() {
- if (remarkPopover) {
- remarkPopover.remove();
- remarkPopover = null;
- }
- }
- function submitPlanRemarks(review) {
- if (!review) return;
- var id = review.getAttribute('data-block-id');
- var list = remarksByBlock[id] || [];
- var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
- var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : '';
- // Remark-less rejection is allowed when the feedback
- // textarea carries the review's substance — one of the
- // two must be non-empty (plan 16 S2).
- if (list.length === 0 && extraText.length === 0) {
- if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error');
- return;
- }
- var block = resolveBlock(id);
- if (!block || !block.answer_action) return;
- // The rejection body keeps the historical shape: every
- // queued remark NUMBERED in queue order, each with its
- // selection quote prefixed like a comment, then the
- // free-text feedback.
- var feedback = list.map(function (item, index) {
- return (item.quote
- ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n'
- : '') + (index + 1) + '. ' + item.body;
- }).join('\n\n');
- if (extraText.length > 0) {
- feedback += (feedback.length > 0 ? '\n\n' : '') + extraText;
- }
- remarksByBlock[id] = [];
- rejectOpenByBlock[id] = false;
- statum.act(block.answer_action, { feedback: feedback }).catch(function () { });
- }
- function syncPlanRemarks() {
- document.querySelectorAll('[data-plan-review]').forEach(function (review) {
- var id = review.getAttribute('data-block-id');
- var open = rejectOpenByBlock[id] ? '1' : '0';
- review.setAttribute('data-plan-open', open);
- var feedback = review.querySelector('[data-plan-feedback]');
- if (feedback) {
- if (rejectOpenByBlock[id]) feedback.removeAttribute('hidden');
- else feedback.setAttribute('hidden', '');
- }
- var list = review.querySelector('[data-plan-remarks]');
- if (!list) return;
- while (list.firstChild) list.removeChild(list.firstChild);
- (remarksByBlock[id] || []).forEach(function (item, index) {
- var entry = document.createElement('li');
- entry.setAttribute('data-plan-remark-id', String(index));
- if (item.quote) {
- var quoted = document.createElement('blockquote');
- quoted.textContent = item.quote;
- entry.appendChild(quoted);
- }
- var body = document.createElement('div');
- body.textContent = item.body;
- entry.appendChild(body);
- var remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'inline';
- remove.setAttribute('data-plan-remove', '');
- remove.textContent = 'Remove';
- entry.appendChild(remove);
- list.appendChild(entry);
- });
- });
- }
- };
- if (window.statum) {
- (function () {
- 'use strict';
- window.showToast = function (message, kind) {
- var stack = document.querySelector('.toast-stack');
- if (!stack) {
- stack = document.createElement('div');
- stack.className = 'toast-stack';
- document.body.appendChild(stack);
- }
- var toast = document.createElement('div');
- toast.className = 'toast ' + (kind || '');
- toast.textContent = message;
- stack.appendChild(toast);
- window.setTimeout(function () {
- toast.classList.add('leaving');
- window.setTimeout(function () { toast.remove(); }, 300);
- }, 5000);
- };
- statum.onNotifyHandler = function (kind, message) {
- showToast(message || kind, kind);
- };
- var quoteHandlers = [];
- var quoteButton = null;
- var quotePopover = null;
- var quotedText = '';
- var activeHandler = null;
- window.initQuoteComments = function (handler) {
- quoteHandlers.push(handler);
- };
- document.addEventListener('mouseup', function (event) {
- if (quoteButton && quoteButton.contains(event.target)) return;
- var selection = window.getSelection();
- var text = selection ? String(selection.toString()).trim() : '';
- activeHandler = matchHandler(event.target);
- if (text.length === 0 || !activeHandler) {
- hideQuoteUi();
- return;
- }
- quotedText = text;
- showQuoteButton(event.pageX, event.pageY);
- });
- document.addEventListener('mousedown', function (event) {
- if (quoteButton && quoteButton.contains(event.target)) return;
- if (quotePopover && quotePopover.contains(event.target)) return;
- hideQuoteUi();
- });
- function matchHandler(node) {
- for (var i = 0; i < quoteHandlers.length; i++) {
- if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
- }
- return null;
- }
- function showQuoteButton(x, y) {
- hideQuoteUi();
- quoteButton = document.createElement('button');
- quoteButton.type = 'button';
- quoteButton.className = 'quote-button';
- quoteButton.textContent = 'Comment on this';
- quoteButton.style.left = Math.max(x - 60, 8) + 'px';
- quoteButton.style.top = (y - 36) + 'px';
- quoteButton.addEventListener('click', function (event) {
- event.preventDefault();
- openQuoteDialog();
- });
- document.body.appendChild(quoteButton);
- }
- function openQuoteDialog() {
- if (quoteButton) { quoteButton.remove(); quoteButton = null; }
- var dialog = document.getElementById('quote-comment-dialog');
- if (!dialog) return;
- var quote = dialog.querySelector('#quote-text');
- var field = dialog.querySelector('input[name="quote"]');
- if (quote) quote.textContent = quotedText;
- if (field) field.value = quotedText;
- var returnField = dialog.querySelector('input[name="return_to"]');
- if (returnField) returnField.value = location.pathname + location.search;
- if (typeof dialog.showModal === 'function') {
- dialog.showModal();
- } else {
- dialog.setAttribute('open', '');
- }
- }
- function hideQuoteUi() {
- if (quoteButton) { quoteButton.remove(); quoteButton = null; }
- if (quotePopover) { quotePopover.remove(); quotePopover = null; }
- activeHandler = null;
- }
- })();
- }
- </script>
- </head>
- <body>
- <header>
- <h1><img class="brand-emblem" alt="" pstm-res-src="clanker-saddle-emblem.png">ClankerSaddle</h1>
- <nav>
- <a href="/">Dashboard</a>
- <!-- spry:nav-begin -->
- <a href="/projects">Projects</a>
- <a href="/tasks">Tasks</a>
- <a href="/goals" class="nav-goal"><span class="goal-icon">🎯</span> Goals</a>
- <a href="/saddles">Saddles</a>
- <a href="/rules">Rules</a>
- <!-- spry:nav-end -->
- <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
- <button type="button" class="inline" stm-action="auth.logout">Log out</button>
- <!-- spry:nav-auth-begin -->
- <!-- spry:nav-auth-end -->
- </span>
- <a stm-if="auth == null" href="/login">Log in</a>
- </nav>
- </header>
- <main>
- <div stm-preloader>Loading…</div>
- <div stm-content hidden>
- <pstm-content></pstm-content>
- </div>
- </main>
- <footer><p>ClankerSaddle — the agent works while you sleep.</p></footer>
- </body>
- </html>
|