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,37 @@
1
+ /**
2
+ * RailsPress Stimulus Controllers
3
+ *
4
+ * Registers all RailsPress controllers with a Stimulus application.
5
+ *
6
+ * Usage in host app:
7
+ *
8
+ * // In application.js or wherever you configure Stimulus
9
+ * import { Application } from "@hotwired/stimulus"
10
+ * import { registerRailspressControllers } from "railspress/controllers"
11
+ *
12
+ * const application = Application.start()
13
+ * registerRailspressControllers(application)
14
+ *
15
+ * Or register controllers individually:
16
+ *
17
+ * import FocalPointController from "railspress/controllers/focal_point_controller"
18
+ * application.register("railspress--focal-point", FocalPointController)
19
+ */
20
+
21
+ import FocalPointController from "./focal_point_controller"
22
+ import DropzoneController from "./dropzone_controller"
23
+ import CropController from "./crop_controller"
24
+ import ImageSectionController from "./image_section_controller"
25
+ import CmsInlineEditorController from "./cms_inline_editor_controller"
26
+
27
+ // Export individual controllers
28
+ export { FocalPointController, DropzoneController, CropController, ImageSectionController, CmsInlineEditorController }
29
+
30
+ // Export registration function for convenience
31
+ export function registerRailspressControllers(application) {
32
+ application.register("railspress--focal-point", FocalPointController)
33
+ application.register("railspress--dropzone", DropzoneController)
34
+ application.register("railspress--crop", CropController)
35
+ application.register("rp-image-section", ImageSectionController)
36
+ application.register("rp--cms-inline-editor", CmsInlineEditorController)
37
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * RailsPress - Main Entry Point
3
+ *
4
+ * Import this file to auto-register all RailsPress Stimulus controllers.
5
+ *
6
+ * Usage (one line in your application.js):
7
+ *
8
+ * import "railspress"
9
+ *
10
+ * That's it! Controllers are auto-registered using window.Stimulus.
11
+ *
12
+ * For manual registration (if you don't use window.Stimulus):
13
+ *
14
+ * import { register } from "railspress"
15
+ * import { application } from "./application"
16
+ * register(application)
17
+ */
18
+
19
+ // Lexxy rich text editor
20
+ import "lexxy"
21
+
22
+ // Import Turbo for Turbo Frames support
23
+ import "@hotwired/turbo-rails"
24
+
25
+ import FocalPointController from "railspress/controllers/focal_point_controller"
26
+ import DropzoneController from "railspress/controllers/dropzone_controller"
27
+ import CropController from "railspress/controllers/crop_controller"
28
+ import ImageSectionController from "railspress/controllers/image_section_controller"
29
+ import CmsInlineEditorController from "railspress/controllers/cms_inline_editor_controller"
30
+ import ContentElementFormController from "railspress/controllers/content_element_form_controller"
31
+
32
+ // Controller definitions with their identifiers
33
+ const controllers = {
34
+ "railspress--focal-point": FocalPointController,
35
+ "railspress--dropzone": DropzoneController,
36
+ "railspress--crop": CropController,
37
+ "railspress--image-section": ImageSectionController,
38
+ "rp--cms-inline-editor": CmsInlineEditorController,
39
+ "railspress--content-element-form": ContentElementFormController
40
+ }
41
+
42
+ /**
43
+ * Register all RailsPress controllers with a Stimulus application.
44
+ * @param {Application} application - The Stimulus application instance
45
+ */
46
+ export function register(application) {
47
+ for (const [identifier, controller] of Object.entries(controllers)) {
48
+ application.register(identifier, controller)
49
+ }
50
+ }
51
+
52
+ // Auto-register using window.Stimulus (set by host app's application.js)
53
+ // This runs as a side-effect when you `import "railspress"`
54
+ if (typeof window !== "undefined" && window.Stimulus) {
55
+ register(window.Stimulus)
56
+ }
57
+
58
+ // Export individual controllers for advanced use cases
59
+ export { FocalPointController, DropzoneController, CropController, ImageSectionController, CmsInlineEditorController, ContentElementFormController }
60
+
61
+ // Re-export the legacy function for backwards compatibility
62
+ export { register as registerRailspressControllers }
@@ -0,0 +1,16 @@
1
+ module Railspress
2
+ class ExportPostsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(export_id)
6
+ export = Export.find(export_id)
7
+
8
+ processor = PostExportProcessor.new(export: export)
9
+ processor.process!
10
+ rescue => e
11
+ export.add_error("Export failed: #{e.message}")
12
+ export.mark_failed!
13
+ raise
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ module Railspress
2
+ class ImportPostsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(import_id, file_paths)
6
+ import = Import.find(import_id)
7
+ paths = Array(file_paths)
8
+
9
+ import.mark_processing!
10
+
11
+ begin
12
+ paths.each do |file_path|
13
+ processor = PostImportProcessor.new(import: import, file_path: file_path)
14
+ processor.process_file(file_path)
15
+ end
16
+ ensure
17
+ finalize_import(import)
18
+ cleanup_uploaded_files(paths)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def finalize_import(import)
25
+ if import.error_count > 0 && import.success_count == 0
26
+ import.mark_failed!
27
+ else
28
+ import.mark_completed!
29
+ end
30
+ end
31
+
32
+ def cleanup_uploaded_files(file_paths)
33
+ tmp_dir = Rails.root.join("tmp").to_s
34
+
35
+ file_paths.each do |path|
36
+ # Only cleanup files in the tmp directory to avoid deleting source files
37
+ next unless path.start_with?(tmp_dir)
38
+ FileUtils.rm_f(path) if File.exist?(path)
39
+ end
40
+ rescue => e
41
+ Rails.logger.warn "Failed to cleanup uploaded import files: #{e.message}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress::HasFocalPoint
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # Unified DSL for declaring an image attachment with focal point support
8
+ #
9
+ # One call that handles everything:
10
+ # - has_one_attached with variant configuration
11
+ # - has_focal_point for focal point editing
12
+ # - railspress_fields registration (if Railspress::Entity included)
13
+ #
14
+ # @param attachment_name [Symbol] Name of the attachment
15
+ # @yield [attachable] Optional block for ActiveStorage variant configuration
16
+ #
17
+ # @example Simple usage (no variants)
18
+ # focal_point_image :cover_image
19
+ #
20
+ # @example With variants
21
+ # focal_point_image :header_image do |attachable|
22
+ # attachable.variant :hero, resize_to_fill: [2100, 900, { crop: :centre }]
23
+ # attachable.variant :card, resize_to_fill: [800, 500, { crop: :centre }]
24
+ # attachable.variant :thumb, resize_to_fill: [400, 250, { crop: :centre }]
25
+ # attachable.variant :og, resize_to_fill: [1200, 630, { crop: :centre }]
26
+ # end
27
+ #
28
+ def focal_point_image(attachment_name, &block)
29
+ # Declare the ActiveStorage attachment with optional variant block
30
+ has_one_attached attachment_name, &block
31
+
32
+ # Add focal point support
33
+ has_focal_point attachment_name
34
+
35
+ # Auto-register with Railspress entity system if available
36
+ if respond_to?(:railspress_fields)
37
+ railspress_fields attachment_name, as: :focal_point_image
38
+ end
39
+ end
40
+
41
+ # Declare focal point support for an attachment
42
+ #
43
+ # Creates a polymorphic association to Railspress::FocalPoint.
44
+ # No migration needed on the host model - data stored in railspress_focal_points table.
45
+ #
46
+ # @param attachment_name [Symbol] Name of the ActiveStorage attachment
47
+ #
48
+ # @example
49
+ # class Post < ApplicationRecord
50
+ # has_one_attached :header_image
51
+ # has_focal_point :header_image
52
+ # end
53
+ #
54
+ # class Project < ApplicationRecord
55
+ # include Railspress::HasFocalPoint
56
+ # has_one_attached :cover_image
57
+ # has_focal_point :cover_image
58
+ # end
59
+ #
60
+ def has_focal_point(attachment_name)
61
+ association_name = :"#{attachment_name}_focal_point"
62
+
63
+ # Define the has_one association for this attachment's focal point
64
+ has_one association_name,
65
+ -> { where(attachment_name: attachment_name.to_s) },
66
+ as: :record,
67
+ class_name: "Railspress::FocalPoint",
68
+ dependent: :destroy,
69
+ autosave: true
70
+
71
+ # Accept nested attributes for form integration
72
+ accepts_nested_attributes_for association_name
73
+
74
+ # Override the association reader to auto-build if nil
75
+ define_method(association_name) do
76
+ super() || send(:"build_#{association_name}", attachment_name: attachment_name.to_s)
77
+ end
78
+
79
+ # Auto-create focal point when image is attached
80
+ after_commit :"ensure_#{attachment_name}_focal_point"
81
+
82
+ define_method(:"ensure_#{attachment_name}_focal_point") do
83
+ attachment = send(attachment_name)
84
+ return unless attachment.attached?
85
+
86
+ # Use the raw association to avoid auto-build, check if persisted
87
+ existing = Railspress::FocalPoint.find_by(
88
+ record_type: self.class.name,
89
+ record_id: id,
90
+ attachment_name: attachment_name.to_s
91
+ )
92
+ return if existing
93
+
94
+ Railspress::FocalPoint.create!(
95
+ record: self,
96
+ attachment_name: attachment_name.to_s,
97
+ focal_x: 0.5,
98
+ focal_y: 0.5
99
+ )
100
+ end
101
+
102
+ # Store attachment name for lookup
103
+ @focal_point_attachments ||= []
104
+ @focal_point_attachments << attachment_name
105
+ end
106
+
107
+ def focal_point_attachments
108
+ @focal_point_attachments || []
109
+ end
110
+ end
111
+
112
+ # Get focal point as hash
113
+ #
114
+ # @param attachment_name [Symbol] Attachment name
115
+ # @return [Hash] { x: Float, y: Float }
116
+ #
117
+ def focal_point(attachment_name = default_focal_attachment)
118
+ fp = send(:"#{attachment_name}_focal_point")
119
+ fp.to_point
120
+ end
121
+
122
+ # Get CSS object-position value
123
+ #
124
+ # @param attachment_name [Symbol] Attachment name
125
+ # @return [String] CSS property value
126
+ #
127
+ def focal_point_css(attachment_name = default_focal_attachment)
128
+ fp = send(:"#{attachment_name}_focal_point")
129
+ fp.to_css
130
+ end
131
+
132
+ # Check if focal point differs from center
133
+ #
134
+ def has_focal_point?(attachment_name = default_focal_attachment)
135
+ fp = send(:"#{attachment_name}_focal_point")
136
+ fp.offset_from_center?
137
+ end
138
+
139
+ # Get override for specific context
140
+ #
141
+ # @param context [Symbol, String] Context name (e.g., :hero, :card)
142
+ # @param attachment_name [Symbol] Attachment name
143
+ # @return [Hash, nil] Override data or nil
144
+ #
145
+ def image_override(context, attachment_name = default_focal_attachment)
146
+ fp = send(:"#{attachment_name}_focal_point")
147
+ fp.override_for(context)
148
+ end
149
+
150
+ # Check if context has custom override (not using focal point)
151
+ #
152
+ def has_image_override?(context, attachment_name = default_focal_attachment)
153
+ fp = send(:"#{attachment_name}_focal_point")
154
+ fp.has_override?(context)
155
+ end
156
+
157
+ # Set override for context
158
+ #
159
+ def set_image_override(context, data, attachment_name = default_focal_attachment)
160
+ fp = send(:"#{attachment_name}_focal_point")
161
+ fp.set_override(context, data)
162
+ end
163
+
164
+ # Clear override for context (revert to focal point)
165
+ #
166
+ def clear_image_override(context, attachment_name = default_focal_attachment)
167
+ fp = send(:"#{attachment_name}_focal_point")
168
+ fp.clear_override(context)
169
+ end
170
+
171
+ # Get the appropriate image for a context
172
+ #
173
+ # Returns the original attachment or a custom uploaded blob.
174
+ # Host apps use this with standard Rails image_tag:
175
+ #
176
+ # <%= image_tag @post.image_for(:hero), style: @post.image_css_for(:hero) %>
177
+ #
178
+ # @param context [Symbol, String] Context name (e.g., :hero, :card)
179
+ # @param attachment_name [Symbol] Attachment name
180
+ # @return [ActiveStorage::Attached, ActiveStorage::Blob] The image to display
181
+ #
182
+ def image_for(context, attachment_name = default_focal_attachment)
183
+ override = image_override(context, attachment_name)
184
+
185
+ if override&.dig(:type) == "upload" && override[:blob_signed_id].present?
186
+ begin
187
+ ActiveStorage::Blob.find_signed!(override[:blob_signed_id])
188
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
189
+ send(attachment_name) # Fallback to original if blob not found
190
+ end
191
+ else
192
+ send(attachment_name)
193
+ end
194
+ end
195
+
196
+ # Get CSS for displaying image in a context
197
+ #
198
+ # Returns object-position CSS for focal point or crop region.
199
+ # Host apps use this with standard Rails image_tag:
200
+ #
201
+ # <%= image_tag @post.image_for(:hero), style: @post.image_css_for(:hero) %>
202
+ #
203
+ # @param context [Symbol, String] Context name (e.g., :hero, :card)
204
+ # @param attachment_name [Symbol] Attachment name
205
+ # @return [String] CSS property value(s)
206
+ #
207
+ def image_css_for(context, attachment_name = default_focal_attachment)
208
+ override = image_override(context, attachment_name)
209
+
210
+ case override&.dig(:type)
211
+ when "crop"
212
+ # Custom crop region - calculate center of crop for object-position
213
+ region = override[:region]&.with_indifferent_access
214
+ if region
215
+ x_offset = (region[:x].to_f + region[:width].to_f / 2) * 100
216
+ y_offset = (region[:y].to_f + region[:height].to_f / 2) * 100
217
+ "object-position: #{x_offset.round(1)}% #{y_offset.round(1)}%"
218
+ else
219
+ focal_point_css(attachment_name)
220
+ end
221
+ when "upload"
222
+ # Custom upload - center it (no focal point data for uploaded images)
223
+ "object-position: 50% 50%"
224
+ else
225
+ # Default: use focal point
226
+ focal_point_css(attachment_name)
227
+ end
228
+ end
229
+
230
+ # Reset focal point to center
231
+ #
232
+ def reset_focal_point!(attachment_name = default_focal_attachment)
233
+ fp = send(:"#{attachment_name}_focal_point")
234
+ fp.reset!
235
+ end
236
+
237
+ private
238
+
239
+ def default_focal_attachment
240
+ self.class.focal_point_attachments.first || :header_image
241
+ end
242
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module SoftDeletable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ scope :active, -> { where(deleted_at: nil) }
9
+ end
10
+
11
+ def deleted?
12
+ deleted_at.present?
13
+ end
14
+
15
+ def soft_delete
16
+ update(deleted_at: Time.current)
17
+ end
18
+
19
+ def restore
20
+ update(deleted_at: nil)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ module Railspress
2
+ module Taggable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_many :taggings,
7
+ as: :taggable,
8
+ class_name: "Railspress::Tagging",
9
+ dependent: :destroy
10
+ has_many :tags,
11
+ through: :taggings,
12
+ class_name: "Railspress::Tag"
13
+ end
14
+
15
+ def tag_list
16
+ tags.pluck(:name).join(", ")
17
+ end
18
+
19
+ def tag_list=(csv_string)
20
+ self.tags = Railspress::Tag.from_csv(csv_string)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ class ContentElement < ApplicationRecord
5
+ include Railspress::SoftDeletable
6
+ include Railspress::HasFocalPoint
7
+
8
+ belongs_to :content_group
9
+ has_many :content_element_versions, dependent: :destroy
10
+ has_one_attached :image
11
+ has_focal_point :image
12
+
13
+ enum :content_type, { text: 0, image: 1 }
14
+
15
+ validates :name, presence: true,
16
+ uniqueness: { scope: :content_group_id, conditions: -> { active } }
17
+ validates :content_type, presence: true
18
+ validates :text_content, presence: true, if: :text?
19
+ validate :content_type_unchanged, on: :update
20
+
21
+ after_save :create_version, if: :should_create_version?
22
+
23
+ scope :ordered, -> { order(position: :asc, created_at: :desc) }
24
+ scope :required, -> { where(required: true) }
25
+ scope :recent, -> { order(updated_at: :desc) }
26
+ scope :by_group, ->(content_group) { where(content_group: content_group) }
27
+ scope :by_content_type, ->(type) { where(content_type: type) }
28
+
29
+ def author
30
+ return nil unless author_id.present? && Railspress.authors_enabled?
31
+ Railspress.author_class.find_by(id: author_id)
32
+ end
33
+
34
+ def author=(user)
35
+ self.author_id = user&.id
36
+ end
37
+
38
+ def value
39
+ if text?
40
+ text_content
41
+ elsif image? && image.attached?
42
+ Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true)
43
+ end
44
+ end
45
+
46
+ def versions
47
+ content_element_versions.ordered
48
+ end
49
+
50
+ def current_version
51
+ content_element_versions.ordered.first
52
+ end
53
+
54
+ def previous_version
55
+ content_element_versions.ordered.second
56
+ end
57
+
58
+ def version_count
59
+ content_element_versions.count
60
+ end
61
+
62
+ def soft_delete
63
+ if required?
64
+ errors.add(:base, "Cannot delete a required content element")
65
+ false
66
+ else
67
+ super
68
+ end
69
+ end
70
+
71
+ def restore_to_version(version_number)
72
+ version = content_element_versions.find_by(version_number: version_number)
73
+ return false unless version
74
+
75
+ update(text_content: version.text_content)
76
+ end
77
+
78
+ private
79
+
80
+ def content_type_unchanged
81
+ if content_type_changed?
82
+ errors.add(:content_type, "cannot be changed after creation")
83
+ end
84
+ end
85
+
86
+ def should_create_version?
87
+ return false unless persisted?
88
+ return false unless saved_changes.present?
89
+
90
+ saved_change_to_text_content?
91
+ end
92
+
93
+ def create_version
94
+ next_version_number = content_element_versions.maximum(:version_number).to_i + 1
95
+
96
+ content_element_versions.create!(
97
+ author_id: author_id,
98
+ text_content: text_content_before_last_save || text_content,
99
+ version_number: next_version_number
100
+ )
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ class ContentElementVersion < ApplicationRecord
5
+ belongs_to :content_element
6
+ has_one_attached :image_version
7
+
8
+ validates :content_element, presence: true
9
+ validates :version_number, presence: true, uniqueness: { scope: :content_element_id }
10
+
11
+ scope :ordered, -> { order(version_number: :desc) }
12
+ scope :recent, -> { order(created_at: :desc) }
13
+
14
+ def author
15
+ return nil unless author_id.present? && Railspress.authors_enabled?
16
+ Railspress.author_class.find_by(id: author_id)
17
+ end
18
+
19
+ def changes_from_previous
20
+ previous = content_element.content_element_versions
21
+ .where("version_number < ?", version_number)
22
+ .order(version_number: :desc)
23
+ .first
24
+
25
+ return {} unless previous
26
+
27
+ changes = {}
28
+ changes[:text_content] = [ previous.text_content, text_content ] if text_content != previous.text_content
29
+ changes.compact
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ class ContentGroup < ApplicationRecord
5
+ include Railspress::SoftDeletable
6
+
7
+ has_many :content_elements, dependent: :destroy
8
+
9
+ validates :name, presence: true, uniqueness: true
10
+
11
+ scope :ordered, -> { order(created_at: :desc) }
12
+ scope :recent, -> { order(created_at: :desc) }
13
+
14
+ def author
15
+ return nil unless author_id.present? && Railspress.authors_enabled?
16
+ Railspress.author_class.find_by(id: author_id)
17
+ end
18
+
19
+ def author=(user)
20
+ self.author_id = user&.id
21
+ end
22
+
23
+ def element_count
24
+ content_elements.active.count
25
+ end
26
+
27
+ def soft_delete
28
+ if content_elements.active.where(required: true).exists?
29
+ errors.add(:base, "Cannot delete group containing required content elements")
30
+ false
31
+ else
32
+ transaction do
33
+ content_elements.each(&:soft_delete)
34
+ update!(deleted_at: Time.current)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,67 @@
1
+ module Railspress
2
+ class Export < ApplicationRecord
3
+ STATUSES = %w[pending processing completed failed].freeze
4
+ EXPORT_TYPES = %w[posts].freeze
5
+
6
+ has_one_attached :file
7
+
8
+ validates :export_type, presence: true, inclusion: { in: EXPORT_TYPES }
9
+ validates :status, presence: true, inclusion: { in: STATUSES }
10
+
11
+ scope :by_type, ->(type) { where(export_type: type) }
12
+ scope :recent, -> { order(created_at: :desc).limit(10) }
13
+ scope :pending, -> { where(status: "pending") }
14
+ scope :processing, -> { where(status: "processing") }
15
+ scope :completed, -> { where(status: "completed") }
16
+ scope :failed, -> { where(status: "failed") }
17
+
18
+ def pending?
19
+ status == "pending"
20
+ end
21
+
22
+ def processing?
23
+ status == "processing"
24
+ end
25
+
26
+ def completed?
27
+ status == "completed"
28
+ end
29
+
30
+ def failed?
31
+ status == "failed"
32
+ end
33
+
34
+ def mark_processing!
35
+ update!(status: "processing")
36
+ end
37
+
38
+ def mark_completed!
39
+ update!(status: "completed")
40
+ end
41
+
42
+ def mark_failed!
43
+ update!(status: "failed")
44
+ end
45
+
46
+ def add_error(message)
47
+ errors_array = parsed_errors
48
+ errors_array << message
49
+ update!(error_messages: errors_array.to_json, error_count: errors_array.size)
50
+ end
51
+
52
+ def increment_success!
53
+ increment!(:success_count)
54
+ end
55
+
56
+ def increment_total!
57
+ increment!(:total_count)
58
+ end
59
+
60
+ def parsed_errors
61
+ return [] if error_messages.blank?
62
+ JSON.parse(error_messages)
63
+ rescue JSON::ParserError
64
+ []
65
+ end
66
+ end
67
+ end