railspress-engine 0.1.2 → 1.2.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 +195 -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 +156 -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 +319 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -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 +62 -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 +10 -3
  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 +23 -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 +51 -40
  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 +215 -21
  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,319 @@
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
+ # Only the display Turbo Frame lives inside the span — the menu and
157
+ # backdrop are created dynamically on document.body by the JS controller
158
+ # to avoid inheriting ancestor stacking contexts (opacity, transforms).
159
+ def inline_wrapper_for(content_element, rendered_content)
160
+ suffix = SecureRandom.hex(4)
161
+ display_frame_id = "cms_display_#{content_element.id}_#{suffix}"
162
+ form_frame_id = "cms_form_#{content_element.id}_#{suffix}"
163
+
164
+ inline_path = railspress.inline_admin_content_element_path(content_element)
165
+ update_path = railspress.admin_content_element_path(content_element)
166
+
167
+ inject_inline_styles
168
+
169
+ content_tag(:span,
170
+ data: {
171
+ controller: "rp--cms-inline-editor",
172
+ "rp--cms-inline-editor-inline-path-value": inline_path,
173
+ "rp--cms-inline-editor-update-path-value": update_path,
174
+ "rp--cms-inline-editor-frame-id-value": display_frame_id,
175
+ "rp--cms-inline-editor-form-frame-id-value": form_frame_id,
176
+ "rp--cms-inline-editor-element-id-value": content_element.id,
177
+ action: "contextmenu->rp--cms-inline-editor#open"
178
+ },
179
+ style: "display:contents"
180
+ ) do
181
+ content_tag("turbo-frame", rendered_content, id: display_frame_id)
182
+ end
183
+ end
184
+
185
+ # Inject the inline editor CSS <style> tag once per page.
186
+ def inject_inline_styles
187
+ return if @_rp_inline_styles_injected
188
+ @_rp_inline_styles_injected = true
189
+
190
+ content_for :head, inline_editor_style_tag
191
+ end
192
+
193
+ def inline_editor_style_tag
194
+ content_tag(:style, INLINE_EDITOR_CSS.html_safe, data: { rp_inline_styles: true })
195
+ end
196
+
197
+ INLINE_EDITOR_CSS = <<~CSS
198
+ [data-controller="rp--cms-inline-editor"]:hover {
199
+ outline: 2px dashed rgba(59, 130, 246, 0.5);
200
+ outline-offset: 2px;
201
+ cursor: context-menu;
202
+ }
203
+ .rp-inline-backdrop {
204
+ position: fixed;
205
+ inset: 0;
206
+ background: rgba(0, 0, 0, 0.15);
207
+ z-index: 9998;
208
+ }
209
+ .rp-inline-menu {
210
+ position: fixed;
211
+ z-index: 9999;
212
+ background: #fff;
213
+ border: 1px solid #e5e7eb;
214
+ border-radius: 8px;
215
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
216
+ width: 380px;
217
+ max-height: 80vh;
218
+ overflow-y: auto;
219
+ padding: 1rem;
220
+ }
221
+ .rp-inline-hidden { display: none !important; }
222
+ .rp-inline-meta {
223
+ display: flex;
224
+ gap: 0.5rem;
225
+ align-items: center;
226
+ margin-bottom: 0.75rem;
227
+ font-size: 0.75rem;
228
+ }
229
+ .rp-inline-meta__group {
230
+ background: #e0e7ff;
231
+ color: #3730a3;
232
+ padding: 0.15rem 0.5rem;
233
+ border-radius: 4px;
234
+ font-weight: 600;
235
+ }
236
+ .rp-inline-meta__name {
237
+ color: #374151;
238
+ font-weight: 500;
239
+ }
240
+ .rp-inline-meta__version {
241
+ color: #9ca3af;
242
+ margin-left: auto;
243
+ }
244
+ .rp-inline-errors {
245
+ background: #fef2f2;
246
+ border: 1px solid #fecaca;
247
+ border-radius: 4px;
248
+ padding: 0.5rem 0.75rem;
249
+ margin-bottom: 0.75rem;
250
+ font-size: 0.8rem;
251
+ color: #991b1b;
252
+ }
253
+ .rp-inline-errors p { margin: 0; }
254
+ .rp-inline-form__textarea {
255
+ width: 100%;
256
+ min-height: 80px;
257
+ padding: 0.5rem;
258
+ border: 1px solid #d1d5db;
259
+ border-radius: 6px;
260
+ font-family: inherit;
261
+ font-size: 0.875rem;
262
+ line-height: 1.5;
263
+ resize: vertical;
264
+ box-sizing: border-box;
265
+ }
266
+ .rp-inline-form__textarea:focus {
267
+ outline: none;
268
+ border-color: #3b82f6;
269
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
270
+ }
271
+ .rp-inline-actions {
272
+ display: flex;
273
+ gap: 0.5rem;
274
+ align-items: center;
275
+ margin-top: 0.75rem;
276
+ }
277
+ .rp-inline-actions__save {
278
+ padding: 0.4rem 1rem;
279
+ background: #3b82f6;
280
+ color: #fff;
281
+ border: none;
282
+ border-radius: 5px;
283
+ font-size: 0.8rem;
284
+ font-weight: 500;
285
+ cursor: pointer;
286
+ }
287
+ .rp-inline-actions__save:hover { background: #2563eb; }
288
+ .rp-inline-actions__cancel {
289
+ padding: 0.4rem 1rem;
290
+ background: #f3f4f6;
291
+ color: #374151;
292
+ border: 1px solid #d1d5db;
293
+ border-radius: 5px;
294
+ font-size: 0.8rem;
295
+ cursor: pointer;
296
+ }
297
+ .rp-inline-actions__cancel:hover { background: #e5e7eb; }
298
+ .rp-inline-actions__admin-link {
299
+ margin-left: auto;
300
+ font-size: 0.75rem;
301
+ color: #6b7280;
302
+ text-decoration: none;
303
+ }
304
+ .rp-inline-actions__admin-link:hover { color: #3b82f6; }
305
+ CSS
306
+ end
307
+
308
+ # Global CMS module for chainable API access outside views.
309
+ # Usage: Railspress::CMS.find("group").load("element").value
310
+ module CMS
311
+ def self.find(group_name)
312
+ unless Railspress.cms_enabled?
313
+ raise Railspress::ConfigurationError,
314
+ "CMS is not enabled. Add `config.enable_cms` to your Railspress initializer."
315
+ end
316
+ CmsHelper::CMSQuery.new.find(group_name)
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,147 @@
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
+ * The menu and backdrop are created dynamically on document.body to avoid
11
+ * inheriting ancestor stacking contexts (opacity, transforms, etc.).
12
+ *
13
+ * Usage (generated by CmsHelper#inline_wrapper_for):
14
+ * <span data-controller="rp--cms-inline-editor"
15
+ * data-rp--cms-inline-editor-inline-path-value="/railspress/admin/content_elements/1/inline"
16
+ * data-rp--cms-inline-editor-frame-id-value="cms_display_1_abc123"
17
+ * data-rp--cms-inline-editor-form-frame-id-value="cms_form_1_abc123"
18
+ * data-rp--cms-inline-editor-element-id-value="1"
19
+ * data-action="contextmenu->rp--cms-inline-editor#open"
20
+ * style="display:contents">
21
+ * <turbo-frame id="cms_display_1_abc123">Content here</turbo-frame>
22
+ * </span>
23
+ */
24
+ export default class extends Controller {
25
+ static values = {
26
+ inlinePath: String,
27
+ frameId: String,
28
+ formFrameId: String,
29
+ elementId: Number,
30
+ loaded: { type: Boolean, default: false }
31
+ }
32
+
33
+ connect() {
34
+ this._handleKeydown = this.handleKeydown.bind(this)
35
+ this._handleSubmitEnd = this.handleSubmitEnd.bind(this)
36
+ this._handleBackdropClick = () => this.close()
37
+ }
38
+
39
+ disconnect() {
40
+ document.removeEventListener("keydown", this._handleKeydown)
41
+ if (this._menu) this._menu.remove()
42
+ if (this._backdrop) this._backdrop.remove()
43
+ }
44
+
45
+ // Lazily create the menu and backdrop on document.body (once per controller)
46
+ _ensureMenuExists() {
47
+ if (this._menu) return
48
+
49
+ this._backdrop = document.createElement("div")
50
+ this._backdrop.className = "rp-inline-backdrop rp-inline-hidden"
51
+ this._backdrop.addEventListener("click", this._handleBackdropClick)
52
+ document.body.appendChild(this._backdrop)
53
+
54
+ this._menu = document.createElement("div")
55
+ this._menu.className = "rp-inline-menu rp-inline-hidden"
56
+ document.body.appendChild(this._menu)
57
+
58
+ this._frame = document.createElement("turbo-frame")
59
+ this._frame.id = this.formFrameIdValue
60
+ this._menu.appendChild(this._frame)
61
+ }
62
+
63
+ open(event) {
64
+ event.preventDefault()
65
+ event.stopPropagation()
66
+
67
+ this._ensureMenuExists()
68
+
69
+ // Close any other open inline editors (one-at-a-time)
70
+ document.querySelectorAll(".rp-inline-menu:not(.rp-inline-hidden)").forEach(menu => {
71
+ if (menu !== this._menu) menu.classList.add("rp-inline-hidden")
72
+ })
73
+ document.querySelectorAll(".rp-inline-backdrop:not(.rp-inline-hidden)").forEach(backdrop => {
74
+ if (backdrop !== this._backdrop) backdrop.classList.add("rp-inline-hidden")
75
+ })
76
+
77
+ // Lazy-load the form Turbo Frame on first open
78
+ if (!this.loadedValue) {
79
+ const url = new URL(this.inlinePathValue, window.location.origin)
80
+ url.searchParams.set("form_frame_id", this.formFrameIdValue)
81
+ url.searchParams.set("display_frame_id", this.frameIdValue)
82
+ this._frame.setAttribute("src", url.toString())
83
+ this.loadedValue = true
84
+ }
85
+
86
+ // Position the menu near the click
87
+ this.positionMenu(event.clientX, event.clientY)
88
+
89
+ // Show menu and backdrop
90
+ this._menu.classList.remove("rp-inline-hidden")
91
+ this._backdrop.classList.remove("rp-inline-hidden")
92
+
93
+ // Listen for escape and form submission
94
+ document.addEventListener("keydown", this._handleKeydown)
95
+ this._menu.addEventListener("turbo:submit-end", this._handleSubmitEnd)
96
+ }
97
+
98
+ close() {
99
+ if (this._menu) this._menu.classList.add("rp-inline-hidden")
100
+ if (this._backdrop) this._backdrop.classList.add("rp-inline-hidden")
101
+ document.removeEventListener("keydown", this._handleKeydown)
102
+ if (this._menu) this._menu.removeEventListener("turbo:submit-end", this._handleSubmitEnd)
103
+ }
104
+
105
+ handleKeydown(event) {
106
+ if (event.key === "Escape") {
107
+ event.preventDefault()
108
+ this.close()
109
+ }
110
+ }
111
+
112
+ handleSubmitEnd(event) {
113
+ if (event.detail.success) {
114
+ this.close()
115
+ // Reload the frame on next open to get fresh data
116
+ this.loadedValue = false
117
+ }
118
+ }
119
+
120
+ positionMenu(x, y) {
121
+ const menu = this._menu
122
+ // Reset position to measure natural dimensions
123
+ menu.style.left = "0px"
124
+ menu.style.top = "0px"
125
+ menu.classList.remove("rp-inline-hidden")
126
+
127
+ const rect = menu.getBoundingClientRect()
128
+ const padding = 8
129
+
130
+ // Constrain within viewport
131
+ let left = x
132
+ let top = y
133
+
134
+ if (left + rect.width + padding > window.innerWidth) {
135
+ left = window.innerWidth - rect.width - padding
136
+ }
137
+ if (left < padding) left = padding
138
+
139
+ if (top + rect.height + padding > window.innerHeight) {
140
+ top = window.innerHeight - rect.height - padding
141
+ }
142
+ if (top < padding) top = padding
143
+
144
+ menu.style.left = `${left}px`
145
+ menu.style.top = `${top}px`
146
+ }
147
+ }
@@ -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
+ }