studio-engine 0.21.0 → 0.23.0

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.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Board
5
+ # Shared 100-gap rank read-model for board-orderable records — the kanban
6
+ # columns (McRitchie Studio tasks / news / content) and the depth-chart lanes.
7
+ # A record carries an integer `position`; the board renders by `board_ordered`
8
+ # (highest position on top), and a drag-reorder restamps the whole column with
9
+ # 100-gaps via `reposition!`. Ranking is ZONE-SCOPED: a fresh rank is computed
10
+ # within the record's zone (a kanban `stage`, a depth-chart position group), so
11
+ # every column ranks independently and 100-spacing leaves room for the next
12
+ # drag insert without a full re-stamp.
13
+ #
14
+ # This is the read/rank half of the board primitive; the write half is
15
+ # Studio::Board::Reorderable (the controller action that calls reposition!).
16
+ #
17
+ # class Task < ApplicationRecord
18
+ # include Studio::Board::Rankable
19
+ # before_create :set_initial_position # seeds the genesis rank
20
+ # end
21
+ #
22
+ # class DepthChartEntry < ApplicationRecord
23
+ # include Studio::Board::Rankable
24
+ # self.board_zone_attr = :position_group # a lane, not a kanban stage
25
+ # end
26
+ module Rankable
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ # The column the record ranks WITHIN. Default matches the three MS kanban
31
+ # boards (stage); a within-lane board overrides it, and `nil` ranks globally.
32
+ class_attribute :board_zone_attr, instance_accessor: false, default: :stage
33
+
34
+ # Board order: highest `position` first (freshest/top card wins), NULLS last,
35
+ # then newest by created_at as a stable tiebreak. Arel.sql: the fragment is a
36
+ # fixed literal, not user input.
37
+ scope :board_ordered, -> { order(Arel.sql("position DESC NULLS LAST, created_at DESC")) }
38
+ end
39
+
40
+ # Seed the genesis rank on create: max(position) within this record's zone plus
41
+ # a 100 gap. A no-op when `position` is already set, so an explicit rank is
42
+ # never clobbered. Wire from the host via `before_create :set_initial_position`.
43
+ def set_initial_position
44
+ return if position.present?
45
+
46
+ self.position = self.class.board_next_position(zone_value_for_rank)
47
+ end
48
+
49
+ # This record's zone value (nil when the model ranks globally).
50
+ def zone_value_for_rank
51
+ attr = self.class.board_zone_attr
52
+ attr && respond_to?(attr) ? public_send(attr) : nil
53
+ end
54
+
55
+ class_methods do
56
+ # max(position) + gap within a zone (or globally when the zone value / attr
57
+ # is blank). The genesis seed and the "bump to top on a stage move" both read
58
+ # from here, so the 100-gap rule lives in one place.
59
+ def board_next_position(zone_value = nil, gap: 100)
60
+ scope = board_zone_attr && zone_value ? where(board_zone_attr => zone_value) : all
61
+ (scope.maximum(:position) || 0) + gap
62
+ end
63
+
64
+ # Restamp a column top-to-bottom in ONE pass. `ids` arrive in DOM order (top
65
+ # first). Under the `position DESC` board sort the TOP card must hold the
66
+ # HIGHEST rank, so with `direction: :desc` (the board default) the first id
67
+ # gets the largest value and each next one drops by `gap` — 100-spacing that
68
+ # leaves gaps for the next drag insert. `direction: :asc` ranks the other way
69
+ # (first id smallest) for an ascending board. This is exactly the per-id
70
+ # `update_all(position: ...)` loop the MS tasks/news/content reorder actions
71
+ # each ran by hand, lifted into the model. Returns the ids it stamped.
72
+ def reposition!(ids, gap: 100, direction: :desc, id_attr: primary_key)
73
+ ids = Array(ids)
74
+ length = ids.length
75
+ ids.each_with_index do |id, index|
76
+ rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
77
+ where(id_attr => id).update_all(position: rank)
78
+ end
79
+ ids
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -1,4 +1,4 @@
1
- <%# locals: (text:, scheme: "neutral") %>
1
+ <%# locals: (text:, scheme: "neutral", data_board_count: nil) %>
2
2
  <%#
3
3
  Stage roles (stage-fresh ... stage-closed) form a shared pipeline palette
4
4
  used by News and Content (and any future pipelines). Mapping:
@@ -32,4 +32,6 @@
32
32
  else "bg-surface-alt text-body border-subtle"
33
33
  end
34
34
  %>
35
- <span class="badge <%= scheme_classes %>"><%= text %></span>
35
+ <%# data_board_count wires the badge as a studio/board count target (updateCounts sets
36
+ its textContent) — additive + opt-in, so every existing caller renders identically. %>
37
+ <span class="badge <%= scheme_classes %>"<% if local_assigns[:data_board_count] %> data-board-count="<%= data_board_count %>"<% end %>><%= text %></span>
@@ -21,7 +21,19 @@
21
21
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
22
22
 
23
23
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
24
- <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
24
+ <%# canvas-confetti is VENDORED into the engine (studio/canvas_confetti.js) and
25
+ shipped through the asset pipeline — no CDN, CSP-safe (same-origin :self),
26
+ zero-per-app dependency. Defines the global `confetti`; studio_confetti.js
27
+ then builds window.studioConfetti (burst/cannons). fireSuccessConfetti below
28
+ is unchanged and rides the same global. %>
29
+ <%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
30
+ <%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
31
+ <%# SortableJS is VENDORED into the engine (studio/sortable.js) and shipped through
32
+ the asset pipeline — no CDN, CSP-safe (same-origin :self). Defines the global
33
+ `Sortable`; the studio/board primitive (studio/_board_assets -> window.studioBoard)
34
+ drives it for drag-reorder kanban AND depth-chart boards. Loaded here (deferred)
35
+ so any board consumer has it with zero per-app wiring, mirroring canvas_confetti. %>
36
+ <%= javascript_include_tag "studio/sortable", defer: true, "data-turbo-track": "reload" %>
25
37
  <script>
26
38
  window.fireSuccessConfetti = function() {
27
39
  if (typeof confetti === 'undefined') return;
@@ -0,0 +1,341 @@
1
+ <%#
2
+ studioBoard factory — the Alpine data behind the engine board primitive
3
+ (studio/board/_board). Ships here at PAGE level on purpose: a <script> inside the
4
+ board component template would be cloned/re-parsed and never run, so a consumer
5
+ renders THIS once in its layout (like studio/_leveling_activity_assets homes
6
+ levelingActionModal and studio/_age_verify_assets homes ageVerifyModal), and the
7
+ board anywhere below just references window.studioBoard. SortableJS is loaded
8
+ globally by layouts/studio/_head (vendored, no CDN); this factory drives it.
9
+
10
+ ONE factory drives BOTH board shapes — it reads its selectors and behavior from
11
+ opts, so the kanban cross-column board (group + move PATCH + toasts + counts) and
12
+ the within-lane depth chart (group:false, a drag handle, reorder-only) are the
13
+ same code with different opts. See studio/board/_board for the full opts contract.
14
+
15
+ CRITICAL BOUNDARY — this factory is UI ONLY. It PATCHes an app-supplied move_url,
16
+ POSTs an app-supplied reorder_url, and reacts to a neutral JSON contract; it holds
17
+ ZERO domain logic. Custom behavior attaches by LISTENING to the dispatched
18
+ window events (studio:board-moved / :board-reordered / :card-added), and heavier
19
+ animation is delegated OPAQUELY to a window[fx] module + window[hook] fns the
20
+ engine never inspects.
21
+ %>
22
+ <script>
23
+ (function () {
24
+ if (window.studioBoard) return;
25
+
26
+ window.studioBoard = function (opts) {
27
+ opts = opts || {};
28
+ return {
29
+ // --- config (from opts) --------------------------------------------
30
+ zoneSelector: opts.zoneSelector || ".kanban-dropzone",
31
+ cardSelector: opts.draggable || ".kanban-card",
32
+ emptySelector: opts.emptySelector || null,
33
+ groupName: (opts.group === false ? null : (opts.group || "studio-board")),
34
+ handle: opts.handle || null,
35
+ sortFilter: opts.filter || ".kanban-empty",
36
+ idAttr: opts.idAttr || "slug",
37
+ zoneAttr: opts.zoneAttr || "stage",
38
+ domIdPrefix: opts.domIdPrefix || "card-",
39
+ moveUrl: opts.moveUrl || null,
40
+ moveParam: opts.moveParam || null,
41
+ reorderUrl: opts.reorderUrl || "",
42
+ reorderPayload: opts.reorderPayload || "slugs",
43
+ optimistic: opts.optimistic !== false,
44
+ toastsEnabled: opts.toasts !== false,
45
+ live: !!opts.live,
46
+ demo: !!opts.demo,
47
+ fxName: opts.fx || null,
48
+ onMoveHook: opts.onMoveHook || null,
49
+ onDropHook: opts.onDropHook || null,
50
+ labels: opts.labels || {},
51
+
52
+ // --- state ---------------------------------------------------------
53
+ toasts: [],
54
+
55
+ // --- init ----------------------------------------------------------
56
+ init: function () {
57
+ // Set data-alpine-ready in $nextTick, NOT synchronously: x-init runs while
58
+ // Alpine is still walking this subtree, so inner @click handlers are not
59
+ // bound yet. $nextTick fires after the directive walk, so the flag then
60
+ // means "the board is fully wired and interactive" — the documented init
61
+ // flake fix carried over from the MS kanbanBoard.
62
+ var self = this;
63
+ this.$nextTick(function () {
64
+ self.$el.dataset.alpineReady = "true";
65
+ self.initSortables();
66
+ if (self.live) {
67
+ self.observeLive();
68
+ if (!self.fx()) self.installExitStreamFallback();
69
+ }
70
+ });
71
+ },
72
+
73
+ fx: function () { return this.fxName ? window[this.fxName] : null; },
74
+ cardId: function (el) { return el ? el.getAttribute("data-" + this.idAttr) : null; },
75
+ zoneKey: function (el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null; },
76
+ label: function (zone) { return this.labels[zone] || zone; },
77
+
78
+ // --- SortableJS wiring ---------------------------------------------
79
+ initSortables: function () {
80
+ if (typeof Sortable === "undefined") return;
81
+ var self = this;
82
+ document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
83
+ var cfg = {
84
+ animation: 150,
85
+ ghostClass: "opacity-30",
86
+ draggable: self.cardSelector,
87
+ filter: self.sortFilter,
88
+ onStart: function () { window.__studioBoardDragging = true; },
89
+ onEnd: function (evt) {
90
+ self.handleSortEnd(evt);
91
+ requestAnimationFrame(function () { window.__studioBoardDragging = false; });
92
+ }
93
+ };
94
+ if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
95
+ if (self.handle) cfg.handle = self.handle;
96
+ Sortable.create(zone, cfg);
97
+ });
98
+ },
99
+
100
+ // A cross-column drop moves the card (PATCH); a same-zone drop is a pure
101
+ // reorder and must NEVER PATCH the zone. Source the from/to from the
102
+ // DROPZONES (evt.from / evt.to), never the card, so an in-place reorder
103
+ // can't mismove.
104
+ handleSortEnd: function (evt) {
105
+ var self = this;
106
+ var card = evt.item;
107
+ var fromZone = evt.from;
108
+ var toZone = evt.to;
109
+ var id = this.cardId(card);
110
+ var moved = fromZone !== toZone;
111
+
112
+ var afterMove = function (ok) {
113
+ if (moved && !ok) return; // reverted — leave the order alone
114
+ self.saveOrder(toZone);
115
+ self.updateCounts();
116
+ };
117
+
118
+ if (moved) {
119
+ Promise.resolve(this.applyMove(card, fromZone, toZone, id)).then(afterMove);
120
+ } else {
121
+ afterMove(true);
122
+ }
123
+ },
124
+
125
+ // Cross-column move: PATCH the zone attr; optimistic revert on failure.
126
+ applyMove: function (card, fromZone, toZone, id) {
127
+ var self = this;
128
+ var newZone = this.zoneKey(toZone);
129
+ var fromKey = this.zoneKey(fromZone);
130
+
131
+ // Moves not configured (or a within-zone board): a cross-zone drop is
132
+ // disallowed — snap back so a stray drag can't silently mismove. In demo
133
+ // mode with no move endpoint, still reflect the move locally.
134
+ if (!this.moveUrl || !this.moveParam) {
135
+ if (this.demo) {
136
+ this.setCardZone(card, newZone);
137
+ this.emit("board-moved", id, fromKey, newZone);
138
+ return true;
139
+ }
140
+ this.revert(card, fromZone);
141
+ return false;
142
+ }
143
+
144
+ if (this.demo) {
145
+ this.setCardZone(card, newZone);
146
+ this.toast("Moved to " + this.label(newZone), "success");
147
+ this.emit("board-moved", id, fromKey, newZone);
148
+ this.hook(this.onMoveHook, { record: id, from: fromKey, to: newZone });
149
+ return true;
150
+ }
151
+
152
+ var url = this.moveUrl.replace(":id", encodeURIComponent(id));
153
+ var body = {};
154
+ body[this.moveParam.resource] = {};
155
+ body[this.moveParam.resource][this.moveParam.attr] = newZone;
156
+
157
+ return this.request(url, "PATCH", body).then(function (resp) {
158
+ if (!resp.ok) {
159
+ return resp.json().catch(function () { return {}; }).then(function (err) {
160
+ throw new Error(err.error || ("Failed (" + resp.status + ")"));
161
+ });
162
+ }
163
+ self.setCardZone(card, newZone);
164
+ self.toast("Moved to " + self.label(newZone), "success");
165
+ self.emit("board-moved", id, fromKey, newZone);
166
+ self.hook(self.onMoveHook, { record: id, from: fromKey, to: newZone });
167
+ return true;
168
+ }).catch(function (e) {
169
+ if (self.optimistic) self.revert(card, fromZone);
170
+ self.toast((e && e.message) || "Move failed", "error");
171
+ self.updateCounts();
172
+ return false;
173
+ });
174
+ },
175
+
176
+ // Save the destination column's order. Reads the ordered id list and POSTs
177
+ // it under the neutral payload key (`slugs` | `ids`) plus the advisory zone.
178
+ saveOrder: function (toZone) {
179
+ var self = this;
180
+ var ids = Array.prototype.slice
181
+ .call(toZone.querySelectorAll(this.cardSelector))
182
+ .map(function (c) { return self.cardId(c); });
183
+ var zone = this.zoneKey(toZone);
184
+ this.emit("board-reordered", null, zone, zone, { ids: ids, zone: zone });
185
+ this.hook(this.onDropHook, { ids: ids, zone: zone });
186
+ if (this.demo || !this.reorderUrl) return;
187
+ var payload = {};
188
+ payload[this.reorderPayload] = ids;
189
+ payload.zone = zone;
190
+ this.request(this.reorderUrl, "POST", payload).catch(function () {
191
+ // Order save failed — positions are stale but the board stays functional.
192
+ });
193
+ },
194
+
195
+ setCardZone: function (card, zone) { card.setAttribute("data-" + this.zoneAttr, zone); },
196
+
197
+ // Revert a failed move: drop the card back into its SOURCE zone (which
198
+ // always exists) above the empty state, and flash a red ring.
199
+ revert: function (card, fromZone) {
200
+ var anchor = this.emptySelector ? fromZone.querySelector(this.emptySelector) : null;
201
+ fromZone.insertBefore(card, anchor);
202
+ card.classList.add("ring-2", "ring-red-500");
203
+ setTimeout(function () { card.classList.remove("ring-2", "ring-red-500"); }, 1500);
204
+ },
205
+
206
+ updateCounts: function () {
207
+ var self = this;
208
+ document.querySelectorAll("[data-board-count]").forEach(function (badge) {
209
+ var key = badge.getAttribute("data-board-count");
210
+ var zone = document.getElementById("dropzone-" + key);
211
+ if (!zone) return;
212
+ var count = zone.querySelectorAll(self.cardSelector).length;
213
+ badge.textContent = count;
214
+ if (self.emptySelector) {
215
+ var empty = zone.querySelector(self.emptySelector);
216
+ if (empty) empty.style.display = count === 0 ? "flex" : "none";
217
+ }
218
+ });
219
+ },
220
+
221
+ // --- live updates (Turbo Streams) ----------------------------------
222
+ // The board subscribes via turbo_stream_from(live_channel); Turbo patches the
223
+ // DOM on every broadcast. This observer adds the flourish Turbo doesn't: a
224
+ // fade/scale-in on each patched-in card (deferred to window[fx] when present)
225
+ // and a count refresh after any column change.
226
+ observeLive: function () {
227
+ var self = this;
228
+ var onChange = function (mutations) {
229
+ for (var i = 0; i < mutations.length; i++) {
230
+ mutations[i].addedNodes.forEach(function (node) {
231
+ if (node.nodeType === 1 && node.matches && node.matches(self.cardSelector)) {
232
+ var fx = self.fx();
233
+ if (fx && fx.onAdd) { fx.onAdd(node); } else { self.animateIn(node); }
234
+ self.emit("card-added", self.cardId(node), null, self.zoneKey(node.parentElement));
235
+ }
236
+ });
237
+ }
238
+ self.updateCounts();
239
+ };
240
+ document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
241
+ new MutationObserver(onChange).observe(zone, { childList: true });
242
+ });
243
+ },
244
+
245
+ animateIn: function (el) {
246
+ el.style.opacity = "0";
247
+ el.style.transform = "scale(0.96)";
248
+ el.classList.add("ring-2", "ring-primary");
249
+ requestAnimationFrame(function () {
250
+ el.style.transition = "opacity 300ms ease, transform 300ms ease";
251
+ el.style.opacity = "";
252
+ el.style.transform = "";
253
+ });
254
+ setTimeout(function () { el.classList.remove("ring-2", "ring-primary"); el.style.transition = ""; }, 900);
255
+ },
256
+
257
+ // Archive re-parent / delete exit animation via a Turbo `remove` stream
258
+ // carrying data-exit-action. Installed only when no fx module owns exits.
259
+ installExitStreamFallback: function () {
260
+ if (window.__studioBoardExitFallback) return;
261
+ window.__studioBoardExitFallback = true;
262
+ var self = this;
263
+ document.addEventListener("turbo:before-stream-render", function (event) {
264
+ var stream = event.target;
265
+ if (stream.getAttribute("action") !== "remove") return;
266
+ var target = stream.getAttribute("target") || "";
267
+ var exitAction = stream.dataset.exitAction;
268
+ if (target.indexOf(self.domIdPrefix) !== 0 || !exitAction) return;
269
+ var renderNow = event.detail.render;
270
+ event.detail.render = function (el) {
271
+ var card = document.getElementById(target);
272
+ if (!card) { renderNow(el); return; }
273
+ self.animateCardExit(card, exitAction).then(function () {
274
+ renderNow(el);
275
+ self.updateCounts();
276
+ });
277
+ };
278
+ });
279
+ },
280
+
281
+ animateCardExit: function (card, kind) {
282
+ if (!card || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve();
283
+ card.style.pointerEvents = "none";
284
+ var frames = kind === "archive"
285
+ ? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
286
+ : [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.6)" }];
287
+ var anim = card.animate(frames, {
288
+ duration: kind === "archive" ? 500 : 420,
289
+ easing: "cubic-bezier(.35,0,.2,1)",
290
+ fill: "forwards"
291
+ });
292
+ return anim.finished.catch(function () {});
293
+ },
294
+
295
+ // --- toasts --------------------------------------------------------
296
+ toast: function (message, type) {
297
+ if (!this.toastsEnabled) {
298
+ if (type === "error" && typeof window.alert === "function") window.alert(message);
299
+ return;
300
+ }
301
+ var self = this;
302
+ var id = Date.now() + Math.random();
303
+ this.toasts.push({ id: id, message: message, type: type, visible: true });
304
+ setTimeout(function () {
305
+ var t = self.toasts.find(function (x) { return x.id === id; });
306
+ if (t) t.visible = false;
307
+ setTimeout(function () {
308
+ self.toasts = self.toasts.filter(function (x) { return x.id !== id; });
309
+ }, 300);
310
+ }, 3000);
311
+ },
312
+
313
+ // --- helpers -------------------------------------------------------
314
+ request: function (url, method, body) {
315
+ var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
316
+ var fetcher = window.authedFetch || window.fetch;
317
+ return fetcher(url, {
318
+ method: method,
319
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
320
+ body: JSON.stringify(body || {})
321
+ });
322
+ },
323
+
324
+ // The primary extension seam — apps LISTEN for these on window and attach
325
+ // confetti / swipe / cart / analytics, never patching this factory.
326
+ emit: function (name, record, from, to, extra) {
327
+ try {
328
+ var detail = Object.assign({ record: record, from: from, to: to }, extra || {});
329
+ window.dispatchEvent(new CustomEvent("studio:" + name, { detail: detail }));
330
+ } catch (e) {}
331
+ },
332
+
333
+ hook: function (hookName, detail) {
334
+ if (!hookName) return;
335
+ var fn = window[hookName];
336
+ if (typeof fn === "function") { try { fn(detail); } catch (e) {} }
337
+ }
338
+ };
339
+ };
340
+ })();
341
+ </script>
@@ -0,0 +1,174 @@
1
+ <%#
2
+ levelingActionModal factory — the Alpine data behind the engine leveling-activity
3
+ modals (studio/modals/blocks/_leveling_activity and its _change_username
4
+ specialization). Ships here, at PAGE level, on purpose: the modal partial mounts
5
+ inside a modal host's single-root template, and a script cloned out of a template
6
+ never runs. So a consumer renders THIS once in its layout (like the other shared
7
+ Alpine factories), and the modal partial anywhere below just references
8
+ window.levelingActionModal. Mirrors how studio/_age_verify_assets homes the
9
+ ageVerifyModal factory.
10
+
11
+ CRITICAL BOUNDARY — this factory is UI ONLY. It POSTs to an app-supplied
12
+ submitUrl and reacts to a NEUTRAL JSON contract; it holds ZERO on-chain logic,
13
+ no wallet taxonomy, no signing. Any second step some apps need (e.g. a wallet
14
+ approval) is delegated OPAQUELY: the engine hands the app-returned "challenge"
15
+ blob to an app-supplied window[finalizeHook] and POSTs back whatever opaque
16
+ "proof" the app returns. The engine never inspects, parses, or decodes either
17
+ blob, so the whole wallet dance stays in the app.
18
+
19
+ Wire contract (submitUrl + finalizeUrl responses are JSON):
20
+ POST submitUrl { value } -> one of:
21
+ { status: "saved", seeds_earned?, seeds_total? } done
22
+ { status: "needs_step", challenge, token } app step required
23
+ { status: "error", message } show message
24
+ when needs_step and a finalizeHook is wired:
25
+ proof = await window[finalizeHook](challenge, { token, onProgress })
26
+ POST finalizeUrl { token, proof } -> { status: "saved" | "error", ... }
27
+
28
+ opts: { hasInput, initialValue, minLength, submitUrl, finalizeUrl, finalizeHook,
29
+ savedEvent, store, leveling, demo, seedsPerLevel }.
30
+ hasInput true renders + validates a single-line input; false is a
31
+ no-field activity (a plain "Complete" action).
32
+ initialValue seed value for the input (a plain string; never a model).
33
+ minLength client-only min length for the input (0 = none).
34
+ submitUrl app endpoint the action POSTs to (the app owns persistence).
35
+ finalizeUrl app endpoint for the opaque second step (omit if none).
36
+ finalizeHook name of a window async fn the app supplies for the step (omit
37
+ if none); it owns EVERY app-side detail of that step.
38
+ savedEvent DOM event dispatched on success, carrying the app payload, so
39
+ the app can run its own follow-on. Default "studio:activity-saved".
40
+ store Alpine modal store name backing close(). Default "modals".
41
+ leveling true shows the seeds celebration on success (a UI flourish).
42
+ demo style-guide preview: resolve locally, skip the network POST.
43
+ seedsPerLevel seeds per level for the celebration bar. Default 100.
44
+ %>
45
+ <script>
46
+ (function () {
47
+ if (window.levelingActionModal) return;
48
+ window.levelingActionModal = function (opts) {
49
+ opts = opts || {};
50
+ return {
51
+ hasInput: !!opts.hasInput,
52
+ value: opts.initialValue || "",
53
+ original: opts.initialValue || "",
54
+ minLength: parseInt(opts.minLength, 10) || 0,
55
+ submitUrl: opts.submitUrl || "",
56
+ finalizeUrl: opts.finalizeUrl || "",
57
+ finalizeHook: opts.finalizeHook || "",
58
+ savedEvent: opts.savedEvent || "studio:activity-saved",
59
+ store: opts.store || "modals",
60
+ leveling: !!opts.leveling,
61
+ demo: !!opts.demo,
62
+ seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
63
+ saving: false,
64
+ error: "",
65
+ saved: false,
66
+ celebrate: false,
67
+ progressLabel: "",
68
+ seedsEarned: 0,
69
+ seedsTotal: 0,
70
+
71
+ // Actionable while there is a real change to save (input activities) or
72
+ // until the one-shot action has fired (no-field activities).
73
+ get changed() {
74
+ if (!this.hasInput) return !this.saved;
75
+ var v = (this.value || "").trim();
76
+ if (v.length < this.minLength) return false;
77
+ return v !== (this.original || "").trim();
78
+ },
79
+
80
+ _dispatch: function (payload) {
81
+ try {
82
+ window.dispatchEvent(new CustomEvent(this.savedEvent, { detail: payload || {} }));
83
+ } catch (e) {}
84
+ },
85
+
86
+ _finishSaved: function (payload) {
87
+ payload = payload || {};
88
+ this.saving = false;
89
+ this.saved = true;
90
+ this.progressLabel = "";
91
+ if (this.hasInput) this.original = (this.value || "").trim();
92
+ this._dispatch(payload);
93
+ if (this.leveling) {
94
+ this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0;
95
+ this.seedsTotal = parseInt(payload.seeds_total, 10) || 0;
96
+ this.celebrate = true;
97
+ }
98
+ },
99
+
100
+ _post: function (url, body) {
101
+ var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
102
+ var fetcher = window.authedFetch || window.fetch;
103
+ return fetcher(url, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
106
+ body: JSON.stringify(body || {})
107
+ }).then(function (r) {
108
+ return r.json().then(function (j) { return { ok: r.ok, body: j || {} }; });
109
+ });
110
+ },
111
+
112
+ save: function () {
113
+ if (this.saving || !this.changed) return;
114
+ this.saving = true;
115
+ this.error = "";
116
+ var self = this;
117
+ if (this.demo) {
118
+ // Style-guide preview: no backend. Resolve to a level-crossing payload
119
+ // so the leveling celebration demonstrates its full flourish.
120
+ setTimeout(function () {
121
+ self._finishSaved({ seeds_earned: 30, seeds_total: self.seedsPerLevel });
122
+ }, 600);
123
+ return;
124
+ }
125
+ var body = this.hasInput ? { value: (this.value || "").trim() } : {};
126
+ this._post(this.submitUrl, body)
127
+ .then(function (res) { return self._handle(res); })
128
+ .catch(function (e) {
129
+ self.error = (e && e.message) || "Something went wrong. Please try again.";
130
+ self.saving = false;
131
+ });
132
+ },
133
+
134
+ _handle: function (res) {
135
+ var body = (res && res.body) || {};
136
+ var status = body.status || (res && res.ok ? "saved" : "error");
137
+ if (status === "saved") { this._finishSaved(body); return; }
138
+ if (status === "needs_step") { return this._step(body); }
139
+ this.error = body.message || "Something went wrong. Please try again.";
140
+ this.saving = false;
141
+ },
142
+
143
+ // The opaque second step. The engine hands the app's "challenge" blob to
144
+ // the app-supplied hook and POSTs back the app's "proof" blob — it inspects
145
+ // neither, so every app-side detail of the step stays in the app.
146
+ _step: function (body) {
147
+ var hook = this.finalizeHook && window[this.finalizeHook];
148
+ var self = this;
149
+ if (typeof hook !== "function") {
150
+ this.error = "This step isn't available.";
151
+ this.saving = false;
152
+ return;
153
+ }
154
+ return Promise.resolve(
155
+ hook(body.challenge, {
156
+ token: body.token,
157
+ onProgress: function (label) { self.progressLabel = label || ""; }
158
+ })
159
+ ).then(function (proof) {
160
+ return self._post(self.finalizeUrl, { token: body.token, proof: proof });
161
+ }).then(function (res) {
162
+ var rb = (res && res.body) || {};
163
+ var status = rb.status || (res && res.ok ? "saved" : "error");
164
+ if (status === "saved") { self._finishSaved(rb); }
165
+ else { self.error = rb.message || "Couldn't complete the step."; self.saving = false; }
166
+ }).catch(function (e) {
167
+ self.error = (e && e.message) || "Couldn't complete the step.";
168
+ self.saving = false;
169
+ });
170
+ }
171
+ };
172
+ };
173
+ })();
174
+ </script>