@bongos/core 1.19.1066 → 1.19.1068
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +50 -30
- package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
- package/docs/copy-inventory.md +64 -43
- package/docs/copy-registry.json +288 -79
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -2
- package/docs/page-readings.json +74 -50
- package/modules/hall-ui/public/studio-lib.js +207 -0
- package/modules/hall-ui/public/studio.css +559 -170
- package/modules/hall-ui/public/studio.html +142 -48
- package/modules/hall-ui/public/studio.js +452 -40
- package/modules/hall-ui/public/studio.states.json +164 -4
- package/modules/hall-ui/records/studio.md +47 -0
- package/modules/lifecycle/cascade.js +48 -96
- package/modules/lifecycle/routes/lifecycle.js +9 -9
- package/modules/lifecycle/tests/cascade.mjs +106 -160
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/grader_reader_lens.mjs +16 -24
- package/tests/hall_audit.mjs +4 -0
- package/tests/hall_studio_home.mjs +429 -0
- package/tests/hall_studio_world.mjs +7 -1
- package/tests/removal_verdict.mjs +5 -2
- package/tests/tweak_replaces_artist_review.mjs +170 -0
|
@@ -1,30 +1,48 @@
|
|
|
1
|
-
// modules/hall-ui/public/studio.js — the
|
|
2
|
-
// R06 of goal 1000074 (the artist's loop), task 1003117.
|
|
1
|
+
// modules/hall-ui/public/studio.js — the studio, /builders/studio.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// THE STUDIO HOME (task 1003971 / BV2.TW16, goal 1000095; ADR 0341 D2, D5, D8
|
|
4
|
+
// and D11). The artist's lofi home in Tweak Mode, built 1:1 on the master
|
|
5
|
+
// design (docs/design/mocks/tweak-mode/Main.dc.html): the night room, a
|
|
6
|
+
// greeting whose words are editable where they stand, the tally top right
|
|
7
|
+
// (GET /copy-desk/tally), Resume back into the page the artist holds (GET
|
|
8
|
+
// /copy-desk/next's resume, into /builders/tweak-editor?page=<id>), and three
|
|
9
|
+
// quick actions that open glass panels in place: the Tweak/CopyWrite Flow (GET
|
|
10
|
+
// /copy-desk/next: the recommendation, take it, pick another), the Approval
|
|
11
|
+
// queue (approval-queue.js, mounted below) and Artist Review Status (GET
|
|
12
|
+
// /copy-desk/pages: three surface lines, "N of M tweaked"). The words and rules
|
|
13
|
+
// are studio-lib.js.
|
|
7
14
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// THE STUDIO'S OWN WORDS are a tweak of THIS page, builders:studio (ADR 0341
|
|
16
|
+
// D2), never a live edit. A first click on them offers to take the studio page
|
|
17
|
+
// (the claim route, D5); once the artist holds it, the lines the studio's own
|
|
18
|
+
// reading lists are typed over in place and autosaved to the round's draft
|
|
19
|
+
// (PUT /copy-desk/pages/builders:studio/draft, the whole draft each time, the
|
|
20
|
+
// tweak editor's rule), and the page itself keeps its words until the round is
|
|
21
|
+
// submitted, applied, approved and shipped like any other. Submit is the
|
|
22
|
+
// editor's: the strip under the words links there.
|
|
23
|
+
//
|
|
24
|
+
// THE OLDER REVIEWS (R06 of goal 1000074, task 1003117). Below the home sits
|
|
25
|
+
// the garden this page used to be, behind a quiet door that shows only while
|
|
26
|
+
// reviews filed before Tweak Mode still wait. It presents the artist's older
|
|
27
|
+
// work ONE PIECE AT A TIME, a stone to tend, with the rest waiting quietly:
|
|
28
|
+
// 1. REVIEW REQUESTS (R04, cascade.js) — RETIRED, draining. A copy/visual
|
|
29
|
+
// ship used to file a two-step artist review at `backlog`, discipline
|
|
30
|
+
// 'artist', source 'cascade'. Tweak Mode removed that rule (task 1004325 /
|
|
31
|
+
// BV2.TW14, ADR 0341 D9), so no new reviews arrive; the ones already filed
|
|
32
|
+
// still show here so they can be answered (they may still hold a deploy
|
|
33
|
+
// under the strict artist gate, ADR 0241). The door goes once none wait.
|
|
15
34
|
// 2. FLAGGED COPY (R02, copy-desk) — open flags over the words the product
|
|
16
|
-
// shows people
|
|
17
|
-
//
|
|
35
|
+
// shows people, tended beside the reviews while the door is open.
|
|
36
|
+
// Best-effort: an instance without the copy-desk module has no queue.
|
|
18
37
|
//
|
|
19
38
|
// It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.
|
|
20
39
|
// And it writes NOTHING to any live string or asset — the goal's no-live-CMS
|
|
21
|
-
// rule, unchanged. What it DOES write
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// tests/hall_studio_world.mjs.
|
|
40
|
+
// rule, unchanged. What it DOES write is named in WRITE_ROUTES below, pinned in
|
|
41
|
+
// tests/hall_studio_world.mjs: the artist's own VERDICT on their own older
|
|
42
|
+
// reviews (since task 1003829), the Approval queue's two decisions (TW12), and
|
|
43
|
+
// the page claim and draft save that make a page, the studio included, the
|
|
44
|
+
// artist's to rewrite (TW16). Every change to the product still lands as a
|
|
45
|
+
// claimed, graded, shipped task.
|
|
28
46
|
//
|
|
29
47
|
// THE PICTURE (task 1003829). A review names the shipped task it is about, and
|
|
30
48
|
// that task may carry a ship-time visual. The room reads it for the card in
|
|
@@ -46,9 +64,9 @@
|
|
|
46
64
|
let busy = false; // a verdict is in flight — one at a time, so a double-click cannot file twice
|
|
47
65
|
let tended = false; // at least one verdict landed this sitting, so an emptied garden reads as finished
|
|
48
66
|
|
|
49
|
-
// Bound the reviews read. cascade.js
|
|
50
|
-
//
|
|
51
|
-
// so this queue
|
|
67
|
+
// Bound the reviews read. cascade.js USED to file a review per qualifying ship
|
|
68
|
+
// (no longer, since TW14: the queue only drains now), and they sit at backlog
|
|
69
|
+
// until claimed or dismissed, so this queue could grow without bound — the unbounded-read
|
|
52
70
|
// shape modules/hall-ui/CLAUDE.md flags as a repeat bug (the Work board's backlog,
|
|
53
71
|
// Home's next-up card, the Goals rollup all had it). 50 is far more than a focus
|
|
54
72
|
// queue tends at once; the footer links to the copy desk and board are the
|
|
@@ -92,6 +110,14 @@
|
|
|
92
110
|
// travels as the route's own path (the tweak editor's take.path shape).
|
|
93
111
|
approve: (pageId) => `${API}/copy-desk/pages/${pageId}/approve`,
|
|
94
112
|
sendBack: (pageId) => `${API}/copy-desk/pages/${pageId}/send-back`,
|
|
113
|
+
// The home's two (task 1003971 / BV2.TW16, ADR 0341 D5 and D11). claim
|
|
114
|
+
// takes a page's tweak round for the artist: "take it" in the Tweak/
|
|
115
|
+
// CopyWrite Flow, and "take the studio page" before the studio's own words
|
|
116
|
+
// are typed over. draft saves the WHOLE draft of the studio page the artist
|
|
117
|
+
// holds (the holder only; the route decides). Neither writes a live string:
|
|
118
|
+
// a round's words reach its page only as a shipped, approved tweak.
|
|
119
|
+
claim: (pageId) => `${API}/copy-desk/pages/${pageId}/claim`,
|
|
120
|
+
draft: (pageId) => `${API}/copy-desk/pages/${pageId}/draft`,
|
|
95
121
|
});
|
|
96
122
|
|
|
97
123
|
// The verdict floor, read off /me. Local like goals-render.js's copy — four
|
|
@@ -108,14 +134,6 @@
|
|
|
108
134
|
// helpers
|
|
109
135
|
// ---------------------------------------------------------------------------
|
|
110
136
|
|
|
111
|
-
function greeting() {
|
|
112
|
-
const h = new Date().getHours();
|
|
113
|
-
if (h < 5) return 'Still up';
|
|
114
|
-
if (h < 12) return 'Good morning';
|
|
115
|
-
if (h < 18) return 'Good afternoon';
|
|
116
|
-
return 'Good evening';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
137
|
// The shipped task a review is ABOUT — cascade.js stamps
|
|
120
138
|
// source_ref = 'cascade-artist-review-on-ship-<taskId>'.
|
|
121
139
|
function reviewShippedId(t) {
|
|
@@ -240,8 +258,8 @@
|
|
|
240
258
|
function emptyCard(done) {
|
|
241
259
|
const heading = done ? 'You have tended everything waiting' : 'Nothing waits right now';
|
|
242
260
|
const body = done
|
|
243
|
-
? 'That is the whole garden. Come back when
|
|
244
|
-
: 'A quiet moment
|
|
261
|
+
? 'That is the whole garden. Come back when someone flags a line, or start again from the top.'
|
|
262
|
+
: 'A quiet moment. When someone flags a line of copy, it waits here for you.';
|
|
245
263
|
const restart = done
|
|
246
264
|
? '<div class="studio-card__actions studio-empty__actions"><button type="button" class="btn-ghost" data-restart>Start over</button></div>'
|
|
247
265
|
: '';
|
|
@@ -252,13 +270,18 @@
|
|
|
252
270
|
// paint
|
|
253
271
|
// ---------------------------------------------------------------------------
|
|
254
272
|
|
|
273
|
+
// The quiet door to the older reviews: shown only while a review filed before
|
|
274
|
+
// Tweak Mode still waits (TW14), since that is the one reason the garden stays.
|
|
255
275
|
function paintHead() {
|
|
256
|
-
const
|
|
257
|
-
$('#
|
|
258
|
-
|
|
259
|
-
$('#
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
const reviews = pieces.filter((p) => p.type === 'review').length;
|
|
277
|
+
const door = $('#st-older');
|
|
278
|
+
if (door) door.hidden = reviews === 0;
|
|
279
|
+
const label = $('#st-older-label');
|
|
280
|
+
if (label) {
|
|
281
|
+
label.textContent = reviews === 1
|
|
282
|
+
? '1 review from before Tweak Mode still waits'
|
|
283
|
+
: `${reviews} reviews from before Tweak Mode still wait`;
|
|
284
|
+
}
|
|
262
285
|
}
|
|
263
286
|
|
|
264
287
|
function paint() {
|
|
@@ -555,10 +578,399 @@
|
|
|
555
578
|
approvals.refresh();
|
|
556
579
|
}
|
|
557
580
|
|
|
581
|
+
// ===========================================================================
|
|
582
|
+
// THE HOME (task 1003971 / BV2.TW16): the tally, Resume, the three quick
|
|
583
|
+
// actions and their panels, and the studio's own words. It needs a real DOM
|
|
584
|
+
// and the two pure libs; without either (the vm tests of the garden, a
|
|
585
|
+
// stale cache) it draws nothing and the garden still works.
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
const lib = window.OTBStudioLib;
|
|
588
|
+
const editorLib = window.OTBTweakEditor;
|
|
589
|
+
const hasDom = typeof document.getElementById === 'function';
|
|
590
|
+
const byId = (id) => (hasDom ? document.getElementById(id) : null);
|
|
591
|
+
const STUDIO = lib ? lib.STUDIO_PAGE : 'builders:studio';
|
|
592
|
+
const SAVE_DEBOUNCE_MS = 700;
|
|
593
|
+
const editorHref = (pageId) => `/builders/tweak-editor?page=${pageId}`;
|
|
594
|
+
|
|
595
|
+
const home = {
|
|
596
|
+
next: null, nextFailed: false,
|
|
597
|
+
pages: null, pagesFailed: false,
|
|
598
|
+
pick: null, picking: false,
|
|
599
|
+
panel: null, opener: null,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// The one claim call site, for a page id the inventory's shape allows (the
|
|
603
|
+
// server validates it against the inventory again).
|
|
604
|
+
async function claimPage(pageId) {
|
|
605
|
+
if (!lib || !lib.PAGE_ID_RE.test(String(pageId || ''))) return { ok: false, data: {} };
|
|
606
|
+
return api.request('POST', WRITE_ROUTES.claim(pageId), {});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function refusalOf(res) {
|
|
610
|
+
const d = (window.OTB.errDetails && window.OTB.errDetails(res.data)) || {};
|
|
611
|
+
const code = (window.OTB.errCode && window.OTB.errCode(res.data)) || (res.data && res.data.error && res.data.error.code) || '';
|
|
612
|
+
return lib.claimWords(code, d);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function setText(id, text) { const el = byId(id); if (el) el.textContent = text; }
|
|
616
|
+
|
|
617
|
+
// ---- the tally, Resume, the details ---------------------------------------
|
|
618
|
+
|
|
619
|
+
async function loadTally() {
|
|
620
|
+
const res = await api.request('GET', `${API}/copy-desk/tally`);
|
|
621
|
+
const parts = res.ok ? lib.tallyParts(res.data) : null;
|
|
622
|
+
const pill = byId('st-tally');
|
|
623
|
+
if (!pill || !parts) return;
|
|
624
|
+
setText('st-tally-credits', parts.credits);
|
|
625
|
+
setText('st-tally-pages', parts.pages);
|
|
626
|
+
setText('st-tally-week', parts.week);
|
|
627
|
+
pill.hidden = false;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function loadNext() {
|
|
631
|
+
const res = await api.request('GET', `${API}/copy-desk/next`);
|
|
632
|
+
if (!res.ok || !res.data) { home.nextFailed = true; return; }
|
|
633
|
+
home.next = res.data;
|
|
634
|
+
setText('st-flow-detail', lib.flowDetail(home.next));
|
|
635
|
+
const resume = home.next.resume;
|
|
636
|
+
if (!resume || !lib.PAGE_ID_RE.test(String(resume.page_id || ''))) return;
|
|
637
|
+
// One more read, for "12 of 38 lines": the editor's own counter.
|
|
638
|
+
const ed = await api.request('GET', `${API}/copy-desk/pages/${resume.page_id}/draft`);
|
|
639
|
+
const counter = ed.ok && ed.data ? ed.data.counter : null;
|
|
640
|
+
const link = byId('st-resume');
|
|
641
|
+
if (link) {
|
|
642
|
+
link.href = editorHref(resume.page_id);
|
|
643
|
+
link.hidden = false;
|
|
644
|
+
}
|
|
645
|
+
setText('st-resume-detail', lib.resumeDetail(resume, counter));
|
|
646
|
+
paintSubLine(lib.subParts(resume, counter));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The greeting's sub-line, written here (not in studio-lib.js) so the copy
|
|
650
|
+
// registry places it at this assignment: it is one of the studio's own lines,
|
|
651
|
+
// and an in-place tweak of it names this string.
|
|
652
|
+
function paintSubLine(p) {
|
|
653
|
+
const sub = byId('studio-sub');
|
|
654
|
+
if (!sub || !p || wedit.editing) return;
|
|
655
|
+
if (p.counted) {
|
|
656
|
+
sub.textContent = `${p.title}, on the ${p.surface} page, is where you left it. ${p.changed} of its ${p.total} lines are yours so far.`;
|
|
657
|
+
} else {
|
|
658
|
+
sub.textContent = `${p.title}, on the ${p.surface} page, is where you left it.`;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function loadPages() {
|
|
663
|
+
const res = await api.request('GET', `${API}/copy-desk/pages`);
|
|
664
|
+
if (!res.ok || !res.data) { home.pagesFailed = true; return; }
|
|
665
|
+
home.pages = res.data;
|
|
666
|
+
setText('st-status-detail', lib.statusDetail(home.pages));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ---- the two small glass panels -------------------------------------------
|
|
670
|
+
|
|
671
|
+
const TITLES = Object.freeze({ flow: 'Tweak/CopyWrite Flow', status: 'Artist Review Status' });
|
|
672
|
+
|
|
673
|
+
function flowHtml() {
|
|
674
|
+
if (!home.next) {
|
|
675
|
+
return `<p class="st-panel__line">${home.nextFailed ? 'The next page could not be read just now. Try again in a moment.' : 'Finding the next page…'}</p>`;
|
|
676
|
+
}
|
|
677
|
+
const all = [home.next.recommended, ...(home.next.alternatives || [])].filter(Boolean);
|
|
678
|
+
const cur = (home.pick && all.find((c) => c.page_id === home.pick)) || all[0];
|
|
679
|
+
if (!cur) return '<p class="st-panel__line">Every page is taken or on its way to the site right now. Come back soon.</p>';
|
|
680
|
+
const others = all.filter((c) => c !== cur);
|
|
681
|
+
const pick = others.length
|
|
682
|
+
? `<button type="button" class="st-pick" data-pick aria-expanded="${home.picking ? 'true' : 'false'}" aria-controls="st-picks">pick another</button>`
|
|
683
|
+
: '';
|
|
684
|
+
const chips = others.map((o) => `<button type="button" class="st-chip" data-pick-id="${escapeHtml(o.page_id)}">${escapeHtml(lib.pickLabel(o))}</button>`).join('');
|
|
685
|
+
return `
|
|
686
|
+
<div class="st-next">
|
|
687
|
+
<span class="st-next__page">${escapeHtml(lib.titleOf(cur))}</span>
|
|
688
|
+
<span class="st-next__why">${escapeHtml(cur.why_line || cur.why || '')}</span>
|
|
689
|
+
</div>
|
|
690
|
+
<div class="st-next__acts">
|
|
691
|
+
<button type="button" class="st-take" data-take="${escapeHtml(cur.page_id)}">take it</button>
|
|
692
|
+
${pick}
|
|
693
|
+
</div>
|
|
694
|
+
<p class="st-panel__err" id="st-take-err" role="alert" hidden></p>
|
|
695
|
+
${others.length ? `<div class="st-picks" id="st-picks"${home.picking ? '' : ' hidden'}>${chips}</div>` : ''}`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function statusHtml() {
|
|
699
|
+
if (!home.pages) {
|
|
700
|
+
return `<p class="st-panel__line">${home.pagesFailed ? 'The pages could not be read just now. Try again in a moment.' : 'Reading the pages…'}</p>`;
|
|
701
|
+
}
|
|
702
|
+
const rows = lib.surfaceRows(home.pages, lib.landingHost(byId('hall-main') && byId('hall-main').getAttribute('data-apex'), window.location && window.location.host)).map((s) => `
|
|
703
|
+
<li class="st-surface">
|
|
704
|
+
<div class="st-surface__head">
|
|
705
|
+
<span class="st-surface__name">${escapeHtml(s.name)}</span>
|
|
706
|
+
<span class="st-surface__where">${escapeHtml(s.where)}</span>
|
|
707
|
+
<span class="st-surface__count">${escapeHtml(s.count)}</span>
|
|
708
|
+
</div>
|
|
709
|
+
<div class="st-surface__track"><div class="st-surface__fill" style="width: ${Number(s.pct)}%"></div></div>
|
|
710
|
+
</li>`).join('');
|
|
711
|
+
const asked = lib.askedLine(home.next && home.next.asked_pages);
|
|
712
|
+
return `<ul class="st-surfaces">${rows}</ul>${asked ? `<p class="st-asked">${escapeHtml(asked)}</p>` : ''}`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function paintPanel() {
|
|
716
|
+
const body = byId('st-panel-body');
|
|
717
|
+
if (!body || !home.panel) return;
|
|
718
|
+
body.innerHTML = home.panel === 'flow' ? flowHtml() : statusHtml();
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function openPanel(kind, opener) {
|
|
722
|
+
const overlay = byId('st-panel');
|
|
723
|
+
if (!overlay || !TITLES[kind]) return;
|
|
724
|
+
home.panel = kind;
|
|
725
|
+
home.opener = opener || null;
|
|
726
|
+
home.picking = false;
|
|
727
|
+
setText('st-panel-title', TITLES[kind]);
|
|
728
|
+
paintPanel();
|
|
729
|
+
overlay.hidden = false;
|
|
730
|
+
document.documentElement.classList.add('st-open');
|
|
731
|
+
const title = byId('st-panel-title');
|
|
732
|
+
if (title) title.focus();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function closePanel() {
|
|
736
|
+
const overlay = byId('st-panel');
|
|
737
|
+
if (!overlay || overlay.hidden) return;
|
|
738
|
+
overlay.hidden = true;
|
|
739
|
+
home.panel = null;
|
|
740
|
+
document.documentElement.classList.remove('st-open');
|
|
741
|
+
if (home.opener && home.opener.focus) home.opener.focus();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function takePage(pageId, button) {
|
|
745
|
+
if (button) button.disabled = true;
|
|
746
|
+
const res = await claimPage(pageId);
|
|
747
|
+
if (res.ok) { window.location.assign(editorHref(pageId)); return; }
|
|
748
|
+
if (button) button.disabled = false;
|
|
749
|
+
const err = byId('st-take-err');
|
|
750
|
+
if (err) { err.textContent = refusalOf(res); err.hidden = false; }
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---- the older reviews' dialog --------------------------------------------
|
|
754
|
+
|
|
755
|
+
function openReviews(opener) {
|
|
756
|
+
const box = byId('st-reviews');
|
|
757
|
+
if (!box) return;
|
|
758
|
+
home.opener = opener || null;
|
|
759
|
+
box.hidden = false;
|
|
760
|
+
document.documentElement.classList.add('st-open');
|
|
761
|
+
const title = byId('st-reviews-title');
|
|
762
|
+
if (title) title.focus();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function closeReviews() {
|
|
766
|
+
const box = byId('st-reviews');
|
|
767
|
+
if (!box || box.hidden) return;
|
|
768
|
+
box.hidden = true;
|
|
769
|
+
document.documentElement.classList.remove('st-open');
|
|
770
|
+
const door = byId('st-older');
|
|
771
|
+
const back = home.opener && !home.opener.hidden ? home.opener : (door && !door.hidden ? door : byId('st-flow'));
|
|
772
|
+
if (back && back.focus) back.focus();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---- the studio's own words, typed over in place ---------------------------
|
|
776
|
+
|
|
777
|
+
const wedit = {
|
|
778
|
+
editing: false, busy: false,
|
|
779
|
+
lines: [], readingHash: null, values: {}, keys: new Map(),
|
|
780
|
+
save: 'saved', timer: null, inflight: null, again: false, lastSent: null, error: '',
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
function wordEls() {
|
|
784
|
+
const box = byId('st-words');
|
|
785
|
+
return box ? Array.from(box.querySelectorAll('[data-words]')) : [];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function paintEditing() {
|
|
789
|
+
const strip = byId('st-editing');
|
|
790
|
+
if (!strip || !wedit.editing) return;
|
|
791
|
+
const changed = editorLib.changedCount(wedit.lines, wedit.values);
|
|
792
|
+
const counter = editorLib.counterText(changed, wedit.lines.length, wedit.save);
|
|
793
|
+
const unread = wedit.keys.size === 0;
|
|
794
|
+
strip.classList.toggle('st-editing--failed', wedit.save === 'failed');
|
|
795
|
+
strip.innerHTML = unread
|
|
796
|
+
? `The studio page is yours, but its reading does not list these words yet. <a href="${editorHref(STUDIO)}">Rewrite it in the editor</a>`
|
|
797
|
+
: `The studio page is yours · ${escapeHtml(counter)}${wedit.error ? ` · ${escapeHtml(wedit.error)}` : ''} · <a href="${editorHref(STUDIO)}">submit it in the editor</a>`;
|
|
798
|
+
strip.hidden = false;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function enterEditing(ed, focusEl) {
|
|
802
|
+
const reading = (ed && ed.reading) || {};
|
|
803
|
+
wedit.lines = reading.lines || [];
|
|
804
|
+
wedit.readingHash = reading.reading_hash || null;
|
|
805
|
+
wedit.values = editorLib.initialValues(wedit.lines, ed && ed.draft);
|
|
806
|
+
wedit.lastSent = JSON.stringify(editorLib.draftBody(wedit.readingHash, wedit.lines, wedit.values));
|
|
807
|
+
wedit.keys = new Map();
|
|
808
|
+
for (const el of wordEls()) {
|
|
809
|
+
const line = lib.matchLine(el.textContent, wedit.lines);
|
|
810
|
+
if (!line) { el.setAttribute('data-unread', ''); continue; }
|
|
811
|
+
wedit.keys.set(el, line.key);
|
|
812
|
+
el.textContent = wedit.values[line.key];
|
|
813
|
+
el.setAttribute('contenteditable', 'true');
|
|
814
|
+
el.setAttribute('spellcheck', 'false');
|
|
815
|
+
el.setAttribute('aria-label', `The studio's words: ${line.text}`);
|
|
816
|
+
}
|
|
817
|
+
wedit.editing = true;
|
|
818
|
+
const hint = byId('st-hint');
|
|
819
|
+
if (hint) hint.hidden = true;
|
|
820
|
+
const offer = byId('st-offer');
|
|
821
|
+
if (offer) offer.hidden = true;
|
|
822
|
+
paintEditing();
|
|
823
|
+
const target = (focusEl && wedit.keys.has(focusEl)) ? focusEl : wordEls().find((el) => wedit.keys.has(el));
|
|
824
|
+
if (target) target.focus();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function readStudio() {
|
|
828
|
+
const res = await api.request('GET', `${API}/copy-desk/pages/${STUDIO}/draft`);
|
|
829
|
+
return res.ok && res.data && res.data.reading ? res.data : null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// A first click on the words: straight into editing when the artist already
|
|
833
|
+
// holds the studio page, the offer to take it otherwise.
|
|
834
|
+
async function beginWords(focusEl) {
|
|
835
|
+
if (wedit.editing || wedit.busy || !lib || !editorLib) return;
|
|
836
|
+
wedit.busy = true;
|
|
837
|
+
const ed = await readStudio();
|
|
838
|
+
wedit.busy = false;
|
|
839
|
+
if (ed && ed.round && ed.round.held_by_you) { enterEditing(ed, focusEl); return; }
|
|
840
|
+
wedit.focusAfter = focusEl || null;
|
|
841
|
+
const offer = byId('st-offer');
|
|
842
|
+
const err = byId('st-offer-err');
|
|
843
|
+
if (err) err.hidden = true;
|
|
844
|
+
if (offer) offer.hidden = false;
|
|
845
|
+
const take = byId('st-offer-take');
|
|
846
|
+
if (take) take.focus();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function takeStudio() {
|
|
850
|
+
const take = byId('st-offer-take');
|
|
851
|
+
const err = byId('st-offer-err');
|
|
852
|
+
if (take) take.disabled = true;
|
|
853
|
+
const res = await claimPage(STUDIO);
|
|
854
|
+
if (!res.ok) {
|
|
855
|
+
if (take) take.disabled = false;
|
|
856
|
+
if (err) { err.textContent = refusalOf(res); err.hidden = false; }
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const ed = await readStudio();
|
|
860
|
+
if (take) take.disabled = false;
|
|
861
|
+
if (!ed) {
|
|
862
|
+
if (err) { err.textContent = 'The studio page is yours, but its words could not be read just now. Open it in the editor.'; err.hidden = false; }
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
enterEditing(ed, wedit.focusAfter);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function scheduleSave() {
|
|
869
|
+
clearTimeout(wedit.timer);
|
|
870
|
+
wedit.timer = setTimeout(saveWords, SAVE_DEBOUNCE_MS);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// The WHOLE draft, every changed line of the studio page (the ones typed here
|
|
874
|
+
// and any the editor saved), one save in flight at a time.
|
|
875
|
+
async function saveWords() {
|
|
876
|
+
clearTimeout(wedit.timer);
|
|
877
|
+
if (!wedit.editing) return;
|
|
878
|
+
if (wedit.inflight) { wedit.again = true; return; }
|
|
879
|
+
const body = editorLib.draftBody(wedit.readingHash, wedit.lines, wedit.values);
|
|
880
|
+
const sent = JSON.stringify(body);
|
|
881
|
+
if (sent === wedit.lastSent) return;
|
|
882
|
+
wedit.save = 'saving';
|
|
883
|
+
wedit.error = '';
|
|
884
|
+
paintEditing();
|
|
885
|
+
wedit.inflight = api.request('PUT', WRITE_ROUTES.draft(STUDIO), body);
|
|
886
|
+
const res = await wedit.inflight;
|
|
887
|
+
wedit.inflight = null;
|
|
888
|
+
if (res.ok) {
|
|
889
|
+
wedit.lastSent = sent;
|
|
890
|
+
wedit.save = 'saved';
|
|
891
|
+
} else {
|
|
892
|
+
wedit.save = 'failed';
|
|
893
|
+
const d = (window.OTB.errDetails && window.OTB.errDetails(res.data)) || {};
|
|
894
|
+
wedit.error = d.what_this_means || errMessage(res.data) || 'the words could not be saved';
|
|
895
|
+
}
|
|
896
|
+
paintEditing();
|
|
897
|
+
if (wedit.again) { wedit.again = false; saveWords(); }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function wireHome() {
|
|
901
|
+
const flow = byId('st-flow');
|
|
902
|
+
if (flow) flow.addEventListener('click', () => openPanel('flow', flow));
|
|
903
|
+
const status = byId('st-status');
|
|
904
|
+
if (status) status.addEventListener('click', () => openPanel('status', status));
|
|
905
|
+
const older = byId('st-older');
|
|
906
|
+
if (older) older.addEventListener('click', () => openReviews(older));
|
|
907
|
+
|
|
908
|
+
const overlay = byId('st-panel');
|
|
909
|
+
if (overlay) {
|
|
910
|
+
overlay.addEventListener('click', (ev) => {
|
|
911
|
+
if (ev.target === overlay || ev.target.closest('[data-close-panel]')) { closePanel(); return; }
|
|
912
|
+
const take = ev.target.closest('[data-take]');
|
|
913
|
+
if (take) { takePage(take.getAttribute('data-take'), take); return; }
|
|
914
|
+
if (ev.target.closest('[data-pick]')) { home.picking = !home.picking; paintPanel(); const b = overlay.querySelector('[data-pick]'); if (b) b.focus(); return; }
|
|
915
|
+
const chip = ev.target.closest('[data-pick-id]');
|
|
916
|
+
if (chip) { home.pick = chip.getAttribute('data-pick-id'); home.picking = false; paintPanel(); const t = overlay.querySelector('[data-take]'); if (t) t.focus(); }
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const reviews = byId('st-reviews');
|
|
920
|
+
if (reviews) {
|
|
921
|
+
reviews.addEventListener('click', (ev) => {
|
|
922
|
+
if (ev.target === reviews || ev.target.closest('[data-close-reviews]')) closeReviews();
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
document.addEventListener('keydown', (ev) => {
|
|
926
|
+
if (ev.key !== 'Escape') return;
|
|
927
|
+
if (home.panel) { closePanel(); return; }
|
|
928
|
+
const box = byId('st-reviews');
|
|
929
|
+
if (box && !box.hidden) closeReviews();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const hint = byId('st-hint');
|
|
933
|
+
if (hint) hint.addEventListener('click', () => beginWords(null));
|
|
934
|
+
const no = byId('st-offer-no');
|
|
935
|
+
if (no) no.addEventListener('click', () => { const o = byId('st-offer'); if (o) o.hidden = true; if (hint) hint.focus(); });
|
|
936
|
+
const take = byId('st-offer-take');
|
|
937
|
+
if (take) take.addEventListener('click', takeStudio);
|
|
938
|
+
for (const el of wordEls()) {
|
|
939
|
+
el.addEventListener('click', () => { if (!wedit.editing) beginWords(el); });
|
|
940
|
+
el.addEventListener('keydown', (ev) => {
|
|
941
|
+
if (ev.key === 'Enter' && wedit.editing) { ev.preventDefault(); el.blur(); }
|
|
942
|
+
});
|
|
943
|
+
el.addEventListener('paste', (ev) => {
|
|
944
|
+
if (!wedit.editing) return;
|
|
945
|
+
ev.preventDefault();
|
|
946
|
+
const text = ((ev.clipboardData && ev.clipboardData.getData('text/plain')) || '').replace(/\s+/g, ' ');
|
|
947
|
+
document.execCommand('insertText', false, text);
|
|
948
|
+
});
|
|
949
|
+
el.addEventListener('input', () => {
|
|
950
|
+
const key = wedit.keys.get(el);
|
|
951
|
+
if (!key) return;
|
|
952
|
+
wedit.values[key] = el.textContent;
|
|
953
|
+
paintEditing();
|
|
954
|
+
scheduleSave();
|
|
955
|
+
});
|
|
956
|
+
el.addEventListener('blur', () => { if (wedit.keys.has(el)) saveWords(); });
|
|
957
|
+
}
|
|
958
|
+
window.addEventListener('pagehide', () => { if (wedit.editing) saveWords(); });
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function bootHome(signedIn) {
|
|
962
|
+
if (!hasDom || !lib) return;
|
|
963
|
+
wireHome();
|
|
964
|
+
if (!signedIn) return;
|
|
965
|
+
await Promise.all([loadTally(), loadNext(), loadPages()]);
|
|
966
|
+
if (home.panel) paintPanel();
|
|
967
|
+
}
|
|
968
|
+
|
|
558
969
|
(async () => {
|
|
559
970
|
mountApprovals();
|
|
560
971
|
const me = await api.request('GET', `${API}/me`);
|
|
561
972
|
if (me.ok) viewer = me.data.builder || me.data;
|
|
973
|
+
bootHome(me.ok);
|
|
562
974
|
// Reviews first (the richer craft), then flags — each newest-first within.
|
|
563
975
|
const [reviews, flags] = await Promise.all([loadReviews(), loadFlags()]);
|
|
564
976
|
pieces = [...reviews, ...flags];
|