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 +4 -4
- data/CHANGELOG.md +18 -0
- data/app/assets/javascripts/studio/sticky_table_header.js +194 -0
- data/app/assets/stylesheets/studio/sticky_table_header.css +48 -0
- data/app/controllers/concerns/studio/admin_models.rb +1 -0
- data/app/helpers/studio/admin_models_table_helper.rb +42 -0
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/lib/studio/engine.rb +7 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +1 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 251aa22a7a41f33e6cab5a4f1f1c38a714aa4a14f2f4b08567c27340e1563f24
|
|
4
|
+
data.tar.gz: 2f8b5ec4901d403c1ffdaf42dee430c69f1c31625c926e8deb43c021a41eb6a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
}
|
|
@@ -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__)
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
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
|