wrap_it_ruby 0.3.2 → 0.3.4

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: fa2cef76d046309295713f567c31c703aad9b7856b3349e9a65900f864940f90
4
- data.tar.gz: ad16cee91fa318b565cfda5b0eececb3c85ccdfca93745704f933591a7036b89
3
+ metadata.gz: 7a551e7f6f103d0580623f12f28c3f75121af83099ad2d853b1a936650a77cbb
4
+ data.tar.gz: 92b6870e46cf110e94e2a821ffe9eb4e5e9244a1952e0db19f5b853e71ab423b
5
5
  SHA512:
6
- metadata.gz: ce33cc961a1b61b8a70fb5f8732d5e48b2442622998518d83f02cbef72e417cf7d31c9eaa369eb708e7cec6f0a33a08b8374b0d889c54688eac80074347efab7
7
- data.tar.gz: e499fa0de180cf977f94c9c7ab800298ec3dbee57a4d375ebdb9c2940f7c24478527e13e86f101f5f462d447820cf2e7aa522d7d31ad5be4e285de2e6d0ce562
6
+ metadata.gz: e7a67218134d13a4bb57e4d6d70ae3c6917aa02a1d40417418c9dde57a975082e407c3f421d8fc4bfc93abee1cc11a76e74a122f74da87e2e569832307b96e1b
7
+ data.tar.gz: fa81fcff2623d919bfdb2b20c22ee5ec12ee25e3fc3fd2513e65fd9791e5c4e04994c5b55ef9e729910b356af96d1772366ed75aeeb63e5d094cfbe1df5796f7
@@ -2,6 +2,60 @@
2
2
  * WrapItRuby engine styles — full layout + iframe proxy.
3
3
  */
4
4
 
5
+ .ui.segments > .ui.segments {
6
+ border-top: 1px solid rgba(34,36,38,.15);
7
+ }
8
+
9
+ .ui.accordion .accordion {
10
+ margin: 0;
11
+ }
12
+
13
+ .ui.styled.accordion > .content {
14
+ padding: 0.5em 1em 0.5em;
15
+ }
16
+
17
+ .drag-handle {
18
+ cursor: grab;
19
+ opacity: 0.4;
20
+ margin-left: 0.5em !important;
21
+ }
22
+
23
+ .drag-handle:active {
24
+ cursor: grabbing;
25
+ }
26
+
27
+ .sortable-chosen details,
28
+ .sortable-ghost details {
29
+ pointer-events: none;
30
+ }
31
+
32
+ details[open] > div > .ui.segments:empty {
33
+ min-height: 30px;
34
+ border: 2px dashed rgba(34,36,38,.15);
35
+ border-radius: 4px;
36
+ margin: 4px;
37
+ }
38
+
39
+ /* Accordion segments: <details> with segment class */
40
+ details.ui.segment {
41
+ padding: 0;
42
+ }
43
+
44
+ details.ui.segment > summary {
45
+ padding: 1em;
46
+ cursor: pointer;
47
+ }
48
+
49
+ details.ui.segment > div {
50
+ padding: 0 1em 1em;
51
+ }
52
+
53
+ .edit-link > .icon {
54
+ line-height: inherit !important;
55
+ }
56
+
57
+
58
+
5
59
  html, body {
6
60
  height: 100%;
7
61
  margin: 0;
@@ -51,8 +105,12 @@ html, body {
51
105
  margin: 7px;
52
106
  margin-top: 0px;
53
107
  border-radius: 15px;
54
- #background-color: grey;
55
108
  overflow: hidden;
109
+
110
+ #site-content-scroll {
111
+ height: 100%;
112
+ overflow-y: auto;
113
+ }
56
114
  }
57
115
  }
58
116
 
@@ -3,32 +3,46 @@
3
3
  module WrapItRuby
4
4
  class MenuSettingsController < ::ApplicationController
5
5
  def index
6
- @menu_items = ::MenuItem.roots.includes(children: :children)
6
+ @menu_items = menu_items_exist? ? MenuItem.roots.includes(children: :children) : []
7
+ end
8
+
9
+ def new
10
+ @menu_item = MenuItem.new
11
+ end
12
+
13
+ def edit
14
+ @menu_item = MenuItem.find(params[:id])
7
15
  end
8
16
 
9
17
  def create
10
- ::MenuItem.create!(menu_item_params)
11
- respond_with_tree_refresh
18
+ MenuItem.create!(menu_item_params)
19
+ redirect_to wrap_it_ruby.menu_settings_path
12
20
  end
13
21
 
14
22
  def update
15
- item = ::MenuItem.find(params[:id])
16
- item.update!(menu_item_params)
17
- respond_with_tree_refresh
23
+ item = MenuItem.find(params[:id])
24
+ position = params.dig(:menu_item, :position) || params[:position]
25
+ if position
26
+ item.move_to(params[:parent_id].presence, position.to_i)
27
+ head :no_content
28
+ else
29
+ item.update!(menu_item_params)
30
+ redirect_to wrap_it_ruby.menu_settings_path
31
+ end
18
32
  end
19
33
 
20
34
  def destroy
21
- item = ::MenuItem.find(params[:id])
35
+ item = MenuItem.find(params[:id])
22
36
  item.destroy!
23
- respond_with_tree_refresh
37
+ redirect_to wrap_it_ruby.menu_settings_path
24
38
  end
25
39
 
26
40
  def sort
27
41
  ordering = params.require(:ordering)
28
42
 
29
- ::MenuItem.transaction do
43
+ MenuItem.transaction do
30
44
  ordering.each do |entry|
31
- item = ::MenuItem.find(entry[:id])
45
+ item = MenuItem.find(entry[:id])
32
46
  new_parent_id = entry[:parent_id].presence
33
47
 
34
48
  if item.parent_id.to_s != new_parent_id.to_s
@@ -40,25 +54,19 @@ module WrapItRuby
40
54
  end
41
55
  end
42
56
 
43
- respond_with_tree_refresh
57
+ redirect_to wrap_it_ruby.menu_settings_path
44
58
  end
45
59
 
46
60
  private
47
61
 
48
62
  def menu_item_params
49
- params.permit(:label, :icon, :route, :url, :item_type, :parent_id)
63
+ params.require(:menu_item).permit(:label, :icon, :route, :url, :item_type, :parent_id)
50
64
  end
51
65
 
52
- def respond_with_tree_refresh
53
- respond_to do |format|
54
- format.turbo_stream do
55
- render turbo_stream: turbo_stream.replace(
56
- 'menu-tree-container',
57
- partial: 'wrap_it_ruby/menu_settings/tree'
58
- )
59
- end
60
- format.html { head :no_content }
61
- end
66
+ def menu_items_exist?
67
+ defined?(MenuItem) && MenuItem.table_exists? && MenuItem.exists?
68
+ rescue StandardError
69
+ false
62
70
  end
63
71
  end
64
72
  end
@@ -29,171 +29,6 @@ module WrapItRuby
29
29
  end
30
30
  end
31
31
 
32
- # Renders the menu-settings, edit, and add modals.
33
- # Call this outside the Menu { } block so modals are not
34
- # nested inside the menu bar.
35
- def render_menu_modals
36
- # Settings modal — sortable tree + add button
37
- concat tag.div(id: 'menu-settings-modal', class: 'ui large modal') {
38
- safe_join([
39
- tag.i(class: 'close icon'),
40
- tag.div(class: 'header') { tag.i(class: 'bars icon') + ' Menu Settings' },
41
- tag.div(class: 'scrolling content') {
42
- tag.div(id: 'menu-tree-container') { sortable_menu_tree }
43
- },
44
- tag.div(class: 'actions') {
45
- safe_join([
46
- tag.button(class: 'ui green button', onclick: 'menuSettingsShowAdd()') {
47
- tag.i(class: 'plus icon') + ' Add Item'
48
- }
49
- ])
50
- }
51
- ])
52
- }
53
-
54
- # Edit modal — stacked on top of settings modal
55
- concat tag.div(id: 'menu-edit-modal', class: 'ui small modal') {
56
- safe_join([
57
- tag.i(class: 'close icon'),
58
- tag.div(class: 'header') { 'Edit Menu Item' },
59
- tag.div(class: 'content') {
60
- tag.form(class: 'ui form', id: 'menu-edit-form') {
61
- safe_join([
62
- tag.input(type: 'hidden', name: 'id', id: 'menu-edit-id'),
63
- tag.div(class: 'two fields') {
64
- safe_join([
65
- tag.div(class: 'field') {
66
- tag.label {
67
- 'Label'
68
- } + tag.input(type: 'text', name: 'label', id: 'menu-edit-label')
69
- },
70
- tag.div(class: 'field') {
71
- tag.label {
72
- 'Icon'
73
- } + tag.input(type: 'text', name: 'icon', id: 'menu-edit-icon',
74
- placeholder: 'e.g. server')
75
- }
76
- ])
77
- },
78
- tag.div(class: 'two fields', id: 'menu-edit-proxy-fields') {
79
- safe_join([
80
- tag.div(class: 'field') {
81
- tag.label {
82
- 'Route'
83
- } + tag.input(type: 'text', name: 'route', id: 'menu-edit-route',
84
- placeholder: '/path')
85
- },
86
- tag.div(class: 'field') {
87
- tag.label {
88
- 'URL'
89
- } + tag.input(type: 'text', name: 'url', id: 'menu-edit-url',
90
- placeholder: 'upstream.example.com')
91
- }
92
- ])
93
- },
94
- tag.div(class: 'field') {
95
- tag.label { 'Type' } +
96
- tag.select(name: 'item_type', id: 'menu-edit-type', class: 'ui dropdown',
97
- onchange: "menuSettingsToggleProxyFields('menu-edit')") {
98
- safe_join([
99
- tag.option(value: 'group') { 'Group' },
100
- tag.option(value: 'proxy') { 'Proxy' }
101
- ])
102
- }
103
- }
104
- ])
105
- }
106
- },
107
- tag.div(class: 'actions') {
108
- safe_join([
109
- tag.button(class: 'ui red left floated button', onclick: 'menuSettingsDelete()') {
110
- tag.i(class: 'trash icon') + ' Delete'
111
- },
112
- tag.button(class: 'ui button', onclick: "$('#menu-edit-modal').modal('hide')") {
113
- 'Cancel'
114
- },
115
- tag.button(class: 'ui green button', onclick: 'menuSettingsSave()') {
116
- tag.i(class: 'save icon') + ' Save'
117
- }
118
- ])
119
- }
120
- ])
121
- }
122
-
123
- # Add modal — stacked on top of settings modal
124
- concat tag.div(id: 'menu-add-modal', class: 'ui small modal') {
125
- safe_join([
126
- tag.i(class: 'close icon'),
127
- tag.div(class: 'header') { 'Add Menu Item' },
128
- tag.div(class: 'content') {
129
- tag.form(class: 'ui form', id: 'menu-add-form') {
130
- safe_join([
131
- tag.div(class: 'field') {
132
- tag.label { 'Type' } +
133
- tag.select(name: 'item_type', id: 'menu-add-type', class: 'ui dropdown',
134
- onchange: "menuSettingsToggleProxyFields('menu-add')") {
135
- safe_join([
136
- tag.option(value: 'group') { 'Group' },
137
- tag.option(value: 'proxy') { 'Proxy' }
138
- ])
139
- }
140
- },
141
- tag.div(class: 'two fields') {
142
- safe_join([
143
- tag.div(class: 'field') {
144
- tag.label {
145
- 'Label'
146
- } + tag.input(type: 'text', name: 'label', id: 'menu-add-label')
147
- },
148
- tag.div(class: 'field') {
149
- tag.label {
150
- 'Icon'
151
- } + tag.input(type: 'text', name: 'icon', id: 'menu-add-icon',
152
- placeholder: 'e.g. server')
153
- }
154
- ])
155
- },
156
- tag.div(class: 'two fields', id: 'menu-add-proxy-fields', style: 'display:none') {
157
- safe_join([
158
- tag.div(class: 'field') {
159
- tag.label {
160
- 'Route'
161
- } + tag.input(type: 'text', name: 'route', id: 'menu-add-route',
162
- placeholder: '/path')
163
- },
164
- tag.div(class: 'field') {
165
- tag.label {
166
- 'URL'
167
- } + tag.input(type: 'text', name: 'url', id: 'menu-add-url',
168
- placeholder: 'upstream.example.com')
169
- }
170
- ])
171
- },
172
- tag.div(class: 'field') {
173
- tag.label { 'Parent' } +
174
- tag.select(name: 'parent_id', id: 'menu-add-parent', class: 'ui dropdown') {
175
- safe_join([
176
- tag.option(value: '') { 'Root (top level)' },
177
- *menu_group_options
178
- ])
179
- }
180
- }
181
- ])
182
- }
183
- },
184
- tag.div(class: 'actions') {
185
- safe_join([
186
- tag.button(class: 'ui button', onclick: "$('#menu-add-modal').modal('hide')") {
187
- 'Cancel'
188
- },
189
- tag.button(class: 'ui green button', onclick: 'menuSettingsCreate()') {
190
- tag.i(class: 'plus icon') + ' Add'
191
- }
192
- ])
193
- }
194
- ])
195
- }
196
- end
197
32
 
198
33
  def all_menu_items
199
34
  flatten_items(menu_config)
@@ -209,9 +44,7 @@ module WrapItRuby
209
44
  .map { |item| item['route'] }
210
45
  end
211
46
 
212
- def reset_menu_cache!
213
- @menu_config = nil
214
- end
47
+ def reset_menu_cache!; end
215
48
 
216
49
  extend self
217
50
 
@@ -220,16 +53,20 @@ module WrapItRuby
220
53
  def render_menu_entry(entry, top_level: false)
221
54
  if entry['items']
222
55
  if top_level
223
- MenuItem(dropdown: true, icon: entry['icon']) do
56
+ MenuItem(dropdown: true) do
57
+ text entry['icon'] if entry['icon']
58
+ text " "
224
59
  text entry['label']
225
- concat tag.i(class: 'dropdown icon')
60
+ Icon(name: "dropdown")
226
61
  SubMenu do
227
62
  entry['items'].each { |child| render_menu_entry(child) }
228
63
  end
229
64
  end
230
65
  else
231
66
  MenuItem do
232
- concat tag.i(class: 'dropdown icon')
67
+ Icon(name: "dropdown")
68
+ text entry['icon'] if entry['icon']
69
+ text " "
233
70
  text entry['label']
234
71
  SubMenu do
235
72
  entry['items'].each { |child| render_menu_entry(child) }
@@ -237,7 +74,11 @@ module WrapItRuby
237
74
  end
238
75
  end
239
76
  else
240
- MenuItem(href: entry['route'], icon: entry['icon']) { text entry['label'] }
77
+ MenuItem(href: entry['route']) {
78
+ text entry['icon'] if entry['icon']
79
+ text " "
80
+ text entry['label']
81
+ }
241
82
  end
242
83
  end
243
84
 
@@ -245,15 +86,20 @@ module WrapItRuby
245
86
  # Pass an array of MenuItem records (roots with children eager-loaded).
246
87
  # The Stimulus controller reads the JSON and initializes sortable-tree.
247
88
  def sortable_menu_tree
248
- items = ::MenuItem.roots.includes(children: :children)
89
+ unless database_menu_available?
90
+ return tag.div { "No menu items. Configure MenuItem in your app or use config/menu.yml" }
91
+ end
92
+ items = WrapItRuby::MenuItem.roots.includes(children: :children)
249
93
  nodes_json = menu_items_to_nodes(items).to_json
250
94
  sort_url = wrap_it_ruby.sort_menu_setting_path(id: 'bulk')
95
+ edit_url_template = wrap_it_ruby.edit_menu_setting_path(id: ':id')
251
96
 
252
97
  tag.div(
253
98
  data: {
254
99
  controller: 'wrap-it-ruby--sortable-tree',
255
100
  "wrap-it-ruby--sortable-tree-nodes-value": nodes_json,
256
101
  "wrap-it-ruby--sortable-tree-sort-url-value": sort_url,
102
+ "wrap-it-ruby--sortable-tree-edit-url-template-value": edit_url_template,
257
103
  "wrap-it-ruby--sortable-tree-lock-root-value": false,
258
104
  "wrap-it-ruby--sortable-tree-collapse-level-value": 3
259
105
  }
@@ -278,21 +124,21 @@ module WrapItRuby
278
124
  end
279
125
  end
280
126
 
281
- # Builds <option> tags for all group items (for parent dropdown in add modal).
282
- # Indents sub-groups with dashes to show hierarchy.
283
- def menu_group_options(items = nil, depth = 0)
284
- items ||= ::MenuItem.groups.where(parent_id: nil).order(:position).includes(children: :children)
127
+ # Returns [label, value] pairs for all group items, suitable for
128
+ # f.select in the new/edit forms. Indents sub-groups to show hierarchy.
129
+ def menu_group_options_for_select(items = nil, depth = 0)
130
+ items ||= WrapItRuby::MenuItem.groups.where(parent_id: nil).order(:position).includes(children: :children)
285
131
  items.flat_map do |item|
286
132
  prefix = "\u00A0\u00A0" * depth + (depth > 0 ? "\u2514 " : '')
287
- opts = [tag.option(value: item.id) { "#{prefix}#{item.label}" }]
133
+ opts = [ [ "#{prefix}#{item.label}", item.id ] ]
288
134
  sub_groups = item.children.select(&:group?)
289
- opts += menu_group_options(sub_groups, depth + 1) if sub_groups.any?
135
+ opts += menu_group_options_for_select(sub_groups, depth + 1) if sub_groups.any?
290
136
  opts
291
137
  end
292
138
  end
293
139
 
294
140
  def flatten_items(items)
295
- items.flat_map { |item| [item, *flatten_items(item.fetch('items', []))] }
141
+ items.flat_map { |item| [ item, *flatten_items(item.fetch('items', [])) ] }
296
142
  end
297
143
 
298
144
  def menu_file
@@ -300,21 +146,21 @@ module WrapItRuby
300
146
  end
301
147
 
302
148
  def load_menu
303
- @menu_config ||= if database_menu_available?
304
- load_menu_from_database
305
- else
306
- YAML.load_file(menu_file)
307
- end
149
+ if database_menu_available?
150
+ load_menu_from_database
151
+ else
152
+ YAML.load_file(menu_file)
153
+ end
308
154
  end
309
155
 
310
156
  def database_menu_available?
311
- defined?(::MenuItem) && ::MenuItem.table_exists? && ::MenuItem.exists?
157
+ defined?(WrapItRuby::MenuItem) && WrapItRuby::MenuItem.table_exists? && WrapItRuby::MenuItem.exists?
312
158
  rescue StandardError
313
159
  false
314
160
  end
315
161
 
316
162
  def load_menu_from_database
317
- ::MenuItem.roots.includes(children: :children).map { |item| item_to_hash(item) }
163
+ WrapItRuby::MenuItem.roots.includes(children: :children).map { |item| item_to_hash(item) }
318
164
  end
319
165
 
320
166
  def item_to_hash(item)
@@ -14,6 +14,9 @@
14
14
  // - Breakout: if navigation inside the iframe lands on a path
15
15
  // belonging to a DIFFERENT menu route, Turbo.visit
16
16
  // swaps the frame to the new route.
17
+ // - Tab sync: mirrors the iframe's <title> and favicon into the
18
+ // parent page so the browser tab reflects the proxied
19
+ // content.
17
20
  //
18
21
  import { Controller } from "@hotwired/stimulus"
19
22
 
@@ -24,12 +27,22 @@ export default class extends Controller {
24
27
  host: String, // proxy host, e.g. "github.com"
25
28
  }
26
29
 
30
+ #originalTitle
31
+ #originalIcons
32
+
27
33
  connect() {
34
+ this.#originalTitle = document.title
35
+ this.#originalIcons = [...document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')]
36
+
28
37
  this.element.addEventListener("load", this.onLoad)
29
38
  window.addEventListener("popstate", this.onPopstate)
30
39
  }
31
40
 
32
41
  disconnect() {
42
+ document.title = this.#originalTitle
43
+ this.#removeIcons()
44
+ this.#originalIcons.forEach(el => document.head.appendChild(el))
45
+
33
46
  this.element.removeEventListener("load", this.onLoad)
34
47
  window.removeEventListener("popstate", this.onPopstate)
35
48
  }
@@ -45,6 +58,7 @@ export default class extends Controller {
45
58
  Turbo.visit(breakout, { action: "advance" })
46
59
  } else {
47
60
  this.#syncHistory(iframePath)
61
+ this.#syncTab()
48
62
  }
49
63
  }
50
64
 
@@ -84,4 +98,50 @@ export default class extends Controller {
84
98
  history.pushState({ iframeSrc: iframePath }, "", browserPath)
85
99
  }
86
100
  }
101
+
102
+ // Read the iframe's <title> and favicon, apply them to the parent page.
103
+ #syncTab() {
104
+ try {
105
+ const doc = this.element.contentDocument
106
+ if (!doc) return
107
+
108
+ if (doc.title) document.title = doc.title
109
+
110
+ const icon = doc.querySelector('link[rel="icon"], link[rel="shortcut icon"]')
111
+ if (icon) {
112
+ const href = this.#resolveHref(icon.getAttribute("href"))
113
+ if (href) this.#setFavicon(href)
114
+ }
115
+ } catch (_) {
116
+ // cross-origin iframe – cannot access contentDocument
117
+ }
118
+ }
119
+
120
+ // Resolve a favicon href from the iframe document.
121
+ // Absolute / data URIs pass through unchanged; root-relative and
122
+ // relative paths are routed through the proxy.
123
+ #resolveHref(raw) {
124
+ if (!raw) return null
125
+ if (raw.startsWith("data:") || /^https?:\/\//.test(raw) || raw.startsWith("//")) return raw
126
+
127
+ if (raw.startsWith("/")) {
128
+ return `/_proxy/${this.hostValue}${raw}`
129
+ }
130
+
131
+ // Relative path – resolve against the iframe's current directory
132
+ const dir = this.element.contentWindow.location.pathname.replace(/\/[^/]*$/, "/")
133
+ return `${dir}${raw}`
134
+ }
135
+
136
+ #setFavicon(href) {
137
+ this.#removeIcons()
138
+ const link = document.createElement("link")
139
+ link.rel = "icon"
140
+ link.href = href
141
+ document.head.appendChild(link)
142
+ }
143
+
144
+ #removeIcons() {
145
+ document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]').forEach(el => el.remove())
146
+ }
87
147
  }
@@ -0,0 +1,74 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import SortableJS from "sortablejs"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ resourceName: { type: String, default: "menu_item" },
7
+ animation: { type: Number, default: 150 },
8
+ }
9
+
10
+ connect() {
11
+ // If reconnecting after a DOM move, skip re-init
12
+ if (this._disconnectTimer) {
13
+ clearTimeout(this._disconnectTimer)
14
+ this._disconnectTimer = null
15
+ return
16
+ }
17
+
18
+ this.sortable = SortableJS.create(this.element, {
19
+ group: {
20
+ name: this.resourceNameValue,
21
+ pull: true,
22
+ put: true,
23
+ },
24
+ animation: this.animationValue,
25
+ draggable: "> .ui.attached.segment",
26
+ handle: ".drag-handle",
27
+ forceFallback: true,
28
+ fallbackOnBody: true,
29
+ emptyInsertThreshold: 20,
30
+ onClone: (evt) => {
31
+ evt.clone.querySelectorAll("[data-controller]").forEach(el => {
32
+ el.removeAttribute("data-controller")
33
+ })
34
+ },
35
+ onEnd: (evt) => {
36
+ this.#persist(evt.item, evt.newIndex, evt.to)
37
+ },
38
+ })
39
+ }
40
+
41
+ disconnect() {
42
+ // Debounce: SortableJS swaps cause disconnect+reconnect in same tick.
43
+ // Wait before destroying so the reconnect can cancel it.
44
+ this._disconnectTimer = setTimeout(() => {
45
+ if (this.sortable) {
46
+ this.sortable.destroy()
47
+ this.sortable = null
48
+ }
49
+ }, 100)
50
+ }
51
+
52
+ #persist(item, newIndex, toContainer) {
53
+ const url = item.dataset.sortableUpdateUrl
54
+ if (!url) return
55
+
56
+ const parentEl = toContainer.closest("[data-sortable-update-url]")
57
+ const parentId = parentEl
58
+ ? parentEl.dataset.sortableUpdateUrl.match(/\/(\d+)$/)?.[1]
59
+ : null
60
+
61
+ fetch(url, {
62
+ method: "PATCH",
63
+ headers: {
64
+ "Content-Type": "application/json",
65
+ "X-CSRF-Token": this.#csrfToken,
66
+ },
67
+ body: JSON.stringify({ position: newIndex + 1, parent_id: parentId }),
68
+ })
69
+ }
70
+
71
+ get #csrfToken() {
72
+ return document.querySelector('meta[name="csrf-token"]')?.content
73
+ }
74
+ }
@@ -1,14 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import SortableTree from "sortable-tree"
3
- import { patch, post, destroy } from "@rails/request.js"
3
+ import { patch } from "@rails/request.js"
4
4
 
5
5
  // Wraps the sortable-tree library as a Stimulus controller.
6
- // Handles drag-to-sort, click-to-edit, and CRUD via modals.
6
+ // Handles drag-to-sort; click navigates to the edit page.
7
7
 
8
8
  export default class extends Controller {
9
9
  static values = {
10
10
  nodes: Array,
11
11
  sortUrl: String,
12
+ editUrlTemplate: String,
12
13
  lockRoot: { type: Boolean, default: true },
13
14
  collapseLevel: { type: Number, default: 2 },
14
15
  }
@@ -43,17 +44,14 @@ export default class extends Controller {
43
44
  : ""
44
45
  return `<span class="st-label-inner">${icon}${typeBadge}<strong>${data.title}</strong>${route}</span>`
45
46
  },
46
- onChange: async ({ nodes, movedNode, srcParentNode, targetParentNode }) => {
47
+ onChange: async ({ nodes }) => {
47
48
  if (!this.hasSortUrlValue) return
48
49
  await this.persistTree(nodes)
49
50
  },
50
- onClick: (event, node) => {
51
- this.editNode(node)
51
+ onClick: (_event, node) => {
52
+ this.navigateToEdit(node)
52
53
  },
53
54
  })
54
-
55
- // Expose global functions for the modal buttons
56
- this.registerGlobalFunctions()
57
55
  }
58
56
 
59
57
  disconnect() {
@@ -61,99 +59,20 @@ export default class extends Controller {
61
59
  this.tree.destroy()
62
60
  this.tree = null
63
61
  }
64
- this.unregisterGlobalFunctions()
65
62
  }
66
63
 
67
- // -- Edit --
68
-
69
- editNode(node) {
70
- const data = node.data
71
- this._currentEditId = data.id
72
-
73
- document.getElementById("menu-edit-id").value = data.id
74
- document.getElementById("menu-edit-label").value = data.title || ""
75
- document.getElementById("menu-edit-icon").value = data.icon || ""
76
- document.getElementById("menu-edit-route").value = data.route || ""
77
- document.getElementById("menu-edit-url").value = data.url || ""
78
- document.getElementById("menu-edit-type").value = data.item_type || "proxy"
79
-
80
- menuSettingsToggleProxyFields("menu-edit")
81
-
82
- $("#menu-edit-modal").modal({ allowMultiple: true }).modal("show")
64
+ navigateToEdit(node) {
65
+ if (!this.hasEditUrlTemplateValue) return
66
+ const url = this.editUrlTemplateValue.replace(":id", node.data.id)
67
+ window.location.href = url
83
68
  }
84
69
 
85
- async saveNode() {
86
- const id = document.getElementById("menu-edit-id").value
87
- const data = {
88
- label: document.getElementById("menu-edit-label").value,
89
- icon: document.getElementById("menu-edit-icon").value,
90
- route: document.getElementById("menu-edit-route").value,
91
- url: document.getElementById("menu-edit-url").value,
92
- item_type: document.getElementById("menu-edit-type").value,
93
- }
94
-
95
- await patch(`/menu/settings/${id}`, {
96
- body: JSON.stringify(data),
97
- contentType: "application/json",
98
- responseKind: "turbo-stream",
99
- })
100
-
101
- $("#menu-edit-modal").modal("hide")
102
- }
103
-
104
- async deleteNode() {
105
- const id = document.getElementById("menu-edit-id").value
106
- if (!confirm("Delete this item and all its children?")) return
107
-
108
- await destroy(`/menu/settings/${id}`, {
109
- responseKind: "turbo-stream",
110
- })
111
-
112
- $("#menu-edit-modal").modal("hide")
113
- }
114
-
115
- // -- Add --
116
-
117
- showAdd() {
118
- document.getElementById("menu-add-label").value = ""
119
- document.getElementById("menu-add-icon").value = ""
120
- document.getElementById("menu-add-route").value = ""
121
- document.getElementById("menu-add-url").value = ""
122
- document.getElementById("menu-add-type").value = "group"
123
- document.getElementById("menu-add-parent").value = ""
124
-
125
- menuSettingsToggleProxyFields("menu-add")
126
-
127
- $("#menu-add-modal").modal({ allowMultiple: true }).modal("show")
128
- }
129
-
130
- async createNode() {
131
- const data = {
132
- label: document.getElementById("menu-add-label").value,
133
- icon: document.getElementById("menu-add-icon").value,
134
- route: document.getElementById("menu-add-route").value,
135
- url: document.getElementById("menu-add-url").value,
136
- item_type: document.getElementById("menu-add-type").value,
137
- parent_id: document.getElementById("menu-add-parent").value || null,
138
- }
139
-
140
- await post("/menu/settings", {
141
- body: JSON.stringify(data),
142
- contentType: "application/json",
143
- responseKind: "turbo-stream",
144
- })
145
-
146
- $("#menu-add-modal").modal("hide")
147
- }
148
-
149
- // -- Sort --
150
-
151
70
  async persistTree(nodes) {
152
71
  const ordering = this.flattenTree(nodes)
153
72
  await patch(this.sortUrlValue, {
154
73
  body: JSON.stringify({ ordering }),
155
74
  contentType: "application/json",
156
- responseKind: "turbo-stream",
75
+ responseKind: "html",
157
76
  })
158
77
  }
159
78
 
@@ -171,28 +90,4 @@ export default class extends Controller {
171
90
  })
172
91
  return result
173
92
  }
174
-
175
- // -- Global functions (called by modal button onclick handlers) --
176
-
177
- registerGlobalFunctions() {
178
- window.menuSettingsSave = () => this.saveNode()
179
- window.menuSettingsDelete = () => this.deleteNode()
180
- window.menuSettingsCreate = () => this.createNode()
181
- window.menuSettingsShowAdd = () => this.showAdd()
182
- window.menuSettingsToggleProxyFields = (prefix) => {
183
- const type = document.getElementById(`${prefix}-type`).value
184
- const proxyFields = document.getElementById(`${prefix}-proxy-fields`)
185
- if (proxyFields) {
186
- proxyFields.style.display = type === "proxy" ? "" : "none"
187
- }
188
- }
189
- }
190
-
191
- unregisterGlobalFunctions() {
192
- delete window.menuSettingsSave
193
- delete window.menuSettingsDelete
194
- delete window.menuSettingsCreate
195
- delete window.menuSettingsShowAdd
196
- delete window.menuSettingsToggleProxyFields
197
- }
198
93
  }
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WrapItRuby
4
+ class MenuItem < ApplicationRecord
5
+ self.table_name = "menu_items"
6
+
7
+ acts_as_tree order: "position"
8
+ acts_as_list scope: :parent
9
+
10
+ scope :roots, -> { where(parent_id: nil).order(:position) }
11
+ scope :groups, -> { where(item_type: "group") }
12
+ scope :links, -> { where(item_type: "link") }
13
+
14
+ validates :label, presence: true
15
+ validates :icon, emoji: { allow_blank: true }
16
+
17
+ after_commit :reset_menu_cache
18
+
19
+ def group? = item_type == "group"
20
+ def link? = item_type == "link"
21
+
22
+ # Move item to a new parent and position.
23
+ # Handles acts_as_list scope change without triggering NOT NULL on position.
24
+ def move_to(new_parent_id, position)
25
+ return insert_at(position) if parent_id.to_s == new_parent_id.to_s
26
+
27
+ # Close gap in old scope
28
+ acts_as_list_class.where(scope_condition)
29
+ .where("position > ?", self.position)
30
+ .update_all("position = position - 1")
31
+
32
+ # Place at bottom of new scope temporarily
33
+ new_bottom = acts_as_list_class.where(parent_id: new_parent_id).maximum(:position).to_i + 1
34
+ update_columns(parent_id: new_parent_id, position: new_bottom)
35
+ reload
36
+
37
+ # Now insert_at works within the new scope
38
+ insert_at(position)
39
+ end
40
+
41
+ # Seed the menu_items table from a YAML menu config file.
42
+ def self.seed_from_yaml!(path)
43
+ transaction do
44
+ destroy_all
45
+ entries = YAML.load_file(path)
46
+ entries.each_with_index do |entry, pos|
47
+ create_entry!(entry, parent: nil, position: pos + 1)
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.create_entry!(hash, parent:, position:)
53
+ item = create!(
54
+ label: hash["label"],
55
+ icon: hash["icon"],
56
+ route: hash["route"],
57
+ url: hash["url"],
58
+ item_type: hash["items"] ? "group" : (hash["type"] || "link"),
59
+ parent_id: parent&.id,
60
+ position: position
61
+ )
62
+
63
+ hash.fetch("items", []).each_with_index do |child, pos|
64
+ create_entry!(child, parent: item, position: pos + 1)
65
+ end
66
+
67
+ item
68
+ end
69
+
70
+ private
71
+
72
+ def reset_menu_cache
73
+ WrapItRuby::MenuHelper.reset_menu_cache! if defined?(WrapItRuby::MenuHelper)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # Edit Menu Item
2
+
3
+ Style('
4
+ form:has(select[name="menu_item[item_type]"] option[value="group"]:checked) .field:has([name="menu_item[route]"]),
5
+ form:has(select[name="menu_item[item_type]"] option[value="group"]:checked) .field:has([name="menu_item[url]"]) {
6
+ display: none;
7
+ }
8
+ ')
9
+
10
+ Container {
11
+ Header(size: :h1, icon: "pencil") {
12
+ text "Edit Menu Item"
13
+ }
14
+
15
+ Segment {
16
+ Form(model: @menu_item, url: update_menu_setting_path(@menu_item), method: :patch) {
17
+ Grid(columns: 2) {
18
+ Column {
19
+ Select(:item_type, [["Group", "group"], ["Link", "link"]], hint: "Group contains children, Link opens a website")
20
+ }
21
+ Column {
22
+ Select(:parent_id, [["No group (top level)", ""]] + menu_group_options_for_select, hint: "Which group this item belongs to")
23
+ }
24
+ }
25
+
26
+ Grid(columns: 2) {
27
+ Column {
28
+ TextField(:label, placeholder: "Menu item label", hint: "Display name in the menu")
29
+ }
30
+ Column {
31
+ EmojiField(:icon, hint: "Emoji shown next to the label")
32
+ }
33
+ }
34
+
35
+ Grid(columns: 2) {
36
+ Column {
37
+ TextField(:route, placeholder: "/path", hint: "The URL to visit after clicking, e.g. #{request.base_url}/git")
38
+ }
39
+ Column {
40
+ TextField(:url, placeholder: "upstream.example.com", hint: "The web address for this website, e.g. github.com")
41
+ }
42
+ }
43
+
44
+ Divider(hidden: true)
45
+
46
+ Button(color: "green", type: "submit") {
47
+ Icon(name: "save")
48
+ text " Save"
49
+ }
50
+ Button(href: menu_settings_path) { text "Cancel" }
51
+ }
52
+ }
53
+
54
+ Divider(hidden: true)
55
+
56
+ ButtonTo(url: destroy_menu_setting_path(@menu_item), method: :delete, color: "red", confirm: "Delete this item and all its children?") {
57
+ Icon(name: "trash")
58
+ text " Delete"
59
+ }
60
+ }
@@ -0,0 +1,86 @@
1
+ Container {
2
+ Header(size: :h1, icon: "bars") {
3
+ text "Menu Settings"
4
+ }
5
+
6
+ Wrapper(id: "menu-sortable",
7
+ html_class: "ui segments",
8
+ data: {
9
+ controller: "wrap-it-ruby--sortable",
10
+ sortable_animation_value: "150",
11
+ sortable_resource_name_value: "menu_item"
12
+ }) {
13
+ @menu_items.each do |item|
14
+ if item.group?
15
+ Accordion(attached: true, data: { sortable_update_url: update_menu_setting_path(item) }) { |a|
16
+ a.title {
17
+ Icon(name: "arrows alternate", class: "drag-handle")
18
+ text item.icon if item.icon
19
+ text " "
20
+ text item.label
21
+ Link(href: edit_menu_setting_path(item), class: "edit-link") { Icon(name: "pencil") }
22
+ }
23
+ Wrapper(html_class: "ui segments",
24
+ data: {
25
+ controller: "wrap-it-ruby--sortable",
26
+ sortable_animation_value: "150",
27
+ sortable_resource_name_value: "menu_item"
28
+ }) {
29
+ item.children.each do |child|
30
+ if child.group?
31
+ Accordion(attached: true, data: { sortable_update_url: update_menu_setting_path(child) }) { |a2|
32
+ a2.title {
33
+ Icon(name: "arrows alternate", class: "drag-handle")
34
+ text child.icon if child.icon
35
+ text " "
36
+ text child.label
37
+ Link(href: edit_menu_setting_path(child), class: "edit-link") { Icon(name: "pencil") }
38
+ }
39
+ Wrapper(html_class: "ui segments",
40
+ data: {
41
+ controller: "wrap-it-ruby--sortable",
42
+ sortable_animation_value: "150",
43
+ sortable_resource_name_value: "menu_item"
44
+ }) {
45
+ child.children.each do |grandchild|
46
+ Segment(attached: true, data: { sortable_update_url: update_menu_setting_path(grandchild) }) {
47
+ Icon(name: "arrows alternate", class: "drag-handle")
48
+ text grandchild.icon if grandchild.icon
49
+ text " "
50
+ text grandchild.label
51
+ Link(href: edit_menu_setting_path(grandchild), class: "edit-link") { Icon(name: "pencil") }
52
+ }
53
+ end
54
+ }
55
+ }
56
+ else
57
+ Segment(attached: true, data: { sortable_update_url: update_menu_setting_path(child) }) {
58
+ Icon(name: "arrows alternate", class: "drag-handle")
59
+ text child.icon if child.icon
60
+ text " "
61
+ text child.label
62
+ Link(href: edit_menu_setting_path(child), class: "edit-link") { Icon(name: "pencil") }
63
+ }
64
+ end
65
+ end
66
+ }
67
+ }
68
+ else
69
+ Segment(attached: true, data: { sortable_update_url: update_menu_setting_path(item) }) {
70
+ Icon(name: "arrows alternate", class: "drag-handle")
71
+ text item.icon if item.icon
72
+ text " "
73
+ text item.label
74
+ Link(href: edit_menu_setting_path(item), class: "edit-link") { Icon(name: "pencil") }
75
+ }
76
+ end
77
+ end
78
+ }
79
+
80
+ Divider(hidden: true)
81
+
82
+ Button(color: "green", href: new_menu_setting_path) {
83
+ Icon(name: "plus")
84
+ text " Add Item"
85
+ }
86
+ }
@@ -0,0 +1,53 @@
1
+ # Add Menu Item
2
+
3
+ Style('
4
+ form:has(select[name="menu_item[item_type]"] option[value="group"]:checked) .field:has([name="menu_item[route]"]),
5
+ form:has(select[name="menu_item[item_type]"] option[value="group"]:checked) .field:has([name="menu_item[url]"]) {
6
+ display: none;
7
+ }
8
+ ')
9
+
10
+ Container {
11
+ Header(size: :h1, icon: "plus") {
12
+ text "Add Menu Item"
13
+ }
14
+
15
+ Segment {
16
+ Form(model: @menu_item, url: create_menu_setting_path, method: :post) {
17
+ Grid(columns: 2) {
18
+ Column {
19
+ Select(:item_type, [["Group", "group"], ["Link", "link"]], hint: "Group contains children, Link opens a website")
20
+ }
21
+ Column {
22
+ Select(:parent_id, [["No group (top level)", ""]] + menu_group_options_for_select, hint: "Which group this item belongs to")
23
+ }
24
+ }
25
+
26
+ Grid(columns: 2) {
27
+ Column {
28
+ TextField(:label, placeholder: "Menu item label", hint: "Display name in the menu")
29
+ }
30
+ Column {
31
+ EmojiField(:icon, hint: "Emoji shown next to the label")
32
+ }
33
+ }
34
+
35
+ Grid(columns: 2) {
36
+ Column {
37
+ TextField(:route, placeholder: "/path", hint: "The URL to visit after clicking, e.g. #{request.base_url}/git")
38
+ }
39
+ Column {
40
+ TextField(:url, placeholder: "upstream.example.com", hint: "The web address for this website, e.g. github.com")
41
+ }
42
+ }
43
+
44
+ Divider(hidden: true)
45
+
46
+ Button(color: "green", type: "submit") {
47
+ Icon(name: "plus")
48
+ text " Add"
49
+ }
50
+ Button(href: menu_settings_path) { text "Cancel" }
51
+ }
52
+ }
53
+ }
data/config/importmap.rb CHANGED
@@ -8,3 +8,7 @@ pin_all_from WrapItRuby::Engine.root.join('app/javascript/wrap_it_ruby/controlle
8
8
  # Sortable tree + request helpers
9
9
  pin 'sortable-tree', to: 'https://esm.sh/sortable-tree@0.7.6'
10
10
  pin '@rails/request.js', to: 'https://ga.jspm.io/npm:@rails/request.js@0.0.11/src/index.js'
11
+
12
+ # Stimulus Sortable (drag-and-drop reordering)
13
+ pin 'sortablejs', to: 'https://ga.jspm.io/npm:sortablejs@1.15.6/modular/sortable.esm.js'
14
+ pin '@stimulus-components/sortable', to: 'https://ga.jspm.io/npm:@stimulus-components/sortable@5.0.1/dist/stimulus-sortable.mjs'
data/config/routes.rb CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  WrapItRuby::Engine.routes.draw do
4
4
  get 'menu/settings', to: 'menu_settings#index', as: :menu_settings
5
- post 'menu/settings', to: 'menu_settings#create', as: :create_menu_setting
6
- patch 'menu/settings/:id/sort', to: 'menu_settings#sort', as: :sort_menu_setting
7
- patch 'menu/settings/:id', to: 'menu_settings#update', as: :update_menu_setting
5
+ get 'menu/settings/new', to: 'menu_settings#new', as: :new_menu_setting
6
+ post 'menu/settings', to: 'menu_settings#create', as: :create_menu_setting
7
+ get 'menu/settings/:id/edit', to: 'menu_settings#edit', as: :edit_menu_setting
8
+ patch 'menu/settings/:id', to: 'menu_settings#update', as: :update_menu_setting
9
+ patch 'menu/settings/:id/sort', to: 'menu_settings#sort', as: :sort_menu_setting
8
10
  delete 'menu/settings/:id', to: 'menu_settings#destroy', as: :destroy_menu_setting
9
11
 
10
12
  get '/*path', to: 'proxy#show', constraints: lambda { |req|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'ui'
4
+ require 'emoji_validator'
4
5
  require 'wrap_it_ruby/middleware/proxy_middleware'
5
6
  require 'wrap_it_ruby/middleware/root_relative_proxy_middleware'
6
7
  require 'wrap_it_ruby/middleware/script_injection_middleware'
@@ -41,6 +42,14 @@ module WrapItRuby
41
42
  app.config.assets.paths << Ui::Engine.root.join('app/assets')
42
43
  end
43
44
 
45
+ # Append engine migrations to the host app's migration paths.
46
+ initializer 'wrap_it_ruby.migrations' do |app|
47
+ config.paths["db/migrate"].expanded.each do |expanded_path|
48
+ app.config.paths["db/migrate"] << expanded_path
49
+ ActiveRecord::Migrator.migrations_paths << expanded_path
50
+ end
51
+ end
52
+
44
53
  # Make engine helpers (MenuHelper, IframeHelper) available in host app views.
45
54
  # MenuHelper#render_menu depends on ComponentHelper from rails-active-ui,
46
55
  # which is already injected into ActionView by the Ui engine.
@@ -27,7 +27,6 @@ module WrapItRuby
27
27
  authorization
28
28
  cache-control
29
29
  content-type
30
- content-length
31
30
  cookie
32
31
  if-match
33
32
  if-modified-since
@@ -36,6 +35,7 @@ module WrapItRuby
36
35
  if-unmodified-since
37
36
  pragma
38
37
  range
38
+ user-agent
39
39
  x-requested-with
40
40
  ].to_set.freeze
41
41
 
@@ -148,9 +148,10 @@ module WrapItRuby
148
148
  headers.add(name, value)
149
149
  end
150
150
 
151
- # Also forward content-type/content-length from Rack env
152
- headers.add('content-type', env['CONTENT_TYPE']) if env['CONTENT_TYPE']
153
- headers.add('content-length', env['CONTENT_LENGTH']) if env['CONTENT_LENGTH']
151
+ # Forward content-type from Rack env (Rack stores it without HTTP_ prefix)
152
+ # Note: content-length is NOT forwarded — Async::HTTP::Client sets it
153
+ # automatically from the body, and duplicates cause nginx to return 400.
154
+ headers.add('content-type', env['CONTENT_TYPE']) if env['CONTENT_TYPE']
154
155
 
155
156
  # 2. Modify: nothing to modify at this stage (cookies pass through
156
157
  # as-is since the .cia.net domain covers both proxy and upstream)
@@ -7,15 +7,22 @@ module WrapItRuby
7
7
  #
8
8
  # Detects the proxy host from two sources (in priority order):
9
9
  #
10
- # 1. X-Proxy-Host header -- set by the interception script on fetch/XHR.
11
- # Signals the request came from inside the proxy. The actual upstream
12
- # host is resolved from the Referer header.
13
- #
14
- # 2. Referer header -- contains /_proxy/{host}/... for requests where the
10
+ # 1. Referer header -- contains /_proxy/{host}/... for requests where the
15
11
  # browser sends the full path. Works for both scripted requests and
16
12
  # asset loads (<img>, <link>, <script>).
17
13
  #
18
- # Rewrites PATH_INFO to /_proxy/{host}{path} so ProxyMiddleware handles it.
14
+ # 2. X-Proxy-Host header -- set by the interception script on fetch/XHR.
15
+ # Signals the request came from inside the proxy.
16
+ #
17
+ # For programmatic requests (fetch/XHR) that carry X-Proxy-Host, the path
18
+ # is rewritten inline to /_proxy/{host}{path}.
19
+ #
20
+ # For browser-initiated requests (script/link/img tags), a 307 redirect is
21
+ # returned instead. This ensures the browser's URL for the resource retains
22
+ # the /_proxy/{host} prefix, which keeps the Referer chain intact for any
23
+ # sub-resources loaded by that resource (e.g. a JS module that imports
24
+ # another module via a root-relative path).
25
+ #
19
26
  # Must be inserted BEFORE ProxyMiddleware in the Rack stack.
20
27
  #
21
28
  class RootRelativeProxyMiddleware
@@ -33,7 +40,21 @@ module WrapItRuby
33
40
  unless path.start_with?(PROXY_PREFIX)
34
41
  host = extract_proxy_host(env)
35
42
  if host
36
- env["PATH_INFO"] = "#{PROXY_PREFIX}/#{host}#{path}"
43
+ proxy_path = "#{PROXY_PREFIX}/#{host}#{path}"
44
+
45
+ if env[PROXY_HOST_HEADER]
46
+ # Programmatic request (fetch/XHR) — rewrite inline.
47
+ # interception.js already sets the correct Referer and
48
+ # X-Proxy-Host header so the chain won't break.
49
+ env["PATH_INFO"] = proxy_path
50
+ else
51
+ # Browser-initiated request — redirect so the browser's URL
52
+ # (and thus Referer for any sub-resource loads) retains the
53
+ # /_proxy/{host} prefix.
54
+ query = env["QUERY_STRING"]
55
+ proxy_path += "?#{query}" unless query.nil? || query.empty?
56
+ return [307, { "location" => proxy_path }, []]
57
+ end
37
58
  end
38
59
  end
39
60
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WrapItRuby
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.4"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wrap_it_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-01 00:00:00.000000000 Z
11
+ date: 2026-03-26 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -93,6 +94,20 @@ dependencies:
93
94
  - - ">="
94
95
  - !ruby/object:Gem::Version
95
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: emoji-validator-rails
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
96
111
  description: Wraps upstream web applications in an iframe via a same-origin reverse
97
112
  proxy. Provides Rack middleware for proxying, script injection, and root-relative
98
113
  URL rewriting, plus Stimulus controllers for browser history synchronisation.
@@ -110,9 +125,12 @@ files:
110
125
  - app/helpers/wrap_it_ruby/iframe_helper.rb
111
126
  - app/helpers/wrap_it_ruby/menu_helper.rb
112
127
  - app/javascript/wrap_it_ruby/controllers/iframe_proxy_controller.js
128
+ - app/javascript/wrap_it_ruby/controllers/sortable_controller.js
113
129
  - app/javascript/wrap_it_ruby/controllers/sortable_tree_controller.js
114
- - app/views/wrap_it_ruby/menu_settings/_tree.html.erb
115
- - app/views/wrap_it_ruby/menu_settings/index.html.erb
130
+ - app/models/wrap_it_ruby/menu_item.rb
131
+ - app/views/wrap_it_ruby/menu_settings/edit.html.ruby
132
+ - app/views/wrap_it_ruby/menu_settings/index.html.ruby
133
+ - app/views/wrap_it_ruby/menu_settings/new.html.ruby
116
134
  - app/views/wrap_it_ruby/proxy/show.html.ruby
117
135
  - config/importmap.rb
118
136
  - config/routes.rb
@@ -130,6 +148,7 @@ homepage: https://github.com/n-at-han-k/wrap-it-ruby
130
148
  licenses:
131
149
  - Apache-2.0
132
150
  metadata: {}
151
+ post_install_message:
133
152
  rdoc_options: []
134
153
  require_paths:
135
154
  - lib
@@ -144,7 +163,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
163
  - !ruby/object:Gem::Version
145
164
  version: '0'
146
165
  requirements: []
147
- rubygems_version: 3.7.2
166
+ rubygems_version: 3.3.15
167
+ signing_key:
148
168
  specification_version: 4
149
169
  summary: Rails engine for iframe-based reverse proxy portals
150
170
  test_files: []
@@ -1 +0,0 @@
1
- <div id="menu-tree-container"><%= sortable_menu_tree %></div>
@@ -1 +0,0 @@
1
- <%= render partial: "wrap_it_ruby/menu_settings/tree" %>