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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -0
- data/app/assets/javascripts/studio/canvas_confetti.js +17 -0
- data/app/assets/javascripts/studio/sortable.js +11 -0
- data/app/assets/javascripts/studio/studio_confetti.js +93 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +47 -0
- data/app/controllers/concerns/studio/board/reorderable.rb +97 -0
- data/app/models/concerns/studio/board/rankable.rb +84 -0
- data/app/views/components/_badge.html.erb +4 -2
- data/app/views/layouts/studio/_head.html.erb +13 -1
- data/app/views/studio/_board_assets.html.erb +341 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +174 -0
- data/app/views/studio/board/_board.html.erb +151 -0
- data/app/views/studio/board/_card_shell.html.erb +47 -0
- data/app/views/studio/board/_column.html.erb +80 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +46 -0
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +159 -0
- data/app/views/style/_modals.html.erb +141 -0
- data/app/views/style/_tasks.html.erb +82 -11
- data/app/views/style/_tricks.html.erb +46 -0
- data/app/views/style/board/_demo_card.html.erb +22 -0
- data/lib/studio/engine.rb +3 -0
- data/lib/studio/version.rb +1 -1
- metadata +14 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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>
|