studio-engine 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0dff947db7bda136b0fcca271b86347449fa0d5f259c102ba5fe50e8e29dea8
4
- data.tar.gz: 898fb4de879042127da3c85ee072603a8cf4d02125037d13d16793ecc8579e17
3
+ metadata.gz: 251aa22a7a41f33e6cab5a4f1f1c38a714aa4a14f2f4b08567c27340e1563f24
4
+ data.tar.gz: 2f8b5ec4901d403c1ffdaf42dee430c69f1c31625c926e8deb43c021a41eb6a9
5
5
  SHA512:
6
- metadata.gz: 91f96cfc96d999ef6ea31aa7d427fb903c538da72470768891dd1e3667d7e5380bf8f931089c24548b9ad844b4c7815e0a9167231dd989c36fd674f04391e831
7
- data.tar.gz: dc439df4fc3125f2a98c1511606008f74df5dc869c98a72ca0c4fe6a8ba4e082d093d5093de211e9b6534e280140cb35a6ba31952dc40b667e5fbe5de1b04552
6
+ metadata.gz: 1c8fa01470ce96a17550dac1d2d70db086b1acf0c6f6ed97a8b31679f2f3559dbb8549687bb79e806b2148a058ab700154bc330f4b94eb6b4216480fb0831d67
7
+ data.tar.gz: 4ae572b5cadc0734b1ef69a8d0ec0d4331c57f3545be08e850dcd2b0d53c0f6d30d5e1b2d287a65542d46dec4b1d803cad6611471f24c533143118daa0fc75b4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## v0.7.0 (2026-06-20)
8
+
9
+ ### Added
10
+ - **`admin_model_table` helper** (`Studio::AdminModelsTableHelper`) — a shared
11
+ shell for `/admin/models` tables (overflow wrapper, `thead`, hover rows,
12
+ colspan empty state). Each consumer `_<key>_table` partial now declares only
13
+ its columns via `headers:` plus a per-row block; the helper is exposed
14
+ automatically through the `Studio::AdminModels` concern. Removes the duplicated
15
+ table boilerplate across mcritchie-studio and turf-monster.
16
+
17
+ ## v0.6.2 (2026-06-19)
18
+
19
+ ### Added
20
+ - **`Studio.sticky_table_headers` opt-in** for shared sticky table headers. The
21
+ flag defaults off and, when enabled by a consumer app, loads the engine-owned
22
+ `studio/sticky_table_header.css` and `studio/sticky_table_header.js` assets
23
+ from the shared Studio head partial.
24
+
7
25
  ## v0.6.1 (2026-06-18)
8
26
 
9
27
  ### Added
@@ -0,0 +1,194 @@
1
+ (() => {
2
+ if (window.StudioStickyTableHeaders?.loaded) return;
3
+
4
+ const MANUAL_TABLE_SELECTOR = "table[data-sticky-table-header]";
5
+ const AUTO_TABLE_SELECTOR = "table";
6
+ const SKIP_CONTAINER_SELECTOR = "[data-sticky-table-skip]";
7
+
8
+ let stickyTables = [];
9
+ let scheduled = false;
10
+ let bootScheduled = false;
11
+ let navResizeObserver = null;
12
+ let tableMutationObserver = null;
13
+
14
+ window.StudioStickyTableHeaders = { loaded: true };
15
+
16
+ function navOffset() {
17
+ const raw = getComputedStyle(document.documentElement).getPropertyValue("--nav-h");
18
+ return Number.parseFloat(raw) || 0;
19
+ }
20
+
21
+ function scheduleUpdate() {
22
+ if (scheduled) return;
23
+ scheduled = true;
24
+ requestAnimationFrame(() => {
25
+ scheduled = false;
26
+ stickyTables.forEach((stickyTable) => stickyTable.update());
27
+ });
28
+ }
29
+
30
+ function scheduleBoot() {
31
+ if (bootScheduled) return;
32
+ bootScheduled = true;
33
+ requestAnimationFrame(() => {
34
+ bootScheduled = false;
35
+ bootStickyTables();
36
+ });
37
+ }
38
+
39
+ class StickyTableHeader {
40
+ constructor(table) {
41
+ this.table = table;
42
+ this.prepareTable();
43
+ this.scroller = table.closest("[data-sticky-table-scroll]") || table.parentElement;
44
+ this.cloneShell = document.createElement("div");
45
+ this.cloneShell.className = "sticky-table-header-clone";
46
+ this.cloneShell.setAttribute("aria-hidden", "true");
47
+
48
+ this.cloneTable = document.createElement("table");
49
+ this.cloneTable.className = table.className;
50
+ this.cloneTable.innerHTML = table.tHead ? table.tHead.outerHTML : "";
51
+ this.cloneShell.appendChild(this.cloneTable);
52
+ document.body.appendChild(this.cloneShell);
53
+
54
+ this.scroller?.addEventListener("scroll", scheduleUpdate, { passive: true });
55
+ }
56
+
57
+ prepareTable() {
58
+ this.table.classList.add("sticky-data-table");
59
+ if (!this.table.hasAttribute("data-sticky-table-header")) {
60
+ this.table.setAttribute("data-sticky-table-header", "auto");
61
+ }
62
+
63
+ const scroller = this.table.closest("[data-sticky-table-scroll]") || this.table.parentElement;
64
+ if (scroller && !scroller.hasAttribute("data-sticky-table-scroll")) {
65
+ scroller.setAttribute("data-sticky-table-scroll", "auto");
66
+ }
67
+ }
68
+
69
+ destroy() {
70
+ this.scroller?.removeEventListener("scroll", scheduleUpdate);
71
+ this.cloneShell.remove();
72
+ }
73
+
74
+ syncColumnWidths() {
75
+ const originalCells = this.table.tHead?.querySelectorAll("th") || [];
76
+ const cloneCells = this.cloneTable.tHead?.querySelectorAll("th") || [];
77
+ originalCells.forEach((cell, index) => {
78
+ if (!cloneCells[index]) return;
79
+ cloneCells[index].style.width = `${cell.getBoundingClientRect().width}px`;
80
+ });
81
+ }
82
+
83
+ update() {
84
+ if (!document.body.contains(this.table) || !this.table.tHead || !this.scroller) {
85
+ this.destroy();
86
+ stickyTables = stickyTables.filter((stickyTable) => stickyTable !== this);
87
+ return;
88
+ }
89
+
90
+ const offset = navOffset();
91
+ const headerRect = this.table.tHead.getBoundingClientRect();
92
+ const tableRect = this.table.getBoundingClientRect();
93
+ const scrollerRect = this.scroller.getBoundingClientRect();
94
+ const cloneHeight = headerRect.height || this.cloneShell.getBoundingClientRect().height;
95
+ const active = headerRect.top <= offset && tableRect.bottom > offset;
96
+
97
+ if (!active) {
98
+ this.cloneShell.style.display = "none";
99
+ this.table.removeAttribute("data-sticky-table-active");
100
+ return;
101
+ }
102
+
103
+ this.syncColumnWidths();
104
+
105
+ const top = Math.min(offset, tableRect.bottom - cloneHeight);
106
+ const translateX = tableRect.left - scrollerRect.left;
107
+
108
+ this.cloneShell.style.display = "block";
109
+ this.cloneShell.style.left = `${scrollerRect.left}px`;
110
+ this.cloneShell.style.top = `${top}px`;
111
+ this.cloneShell.style.width = `${scrollerRect.width}px`;
112
+ this.cloneShell.style.height = `${cloneHeight}px`;
113
+ this.cloneTable.style.width = `${tableRect.width}px`;
114
+ this.cloneTable.style.transform = `translateX(${translateX}px)`;
115
+ this.table.setAttribute("data-sticky-table-active", "true");
116
+ }
117
+ }
118
+
119
+ function bootStickyTables() {
120
+ startTableObserver();
121
+
122
+ if (navResizeObserver) navResizeObserver.disconnect();
123
+ if (window.ResizeObserver) {
124
+ const header = document.querySelector("header");
125
+ if (header) {
126
+ navResizeObserver = new ResizeObserver(scheduleUpdate);
127
+ navResizeObserver.observe(header);
128
+ }
129
+ }
130
+
131
+ stickyTables.forEach((stickyTable) => stickyTable.destroy());
132
+ stickyTables = stickyTableCandidates().map((table) => new StickyTableHeader(table));
133
+ scheduleUpdate();
134
+ }
135
+
136
+ function startTableObserver() {
137
+ if (!window.MutationObserver || tableMutationObserver || !document.body) return;
138
+
139
+ tableMutationObserver = new MutationObserver((mutations) => {
140
+ const shouldReboot = mutations.some((mutation) => (
141
+ Array.from(mutation.addedNodes).some(nodeContainsCandidateTable) ||
142
+ Array.from(mutation.removedNodes).some(nodeContainsCandidateTable)
143
+ ));
144
+
145
+ if (shouldReboot) scheduleBoot();
146
+ });
147
+
148
+ tableMutationObserver.observe(document.body, { childList: true, subtree: true });
149
+ }
150
+
151
+ function nodeContainsCandidateTable(node) {
152
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
153
+ if (node.matches(".sticky-table-header-clone") || node.closest(".sticky-table-header-clone")) return false;
154
+
155
+ return node.matches("table, thead, th") || Boolean(node.querySelector("table, thead, th"));
156
+ }
157
+
158
+ function stickyTableCandidates() {
159
+ const candidates = [];
160
+ const seen = new Set();
161
+
162
+ document.querySelectorAll(`${MANUAL_TABLE_SELECTOR}, ${AUTO_TABLE_SELECTOR}`).forEach((table) => {
163
+ if (seen.has(table) || !shouldEnhanceTable(table)) return;
164
+ seen.add(table);
165
+ candidates.push(table);
166
+ });
167
+
168
+ return candidates;
169
+ }
170
+
171
+ function shouldEnhanceTable(table) {
172
+ if (table.getAttribute("data-sticky-table-header") === "false") return false;
173
+ if (table.closest(SKIP_CONTAINER_SELECTOR)) return false;
174
+ if (table.closest(".sticky-table-header-clone")) return false;
175
+ if (table.closest("template")) return false;
176
+ if (table.getAttribute("role")?.toLowerCase() === "presentation") return false;
177
+ if (!table.tHead || !table.tHead.querySelector("th")) return false;
178
+
179
+ return true;
180
+ }
181
+
182
+ document.addEventListener("turbo:load", bootStickyTables);
183
+ document.addEventListener("DOMContentLoaded", bootStickyTables);
184
+ document.addEventListener("turbo:before-cache", () => {
185
+ if (navResizeObserver) navResizeObserver.disconnect();
186
+ if (tableMutationObserver) tableMutationObserver.disconnect();
187
+ navResizeObserver = null;
188
+ tableMutationObserver = null;
189
+ stickyTables.forEach((stickyTable) => stickyTable.destroy());
190
+ stickyTables = [];
191
+ });
192
+ window.addEventListener("scroll", scheduleUpdate, { passive: true });
193
+ window.addEventListener("resize", scheduleUpdate, { passive: true });
194
+ })();
@@ -0,0 +1,48 @@
1
+ .sticky-data-table {
2
+ border-collapse: separate;
3
+ border-spacing: 0;
4
+ }
5
+
6
+ .sticky-data-table thead th {
7
+ background: var(--color-surface-alt);
8
+ box-shadow:
9
+ inset 0 -1px 0 var(--color-border),
10
+ 0 10px 18px -18px rgba(15, 23, 42, 0.55);
11
+ transition:
12
+ background-color 200ms ease,
13
+ box-shadow 200ms ease,
14
+ top 200ms ease;
15
+ }
16
+
17
+ .dark .sticky-data-table thead th {
18
+ box-shadow:
19
+ inset 0 -1px 0 var(--color-border),
20
+ 0 12px 22px -18px rgba(0, 0, 0, 0.85);
21
+ }
22
+
23
+ .sticky-table-header-clone {
24
+ position: fixed;
25
+ display: none;
26
+ overflow: hidden;
27
+ pointer-events: none;
28
+ z-index: 45;
29
+ }
30
+
31
+ .sticky-table-header-clone table {
32
+ border-collapse: separate;
33
+ border-spacing: 0;
34
+ }
35
+
36
+ .sticky-table-header-clone thead th {
37
+ position: static;
38
+ background: var(--color-surface-alt);
39
+ box-shadow:
40
+ inset 0 -1px 0 var(--color-border),
41
+ 0 12px 22px -18px rgba(15, 23, 42, 0.65);
42
+ }
43
+
44
+ .dark .sticky-table-header-clone thead th {
45
+ box-shadow:
46
+ inset 0 -1px 0 var(--color-border),
47
+ 0 14px 24px -18px rgba(0, 0, 0, 0.9);
48
+ }
@@ -13,6 +13,7 @@ module Studio
13
13
 
14
14
  included do
15
15
  before_action :set_model_config, only: :show
16
+ helper Studio::AdminModelsTableHelper
16
17
  helper_method :team_sort_url, :team_sort_indicator, :team_sport_emoji,
17
18
  :team_record_json, :admin_models_table_partial
18
19
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Shared shell for /admin/models tables. Renders the overflow wrapper + thead +
5
+ # tbody + empty state so each consumer `_<key>_table` partial only declares its
6
+ # columns. Exposed to consumer admin/models views via the AdminModels concern.
7
+ #
8
+ # <%= admin_model_table(records, key: "users", min_width: 760,
9
+ # headers: ["User", "Role", "Created"], empty: "No users found.") do |user| %>
10
+ # <td class="px-4 py-3"><%= user.display_name %></td>
11
+ # <td class="px-4 py-3"><%= user.role %></td>
12
+ # <td class="px-4 py-3"><%= time_ago_in_words(user.created_at) %> ago</td>
13
+ # <% end %>
14
+ #
15
+ # The block yields each record and returns that row's <td> cells. Output matches
16
+ # the long-standing markup (id="models-<key>-table", overflow wrapper, hover
17
+ # rows, colspan empty state) so existing view assertions keep passing.
18
+ module AdminModelsTableHelper
19
+ def admin_model_table(records, key:, headers:, min_width: 940, empty: "No records found.", &row)
20
+ header_row = content_tag(:tr, safe_join(headers.map { |label|
21
+ content_tag(:th, label, class: "px-4 py-3 text-left font-semibold")
22
+ }))
23
+
24
+ body =
25
+ if records.any?
26
+ safe_join(records.map { |record|
27
+ content_tag(:tr, capture(record, &row), class: "hover:bg-surface-alt/60 transition")
28
+ })
29
+ else
30
+ content_tag(:tr, content_tag(:td, empty,
31
+ colspan: headers.size, class: "px-4 py-10 text-center text-muted"))
32
+ end
33
+
34
+ table = content_tag(:table,
35
+ content_tag(:thead, header_row, class: "bg-surface-alt text-muted text-xs uppercase tracking-wide") +
36
+ content_tag(:tbody, body, class: "divide-y divide-subtle"),
37
+ id: "models-#{key}-table", class: "w-full min-w-[#{min_width}px] text-sm")
38
+
39
+ content_tag(:div, table, class: "overflow-x-auto")
40
+ end
41
+ end
42
+ end
@@ -107,4 +107,10 @@
107
107
 
108
108
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
109
109
  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
110
+ <% if Studio.sticky_table_headers %>
111
+ <%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
112
+ <% end %>
110
113
  <%= javascript_importmap_tags %>
114
+ <% if Studio.sticky_table_headers %>
115
+ <%= javascript_include_tag "studio/sticky_table_header", defer: true, "data-turbo-track": "reload" %>
116
+ <% end %>
data/lib/studio/engine.rb CHANGED
@@ -1,5 +1,12 @@
1
1
  module Studio
2
2
  class Engine < ::Rails::Engine
3
+ initializer "studio.assets" do |app|
4
+ app.config.assets.precompile += %w[
5
+ studio/sticky_table_header.css
6
+ studio/sticky_table_header.js
7
+ ]
8
+ end
9
+
3
10
  rake_tasks do
4
11
  load File.expand_path("../tasks/studio_email.rake", __dir__)
5
12
  load File.expand_path("../tasks/studio_ses.rake", __dir__)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -20,6 +20,7 @@ module Studio
20
20
  mattr_accessor :sso_logo, default: nil
21
21
  mattr_accessor :wallet_address_method, default: nil
22
22
  mattr_accessor :theme_logos, default: []
23
+ mattr_accessor :sticky_table_headers, default: false
23
24
 
24
25
  # ---- Authentication ------------------------------------------------------
25
26
  # Which sign-in methods this app offers. The shared login/signup views render
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
11
+ date: 2026-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -142,6 +142,8 @@ files:
142
142
  - README.md
143
143
  - app/assets/images/resend-favicon.png
144
144
  - app/assets/images/ses-favicon.png
145
+ - app/assets/javascripts/studio/sticky_table_header.js
146
+ - app/assets/stylesheets/studio/sticky_table_header.css
145
147
  - app/controllers/concerns/solana/session_auth.rb
146
148
  - app/controllers/concerns/studio/admin_models.rb
147
149
  - app/controllers/concerns/studio/error_handling.rb
@@ -156,6 +158,7 @@ files:
156
158
  - app/controllers/solana_sessions_controller.rb
157
159
  - app/controllers/studio/local_emails_controller.rb
158
160
  - app/controllers/theme_settings_controller.rb
161
+ - app/helpers/studio/admin_models_table_helper.rb
159
162
  - app/helpers/studio_email_delivery_helper.rb
160
163
  - app/helpers/studio_theme_helper.rb
161
164
  - app/jobs/error_log_cleanup_job.rb