ruby_cms 0.1.9 → 0.2.0.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +454 -58
  4. data/app/controllers/ruby_cms/admin/base_controller.rb +33 -12
  5. data/app/controllers/ruby_cms/admin/content_block_versions_controller.rb +62 -0
  6. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +40 -0
  7. data/app/helpers/ruby_cms/admin/dashboard_helper.rb +20 -0
  8. data/app/javascript/controllers/ruby_cms/content_block_history_controller.js +91 -0
  9. data/app/javascript/controllers/ruby_cms/index.js +4 -0
  10. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +33 -29
  11. data/app/models/concerns/content_block/versionable.rb +80 -0
  12. data/app/models/content_block.rb +1 -0
  13. data/app/models/content_block_version.rb +34 -0
  14. data/app/views/admin/content_block_versions/index.html.erb +52 -0
  15. data/app/views/admin/content_block_versions/show.html.erb +37 -0
  16. data/app/views/ruby_cms/admin/content_block_versions/index.html.erb +52 -0
  17. data/app/views/ruby_cms/admin/content_block_versions/show.html.erb +37 -0
  18. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +12 -0
  19. data/app/views/ruby_cms/admin/dashboard/blocks/_analytics_overview.html.erb +53 -0
  20. data/app/views/ruby_cms/admin/dashboard/blocks/_content_blocks_stats.html.erb +17 -0
  21. data/app/views/ruby_cms/admin/dashboard/blocks/_permissions_stats.html.erb +17 -0
  22. data/app/views/ruby_cms/admin/dashboard/blocks/_quick_actions.html.erb +62 -0
  23. data/app/views/ruby_cms/admin/dashboard/blocks/_recent_errors.html.erb +39 -0
  24. data/app/views/ruby_cms/admin/dashboard/blocks/_users_stats.html.erb +17 -0
  25. data/app/views/ruby_cms/admin/dashboard/blocks/_visitor_errors_stats.html.erb +24 -0
  26. data/app/views/ruby_cms/admin/dashboard/index.html.erb +22 -180
  27. data/config/importmap.rb +2 -0
  28. data/config/routes.rb +8 -0
  29. data/db/migrate/20260328000001_create_content_block_versions.rb +22 -0
  30. data/lib/generators/ruby_cms/admin_page_generator.rb +126 -0
  31. data/lib/generators/ruby_cms/templates/admin_page/controller.rb.tt +8 -0
  32. data/lib/generators/ruby_cms/templates/admin_page/index.html.erb.tt +11 -0
  33. data/lib/ruby_cms/dashboard_blocks.rb +91 -0
  34. data/lib/ruby_cms/engine/admin_permissions.rb +69 -0
  35. data/lib/ruby_cms/engine/content_blocks_tasks.rb +66 -0
  36. data/lib/ruby_cms/engine/css.rb +14 -0
  37. data/lib/ruby_cms/engine/dashboard_registration.rb +66 -0
  38. data/lib/ruby_cms/engine/navigation_registration.rb +80 -0
  39. data/lib/ruby_cms/engine.rb +43 -278
  40. data/lib/ruby_cms/icons.rb +118 -0
  41. data/lib/ruby_cms/version.rb +1 -1
  42. data/lib/ruby_cms.rb +36 -10
  43. metadata +28 -1
@@ -2,9 +2,21 @@
2
2
 
3
3
  require_relative "settings_registry"
4
4
  require_relative "settings"
5
+ require_relative "dashboard_blocks"
6
+ require_relative "engine/css"
7
+ require_relative "engine/dashboard_registration"
8
+ require_relative "engine/navigation_registration"
9
+ require_relative "engine/admin_permissions"
10
+ require_relative "engine/content_blocks_tasks"
5
11
 
6
12
  module RubyCms
7
13
  class Engine < ::Rails::Engine
14
+ extend RubyCms::EngineCss
15
+ extend RubyCms::EngineDashboardRegistration
16
+ extend RubyCms::EngineNavigationRegistration
17
+ extend RubyCms::EngineAdminPermissions
18
+ extend RubyCms::EngineContentBlocksTasks
19
+
8
20
  # Do not isolate namespace so we can use /admin and explicit table names.
9
21
  # Engine models use unprefixed table names: content_blocks, preferences, permissions, user_permissions, visitor_errors.
10
22
 
@@ -54,6 +66,8 @@ module RubyCms
54
66
 
55
67
  # Ensure Ahoy is loaded before host's config/initializers/ahoy.rb runs
56
68
  initializer "ruby_cms.require_ahoy", before: :load_config_initializers do
69
+ next if RubyCms::Engine.assets_precompile_phase?
70
+
57
71
  require "ahoy_matey"
58
72
  end
59
73
 
@@ -69,6 +83,7 @@ module RubyCms
69
83
  helper RubyCms::BulkActionTableHelper
70
84
  helper RubyCms::Admin::BulkActionTableHelper
71
85
  helper RubyCms::Admin::AdminPageHelper
86
+ helper RubyCms::Admin::DashboardHelper
72
87
  end
73
88
  end
74
89
 
@@ -90,175 +105,42 @@ module RubyCms
90
105
  end
91
106
 
92
107
  initializer "ruby_cms.nav" do
108
+ next if RubyCms::Engine.assets_precompile_phase?
109
+
93
110
  Rails.application.config.to_prepare do
94
111
  RubyCms::Engine.register_main_nav_items
95
112
  RubyCms::Engine.register_settings_nav_items
96
113
  end
97
114
  end
98
115
 
99
- initializer "ruby_cms.settings_import", after: :load_config_initializers do
100
- RubyCms::Settings.import_initializer_values!
101
- end
102
-
103
- # After host initializers (e.g. register_permission_keys), ensure Permission rows exist
104
- # so can?(:manage_backups) and other keys do not fail on Permission.exists? checks.
105
- initializer "ruby_cms.ensure_permission_rows", after: :load_config_initializers do
106
- RubyCms::Permission.ensure_defaults!
107
- rescue StandardError => e
108
- Rails.logger.warn("[RubyCMS] Permission.ensure_defaults! skipped: #{e.message}")
109
- end
110
-
111
- def self.register_main_nav_items
112
- RubyCms.nav_register(
113
- key: :dashboard,
114
- label: "Dashboard",
115
- path: lambda(&:ruby_cms_admin_root_path),
116
- icon: dashboard_icon_path,
117
- section: RubyCms::NAV_SECTION_MAIN,
118
- permission: :manage_admin,
119
- order: 1
120
- )
121
- RubyCms.nav_register(
122
- key: :visual_editor,
123
- label: "Visual editor",
124
- path: lambda(&:ruby_cms_admin_visual_editor_path),
125
- icon: visual_editor_icon_path,
126
- section: RubyCms::NAV_SECTION_MAIN,
127
- permission: :manage_content_blocks,
128
- order: 2
129
- )
130
- RubyCms.nav_register(
131
- key: :content_blocks,
132
- label: "Content blocks",
133
- path: lambda(&:ruby_cms_admin_content_blocks_path),
134
- icon: content_blocks_icon_path,
135
- section: RubyCms::NAV_SECTION_MAIN,
136
- permission: :manage_content_blocks,
137
- order: 3
138
- )
139
- end
116
+ initializer "ruby_cms.dashboard_blocks" do
117
+ next if RubyCms::Engine.assets_precompile_phase?
140
118
 
141
- def self.dashboard_icon_path
142
- # Heroicons HomeIcon (outline)
143
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
144
- 'd="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3' \
145
- 'm-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>'
119
+ RubyCms::Engine.register_default_dashboard_blocks
146
120
  end
147
121
 
148
- def self.content_blocks_icon_path
149
- # Heroicons DocumentDuplicateIcon (outline)
150
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
151
- 'd="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414' \
152
- 'a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 ' \
153
- '0 002-2v-2"></path>'
154
- end
122
+ initializer "ruby_cms.versionable" do
123
+ next if RubyCms::Engine.assets_precompile_phase?
155
124
 
156
- def self.visual_editor_icon_path
157
- # Heroicons PencilSquareIcon (outline)
158
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
159
- 'd="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 ' \
160
- '0 012.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>'
161
- end
162
-
163
- def self.register_settings_nav_items
164
- RubyCms.nav_register(
165
- key: :analytics,
166
- label: "Analytics",
167
- path: lambda(&:ruby_cms_admin_analytics_path),
168
- section: RubyCms::NAV_SECTION_BOTTOM,
169
- icon: analytics_icon_path,
170
- permission: :manage_analytics,
171
- order: 1
172
- )
173
- RubyCms.nav_register(
174
- key: :permissions,
175
- label: "Permissions",
176
- path: lambda(&:ruby_cms_admin_permissions_path),
177
- section: RubyCms::NAV_SECTION_BOTTOM,
178
- icon: permissions_icon_path,
179
- permission: :manage_permissions,
180
- order: 2
181
- )
182
- RubyCms.nav_register(
183
- key: :visitor_errors,
184
- label: "Visitor errors",
185
- path: lambda(&:ruby_cms_admin_visitor_errors_path),
186
- section: RubyCms::NAV_SECTION_BOTTOM,
187
- icon: visitor_errors_icon_path,
188
- permission: :manage_visitor_errors,
189
- order: 3
190
- )
191
- RubyCms.nav_register(
192
- key: :users,
193
- label: "Users",
194
- path: lambda(&:ruby_cms_admin_users_path),
195
- section: RubyCms::NAV_SECTION_BOTTOM,
196
- icon: users_icon_path,
197
- permission: :manage_permissions,
198
- order: 4
199
- )
200
- RubyCms.nav_register(
201
- key: :settings,
202
- label: "Settings",
203
- path: lambda(&:ruby_cms_admin_settings_path),
204
- section: RubyCms::NAV_SECTION_BOTTOM,
205
- icon: settings_icon_path,
206
- permission: :manage_admin,
207
- order: 5
208
- )
209
- end
210
-
211
- def self.settings_icon_path
212
- # Heroicons Cog6ToothIcon (outline)
213
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
214
- 'd="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 ' \
215
- '1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 ' \
216
- '1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 ' \
217
- '6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 ' \
218
- '0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 ' \
219
- '1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 ' \
220
- '6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 ' \
221
- '1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 ' \
222
- '1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"></path>' \
223
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>'
224
- end
225
-
226
- def self.visitor_errors_icon_path
227
- # Heroicons ExclamationTriangleIcon (outline)
228
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
229
- 'd="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 ' \
230
- '4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>'
125
+ Rails.application.config.to_prepare do
126
+ ContentBlock.include(ContentBlock::Versionable) unless ContentBlock <= ContentBlock::Versionable
127
+ end
231
128
  end
232
129
 
233
- def self.permissions_icon_path
234
- # Heroicons ShieldCheckIcon (outline)
235
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
236
- 'd="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01' \
237
- '-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 ' \
238
- '9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>'
239
- end
130
+ initializer "ruby_cms.settings_import", after: :load_config_initializers do
131
+ next if RubyCms::Engine.assets_precompile_phase?
240
132
 
241
- def self.users_icon_path
242
- # Heroicons UserGroupIcon (outline)
243
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
244
- 'd="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 ' \
245
- '00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>'
133
+ RubyCms::Settings.import_initializer_values!
246
134
  end
247
135
 
248
- def self.analytics_icon_path
249
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
250
- 'd="M3 3v18h18"></path>' \
251
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
252
- 'd="M7 13l3-3 3 2 4-5"></path>'
253
- end
136
+ # After host initializers (e.g. register_permission_keys), ensure Permission rows exist
137
+ # so can?(:manage_backups) and other keys do not fail on Permission.exists? checks.
138
+ initializer "ruby_cms.ensure_permission_rows", after: :load_config_initializers do
139
+ next if RubyCms::Engine.assets_precompile_phase?
254
140
 
255
- def self.compile_admin_css(dest_path)
256
- gem_root = begin
257
- root
258
- rescue StandardError
259
- Pathname.new(File.expand_path("../..", __dir__))
260
- end
261
- RubyCms::CssCompiler.compile(gem_root, dest_path)
141
+ RubyCms::Permission.ensure_defaults!
142
+ rescue StandardError => e
143
+ Rails.logger.warn("[RubyCMS] Permission.ensure_defaults! skipped: #{e.message}")
262
144
  end
263
145
 
264
146
  config.paths.add "db/migrate", with: "db/migrate"
@@ -372,131 +254,6 @@ module RubyCms
372
254
  end
373
255
  end
374
256
 
375
- def self.grant_admin_permissions_to_admin_users
376
- return unless defined?(::User) && User.column_names.include?("admin")
377
-
378
- permission_keys = RubyCms::Permission.all_keys
379
- permissions = RubyCms::Permission.where(key: permission_keys).index_by(&:key)
380
- User.where(admin: true).find_each do |u|
381
- permission_keys.each do |key|
382
- perm = permissions[key]
383
- next if perm.nil?
384
-
385
- RubyCms::UserPermission.find_or_create_by!(user: u, permission: perm)
386
- end
387
- end
388
- end
389
-
390
- def self.extract_email_from_args(args)
391
- args[:email] || ENV["email"] || ENV.fetch("EMAIL", nil)
392
- end
393
-
394
- def self.validate_email_present(email)
395
- return if email.present?
396
-
397
- warn "Usage: rails ruby_cms:grant_manage_admin " \
398
- "email=user@example.com"
399
- raise "Email is required"
400
- end
401
-
402
- def self.find_user_by_email(email)
403
- user_class = Rails.application.config.ruby_cms.user_class_name
404
- .constantize
405
- find_user_by_email_address(user_class, email) ||
406
- find_user_by_email_column(user_class, email)
407
- end
408
-
409
- def self.find_user_by_email_address(user_class, email)
410
- return unless user_class.column_names.include?("email_address")
411
-
412
- user_class.find_by(email_address: email)
413
- end
414
-
415
- def self.find_user_by_email_column(user_class, email)
416
- return unless user_class.column_names.include?("email")
417
-
418
- user_class.find_by(email:)
419
- end
420
-
421
- def self.validate_user_found(user, email)
422
- return if user
423
-
424
- warn "User not found: #{email}"
425
- raise "User not found: #{email}"
426
- end
427
-
428
- def self.grant_manage_admin_permission(user, email)
429
- RubyCms::Permission.ensure_defaults!
430
- RubyCms::Permission.all_keys.each do |key|
431
- perm = RubyCms::Permission.find_by(key:)
432
- next unless perm
433
-
434
- RubyCms::UserPermission.find_or_create_by!(user: user, permission: perm)
435
- end
436
- puts "Granted full admin permissions to #{email}" # rubocop:disable Rails/Output
437
- end
438
-
439
- def self.parse_locales_dir(locales_dir_arg)
440
- return nil unless locales_dir_arg.presence
441
-
442
- Pathname.new(locales_dir_arg)
443
- end
444
-
445
- def self.parse_import_options
446
- {
447
- create_missing: ENV["create_missing"] != "false",
448
- update_existing: ENV["update_existing"] != "false",
449
- published: ENV["published"] == "true"
450
- }
451
- end
452
-
453
- def self.display_export_summary(summary)
454
- if summary.empty?
455
- puts "No content blocks found to export." # rubocop:disable Rails/Output
456
- else
457
- puts "Exported content blocks to locale files:" # rubocop:disable Rails/Output
458
- summary.each do |locale, count|
459
- # rubocop:disable Rails/Output
460
- puts " #{locale}: #{count} block(s) updated " \
461
- "in config/locales/#{locale}.yml"
462
- # rubocop:enable Rails/Output
463
- end
464
- end
465
- end
466
-
467
- def self.display_import_summary(summary)
468
- $stdout.puts "Import summary:"
469
- $stdout.puts " Created: #{summary[:created]}"
470
- $stdout.puts " Updated: #{summary[:updated]}"
471
- $stdout.puts " Skipped: #{summary[:skipped]}"
472
- return unless summary[:errors].any?
473
-
474
- $stdout.puts " Errors:"
475
- summary[:errors].each {|e| $stdout.puts " - #{e}" }
476
- end
477
-
478
- def self.display_sync_summary(result, import_after)
479
- display_export_results(result[:export])
480
- display_import_results(result[:import], import_after) if import_after
481
- end
482
-
483
- def self.display_export_results(export_data)
484
- $stdout.puts "Sync complete!"
485
- $stdout.puts "\nExport summary:"
486
- export_data.each do |locale, count|
487
- $stdout.puts " #{locale}: #{count} block(s) updated"
488
- end
489
- end
490
-
491
- def self.display_import_results(import_data, import_after)
492
- return unless import_after && import_data.any?
493
-
494
- $stdout.puts "\nImport summary:"
495
- $stdout.puts " Created: #{import_data[:created]}"
496
- $stdout.puts " Updated: #{import_data[:updated]}"
497
- $stdout.puts " Skipped: #{import_data[:skipped]}"
498
- end
499
-
500
257
  initializer "ruby_cms.load_migrations" do |app|
501
258
  next unless app.config.respond_to?(:paths)
502
259
 
@@ -504,5 +261,13 @@ module RubyCms
504
261
  app.config.paths["db/migrate"] << path
505
262
  end
506
263
  end
264
+
265
+ def self.assets_precompile_phase?
266
+ command = File.basename($PROGRAM_NAME.to_s)
267
+ rake_assets_precompile = command == "rake" && ARGV.include?("assets:precompile")
268
+ rails_assets_precompile = command == "rails" && ARGV.include?("assets:precompile")
269
+
270
+ rake_assets_precompile || rails_assets_precompile
271
+ end
507
272
  end
508
273
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Icons
5
+ # Named Heroicon SVG path fragments (outline style, 24x24 viewBox).
6
+ # Use symbol keys with nav_register / register_page: `icon: :home`
7
+ # Raw SVG strings are still accepted for custom icons.
8
+ REGISTRY = {
9
+ home: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
10
+ 'd="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3' \
11
+ 'm-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>',
12
+
13
+ pencil_square: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
14
+ 'd="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 ' \
15
+ '0 012.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>',
16
+
17
+ document_duplicate: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
18
+ 'd="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414' \
19
+ 'a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 ' \
20
+ '0 002-2v-2"></path>',
21
+
22
+ chart_bar: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
23
+ 'd="M3 3v18h18"></path>' \
24
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
25
+ 'd="M7 13l3-3 3 2 4-5"></path>',
26
+
27
+ shield_check: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
28
+ 'd="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01' \
29
+ '-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 ' \
30
+ '9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>',
31
+
32
+ exclamation_triangle: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
33
+ 'd="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 ' \
34
+ '4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>',
35
+
36
+ user_group: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
37
+ 'd="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 ' \
38
+ '00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>',
39
+
40
+ cog_6_tooth: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
41
+ 'd="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 ' \
42
+ '1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 ' \
43
+ '1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 ' \
44
+ '6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 ' \
45
+ '0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 ' \
46
+ '1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 ' \
47
+ '6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 ' \
48
+ '1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 ' \
49
+ '1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"></path>' \
50
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>',
51
+
52
+ # Extra icons for host-app admin pages
53
+ archive_box: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
54
+ 'd="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>',
55
+
56
+ folder: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
57
+ 'd="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>',
58
+
59
+ bell: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
60
+ 'd="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>',
61
+
62
+ clock: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
63
+ 'd="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>',
64
+
65
+ tag: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
66
+ 'd="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>',
67
+
68
+ cube: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
69
+ 'd="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>',
70
+
71
+ envelope: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
72
+ 'd="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>',
73
+
74
+ wrench: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
75
+ 'd="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>' \
76
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>',
77
+
78
+ globe: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
79
+ 'd="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>',
80
+
81
+ photograph: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
82
+ 'd="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>',
83
+
84
+ list_bullet: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
85
+ 'd="M4 6h16M4 10h16M4 14h16M4 18h16"></path>',
86
+
87
+ plus_circle: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
88
+ 'd="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"></path>',
89
+
90
+ trash: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
91
+ 'd="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>',
92
+
93
+ eye: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
94
+ 'd="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>' \
95
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
96
+ 'd="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>',
97
+
98
+ lock_closed: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
99
+ 'd="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>',
100
+
101
+ currency_dollar: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
102
+ 'd="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>'
103
+ }.freeze
104
+
105
+ def self.resolve(name_or_svg)
106
+ return name_or_svg if name_or_svg.kind_of?(String) && name_or_svg.include?("<")
107
+ return nil if name_or_svg.nil?
108
+
109
+ REGISTRY.fetch(name_or_svg.to_sym) do
110
+ raise ArgumentError, "Unknown icon: #{name_or_svg}. Available: #{REGISTRY.keys.join(', ')}"
111
+ end
112
+ end
113
+
114
+ def self.available
115
+ REGISTRY.keys
116
+ end
117
+ end
118
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyCms
4
- VERSION = "0.1.9"
4
+ VERSION = "0.2.0.1"
5
5
  end
data/lib/ruby_cms.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "ruby_cms/version"
4
4
  require_relative "ruby_cms/css_compiler"
5
5
  require_relative "ruby_cms/settings_registry"
6
6
  require_relative "ruby_cms/settings"
7
+ require_relative "ruby_cms/icons"
8
+ require_relative "ruby_cms/dashboard_blocks"
7
9
  require_relative "ruby_cms/engine"
8
10
  require_relative "ruby_cms/app_integration"
9
11
  require_relative "ruby_cms/content_blocks_sync"
@@ -45,11 +47,6 @@ module RubyCms
45
47
  NAV_SECTION_MAIN = "main"
46
48
  NAV_SECTION_BOTTOM = "Settings"
47
49
 
48
- ANALYTICS_ICON_PATH = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
49
- 'd="M3 3v18h18"></path>' \
50
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
51
- 'd="M7 13l3-3 3 2 4-5"></path>'
52
-
53
50
  def self.configure
54
51
  yield(Rails.application.config.ruby_cms)
55
52
  end
@@ -64,8 +61,8 @@ module RubyCms
64
61
  # Options:
65
62
  # - key: required Symbol/String
66
63
  # - label: required String
67
- # - path: required String or callable(view_context) -> path
68
- # - icon: optional SVG path fragment
64
+ # - path: String, Symbol (route helper name, auto-wrapped via main_app), or callable(view_context) -> path
65
+ # - icon: Symbol (named icon from RubyCms::Icons) or raw SVG path fragment string
69
66
  # - section: "main" | "Settings" (or nil => main)
70
67
  # - order: optional Integer for sorting within section
71
68
  # - permission: optional permission key (e.g., :manage_analytics)
@@ -74,11 +71,13 @@ module RubyCms
74
71
  def self.nav_register(key:, label:, path:, icon: nil, section: nil, order: nil, permission: nil, default_visible: true, **options)
75
72
  normalized_key = key.to_sym
76
73
  normalized_section = section.presence || NAV_SECTION_MAIN
74
+ resolved_path = path.kind_of?(Symbol) ? ->(v) { v.main_app.send(path) } : path
75
+ resolved_icon = icon.nil? ? nil : RubyCms::Icons.resolve(icon)
77
76
  entry = {
78
77
  key: normalized_key,
79
78
  label: label.to_s,
80
- path: path,
81
- icon: icon,
79
+ path: resolved_path,
80
+ icon: resolved_icon,
82
81
  section: normalized_section,
83
82
  order: order,
84
83
  permission: permission&.to_s,
@@ -94,8 +93,35 @@ module RubyCms
94
93
  entry
95
94
  end
96
95
 
96
+ VALID_PAGE_SECTIONS = %i[main settings].freeze
97
+
98
+ # Unified API to register an admin page: nav item + permission key in one call.
99
+ # Accepts :main or :settings for section (resolved to NAV_SECTION_MAIN / NAV_SECTION_BOTTOM).
100
+ # Automatically registers the permission key if provided.
101
+ def self.register_page(
102
+ key:, label:, path:, icon: nil, section: :main, order: nil,
103
+ permission: nil, default_visible: true, **
104
+ )
105
+ raise ArgumentError, "register_page section must be :main or :settings, got #{section.inspect}" unless VALID_PAGE_SECTIONS.include?(section.to_s.to_sym)
106
+
107
+ register_permission_keys(permission) if permission.present?
108
+ resolved_section = section.to_s == "settings" ? NAV_SECTION_BOTTOM : NAV_SECTION_MAIN
109
+ nav_register(
110
+ key: key,
111
+ label: label,
112
+ path: path,
113
+ icon: icon,
114
+ section: resolved_section,
115
+ order: order,
116
+ permission: permission,
117
+ default_visible: default_visible,
118
+ **
119
+ )
120
+ end
121
+
97
122
  # Returns only entries that pass settings + permissions + conditional checks,
98
123
  # sorted by saved nav_order (Settings → Navigation drag-and-drop) when set, else by section + order.
124
+ # Fail-closed: returns empty array on error (never exposes unfiltered items).
99
125
  def self.visible_nav_registry(view_context: nil, user: nil)
100
126
  list = nav_registry
101
127
  .select {|item| nav_entry_visible?(item, view_context:, user:) }
@@ -103,7 +129,7 @@ module RubyCms
103
129
  localize_nav_labels(apply_nav_order(list))
104
130
  rescue StandardError => e
105
131
  Rails.logger.error("[RubyCMS] Error filtering navigation: #{e.message}") if defined?(Rails.logger)
106
- nav_registry
132
+ []
107
133
  end
108
134
 
109
135
  module Nav