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
@@ -0,0 +1,733 @@
1
+ module Railspress
2
+ # Helper methods for building consistent admin views.
3
+ # Use these helpers to ensure styling consistency across all entity views.
4
+ module AdminHelper
5
+ # ============================================================
6
+ # FIELD RENDERING HELPERS
7
+ # ============================================================
8
+
9
+ # Master dispatcher that renders the appropriate input based on type.
10
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
11
+ # @param name [Symbol] the field name
12
+ # @param type [Symbol] the field type (:string, :text, :rich_text, :boolean, :datetime, :date, :integer, :decimal, :attachment, :attachments, :select)
13
+ # @param options [Hash] additional options passed to the specific renderer
14
+ # @return [String] rendered HTML
15
+ #
16
+ # @example Basic usage
17
+ # rp_render_field(f, :title, type: :string)
18
+ # rp_render_field(f, :content, type: :rich_text)
19
+ # rp_render_field(f, :featured, type: :boolean, label: "Featured post?")
20
+ def rp_render_field(form, name, type:, **options)
21
+ case type
22
+ when :string
23
+ rp_string_field(form, name, **options)
24
+ when :text
25
+ rp_text_field(form, name, **options)
26
+ when :rich_text
27
+ rp_rich_text_field(form, name, **options)
28
+ when :boolean
29
+ rp_boolean_field(form, name, **options)
30
+ when :datetime
31
+ rp_datetime_field(form, name, **options)
32
+ when :date
33
+ rp_date_field(form, name, **options)
34
+ when :integer
35
+ rp_integer_field(form, name, **options)
36
+ when :decimal
37
+ rp_decimal_field(form, name, **options)
38
+ when :attachment
39
+ rp_attachment_field(form, name, multiple: false, **options)
40
+ when :attachments
41
+ rp_attachment_field(form, name, multiple: true, **options)
42
+ when :focal_point_image
43
+ rp_focal_point_image_field(form, name, **options)
44
+ when :select
45
+ rp_select_field(form, name, **options)
46
+ when :list
47
+ rp_list_field(form, name, **options)
48
+ when :lines
49
+ rp_lines_field(form, name, **options)
50
+ else
51
+ rp_string_field(form, name, **options)
52
+ end
53
+ end
54
+
55
+ # Renders a string input field with label.
56
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
57
+ # @param name [Symbol] the field name
58
+ # @param primary [Boolean] whether this is the primary/title input
59
+ # @param mono [Boolean] whether to use monospace font
60
+ # @param placeholder [String] placeholder text
61
+ # @param required [Boolean] whether field is required
62
+ # @param label [String] custom label text
63
+ # @param hint [String] hint text shown below input
64
+ # @return [String] rendered HTML
65
+ def rp_string_field(form, name, primary: false, mono: false, placeholder: nil, required: false, label: nil, hint: nil, **options)
66
+ placeholder ||= "Enter #{name.to_s.humanize.downcase}..."
67
+ input_class = rp_input_class(primary: primary, mono: mono)
68
+
69
+ content_tag(:div, class: "rp-form-group") do
70
+ output = form.label(name, label, class: rp_label_class(required: required))
71
+ output += form.text_field(name, class: input_class, placeholder: placeholder, required: required, **options)
72
+ output += rp_hint(hint) if hint
73
+ output
74
+ end
75
+ end
76
+
77
+ # Renders a text area field with label.
78
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
79
+ # @param name [Symbol] the field name
80
+ # @param rows [Integer] number of rows
81
+ # @param placeholder [String] placeholder text
82
+ # @param label [String] custom label text
83
+ # @param hint [String] hint text shown below input
84
+ # @return [String] rendered HTML
85
+ def rp_text_field(form, name, rows: 4, placeholder: nil, label: nil, hint: nil, **options)
86
+ placeholder ||= "Enter #{name.to_s.humanize.downcase}..."
87
+
88
+ content_tag(:div, class: "rp-form-group") do
89
+ output = form.label(name, label, class: "rp-label")
90
+ output += form.text_area(name, rows: rows, class: "rp-input", placeholder: placeholder, **options)
91
+ output += rp_hint(hint) if hint
92
+ output
93
+ end
94
+ end
95
+
96
+ # Renders a rich text (Trix) editor field with label.
97
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
98
+ # @param name [Symbol] the field name
99
+ # @param placeholder [String] placeholder text
100
+ # @param label [String] custom label text
101
+ # @param hint [String] hint text shown below input
102
+ # @return [String] rendered HTML
103
+ def rp_rich_text_field(form, name, placeholder: nil, label: nil, hint: nil, **options)
104
+ content_tag(:div, class: "rp-form-group") do
105
+ output = form.label(name, label, class: "rp-label")
106
+ output += form.rich_text_area(name, class: "rp-rich-text", **options)
107
+ output += rp_hint(hint) if hint
108
+ output
109
+ end
110
+ end
111
+
112
+ # Renders a boolean checkbox field.
113
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
114
+ # @param name [Symbol] the field name
115
+ # @param label [String] custom label text
116
+ # @return [String] rendered HTML
117
+ def rp_boolean_field(form, name, label: nil, hint: nil, **options)
118
+ label_text = label || name.to_s.humanize
119
+
120
+ content_tag(:div, class: "rp-form-group") do
121
+ content_tag(:label, class: "rp-checkbox-label") do
122
+ form.check_box(name, options) + " ".html_safe + label_text
123
+ end +
124
+ (hint ? content_tag(:span, hint, class: "rp-hint") : "".html_safe)
125
+ end
126
+ end
127
+
128
+ # Renders a datetime-local input field with label.
129
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
130
+ # @param name [Symbol] the field name
131
+ # @param label [String] custom label text
132
+ # @param hint [String] hint text shown below input
133
+ # @return [String] rendered HTML
134
+ def rp_datetime_field(form, name, label: nil, hint: nil, **options)
135
+ content_tag(:div, class: "rp-form-group") do
136
+ output = form.label(name, label, class: "rp-label")
137
+ output += form.datetime_local_field(name, class: "rp-input", **options)
138
+ output += rp_hint(hint) if hint
139
+ output
140
+ end
141
+ end
142
+
143
+ # Renders a date input field with label.
144
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
145
+ # @param name [Symbol] the field name
146
+ # @param label [String] custom label text
147
+ # @param hint [String] hint text shown below input
148
+ # @return [String] rendered HTML
149
+ def rp_date_field(form, name, label: nil, hint: nil, **options)
150
+ content_tag(:div, class: "rp-form-group") do
151
+ output = form.label(name, label, class: "rp-label")
152
+ output += form.date_field(name, class: "rp-input", **options)
153
+ output += rp_hint(hint) if hint
154
+ output
155
+ end
156
+ end
157
+
158
+ # Renders an integer number input field with label.
159
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
160
+ # @param name [Symbol] the field name
161
+ # @param label [String] custom label text
162
+ # @param hint [String] hint text shown below input
163
+ # @return [String] rendered HTML
164
+ def rp_integer_field(form, name, label: nil, hint: nil, **options)
165
+ content_tag(:div, class: "rp-form-group") do
166
+ output = form.label(name, label, class: "rp-label")
167
+ output += form.number_field(name, class: "rp-input", step: 1, **options)
168
+ output += rp_hint(hint) if hint
169
+ output
170
+ end
171
+ end
172
+
173
+ # Renders a decimal number input field with label.
174
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
175
+ # @param name [Symbol] the field name
176
+ # @param label [String] custom label text
177
+ # @param hint [String] hint text shown below input
178
+ # @return [String] rendered HTML
179
+ def rp_decimal_field(form, name, label: nil, hint: nil, **options)
180
+ content_tag(:div, class: "rp-form-group") do
181
+ output = form.label(name, label, class: "rp-label")
182
+ output += form.number_field(name, class: "rp-input", step: "any", **options)
183
+ output += rp_hint(hint) if hint
184
+ output
185
+ end
186
+ end
187
+
188
+ # Renders a select dropdown field with label.
189
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
190
+ # @param name [Symbol] the field name
191
+ # @param choices [Array] options for select (array of [text, value] or just values)
192
+ # @param include_blank [Boolean, String] whether to include blank option
193
+ # @param label [String] custom label text
194
+ # @param hint [String] hint text shown below input
195
+ # @return [String] rendered HTML
196
+ #
197
+ # @example Basic usage
198
+ # rp_select_field(f, :status, choices: Post.statuses.keys)
199
+ # rp_select_field(f, :category_id, choices: Category.pluck(:name, :id), include_blank: "No category")
200
+ def rp_select_field(form, name, choices:, include_blank: false, label: nil, hint: nil, **options)
201
+ content_tag(:div, class: "rp-form-group") do
202
+ output = form.label(name, label, class: "rp-label")
203
+ output += form.select(name, choices, { include_blank: include_blank }, { class: "rp-select" }.merge(options))
204
+ output += rp_hint(hint) if hint
205
+ output
206
+ end
207
+ end
208
+
209
+ # Renders a comma-separated list input field with label.
210
+ # Uses the virtual attribute `#{name}_list` for form binding.
211
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
212
+ # @param name [Symbol] the field name (e.g., :tech_stack)
213
+ # @param placeholder [String] placeholder text
214
+ # @param label [String] custom label text
215
+ # @param hint [String] hint text shown below input
216
+ # @return [String] rendered HTML
217
+ #
218
+ # @example Usage
219
+ # rp_list_field(f, :tech_stack)
220
+ # rp_list_field(f, :tech_stack, hint: "Add technologies separated by commas")
221
+ def rp_list_field(form, name, placeholder: nil, label: nil, hint: nil, **options)
222
+ virtual_name = "#{name}_list"
223
+ placeholder ||= "Item 1, Item 2, Item 3"
224
+
225
+ content_tag(:div, class: "rp-form-group") do
226
+ output = form.label(virtual_name, label || name.to_s.humanize, class: "rp-label")
227
+ output += form.text_field(virtual_name, class: "rp-input", placeholder: placeholder, **options)
228
+ output += rp_hint(hint || "Separate items with commas")
229
+ output
230
+ end
231
+ end
232
+
233
+ # Renders a line-separated list textarea field with label.
234
+ # Uses the virtual attribute `#{name}_list` for form binding.
235
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
236
+ # @param name [Symbol] the field name (e.g., :highlights)
237
+ # @param rows [Integer] number of textarea rows
238
+ # @param placeholder [String] placeholder text
239
+ # @param label [String] custom label text
240
+ # @param hint [String] hint text shown below input
241
+ # @return [String] rendered HTML
242
+ #
243
+ # @example Usage
244
+ # rp_lines_field(f, :highlights)
245
+ # rp_lines_field(f, :highlights, rows: 6, hint: "Each line becomes one item")
246
+ def rp_lines_field(form, name, rows: 5, placeholder: nil, label: nil, hint: nil, **options)
247
+ virtual_name = "#{name}_list"
248
+ placeholder ||= "One item per line"
249
+
250
+ content_tag(:div, class: "rp-form-group") do
251
+ output = form.label(virtual_name, label || name.to_s.humanize, class: "rp-label")
252
+ output += form.text_area(virtual_name, rows: rows, class: "rp-input", placeholder: placeholder, **options)
253
+ output += rp_hint(hint || "Enter one item per line")
254
+ output
255
+ end
256
+ end
257
+
258
+ # Renders a file attachment field with preview and removal option.
259
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
260
+ # @param name [Symbol] the field name
261
+ # @param multiple [Boolean] whether to allow multiple files
262
+ # @param accept [String] accepted file types (e.g., "image/*")
263
+ # @param record [ActiveRecord::Base] the record (defaults to form.object)
264
+ # @param param_key [String] the param key for removal checkbox
265
+ # @param label [String] custom label text
266
+ # @param hint [String] hint text shown below input
267
+ # @return [String] rendered HTML
268
+ def rp_attachment_field(form, name, multiple: false, accept: "image/*", record: nil, param_key: nil, label: nil, hint: nil, **options)
269
+ record ||= form.object
270
+ param_key ||= record.model_name.param_key
271
+ attachment = record.public_send(name)
272
+
273
+ content_tag(:div, class: "rp-form-group") do
274
+ output = "".html_safe
275
+
276
+ if multiple && attachment.attached?
277
+ # Multiple attachments preview
278
+ output += content_tag(:div, class: "rp-gallery-preview") do
279
+ attachment.map do |att|
280
+ content_tag(:div, class: "rp-gallery-item") do
281
+ item = if att.image?
282
+ image_tag(main_app.url_for(att), class: "rp-gallery-thumb")
283
+ else
284
+ content_tag(:div, class: "rp-gallery-file") do
285
+ content_tag(:span, "📄", class: "rp-gallery-file-icon") +
286
+ content_tag(:span, att.filename, class: "rp-gallery-file-name")
287
+ end
288
+ end
289
+ item += content_tag(:label, class: "rp-gallery-remove") do
290
+ check_box_tag("#{param_key}[remove_#{name}][]", att.id, false) + " Remove"
291
+ end
292
+ item
293
+ end
294
+ end.join.html_safe
295
+ end
296
+ output += form.label(name, label || "Add images", class: "rp-label")
297
+ output += form.file_field(name, multiple: true, accept: accept, class: "rp-file-input", direct_upload: true, **options)
298
+ output += rp_hint(hint || "Select multiple images to upload") if hint != false
299
+ elsif !multiple && attachment.attached?
300
+ # Single attachment preview
301
+ output += content_tag(:div, class: "rp-attachment-preview") do
302
+ preview = if attachment.image?
303
+ image_tag(main_app.url_for(attachment), class: "rp-attachment-thumb")
304
+ else
305
+ content_tag(:div, class: "rp-attachment-file") do
306
+ content_tag(:span, attachment.filename, class: "rp-attachment-file-name")
307
+ end
308
+ end
309
+ preview += content_tag(:label, class: "rp-attachment-remove") do
310
+ check_box_tag("#{param_key}[remove_#{name}]", "1", false) + " Remove"
311
+ end
312
+ preview
313
+ end
314
+ output += form.label(name, label, class: "rp-label")
315
+ output += form.file_field(name, accept: accept, class: "rp-file-input", direct_upload: true, **options)
316
+ output += rp_hint(hint) if hint
317
+ else
318
+ # No attachment yet
319
+ output += form.label(name, label, class: "rp-label")
320
+ if multiple
321
+ output += form.file_field(name, multiple: true, accept: accept, class: "rp-file-input", direct_upload: true, **options)
322
+ else
323
+ output += form.file_field(name, accept: accept, class: "rp-file-input", direct_upload: true, **options)
324
+ end
325
+ output += rp_hint(hint) if hint
326
+ end
327
+
328
+ output
329
+ end
330
+ end
331
+
332
+ # Renders a focal point image field with the compact/editor UI.
333
+ # For persisted records with images, shows the compact view with Edit button.
334
+ # For new records or no image, shows a dropzone upload.
335
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
336
+ # @param name [Symbol] the attachment field name (e.g., :main_image)
337
+ # @param record [ActiveRecord::Base] the record (defaults to form.object)
338
+ # @param label [String] custom label text
339
+ # @return [String] rendered HTML
340
+ def rp_focal_point_image_field(form, name, record: nil, label: nil, **options)
341
+ record ||= form.object
342
+ label ||= name.to_s.humanize
343
+ attachment = record.public_send(name)
344
+ has_image = attachment.attached? && attachment.blob&.persisted?
345
+
346
+ if record.persisted? && has_image
347
+ # Persisted record with image - render focal point compact view
348
+ render partial: "railspress/admin/shared/image_section_compact",
349
+ locals: {
350
+ record: record,
351
+ attachment_name: name,
352
+ label: label
353
+ }
354
+ else
355
+ # New record or no image - render dropzone
356
+ content_tag(:div, class: "rp-form-group") do
357
+ output = content_tag(:label, label, class: "rp-label")
358
+ if has_image
359
+ # Image uploaded but record not saved yet - show preview
360
+ output += content_tag(:div, class: "rp-image-section__compact") do
361
+ preview = content_tag(:div, class: "rp-image-section__thumb") do
362
+ image_tag(main_app.url_for(attachment.variant(resize_to_limit: [ 120, 80 ])), alt: "")
363
+ end
364
+ preview += content_tag(:div, class: "rp-image-section__info") do
365
+ content_tag(:span, attachment.filename, class: "rp-image-section__filename") +
366
+ content_tag(:span, number_to_human_size(attachment.byte_size), class: "rp-image-section__meta")
367
+ end
368
+ preview += content_tag(:div, class: "rp-image-section__actions") do
369
+ content_tag(:label, class: "rp-btn rp-btn--outline rp-btn--sm") do
370
+ "Change".html_safe + form.file_field(name, accept: "image/*", class: "rp-sr-only", direct_upload: true)
371
+ end
372
+ end
373
+ preview
374
+ end
375
+ output += rp_hint("Save to enable focal point editing.")
376
+ else
377
+ # No image - show dropzone
378
+ output += render(partial: "railspress/admin/shared/dropzone",
379
+ locals: { form: form, field_name: name, prompt: "Click to upload #{label.downcase}" })
380
+ end
381
+ output
382
+ end
383
+ end
384
+ end
385
+
386
+ # ============================================================
387
+ # TABLE ACTION HELPERS
388
+ # ============================================================
389
+
390
+ # Renders the standard edit icon button for table rows.
391
+ # @param path [String] the edit path
392
+ # @param title [String] tooltip text
393
+ # @return [String] rendered HTML
394
+ def rp_edit_icon(path, title: "Edit")
395
+ link_to path, class: "rp-icon-btn", title: title do
396
+ rp_icon(:edit)
397
+ end
398
+ end
399
+
400
+ # Renders the standard delete icon button for table rows.
401
+ # @param path [String] the delete path
402
+ # @param confirm [String] confirmation message
403
+ # @param title [String] tooltip text
404
+ # @return [String] rendered HTML
405
+ def rp_delete_icon(path, confirm: "Delete this item?", title: "Delete", disabled: false, disabled_title: nil)
406
+ if disabled
407
+ content_tag(:span, class: "rp-icon-btn rp-icon-btn--danger rp-icon-btn--disabled",
408
+ title: disabled_title || title) do
409
+ rp_icon(:trash)
410
+ end
411
+ else
412
+ button_to path, method: :delete,
413
+ data: { turbo_confirm: confirm },
414
+ class: "rp-icon-btn rp-icon-btn--danger", title: title do
415
+ rp_icon(:trash)
416
+ end
417
+ end
418
+ end
419
+
420
+ # Renders standard edit and delete action icons for table rows.
421
+ # @param edit_path [String] the edit path
422
+ # @param delete_path [String] the delete path
423
+ # @param confirm [String] confirmation message for delete
424
+ # @return [String] rendered HTML
425
+ #
426
+ # @example Usage
427
+ # rp_table_actions(edit_admin_category_path(category), admin_category_path(category), confirm: "Delete this category?")
428
+ def rp_table_action_icons(edit_path:, delete_path:, confirm: "Delete this item?",
429
+ delete_disabled: false, disabled_title: nil)
430
+ rp_edit_icon(edit_path) +
431
+ rp_delete_icon(delete_path, confirm: confirm,
432
+ disabled: delete_disabled, disabled_title: disabled_title)
433
+ end
434
+
435
+ # Renders an SVG icon.
436
+ # @param name [Symbol] the icon name (:edit, :trash, :plus, :search)
437
+ # @return [String] rendered SVG HTML
438
+ def rp_icon(name)
439
+ icons = {
440
+ edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>',
441
+ trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>',
442
+ plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>',
443
+ search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>'
444
+ }
445
+ icons[name]&.html_safe || ""
446
+ end
447
+
448
+ # ============================================================
449
+ # TABLE DISPLAY HELPERS
450
+ # ============================================================
451
+
452
+ # Truncates text with ellipsis, HTML-safe.
453
+ # @param text [String] the text to truncate
454
+ # @param length [Integer] maximum length
455
+ # @return [String] truncated text
456
+ def rp_truncated_text(text, length: 50)
457
+ truncate(text.to_s, length: length)
458
+ end
459
+
460
+ # Shows a colored badge for status values.
461
+ # @param value [String, Symbol] the status value
462
+ # @param type [Symbol] badge type (:success, :warning, :danger, :default, or status name like :published, :draft)
463
+ # @return [String] rendered HTML
464
+ #
465
+ # @example Usage
466
+ # rp_status_badge(post.status)
467
+ # rp_status_badge("Active", type: :success)
468
+ def rp_status_badge(value, type: nil)
469
+ type ||= value.to_s.downcase.to_sym
470
+ rp_badge(value.to_s.titleize, status: type)
471
+ end
472
+
473
+ # Shows "Yes" / "No" badge with appropriate styling.
474
+ # @param value [Boolean] the boolean value
475
+ # @return [String] rendered HTML
476
+ #
477
+ # @example Usage
478
+ # rp_boolean_badge(post.featured) # => green "Yes" or gray "No"
479
+ def rp_boolean_badge(value)
480
+ if value
481
+ rp_badge("Yes", status: :published)
482
+ else
483
+ rp_badge("No", status: :draft)
484
+ end
485
+ end
486
+
487
+ # Shows attachment status badge.
488
+ # @param attachment [ActiveStorage::Attached] the attachment or attachments
489
+ # @return [String] rendered HTML
490
+ #
491
+ # @example Usage
492
+ # rp_attachment_badge(post.header_image) # => "Attached" or "None"
493
+ # rp_attachment_badge(project.gallery) # => "5 images" or "None"
494
+ def rp_attachment_badge(attachment)
495
+ if attachment.respond_to?(:attached?) && attachment.attached?
496
+ if attachment.respond_to?(:count)
497
+ count = attachment.count
498
+ rp_badge(pluralize(count, "file"), status: :published)
499
+ else
500
+ rp_badge("Attached", status: :published)
501
+ end
502
+ else
503
+ rp_badge("None", status: :draft)
504
+ end
505
+ end
506
+
507
+ # ============================================================
508
+ # FLASH/FEEDBACK HELPERS
509
+ # ============================================================
510
+
511
+ # Renders all flash message types with appropriate styling.
512
+ # @return [String] rendered HTML
513
+ #
514
+ # @example Usage (in layout)
515
+ # <%= rp_flash_messages %>
516
+ def rp_flash_messages
517
+ return unless flash.any?
518
+
519
+ flash_type_classes = {
520
+ notice: "rp-flash--success",
521
+ alert: "rp-flash--danger",
522
+ warning: "rp-flash--warning",
523
+ info: "rp-flash--info"
524
+ }
525
+
526
+ content_tag(:div, class: "rp-flash-container") do
527
+ flash.map do |type, message|
528
+ css_class = flash_type_classes[type.to_sym] || "rp-flash--info"
529
+ content_tag(:div, message, class: "rp-flash #{css_class}")
530
+ end.join.html_safe
531
+ end
532
+ end
533
+
534
+ # ============================================================
535
+ # LAYOUT HELPERS (existing methods below)
536
+ # ============================================================
537
+ # Renders a page header with title and optional action buttons.
538
+ # @param title [String] the page title
539
+ # @param actions [Hash] action links to render (label => path or label => [path, options])
540
+ # @return [String] rendered HTML
541
+ #
542
+ # @example Basic usage
543
+ # <%= rp_page_header "Posts" %>
544
+ #
545
+ # @example With primary action
546
+ # <%= rp_page_header "Posts", "New Post" => new_admin_post_path %>
547
+ #
548
+ # @example With multiple actions
549
+ # <%= rp_page_header "Posts",
550
+ # "Export" => [admin_exports_path, class: "rp-btn rp-btn--secondary"],
551
+ # "New Post" => new_admin_post_path %>
552
+ def rp_page_header(title, actions = {})
553
+ content_tag(:div, class: "rp-page-header") do
554
+ header_content = content_tag(:h1, title, class: "rp-page-title")
555
+
556
+ if actions.any?
557
+ action_links = actions.map do |label, target|
558
+ path, options = target.is_a?(Array) ? target : [ target, {} ]
559
+ btn_class = options.delete(:class) || "rp-btn rp-btn--primary"
560
+ link_to(label, path, options.merge(class: btn_class))
561
+ end.join.html_safe
562
+
563
+ header_content += content_tag(:div, action_links, class: "rp-page-actions")
564
+ end
565
+
566
+ header_content
567
+ end
568
+ end
569
+
570
+ # Renders a standalone page title (for new/edit pages without actions).
571
+ # @param title [String] the page title
572
+ # @return [String] rendered HTML
573
+ def rp_page_title(title)
574
+ content_tag(:h1, title, class: "rp-page-title rp-page-title--standalone")
575
+ end
576
+
577
+ # Renders a card wrapper for content.
578
+ # @param padded [Boolean] whether to add internal padding
579
+ # @param options [Hash] additional HTML attributes
580
+ # @yield the card content
581
+ # @return [String] rendered HTML
582
+ #
583
+ # @example Basic card
584
+ # <%= rp_card do %>
585
+ # <table>...</table>
586
+ # <% end %>
587
+ #
588
+ # @example Padded card for forms
589
+ # <%= rp_card(padded: true) do %>
590
+ # <%= render "form" %>
591
+ # <% end %>
592
+ def rp_card(padded: false, **options, &block)
593
+ classes = [ "rp-card" ]
594
+ classes << "rp-card--padded" if padded
595
+ classes << options.delete(:class) if options[:class]
596
+
597
+ content_tag(:div, options.merge(class: classes.join(" ")), &block)
598
+ end
599
+
600
+ # Renders form errors in the standard style.
601
+ # @param record [ActiveRecord::Base] the record to check for errors
602
+ # @return [String, nil] rendered HTML or nil if no errors
603
+ def rp_form_errors(record)
604
+ return unless record.errors.any?
605
+
606
+ content_tag(:div, class: "rp-form-errors") do
607
+ content_tag(:ul) do
608
+ record.errors.full_messages.map do |msg|
609
+ content_tag(:li, msg)
610
+ end.join.html_safe
611
+ end
612
+ end
613
+ end
614
+
615
+ # Renders a form group (label + input wrapper) with consistent styling.
616
+ # @yield the form group content (label and input)
617
+ # @return [String] rendered HTML
618
+ def rp_form_group(&block)
619
+ content_tag(:div, class: "rp-form-group", &block)
620
+ end
621
+
622
+ # Renders form actions (submit + cancel) with consistent styling.
623
+ # @param form [ActionView::Helpers::FormBuilder] the form builder
624
+ # @param cancel_path [String] path for cancel link
625
+ # @param submit_text [String] text for submit button (defaults to form default)
626
+ # @return [String] rendered HTML
627
+ def rp_form_actions(form, cancel_path, submit_text: nil)
628
+ content_tag(:div, class: "rp-form-actions") do
629
+ submit_options = { class: "rp-btn rp-btn--primary" }
630
+ buttons = form.submit(submit_text, submit_options)
631
+ buttons += link_to("Cancel", cancel_path, class: "rp-btn rp-btn--secondary")
632
+ buttons
633
+ end
634
+ end
635
+
636
+ # Renders a sidebar section for complex forms.
637
+ # @param title [String] the section title
638
+ # @yield the section content
639
+ # @return [String] rendered HTML
640
+ def rp_sidebar_section(title, &block)
641
+ content_tag(:div, class: "rp-sidebar-section") do
642
+ content_tag(:h3, title, class: "rp-sidebar-title") +
643
+ capture(&block)
644
+ end
645
+ end
646
+
647
+ # Renders an empty state message for lists with no items.
648
+ # @param message [String] the message to display
649
+ # @param link_text [String, nil] optional link text
650
+ # @param link_path [String, nil] optional link path
651
+ # @return [String] rendered HTML
652
+ def rp_empty_state(message, link_text: nil, link_path: nil)
653
+ content = message
654
+ content += " " + link_to(link_text, link_path, class: "rp-link") + "." if link_text && link_path
655
+
656
+ content_tag(:p, content.html_safe, class: "rp-empty-state")
657
+ end
658
+
659
+ # Renders a badge with the appropriate status styling.
660
+ # @param text [String] the badge text
661
+ # @param status [Symbol, String] the status type (:draft, :published, :pending, etc.)
662
+ # @return [String] rendered HTML
663
+ def rp_badge(text, status:)
664
+ content_tag(:span, text, class: "rp-badge rp-badge--#{status}")
665
+ end
666
+
667
+ # Renders a hint/help text below a form input.
668
+ # @param text [String] the hint text
669
+ # @return [String] rendered HTML
670
+ def rp_hint(text)
671
+ content_tag(:p, text, class: "rp-hint")
672
+ end
673
+
674
+ # CSS classes for a standard text input.
675
+ # @param primary [Boolean] whether this is the primary/title input
676
+ # @param mono [Boolean] whether to use monospace font (for slugs, codes)
677
+ # @param size [Symbol] input size (:sm, :lg, or nil for default)
678
+ # @return [String] CSS class string
679
+ def rp_input_class(primary: false, mono: false, size: nil)
680
+ classes = [ "rp-input" ]
681
+ classes << "rp-input--title" if primary
682
+ classes << "rp-input--mono" if mono
683
+ classes << "rp-input--#{size}" if size
684
+ classes.join(" ")
685
+ end
686
+
687
+ # CSS classes for a label.
688
+ # @param large [Boolean] whether to use large label style
689
+ # @param required [Boolean] whether to show required indicator
690
+ # @return [String] CSS class string
691
+ def rp_label_class(large: false, required: false)
692
+ classes = [ "rp-label" ]
693
+ classes << "rp-label--lg" if large
694
+ classes << "rp-label--required" if required
695
+ classes.join(" ")
696
+ end
697
+
698
+ # Renders a sortable table header link.
699
+ # Clicking toggles between ascending and descending order.
700
+ # @param column [Symbol, String] the column name for sorting
701
+ # @param label [String] the display text for the header
702
+ # @param current_sort [String, nil] the currently sorted column
703
+ # @param current_direction [String] current sort direction ("asc" or "desc")
704
+ # @return [String] rendered HTML
705
+ #
706
+ # @example Basic usage
707
+ # <%= rp_sortable_header(:title, "Title", current_sort: @sort, current_direction: @direction) %>
708
+ def rp_sortable_header(column, label, current_sort:, current_direction:)
709
+ column = column.to_s
710
+ is_active = current_sort == column
711
+ # Toggle direction if clicking the same column, otherwise default to asc
712
+ new_direction = is_active && current_direction == "asc" ? "desc" : "asc"
713
+
714
+ classes = [ "rp-sortable" ]
715
+ classes << "rp-sortable--active" if is_active
716
+ classes << "rp-sortable--#{current_direction}" if is_active
717
+
718
+ link_to(
719
+ label,
720
+ url_for(request.query_parameters.merge(sort: column, direction: new_direction)),
721
+ class: classes.join(" ")
722
+ )
723
+ end
724
+
725
+ # Renders a non-sortable table header.
726
+ # @param label [String] the display text for the header
727
+ # @param options [Hash] additional HTML attributes
728
+ # @return [String] rendered HTML
729
+ def rp_table_header(label, **options)
730
+ content_tag(:span, label, options)
731
+ end
732
+ end
733
+ end