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.
- checksums.yaml +4 -4
- data/LICENSE +20 -0
- data/README.md +195 -25
- data/app/assets/javascripts/railspress/admin.js +39 -0
- data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
- data/app/assets/stylesheets/application.css +0 -0
- data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
- data/app/assets/stylesheets/railspress/admin/base.css +25 -0
- data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
- data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
- data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
- data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
- data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
- data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
- data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
- data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
- data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
- data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
- data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
- data/app/assets/stylesheets/railspress/admin/page.css +111 -0
- data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
- data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
- data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
- data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
- data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
- data/app/assets/stylesheets/railspress/application.css +44 -13
- data/app/controllers/railspress/admin/base_controller.rb +6 -3
- data/app/controllers/railspress/admin/categories_controller.rb +1 -1
- data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
- data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
- data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
- data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
- data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
- data/app/controllers/railspress/admin/entities_controller.rb +157 -0
- data/app/controllers/railspress/admin/exports_controller.rb +55 -0
- data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
- data/app/controllers/railspress/admin/imports_controller.rb +63 -0
- data/app/controllers/railspress/admin/posts_controller.rb +58 -4
- data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
- data/app/controllers/railspress/admin/tags_controller.rb +1 -1
- data/app/controllers/railspress/application_controller.rb +1 -0
- data/app/helpers/railspress/admin_helper.rb +733 -0
- data/app/helpers/railspress/application_helper.rb +23 -0
- data/app/helpers/railspress/cms_helper.rb +319 -0
- data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
- data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
- data/app/javascript/railspress/controllers/crop_controller.js +224 -0
- data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
- data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
- data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
- data/app/javascript/railspress/controllers/index.js +37 -0
- data/app/javascript/railspress/index.js +62 -0
- data/app/jobs/railspress/export_posts_job.rb +16 -0
- data/app/jobs/railspress/import_posts_job.rb +44 -0
- data/app/models/concerns/railspress/has_focal_point.rb +242 -0
- data/app/models/concerns/railspress/soft_deletable.rb +23 -0
- data/app/models/concerns/railspress/taggable.rb +23 -0
- data/app/models/railspress/content_element.rb +103 -0
- data/app/models/railspress/content_element_version.rb +32 -0
- data/app/models/railspress/content_group.rb +39 -0
- data/app/models/railspress/export.rb +67 -0
- data/app/models/railspress/focal_point.rb +70 -0
- data/app/models/railspress/import.rb +65 -0
- data/app/models/railspress/post.rb +102 -15
- data/app/models/railspress/post_export_processor.rb +162 -0
- data/app/models/railspress/post_import_processor.rb +382 -0
- data/app/models/railspress/tag.rb +10 -3
- data/app/models/railspress/tagging.rb +11 -0
- data/app/services/railspress/content_export_service.rb +122 -0
- data/app/services/railspress/content_import_service.rb +228 -0
- data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
- data/app/views/active_storage/blobs/_blob.html.erb +1 -1
- data/app/views/layouts/railspress/admin.html.erb +3 -1
- data/app/views/railspress/admin/categories/index.html.erb +11 -15
- data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
- data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
- data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
- data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
- data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
- data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
- data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
- data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
- data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
- data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
- data/app/views/railspress/admin/entities/_form.html.erb +53 -0
- data/app/views/railspress/admin/entities/edit.html.erb +4 -0
- data/app/views/railspress/admin/entities/index.html.erb +74 -0
- data/app/views/railspress/admin/entities/new.html.erb +4 -0
- data/app/views/railspress/admin/entities/show.html.erb +117 -0
- data/app/views/railspress/admin/exports/show.html.erb +62 -0
- data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
- data/app/views/railspress/admin/imports/show.html.erb +137 -0
- data/app/views/railspress/admin/posts/_form.html.erb +102 -28
- data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
- data/app/views/railspress/admin/posts/index.html.erb +47 -36
- data/app/views/railspress/admin/posts/show.html.erb +55 -19
- data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
- data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
- data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
- data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
- data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
- data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
- data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
- data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
- data/app/views/railspress/admin/tags/index.html.erb +12 -16
- data/config/brakeman.ignore +18 -0
- data/config/importmap.rb +23 -0
- data/config/routes.rb +62 -1
- data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
- data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
- data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
- data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
- data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
- data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
- data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
- data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
- data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
- data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
- data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
- data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
- data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
- data/lib/generators/railspress/entity/entity_generator.rb +89 -0
- data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
- data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
- data/lib/generators/railspress/install/install_generator.rb +51 -40
- data/lib/generators/railspress/install/templates/initializer.rb +29 -0
- data/lib/railspress/engine.rb +38 -0
- data/lib/railspress/entity.rb +239 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +198 -8
- data/lib/tasks/railspress_tasks.rake +49 -4
- metadata +215 -21
- data/MIT-LICENSE +0 -20
- data/app/assets/stylesheets/railspress/admin.css +0 -1207
- 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
|
+
}
|