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 +4 -4
- data/app/assets/stylesheets/wrap_it_ruby/application.css +59 -1
- data/app/controllers/wrap_it_ruby/menu_settings_controller.rb +30 -22
- data/app/helpers/wrap_it_ruby/menu_helper.rb +33 -187
- data/app/javascript/wrap_it_ruby/controllers/iframe_proxy_controller.js +60 -0
- data/app/javascript/wrap_it_ruby/controllers/sortable_controller.js +74 -0
- data/app/javascript/wrap_it_ruby/controllers/sortable_tree_controller.js +11 -116
- data/app/models/wrap_it_ruby/menu_item.rb +76 -0
- data/app/views/wrap_it_ruby/menu_settings/edit.html.ruby +60 -0
- data/app/views/wrap_it_ruby/menu_settings/index.html.ruby +86 -0
- data/app/views/wrap_it_ruby/menu_settings/new.html.ruby +53 -0
- data/config/importmap.rb +4 -0
- data/config/routes.rb +5 -3
- data/lib/wrap_it_ruby/engine.rb +9 -0
- data/lib/wrap_it_ruby/middleware/proxy_middleware.rb +5 -4
- data/lib/wrap_it_ruby/middleware/root_relative_proxy_middleware.rb +28 -7
- data/lib/wrap_it_ruby/version.rb +1 -1
- metadata +25 -5
- data/app/views/wrap_it_ruby/menu_settings/_tree.html.erb +0 -1
- data/app/views/wrap_it_ruby/menu_settings/index.html.erb +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a551e7f6f103d0580623f12f28c3f75121af83099ad2d853b1a936650a77cbb
|
|
4
|
+
data.tar.gz: 92b6870e46cf110e94e2a821ffe9eb4e5e9244a1952e0db19f5b853e71ab423b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
11
|
-
|
|
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 =
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
35
|
+
item = MenuItem.find(params[:id])
|
|
22
36
|
item.destroy!
|
|
23
|
-
|
|
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
|
-
|
|
43
|
+
MenuItem.transaction do
|
|
30
44
|
ordering.each do |entry|
|
|
31
|
-
item =
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
56
|
+
MenuItem(dropdown: true) do
|
|
57
|
+
text entry['icon'] if entry['icon']
|
|
58
|
+
text " "
|
|
224
59
|
text entry['label']
|
|
225
|
-
|
|
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
|
-
|
|
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']
|
|
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
|
-
|
|
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
|
-
#
|
|
282
|
-
# Indents sub-groups
|
|
283
|
-
def
|
|
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 = [
|
|
133
|
+
opts = [ [ "#{prefix}#{item.label}", item.id ] ]
|
|
288
134
|
sub_groups = item.children.select(&:group?)
|
|
289
|
-
opts +=
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
|
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
|
|
47
|
+
onChange: async ({ nodes }) => {
|
|
47
48
|
if (!this.hasSortUrlValue) return
|
|
48
49
|
await this.persistTree(nodes)
|
|
49
50
|
},
|
|
50
|
-
onClick: (
|
|
51
|
-
this.
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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: "
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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|
|
data/lib/wrap_it_ruby/engine.rb
CHANGED
|
@@ -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
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
data/lib/wrap_it_ruby/version.rb
CHANGED
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.
|
|
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:
|
|
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/
|
|
115
|
-
- app/views/wrap_it_ruby/menu_settings/
|
|
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.
|
|
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" %>
|