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,58 @@
1
+ /*
2
+ * RailsPress Admin - CSS Variables
3
+ * Design tokens for the admin interface
4
+ */
5
+
6
+ :root {
7
+ /* Typography */
8
+ --rp-font-display: 'Source Serif 4', Georgia, serif;
9
+ --rp-font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
10
+ --rp-font-mono: 'SF Mono', Consolas, monospace;
11
+
12
+ /* Colors - Warm Neutrals */
13
+ --rp-sidebar-bg: #1a1d21;
14
+ --rp-sidebar-hover: #2a2e33;
15
+ --rp-sidebar-text: #a8adb3;
16
+ --rp-sidebar-text-active: #ffffff;
17
+
18
+ --rp-bg: #f8f7f5;
19
+ --rp-bg-elevated: #ffffff;
20
+ --rp-border: #e5e2dc;
21
+ --rp-border-light: #f0ede8;
22
+
23
+ --rp-text: #2c2a27;
24
+ --rp-text-muted: #6b6860;
25
+ --rp-text-light: #9a958c;
26
+
27
+ /* Accents */
28
+ --rp-primary: #3d5a80;
29
+ --rp-primary-hover: #2c4a6e;
30
+ --rp-primary-light: #e8eef4;
31
+
32
+ --rp-success: #4a7c59;
33
+ --rp-success-light: #e8f2eb;
34
+
35
+ --rp-danger: #a63d40;
36
+ --rp-danger-light: #f9ebeb;
37
+
38
+ --rp-info-light: #f0f7fb;
39
+
40
+ /* Spacing */
41
+ --rp-space-xs: 0.25rem;
42
+ --rp-space-sm: 0.5rem;
43
+ --rp-space-md: 1rem;
44
+ --rp-space-lg: 1.5rem;
45
+ --rp-space-xl: 2rem;
46
+ --rp-space-2xl: 3rem;
47
+
48
+ /* Sizing */
49
+ --rp-sidebar-width: 240px;
50
+ --rp-sidebar-width-collapsed: 64px;
51
+ --rp-header-height: 56px;
52
+ --rp-radius: 6px;
53
+
54
+ /* Shadows - Enhanced for depth */
55
+ --rp-shadow-sm: 0 1px 3px rgba(44, 42, 39, 0.06), 0 1px 2px rgba(44, 42, 39, 0.04);
56
+ --rp-shadow-md: 0 4px 16px rgba(44, 42, 39, 0.1), 0 2px 4px rgba(44, 42, 39, 0.06);
57
+ --rp-shadow-lg: 0 8px 24px rgba(44, 42, 39, 0.12), 0 4px 8px rgba(44, 42, 39, 0.08);
58
+ }
@@ -1,15 +1,46 @@
1
1
  /*
2
- * This is a manifest file that'll be compiled into application.css, which will include all the files
3
- * listed below.
4
- *
5
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
- * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
- *
8
- * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
- * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
- * files in this directory. Styles in this file should be added after the last require_* statement.
11
- * It is generally better to create a new file per style scope.
12
- *
13
- *= require_tree .
14
- *= require_self
2
+ * RailsPress Admin Styles
3
+ * Manifest file - loads all admin stylesheets in order
4
+ *
5
+ *= require ./admin/variables
6
+ *= require ./admin/base
7
+ *= require ./admin/layout
8
+ *= require ./admin/page
9
+ *= require ./admin/cards
10
+ *= require ./admin/stats
11
+ *= require ./admin/tables
12
+ *= require ./admin/filters
13
+ *= require ./admin/forms
14
+ *= require ./admin/buttons
15
+ *= require ./admin/badges
16
+ *= require ./admin/lists
17
+ *= require ./admin/flash
18
+ *= require ./admin/utilities
19
+ *= require ./admin/responsive
20
+ *= require ./admin/components/lexxy
21
+ *= require ./admin/components/imports
22
+ *= require ./admin/components/exports
23
+ */
24
+ /*
25
+ * RailsPress Admin Styles
15
26
  */
27
+
28
+ @import url("admin/variables.css");
29
+ @import url("admin/base.css");
30
+ @import url("admin/layout.css");
31
+ @import url("admin/page.css");
32
+ @import url("admin/cards.css");
33
+ @import url("admin/stats.css");
34
+ @import url("admin/tables.css");
35
+ @import url("admin/filters.css");
36
+ @import url("admin/forms.css");
37
+ @import url("admin/buttons.css");
38
+ @import url("admin/badges.css");
39
+ @import url("admin/lists.css");
40
+ @import url("admin/flash.css");
41
+ @import url("admin/utilities.css");
42
+ @import url("admin/responsive.css");
43
+ @import url("admin/components/lexxy.css");
44
+ @import url("admin/components/imports.css");
45
+ @import url("admin/components/exports.css");
46
+ @import url("admin/components/focal_point.css");
@@ -1,9 +1,12 @@
1
1
  module Railspress
2
2
  module Admin
3
3
  class BaseController < ActionController::Base
4
+ protect_from_forgery with: :exception
5
+
4
6
  layout "railspress/admin"
7
+ helper Railspress::AdminHelper
5
8
 
6
- helper_method :current_author, :available_authors, :authors_enabled?, :header_images_enabled?
9
+ helper_method :current_author, :available_authors, :authors_enabled?, :post_images_enabled?
7
10
 
8
11
  # Authentication hook - to be configured later
9
12
  # before_action :authenticate_admin!
@@ -18,8 +21,8 @@ module Railspress
18
21
  Railspress.authors_enabled?
19
22
  end
20
23
 
21
- def header_images_enabled?
22
- Railspress.header_images_enabled?
24
+ def post_images_enabled?
25
+ Railspress.post_images_enabled?
23
26
  end
24
27
 
25
28
  def current_author
@@ -1,7 +1,7 @@
1
1
  module Railspress
2
2
  module Admin
3
3
  class CategoriesController < BaseController
4
- before_action :set_category, only: [:edit, :update, :destroy]
4
+ before_action :set_category, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @categories = Category.ordered
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class CmsTransfersController < BaseController
6
+ def show
7
+ load_content_summary
8
+ end
9
+
10
+ def export
11
+ result = ContentExportService.new.call
12
+
13
+ send_data result.zip_data,
14
+ filename: result.filename,
15
+ type: "application/zip",
16
+ disposition: "attachment"
17
+ end
18
+
19
+ def import
20
+ if params[:file].blank?
21
+ redirect_to admin_cms_transfer_path, alert: "Please select a ZIP file to import."
22
+ return
23
+ end
24
+
25
+ @result = ContentImportService.new(params[:file]).call
26
+ load_content_summary
27
+
28
+ if @result.errors.any?
29
+ flash.now[:alert] = "Import completed with #{@result.errors.size} error(s)."
30
+ else
31
+ flash.now[:notice] = "Import successful! #{@result.total_processed} items processed."
32
+ end
33
+
34
+ render :show
35
+ rescue ArgumentError => e
36
+ redirect_to admin_cms_transfer_path, alert: e.message
37
+ end
38
+
39
+ private
40
+
41
+ def load_content_summary
42
+ @groups = ContentGroup.active.includes(:content_elements).order(:name)
43
+ @group_count = @groups.size
44
+ @element_count = ContentElement.active.count
45
+ @image_count = ContentElement.active.image.count
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class ContentElementVersionsController < BaseController
6
+ def show
7
+ @version = ContentElementVersion.find(params[:id])
8
+ @content_element = @version.content_element
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class ContentElementsController < BaseController
6
+ before_action :set_content_element, only: [ :show, :edit, :update, :destroy, :inline, :image_editor ]
7
+
8
+ def index
9
+ scope = ContentElement.active
10
+ scope = scope.where(content_group_id: params[:content_group_id]) if params[:content_group_id].present?
11
+ @content_elements = scope.includes(:content_group)
12
+ .order(created_at: :desc)
13
+ @content_groups = ContentGroup.active.order(:name)
14
+ end
15
+
16
+ def show
17
+ @versions = @content_element.versions.limit(10)
18
+ end
19
+
20
+ def new
21
+ @content_element = ContentElement.new
22
+ @content_groups = ContentGroup.active.order(:name)
23
+ @content_element.content_group_id = params[:content_group_id] if params[:content_group_id].present?
24
+ end
25
+
26
+ def create
27
+ @content_element = ContentElement.new(content_element_params)
28
+ @content_element.author_id = current_author&.id if authors_enabled?
29
+
30
+ if @content_element.save
31
+ redirect_to admin_content_element_path(@content_element), notice: "Content element '#{@content_element.name}' created."
32
+ else
33
+ @content_groups = ContentGroup.active.order(:name)
34
+ render :new, status: :unprocessable_entity
35
+ end
36
+ end
37
+
38
+ def edit
39
+ @content_groups = ContentGroup.active.order(:name)
40
+ end
41
+
42
+ def update
43
+ if @content_element.update(content_element_params)
44
+ Railspress::CmsHelper.clear_cache if defined?(Railspress::CmsHelper)
45
+
46
+ if request.headers["Turbo-Frame"].present?
47
+ form_frame_id = params[:form_frame_id].presence || "cms_inline_editor_form_#{@content_element.id}"
48
+ display_frame_id = params[:display_frame_id].presence
49
+
50
+ streams = []
51
+ streams << turbo_stream.replace(
52
+ form_frame_id,
53
+ partial: "railspress/admin/content_elements/inline_form_frame",
54
+ locals: { content_element: @content_element, form_frame_id: form_frame_id, display_frame_id: display_frame_id }
55
+ )
56
+ if display_frame_id
57
+ streams << turbo_stream.replace(
58
+ display_frame_id,
59
+ helpers.cms_element_display_frame(@content_element, display_frame_id)
60
+ )
61
+ end
62
+ render turbo_stream: streams
63
+ else
64
+ redirect_to admin_content_element_path(@content_element), notice: "Content element '#{@content_element.name}' updated."
65
+ end
66
+ else
67
+ @content_groups = ContentGroup.active.order(:name)
68
+
69
+ if request.headers["Turbo-Frame"].present?
70
+ form_frame_id = params[:form_frame_id].presence || "cms_inline_editor_form_#{@content_element.id}"
71
+ display_frame_id = params[:display_frame_id].presence
72
+
73
+ render turbo_stream: turbo_stream.replace(
74
+ form_frame_id,
75
+ partial: "railspress/admin/content_elements/inline_form_frame",
76
+ locals: { content_element: @content_element, form_frame_id: form_frame_id, display_frame_id: display_frame_id }
77
+ ), status: :unprocessable_entity
78
+ else
79
+ render :edit, status: :unprocessable_entity
80
+ end
81
+ end
82
+ end
83
+
84
+ def destroy
85
+ if @content_element.soft_delete
86
+ redirect_to admin_content_elements_path,
87
+ notice: "Content element '#{@content_element.name}' deleted."
88
+ else
89
+ redirect_to admin_content_elements_path,
90
+ alert: "Cannot delete '#{@content_element.name}' — it is a required element. To delete it, first unmark it as required."
91
+ end
92
+ end
93
+
94
+ def inline
95
+ if request.headers["Turbo-Frame"].present?
96
+ render partial: "railspress/admin/content_elements/inline_form_frame",
97
+ locals: {
98
+ content_element: @content_element,
99
+ form_frame_id: params[:form_frame_id],
100
+ display_frame_id: params[:display_frame_id]
101
+ }
102
+ else
103
+ redirect_to edit_admin_content_element_path(@content_element)
104
+ end
105
+ end
106
+
107
+ def image_editor
108
+ if params[:compact] == "true"
109
+ render partial: "railspress/admin/shared/image_section_compact",
110
+ locals: {
111
+ record: @content_element,
112
+ attachment_name: :image,
113
+ label: "Element Image",
114
+ editor_url: image_editor_admin_content_element_path(@content_element)
115
+ }
116
+ else
117
+ focal_point = @content_element.image_focal_point
118
+ focal_point.save! if focal_point.new_record?
119
+
120
+ render partial: "railspress/admin/shared/image_section_editor",
121
+ locals: {
122
+ record: @content_element,
123
+ attachment_name: :image,
124
+ contexts: Railspress.image_contexts
125
+ }
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def set_content_element
132
+ @content_element = ContentElement.active.find(params[:id])
133
+ rescue ActiveRecord::RecordNotFound
134
+ redirect_to admin_content_elements_path, alert: "Content element not found."
135
+ end
136
+
137
+ def content_element_params
138
+ params.require(:content_element).permit(:name, :content_group_id, :content_type, :position, :text_content, :image, :required, :image_hint,
139
+ image_focal_point_attributes: [ :id, :focal_x, :focal_y ])
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class ContentGroupsController < BaseController
6
+ before_action :set_content_group, only: [ :show, :edit, :update, :destroy ]
7
+
8
+ def index
9
+ @content_groups = ContentGroup.active
10
+ .includes(:content_elements)
11
+ .order(created_at: :desc)
12
+ end
13
+
14
+ def show
15
+ @content_elements = @content_group.content_elements
16
+ .active
17
+ .ordered
18
+ end
19
+
20
+ def new
21
+ @content_group = ContentGroup.new
22
+ end
23
+
24
+ def create
25
+ @content_group = ContentGroup.new(content_group_params)
26
+ @content_group.author_id = current_author&.id if authors_enabled?
27
+
28
+ if @content_group.save
29
+ redirect_to admin_content_group_path(@content_group), notice: "Content group '#{@content_group.name}' created."
30
+ else
31
+ render :new, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def edit
36
+ end
37
+
38
+ def update
39
+ if @content_group.update(content_group_params)
40
+ redirect_to admin_content_group_path(@content_group), notice: "Content group '#{@content_group.name}' updated."
41
+ else
42
+ render :edit, status: :unprocessable_entity
43
+ end
44
+ end
45
+
46
+ def destroy
47
+ if @content_group.soft_delete
48
+ redirect_to admin_content_groups_path,
49
+ notice: "Content group '#{@content_group.name}' deleted."
50
+ else
51
+ redirect_to admin_content_group_path(@content_group),
52
+ alert: "Cannot delete '#{@content_group.name}' — it contains required content elements."
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def set_content_group
59
+ @content_group = ContentGroup.active.find(params[:id])
60
+ rescue ActiveRecord::RecordNotFound
61
+ redirect_to admin_content_groups_path, alert: "Content group not found."
62
+ end
63
+
64
+ def content_group_params
65
+ params.require(:content_group).permit(:name, :description)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -6,6 +6,12 @@ module Railspress
6
6
  @categories_count = Category.count
7
7
  @tags_count = Tag.count
8
8
  @recent_posts = Post.ordered.limit(5)
9
+
10
+ if Railspress.cms_enabled?
11
+ @content_groups_count = ContentGroup.active.count
12
+ @content_elements_count = ContentElement.active.count
13
+ @recent_content_elements = ContentElement.active.includes(:content_group).order(updated_at: :desc).limit(5)
14
+ end
9
15
  end
10
16
  end
11
17
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class EntitiesController < BaseController
6
+ before_action :set_entity_config
7
+ before_action :set_record, only: [ :show, :edit, :update, :destroy, :image_editor ]
8
+
9
+ def index
10
+ @records = entity_class.order(created_at: :desc)
11
+ end
12
+
13
+ def show
14
+ end
15
+
16
+ def new
17
+ @record = entity_class.new
18
+ end
19
+
20
+ def create
21
+ @record = entity_class.new(entity_params)
22
+
23
+ if @record.save
24
+ redirect_to entity_index_path, notice: "#{entity_config.singular_label} created."
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit
31
+ end
32
+
33
+ def update
34
+ purge_removed_attachments
35
+ if @record.update(entity_params)
36
+ redirect_to entity_index_path, notice: "#{entity_config.singular_label} updated."
37
+ else
38
+ render :edit, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ def destroy
43
+ @record.destroy
44
+ redirect_to entity_index_path, notice: "#{entity_config.singular_label} deleted."
45
+ end
46
+
47
+ # GET /admin/entities/:entity_type/:id/image_editor/:attachment
48
+ # Returns the expanded image editor in a Turbo Frame
49
+ # Pass ?compact=true to get the compact view (for Cancel)
50
+ def image_editor
51
+ allowed = entity_config.fields
52
+ .select { |_, f| [ :attachment, :attachments, :focal_point_image ].include?(f[:type]) }
53
+ .keys.map(&:to_s)
54
+ unless allowed.include?(params[:attachment])
55
+ raise ActionController::RoutingError, "Invalid attachment"
56
+ end
57
+ @attachment_name = params[:attachment].to_sym
58
+
59
+ if params[:compact] == "true"
60
+ render partial: "railspress/admin/shared/image_section_compact",
61
+ locals: {
62
+ record: @record,
63
+ attachment_name: @attachment_name,
64
+ label: entity_config.singular_label
65
+ }
66
+ else
67
+ # Ensure focal point is persisted before editing
68
+ focal_point = @record.send("#{@attachment_name}_focal_point")
69
+ focal_point.save! if focal_point.new_record?
70
+
71
+ render partial: "railspress/admin/shared/image_section_editor",
72
+ locals: {
73
+ record: @record,
74
+ attachment_name: @attachment_name,
75
+ contexts: Railspress.image_contexts
76
+ }
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def set_entity_config
83
+ @entity_config = Railspress.entity_for(params[:entity_type])
84
+ raise ActionController::RoutingError, "Entity not found: #{params[:entity_type]}" unless @entity_config
85
+ end
86
+
87
+ def entity_config
88
+ @entity_config
89
+ end
90
+ helper_method :entity_config
91
+
92
+ def entity_class
93
+ entity_config.model_class
94
+ end
95
+ helper_method :entity_class
96
+
97
+ def set_record
98
+ @record = entity_class.find(params[:id])
99
+ end
100
+
101
+ def entity_params
102
+ permitted = []
103
+ entity_config.fields.each do |name, field|
104
+ case field[:type]
105
+ when :attachments
106
+ permitted << { name => [] }
107
+ when :list, :lines
108
+ # Permit virtual attribute for HTML form input
109
+ permitted << "#{name}_list"
110
+ # Also permit direct array for API/agent access
111
+ permitted << { name => [] }
112
+ else
113
+ permitted << name
114
+ end
115
+ end
116
+ params.require(entity_config.param_key).permit(*permitted)
117
+ end
118
+
119
+ def purge_removed_attachments
120
+ entity_config.fields.each do |name, field|
121
+ next unless [ :attachment, :attachments ].include?(field[:type])
122
+
123
+ remove_key = "remove_#{name}"
124
+ remove_ids = params.dig(entity_config.param_key, remove_key)
125
+ next if remove_ids.blank?
126
+
127
+ if field[:type] == :attachments
128
+ @record.public_send(name).where(id: remove_ids).each(&:purge)
129
+ else
130
+ @record.public_send(name).purge if remove_ids == "1"
131
+ end
132
+ end
133
+ end
134
+
135
+ # Route helpers for views
136
+ def entity_index_path
137
+ railspress.admin_entity_index_path(entity_type: entity_config.route_key)
138
+ end
139
+ helper_method :entity_index_path
140
+
141
+ def entity_show_path(record)
142
+ railspress.admin_entity_path(entity_type: entity_config.route_key, id: record.id)
143
+ end
144
+ helper_method :entity_show_path
145
+
146
+ def entity_new_path
147
+ railspress.admin_new_entity_path(entity_type: entity_config.route_key)
148
+ end
149
+ helper_method :entity_new_path
150
+
151
+ def entity_edit_path(record)
152
+ railspress.admin_edit_entity_path(entity_type: entity_config.route_key, id: record.id)
153
+ end
154
+ helper_method :entity_edit_path
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,55 @@
1
+ module Railspress
2
+ module Admin
3
+ class ExportsController < BaseController
4
+ before_action :validate_export_type, only: [ :show ]
5
+ before_action :set_export, only: [ :download ]
6
+
7
+ def show
8
+ @export_type = params[:type]
9
+ @back_path = { "posts" => admin_posts_path }[@export_type]
10
+ @recent_exports = Export.by_type(@export_type).recent
11
+ @post_count = Post.count
12
+ end
13
+
14
+ def create
15
+ export = Export.create!(
16
+ export_type: export_params[:export_type],
17
+ status: "pending"
18
+ )
19
+
20
+ ExportPostsJob.perform_later(export.id)
21
+
22
+ redirect_to typed_admin_exports_path(type: export_params[:export_type]),
23
+ notice: "Export started. You'll be able to download the file once processing completes."
24
+ end
25
+
26
+ def download
27
+ if @export.file.attached?
28
+ send_data @export.file.download,
29
+ filename: @export.filename,
30
+ type: "application/zip",
31
+ disposition: "attachment"
32
+ else
33
+ redirect_to typed_admin_exports_path(type: @export.export_type),
34
+ alert: "Export file not available."
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def validate_export_type
41
+ unless Export::EXPORT_TYPES.include?(params[:type])
42
+ redirect_to admin_root_path, alert: "Invalid export type."
43
+ end
44
+ end
45
+
46
+ def set_export
47
+ @export = Export.find(params[:id])
48
+ end
49
+
50
+ def export_params
51
+ params.require(:export).permit(:export_type)
52
+ end
53
+ end
54
+ end
55
+ end