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
|
@@ -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
|