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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class FocalPointsController < BaseController
6
+ before_action :set_focal_point
7
+
8
+ # PATCH /admin/focal_points/:id
9
+ # Handles focal point updates, image changes, and image removal
10
+ def update
11
+ image_changed = false
12
+ image_removed = false
13
+
14
+ # Handle image removal
15
+ if params[:remove_image] == "1"
16
+ @record.send(@attachment_name).purge
17
+ image_removed = true
18
+ # Handle image change (only if not removing)
19
+ elsif params[:image].present?
20
+ @record.send("#{@attachment_name}=", params[:image])
21
+ if @record.save
22
+ image_changed = true
23
+ else
24
+ return render_error(@record.errors.full_messages.join(", "))
25
+ end
26
+ end
27
+
28
+ # If image was changed or removed, redirect to refresh the page
29
+ if image_changed || image_removed
30
+ return redirect_to_record(notice: image_removed ? "Image removed" : "Image updated")
31
+ end
32
+
33
+ # Otherwise, update focal point and return turbo frame
34
+ if @focal_point.update(focal_point_params)
35
+ respond_to do |format|
36
+ format.turbo_stream do
37
+ render turbo_stream: turbo_stream.replace(
38
+ helpers.dom_id(@record, "image_section_#{@attachment_name}"),
39
+ partial: "railspress/admin/shared/image_section_compact",
40
+ locals: {
41
+ record: @record,
42
+ attachment_name: @attachment_name.to_sym,
43
+ flash_message: "Focal point saved"
44
+ }
45
+ )
46
+ end
47
+ format.html do
48
+ redirect_to_record(notice: "Focal point saved")
49
+ end
50
+ end
51
+ else
52
+ render_error(@focal_point.errors.full_messages.join(", "))
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def set_focal_point
59
+ @focal_point = FocalPoint.find(params[:id])
60
+ @record = @focal_point.record
61
+ @attachment_name = @focal_point.attachment_name
62
+ end
63
+
64
+ def focal_point_params
65
+ params.require(:focal_point).permit(:focal_x, :focal_y, :overrides)
66
+ end
67
+
68
+ def redirect_to_record(notice: nil)
69
+ path = if @record.is_a?(Railspress::Post)
70
+ edit_admin_post_path(@record)
71
+ elsif @record.is_a?(Railspress::ContentElement)
72
+ edit_admin_content_element_path(@record)
73
+ else
74
+ entity_type = @record.class.railspress_config.route_key
75
+ admin_edit_entity_path(entity_type: entity_type, id: @record.id)
76
+ end
77
+ redirect_to path, notice: notice
78
+ end
79
+
80
+ def render_error(message)
81
+ respond_to do |format|
82
+ format.turbo_stream do
83
+ render turbo_stream: turbo_stream.replace(
84
+ helpers.dom_id(@record, "image_section_#{@attachment_name}"),
85
+ partial: "railspress/admin/shared/image_section_editor",
86
+ locals: {
87
+ record: @record,
88
+ attachment_name: @attachment_name.to_sym,
89
+ error_message: message
90
+ }
91
+ )
92
+ end
93
+ format.html do
94
+ redirect_to_record(notice: message)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,63 @@
1
+ module Railspress
2
+ module Admin
3
+ class ImportsController < BaseController
4
+ before_action :validate_import_type, only: [ :show ]
5
+
6
+ def show
7
+ @import_type = params[:type]
8
+ @back_path = { "posts" => admin_posts_path }[@import_type]
9
+ @recent_imports = Import.by_type(@import_type).recent
10
+ end
11
+
12
+ def create
13
+ uploaded_files = Array(import_params[:file]).reject(&:blank?)
14
+
15
+ if uploaded_files.empty?
16
+ redirect_to typed_admin_imports_path(type: import_params[:import_type]),
17
+ alert: "Please select at least one file to import."
18
+ return
19
+ end
20
+
21
+ # Create import record
22
+ import = Import.create!(
23
+ import_type: import_params[:import_type],
24
+ filename: uploaded_files.size == 1 ? uploaded_files.first.original_filename : "#{uploaded_files.size} files",
25
+ content_type: uploaded_files.first.content_type,
26
+ status: "pending"
27
+ )
28
+
29
+ # Save uploaded files to tmp
30
+ file_paths = save_uploaded_files(import, uploaded_files)
31
+
32
+ # Enqueue job
33
+ ImportPostsJob.perform_later(import.id, file_paths)
34
+
35
+ redirect_to typed_admin_imports_path(type: import_params[:import_type]),
36
+ notice: "Import started. #{uploaded_files.size} file(s) queued for processing."
37
+ end
38
+
39
+ private
40
+
41
+ def validate_import_type
42
+ unless Import::IMPORT_TYPES.include?(params[:type])
43
+ redirect_to admin_root_path, alert: "Invalid import type."
44
+ end
45
+ end
46
+
47
+ def import_params
48
+ params.require(:import).permit(:import_type, file: [])
49
+ end
50
+
51
+ def save_uploaded_files(import, uploaded_files)
52
+ upload_dir = Rails.root.join("tmp", "uploads", "import_#{import.id}")
53
+ FileUtils.mkdir_p(upload_dir)
54
+
55
+ uploaded_files.map do |file|
56
+ path = upload_dir.join(file.original_filename)
57
+ File.open(path, "wb") { |f| f.write(file.read) }
58
+ path.to_s
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,11 +1,25 @@
1
1
  module Railspress
2
2
  module Admin
3
3
  class PostsController < BaseController
4
- before_action :set_post, only: [:show, :edit, :update, :destroy]
5
- before_action :load_categories, only: [:new, :create, :edit, :update]
4
+ before_action :set_post, only: [ :show, :edit, :update, :destroy, :image_editor ]
5
+ before_action :load_categories, only: [ :new, :create, :edit, :update ]
6
6
 
7
7
  def index
8
- @posts = Post.includes(:category, :tags).ordered
8
+ @sort = params[:sort].presence || "created_at"
9
+ @direction = params[:direction].presence || "desc"
10
+
11
+ @posts = Post.includes(:category, :tags)
12
+ .search(params[:q])
13
+ .by_category(params[:category_id])
14
+ .by_status(params[:status])
15
+ .sorted_by(@sort, @direction)
16
+
17
+ @total_count = @posts.count
18
+ @page = [ params[:page].to_i, 1 ].max
19
+ @total_pages = (@total_count.to_f / Post::PER_PAGE).ceil
20
+ @posts = @posts.page(@page)
21
+
22
+ @categories = Category.ordered
9
23
  end
10
24
 
11
25
  def show
@@ -41,6 +55,38 @@ module Railspress
41
55
  redirect_to admin_posts_path, notice: "Post deleted."
42
56
  end
43
57
 
58
+ ALLOWED_ATTACHMENTS = %w[header_image].freeze
59
+
60
+ # GET /admin/posts/:id/image_editor/:attachment
61
+ # Returns the expanded image editor in a Turbo Frame
62
+ # Pass ?compact=true to get the compact view (for Cancel)
63
+ def image_editor
64
+ unless ALLOWED_ATTACHMENTS.include?(params[:attachment])
65
+ raise ActionController::RoutingError, "Invalid attachment"
66
+ end
67
+ @attachment_name = params[:attachment].to_sym
68
+
69
+ if params[:compact] == "true"
70
+ render partial: "railspress/admin/shared/image_section_compact",
71
+ locals: {
72
+ record: @post,
73
+ attachment_name: @attachment_name,
74
+ label: "Main Image"
75
+ }
76
+ else
77
+ # Ensure focal point is persisted before editing
78
+ focal_point = @post.send("#{@attachment_name}_focal_point")
79
+ focal_point.save! if focal_point.new_record?
80
+
81
+ render partial: "railspress/admin/shared/image_section_editor",
82
+ locals: {
83
+ record: @post,
84
+ attachment_name: @attachment_name,
85
+ contexts: Railspress.image_contexts
86
+ }
87
+ end
88
+ end
89
+
44
90
  private
45
91
 
46
92
  def set_post
@@ -58,12 +104,20 @@ module Railspress
58
104
  :category_id,
59
105
  :content,
60
106
  :status,
107
+ :published_at,
108
+ :reading_time,
61
109
  :meta_title,
62
110
  :meta_description,
63
111
  :tag_list
64
112
  ]
65
113
  permitted << :author_id if authors_enabled?
66
- permitted.push(:header_image, :remove_header_image) if header_images_enabled?
114
+ if post_images_enabled?
115
+ permitted.push(:header_image, :remove_header_image)
116
+ # Focal point nested attributes
117
+ if Railspress.focal_points_enabled?
118
+ permitted.push(header_image_focal_point_attributes: [ :focal_x, :focal_y, :overrides ])
119
+ end
120
+ end
67
121
  params.require(:post).permit(permitted)
68
122
  end
69
123
  end
@@ -0,0 +1,30 @@
1
+ module Railspress
2
+ module Admin
3
+ class PrototypesController < BaseController
4
+ # Prototype views for iterating on UI/UX
5
+ # Access at: /admin/prototypes/:name
6
+ #
7
+ # These views use real CSS and Stimulus controllers
8
+ # but work with mock data for fast iteration.
9
+
10
+ def image_section
11
+ # Mock data for prototyping
12
+ @mock_image_url = "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80"
13
+ @mock_filename = "mountain-landscape.jpg"
14
+ @mock_filesize = "1.2 MB"
15
+ @mock_focal_point = { x: 0.35, y: 0.45 }
16
+ @contexts = Railspress.image_contexts.presence || default_contexts
17
+ end
18
+
19
+ private
20
+
21
+ def default_contexts
22
+ {
23
+ hero: { aspect: [ 21, 9 ] },
24
+ card: { aspect: [ 16, 10 ] },
25
+ tall: { aspect: [ 4, 5 ] }
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,7 +1,7 @@
1
1
  module Railspress
2
2
  module Admin
3
3
  class TagsController < BaseController
4
- before_action :set_tag, only: [:edit, :update, :destroy]
4
+ before_action :set_tag, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @tags = Tag.ordered
@@ -1,4 +1,5 @@
1
1
  module Railspress
2
2
  class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
3
4
  end
4
5
  end