railspress-engine 0.1.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +193 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +145 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +338 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +130 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +59 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +7 -2
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +20 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +11 -20
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +203 -14
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. data/app/models/railspress/post_tag.rb +0 -8
@@ -1,4 +1,27 @@
1
1
  module Railspress
2
2
  module ApplicationHelper
3
+ # Formats reading time for display
4
+ # @param post [Railspress::Post] the post to format reading time for
5
+ # @param format [Symbol] :short for "5 min" or :long for "5 minute read"
6
+ # @return [String] formatted reading time
7
+ def rp_reading_time(post, format: :short)
8
+ minutes = post.reading_time_display
9
+ case format
10
+ when :long
11
+ "#{minutes} minute read"
12
+ else
13
+ "#{minutes} min"
14
+ end
15
+ end
16
+
17
+ # Returns the featured image URL for a post, useful for og:image meta tags
18
+ # @param post [Railspress::Post] the post
19
+ # @param variant [Hash] image variant options (default: resize_to_limit: [1200, 630])
20
+ # @return [String, nil] the image URL or nil if no image attached
21
+ def rp_featured_image_url(post, variant: { resize_to_limit: [ 1200, 630 ] })
22
+ return nil unless post.header_image.attached?
23
+
24
+ main_app.url_for(post.header_image.variant(variant))
25
+ end
3
26
  end
4
27
  end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ # CMS Helper provides a clean API for loading content elements in views.
5
+ #
6
+ # Usage in views:
7
+ # <%= cms_value("Homepage", "Hero H1") %>
8
+ #
9
+ # <%= cms_element(group: "Homepage", name: "Hero H1") do |value| %>
10
+ # <h1><%= value %></h1>
11
+ # <% end %>
12
+ #
13
+ # Chainable API (works in controllers, services, etc.):
14
+ # Railspress::CMS.find("Homepage").load("Hero H1").value
15
+ # Railspress::CMS.find("Homepage").load("Hero H1").element
16
+ #
17
+ module CmsHelper
18
+ # Stub module included when CMS is disabled.
19
+ # Raises a descriptive error instead of NoMethodError.
20
+ module DisabledStub
21
+ def cms_element(*)
22
+ raise Railspress::ConfigurationError,
23
+ "CMS is not enabled. Add `config.enable_cms` to your Railspress initializer."
24
+ end
25
+
26
+ def cms_value(*)
27
+ raise Railspress::ConfigurationError,
28
+ "CMS is not enabled. Add `config.enable_cms` to your Railspress initializer."
29
+ end
30
+ end
31
+
32
+ # Request-level cache to avoid repeated queries
33
+ def self.cache
34
+ @cache ||= {}
35
+ end
36
+
37
+ def self.clear_cache
38
+ @cache = {}
39
+ end
40
+
41
+ # Chainable query class for content retrieval
42
+ class CMSQuery
43
+ def initialize
44
+ @group_name = nil
45
+ @element_name = nil
46
+ end
47
+
48
+ def find(group_name)
49
+ @group_name = group_name
50
+ self
51
+ end
52
+
53
+ def load(element_name)
54
+ @element_name = element_name
55
+ self
56
+ end
57
+
58
+ def element
59
+ return nil unless @group_name && @element_name
60
+
61
+ cache_key = "#{@group_name}:#{@element_name}"
62
+ cached = CmsHelper.cache[cache_key]
63
+ return cached if cached
64
+
65
+ group = Railspress::ContentGroup.active.find_by(name: @group_name)
66
+ return nil unless group
67
+
68
+ found = group.content_elements.active.find_by(name: @element_name)
69
+ CmsHelper.cache[cache_key] = found if found
70
+ found
71
+ rescue ActiveRecord::RecordNotFound
72
+ nil
73
+ end
74
+
75
+ def value
76
+ element&.value
77
+ end
78
+ end
79
+
80
+ # Get a content element's value by group and element name.
81
+ # @param group_name [String] the content group name
82
+ # @param element_name [String] the content element name
83
+ # @return [String, nil] the element value or nil
84
+ def cms_value(group_name, element_name)
85
+ Railspress::CMS.find(group_name).load(element_name).value
86
+ end
87
+
88
+ # Render a content element, optionally with a block for custom rendering.
89
+ # When inline editing is enabled, wraps content in a Stimulus-controlled
90
+ # <span> with context menu and Turbo Frame markup for right-click editing.
91
+ #
92
+ # @param group [String] the content group name
93
+ # @param name [String] the content element name
94
+ # @param html_options [Hash] additional HTML options
95
+ # @yield [value, element] optional block for custom rendering
96
+ # @return [String] rendered content
97
+ def cms_element(group:, name:, html_options: {}, &block)
98
+ content_element = Railspress::CMS.find(group).load(name).element
99
+ element_value = content_element&.value
100
+
101
+ if content_element&.image? && content_element&.image&.attached?
102
+ img_options = html_options.dup
103
+ if content_element.has_focal_point?(:image)
104
+ focal_css = content_element.focal_point_css(:image)
105
+ existing_style = img_options[:style].to_s
106
+ img_options[:style] = [ existing_style, focal_css ].reject(&:blank?).join("; ")
107
+ end
108
+ img_options[:alt] ||= content_element.name
109
+ return image_tag(main_app.url_for(content_element.image), img_options)
110
+ end
111
+
112
+ rendered = if block_given?
113
+ args = block.arity.zero? ? [] : [ element_value, content_element ]
114
+ capture(*args, &block)
115
+ else
116
+ element_value
117
+ end
118
+
119
+ if content_element && !content_element.image? && inline_editor_enabled?
120
+ inline_wrapper_for(content_element, rendered)
121
+ else
122
+ rendered
123
+ end
124
+ end
125
+
126
+ # Check if inline editing is enabled for the current request.
127
+ # Uses the configured inline_editing_check proc.
128
+ # @return [Boolean]
129
+ def inline_editor_enabled?
130
+ check = Railspress.inline_editing_check
131
+ return false unless check
132
+
133
+ check.call(self)
134
+ rescue
135
+ false
136
+ end
137
+
138
+ # Render the display content within a Turbo Frame for inline replacement.
139
+ # Used by the controller to replace display content after inline save.
140
+ # @param content_element [ContentElement] the element
141
+ # @param display_frame_id [String] the Turbo Frame ID
142
+ # @return [String] HTML safe turbo-frame wrapped content
143
+ def cms_element_display_frame(content_element, display_frame_id)
144
+ content_tag("turbo-frame", content_element.value, id: display_frame_id)
145
+ end
146
+
147
+ # Return a new CMSQuery instance for chainable API in views.
148
+ # @return [CMSQuery]
149
+ def cms
150
+ CmsHelper::CMSQuery.new
151
+ end
152
+
153
+ private
154
+
155
+ # Wrap content in a Stimulus-controlled <span> for inline editing.
156
+ # Includes a display Turbo Frame, hidden context menu with form Turbo Frame,
157
+ # and hidden backdrop.
158
+ def inline_wrapper_for(content_element, rendered_content)
159
+ suffix = SecureRandom.hex(4)
160
+ display_frame_id = "cms_display_#{content_element.id}_#{suffix}"
161
+ form_frame_id = "cms_form_#{content_element.id}_#{suffix}"
162
+
163
+ inline_path = railspress.inline_admin_content_element_path(content_element)
164
+ update_path = railspress.admin_content_element_path(content_element)
165
+
166
+ inject_inline_styles
167
+
168
+ content_tag(:span,
169
+ data: {
170
+ controller: "rp--cms-inline-editor",
171
+ "rp--cms-inline-editor-inline-path-value": inline_path,
172
+ "rp--cms-inline-editor-update-path-value": update_path,
173
+ "rp--cms-inline-editor-frame-id-value": display_frame_id,
174
+ "rp--cms-inline-editor-form-frame-id-value": form_frame_id,
175
+ "rp--cms-inline-editor-element-id-value": content_element.id,
176
+ action: "contextmenu->rp--cms-inline-editor#open"
177
+ },
178
+ style: "display:contents"
179
+ ) do
180
+ # Display frame (replaced after save)
181
+ display = content_tag("turbo-frame", rendered_content, id: display_frame_id)
182
+
183
+ # Context menu panel (hidden by default)
184
+ menu = content_tag(:div, class: "rp-inline-menu rp-inline-hidden",
185
+ data: { "rp--cms-inline-editor-target": "menu" }
186
+ ) do
187
+ # Form Turbo Frame (lazy-loaded on first open)
188
+ content_tag("turbo-frame", "", id: form_frame_id,
189
+ src: nil,
190
+ data: { "rp--cms-inline-editor-target": "frame" })
191
+ end
192
+
193
+ # Backdrop (hidden by default)
194
+ backdrop = content_tag(:div, "", class: "rp-inline-backdrop rp-inline-hidden",
195
+ data: {
196
+ "rp--cms-inline-editor-target": "backdrop",
197
+ action: "click->rp--cms-inline-editor#close"
198
+ })
199
+
200
+ safe_join([ display, menu, backdrop ])
201
+ end
202
+ end
203
+
204
+ # Inject the inline editor CSS <style> tag once per page.
205
+ def inject_inline_styles
206
+ return if @_rp_inline_styles_injected
207
+ @_rp_inline_styles_injected = true
208
+
209
+ content_for :head, inline_editor_style_tag
210
+ end
211
+
212
+ def inline_editor_style_tag
213
+ content_tag(:style, INLINE_EDITOR_CSS.html_safe, data: { rp_inline_styles: true })
214
+ end
215
+
216
+ INLINE_EDITOR_CSS = <<~CSS
217
+ [data-controller="rp--cms-inline-editor"]:hover {
218
+ outline: 2px dashed rgba(59, 130, 246, 0.5);
219
+ outline-offset: 2px;
220
+ cursor: context-menu;
221
+ }
222
+ .rp-inline-backdrop {
223
+ position: fixed;
224
+ inset: 0;
225
+ background: rgba(0, 0, 0, 0.15);
226
+ z-index: 9998;
227
+ }
228
+ .rp-inline-menu {
229
+ position: fixed;
230
+ z-index: 9999;
231
+ background: #fff;
232
+ border: 1px solid #e5e7eb;
233
+ border-radius: 8px;
234
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
235
+ width: 380px;
236
+ max-height: 80vh;
237
+ overflow-y: auto;
238
+ padding: 1rem;
239
+ }
240
+ .rp-inline-hidden { display: none !important; }
241
+ .rp-inline-meta {
242
+ display: flex;
243
+ gap: 0.5rem;
244
+ align-items: center;
245
+ margin-bottom: 0.75rem;
246
+ font-size: 0.75rem;
247
+ }
248
+ .rp-inline-meta__group {
249
+ background: #e0e7ff;
250
+ color: #3730a3;
251
+ padding: 0.15rem 0.5rem;
252
+ border-radius: 4px;
253
+ font-weight: 600;
254
+ }
255
+ .rp-inline-meta__name {
256
+ color: #374151;
257
+ font-weight: 500;
258
+ }
259
+ .rp-inline-meta__version {
260
+ color: #9ca3af;
261
+ margin-left: auto;
262
+ }
263
+ .rp-inline-errors {
264
+ background: #fef2f2;
265
+ border: 1px solid #fecaca;
266
+ border-radius: 4px;
267
+ padding: 0.5rem 0.75rem;
268
+ margin-bottom: 0.75rem;
269
+ font-size: 0.8rem;
270
+ color: #991b1b;
271
+ }
272
+ .rp-inline-errors p { margin: 0; }
273
+ .rp-inline-form__textarea {
274
+ width: 100%;
275
+ min-height: 80px;
276
+ padding: 0.5rem;
277
+ border: 1px solid #d1d5db;
278
+ border-radius: 6px;
279
+ font-family: inherit;
280
+ font-size: 0.875rem;
281
+ line-height: 1.5;
282
+ resize: vertical;
283
+ box-sizing: border-box;
284
+ }
285
+ .rp-inline-form__textarea:focus {
286
+ outline: none;
287
+ border-color: #3b82f6;
288
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
289
+ }
290
+ .rp-inline-actions {
291
+ display: flex;
292
+ gap: 0.5rem;
293
+ align-items: center;
294
+ margin-top: 0.75rem;
295
+ }
296
+ .rp-inline-actions__save {
297
+ padding: 0.4rem 1rem;
298
+ background: #3b82f6;
299
+ color: #fff;
300
+ border: none;
301
+ border-radius: 5px;
302
+ font-size: 0.8rem;
303
+ font-weight: 500;
304
+ cursor: pointer;
305
+ }
306
+ .rp-inline-actions__save:hover { background: #2563eb; }
307
+ .rp-inline-actions__cancel {
308
+ padding: 0.4rem 1rem;
309
+ background: #f3f4f6;
310
+ color: #374151;
311
+ border: 1px solid #d1d5db;
312
+ border-radius: 5px;
313
+ font-size: 0.8rem;
314
+ cursor: pointer;
315
+ }
316
+ .rp-inline-actions__cancel:hover { background: #e5e7eb; }
317
+ .rp-inline-actions__admin-link {
318
+ margin-left: auto;
319
+ font-size: 0.75rem;
320
+ color: #6b7280;
321
+ text-decoration: none;
322
+ }
323
+ .rp-inline-actions__admin-link:hover { color: #3b82f6; }
324
+ CSS
325
+ end
326
+
327
+ # Global CMS module for chainable API access outside views.
328
+ # Usage: Railspress::CMS.find("group").load("element").value
329
+ module CMS
330
+ def self.find(group_name)
331
+ unless Railspress.cms_enabled?
332
+ raise Railspress::ConfigurationError,
333
+ "CMS is not enabled. Add `config.enable_cms` to your Railspress initializer."
334
+ end
335
+ CmsHelper::CMSQuery.new.find(group_name)
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,130 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * CMS Inline Editor Controller
5
+ *
6
+ * Enables right-click inline editing of CMS content elements on live pages.
7
+ * Opens a positioned context menu with a Turbo Frame that lazy-loads an
8
+ * inline edit form from the admin controller.
9
+ *
10
+ * Usage (generated by CmsHelper#inline_wrapper_for):
11
+ * <span data-controller="rp--cms-inline-editor"
12
+ * data-rp--cms-inline-editor-inline-path-value="/railspress/admin/content_elements/1/inline"
13
+ * data-rp--cms-inline-editor-frame-id-value="cms_display_1_abc123"
14
+ * data-rp--cms-inline-editor-form-frame-id-value="cms_form_1_abc123"
15
+ * data-rp--cms-inline-editor-element-id-value="1"
16
+ * data-action="contextmenu->rp--cms-inline-editor#open"
17
+ * style="display:contents">
18
+ *
19
+ * <turbo-frame id="cms_display_1_abc123">Content here</turbo-frame>
20
+ * <div class="rp-inline-menu rp-inline-hidden" data-rp--cms-inline-editor-target="menu">
21
+ * <turbo-frame id="cms_form_1_abc123" data-rp--cms-inline-editor-target="frame"></turbo-frame>
22
+ * </div>
23
+ * <div class="rp-inline-backdrop rp-inline-hidden" data-rp--cms-inline-editor-target="backdrop"></div>
24
+ * </span>
25
+ */
26
+ export default class extends Controller {
27
+ static targets = ["menu", "frame", "backdrop"]
28
+
29
+ static values = {
30
+ inlinePath: String,
31
+ frameId: String,
32
+ formFrameId: String,
33
+ elementId: Number,
34
+ loaded: { type: Boolean, default: false }
35
+ }
36
+
37
+ connect() {
38
+ this._handleKeydown = this.handleKeydown.bind(this)
39
+ this._handleSubmitEnd = this.handleSubmitEnd.bind(this)
40
+ }
41
+
42
+ disconnect() {
43
+ document.removeEventListener("keydown", this._handleKeydown)
44
+ this.element.removeEventListener("turbo:submit-end", this._handleSubmitEnd)
45
+ }
46
+
47
+ open(event) {
48
+ event.preventDefault()
49
+ event.stopPropagation()
50
+
51
+ // Close any other open inline editors (one-at-a-time)
52
+ document.querySelectorAll(".rp-inline-menu:not(.rp-inline-hidden)").forEach(menu => {
53
+ const controller = this.application.getControllerForElementAndIdentifier(
54
+ menu.closest("[data-controller*='rp--cms-inline-editor']"),
55
+ "rp--cms-inline-editor"
56
+ )
57
+ if (controller && controller !== this) controller.close()
58
+ })
59
+
60
+ // Lazy-load the form Turbo Frame on first open
61
+ if (!this.loadedValue && this.hasFrameTarget) {
62
+ const url = new URL(this.inlinePathValue, window.location.origin)
63
+ url.searchParams.set("form_frame_id", this.formFrameIdValue)
64
+ url.searchParams.set("display_frame_id", this.frameIdValue)
65
+ this.frameTarget.setAttribute("src", url.toString())
66
+ this.loadedValue = true
67
+ }
68
+
69
+ // Position the menu near the click
70
+ this.positionMenu(event.clientX, event.clientY)
71
+
72
+ // Show menu and backdrop
73
+ this.menuTarget.classList.remove("rp-inline-hidden")
74
+ this.backdropTarget.classList.remove("rp-inline-hidden")
75
+
76
+ // Listen for escape and form submission
77
+ document.addEventListener("keydown", this._handleKeydown)
78
+ this.element.addEventListener("turbo:submit-end", this._handleSubmitEnd)
79
+ }
80
+
81
+ close() {
82
+ this.menuTarget.classList.add("rp-inline-hidden")
83
+ this.backdropTarget.classList.add("rp-inline-hidden")
84
+ document.removeEventListener("keydown", this._handleKeydown)
85
+ this.element.removeEventListener("turbo:submit-end", this._handleSubmitEnd)
86
+ }
87
+
88
+ handleKeydown(event) {
89
+ if (event.key === "Escape") {
90
+ event.preventDefault()
91
+ this.close()
92
+ }
93
+ }
94
+
95
+ handleSubmitEnd(event) {
96
+ if (event.detail.success) {
97
+ this.close()
98
+ // Reload the frame on next open to get fresh data
99
+ this.loadedValue = false
100
+ }
101
+ }
102
+
103
+ positionMenu(x, y) {
104
+ const menu = this.menuTarget
105
+ // Reset position to measure natural dimensions
106
+ menu.style.left = "0px"
107
+ menu.style.top = "0px"
108
+ menu.classList.remove("rp-inline-hidden")
109
+
110
+ const rect = menu.getBoundingClientRect()
111
+ const padding = 8
112
+
113
+ // Constrain within viewport
114
+ let left = x
115
+ let top = y
116
+
117
+ if (left + rect.width + padding > window.innerWidth) {
118
+ left = window.innerWidth - rect.width - padding
119
+ }
120
+ if (left < padding) left = padding
121
+
122
+ if (top + rect.height + padding > window.innerHeight) {
123
+ top = window.innerHeight - rect.height - padding
124
+ }
125
+ if (top < padding) top = padding
126
+
127
+ menu.style.left = `${left}px`
128
+ menu.style.top = `${top}px`
129
+ }
130
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["typeSelect", "textFields", "imageFields"]
5
+
6
+ connect() {
7
+ this.toggle()
8
+ }
9
+
10
+ toggle() {
11
+ const isImage = this.typeSelectTarget.value === "image"
12
+ this.textFieldsTarget.style.display = isImage ? "none" : ""
13
+ this.imageFieldsTarget.style.display = isImage ? "" : "none"
14
+ }
15
+ }