ruby_cms 0.1.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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
data/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # RubyCMS
2
+
3
+ Reusable Rails engine: admin-only auth, permissions, admin shell, content blocks, and visual editor.
4
+
5
+ **Vision:** The CMS manages content (content blocks, visual editor); the programmer builds the SaaS product (auth, billing, dashboards, etc.). You define pages and templates in your app; you edit content using the visual editor.
6
+
7
+ ## Features
8
+
9
+ - **Visual Editor** - Inline editing of content blocks
10
+ - **Content Blocks** - Reusable content snippets with rich text support
11
+ - **Permissions** - Fine-grained permission system
12
+ - **Users** - User management with permission assignments
13
+ - **Visitor Error Tracking** - Automatic exception logging with admin interface
14
+ - **Page View Tracking** - Ahoy-based analytics for page views and events
15
+
16
+ ## Documentation
17
+
18
+ - **[Installation](#installation)** - Get started with RubyCMS
19
+ - **[Usage](#usage)** - Basic usage examples
20
+
21
+ ## Installation {#installation}
22
+
23
+ ### 1. Create a Rails app (or use an existing one)
24
+
25
+ ```bash
26
+ rails new my_cms_app -d sqlite3
27
+ cd my_cms_app
28
+ ```
29
+
30
+ ### 2. Install RubyCMS
31
+
32
+ ```bash
33
+ rails g ruby_cms:install
34
+ ```
35
+
36
+ This generator:
37
+
38
+ - configures RubyCMS (`config/initializers/ruby_cms.rb`) and mounts the engine,
39
+ - ensures authentication is present (generates `User`, `Session`, and `Authentication` on Rails 8+ apps that need it),
40
+ - installs Action Text / Active Storage when missing and runs the required `db:migrate`,
41
+ - creates the RubyCMS tables and seeds permissions,
42
+ - guides you through picking or creating the first admin user and granting CMS permissions.
43
+
44
+ ### 3. Resolve route conflicts
45
+
46
+ If the app already has `/admin` routes, remove or change them so RubyCMS can use `/admin`. The install adds `mount RubyCms::Engine => "/"`; keep it after your main routes (e.g. `root`, `resources`) so it doesn’t override them.
47
+
48
+ ## Usage
49
+
50
+ ### Content blocks
51
+
52
+ In any view:
53
+
54
+ ```erb
55
+ <%= content_block("hero_title", default: "Welcome") %>
56
+ <%= content_block("footer", cache: true) %>
57
+ ```
58
+
59
+ **Important:** For **placeholders** (input `placeholder`, `alt`, meta tags), use `wrap: false` or `content_block_text`. The `content_block` helper normally wraps content in a `<span>` for the visual editor; that HTML must not go into placeholder attributes:
60
+
61
+ ```erb
62
+ <%= text_field_tag :name, nil, placeholder: content_block("contact.name_placeholder", wrap: false, fallback: "Your name") %>
63
+ <%= text_area_tag :message, nil, placeholder: content_block("contact.message_placeholder", wrap: false, fallback: "Your message...") %>
64
+ ```
65
+
66
+ Or use `content_block_text` (equivalent to `content_block(..., wrap: false)`):
67
+
68
+ ```erb
69
+ <%= text_field_tag :name, nil, placeholder: content_block_text("contact.name_placeholder", fallback: "Your name") %>
70
+ ```
71
+
72
+ For **lists** (badges, tags) that you need to iterate over, use `content_block_list_items`—it returns an array instead of HTML:
73
+
74
+ ```erb
75
+ <% content_block_list_items("education.item.badges", fallback: item[:badges]).each do |badge| %>
76
+ <%= tag.span badge, class: "badge" %>
77
+ <% end %>
78
+ ```
79
+
80
+ Store list content as JSON (`["Ruby", "Rails"]`) or newline-separated text in the CMS.
81
+
82
+ Create and edit blocks under **Admin → Content blocks**.
83
+
84
+ ### Seeding content blocks from YAML
85
+
86
+ 1. In `config/initializers/ruby_cms.rb`, set the translation namespace (the install generator sets this by default):
87
+
88
+ ```ruby
89
+ c.content_blocks_translation_namespace = "content_blocks"
90
+ ```
91
+
92
+ 2. Add content under that key in your locale files (e.g. `config/locales/en.yml`):
93
+
94
+ ```yaml
95
+ en:
96
+ content_blocks:
97
+ hero_title: "Welcome to my site"
98
+ about_intro: "We build things."
99
+ footer_copyright: "© 2025"
100
+ ```
101
+
102
+ 3. Run the seed task to import into the database (creates/updates blocks, marks them published):
103
+
104
+ ```bash
105
+ rails ruby_cms:content_blocks:seed
106
+ ```
107
+
108
+ Or call it from `db/seeds.rb`:
109
+
110
+ ```ruby
111
+ Rake::Task["ruby_cms:content_blocks:seed"].invoke
112
+ ```
113
+
114
+ ENV overrides: `published=false` to import as unpublished; `create_missing=false` or `update_existing=false` to limit what is changed.
115
+
116
+ ### Visual editor
117
+
118
+ 1. **Preview templates** come from `config.ruby_cms.preview_templates` in `config/initializers/ruby_cms.rb`.
119
+
120
+ ```ruby
121
+ c.preview_templates = { "home" => "pages/home", "about" => "pages/about" }
122
+ c.preview_data = ->(page_key, view) { { products: Product.limit(5) } }
123
+ ```
124
+
125
+ 2. Create the view templates (e.g. `app/views/pages/home.html.erb`) and use the `content_block("key")` helper for editable regions. Wrap editable elements in `<div class="ruby_cms-content-block" data-content-key="...">`.
126
+
127
+ 3. Open **Admin → Visual editor**, pick a page, and click any content block in the preview to edit in the modal.
128
+
129
+ 4. **postMessage**: The preview iframe and parent communicate via postMessage for content block editing and updates.
130
+
131
+ ### Visitor Error Tracking
132
+
133
+ RubyCMS automatically captures unhandled exceptions from public pages (non-admin) and logs them to the `ruby_cms_visitor_errors` table.
134
+
135
+ **Note**: Error logging is disabled in development environment (errors are skipped and only re-raised for normal Rails error pages).
136
+
137
+ #### How it works
138
+
139
+ 1. The install generator adds `RubyCms::VisitorErrorCapture` to your `ApplicationController` with `rescue_from StandardError`
140
+ 2. When an exception occurs in production/staging, it's logged with full context (backtrace, request params, IP, user agent, etc.)
141
+ 3. The exception is re-raised so users still see the standard error page
142
+ 4. Admin users can view and manage errors at `/admin/visitor_errors`
143
+
144
+ #### What gets logged
145
+
146
+ - Error class and message
147
+ - Request path and method
148
+ - IP address and user agent
149
+ - Session ID
150
+ - First 10 lines of backtrace
151
+ - Sanitized request params (passwords/tokens excluded)
152
+
153
+ #### Admin interface
154
+
155
+ Visit `/admin/visitor_errors` to:
156
+
157
+ - View all errors with filtering by path, error type, and resolved status
158
+ - See full error details including backtrace and request context
159
+ - Mark errors as resolved (single or bulk)
160
+ - Delete errors (bulk action)
161
+
162
+ ### Page View Tracking (Ahoy)
163
+
164
+ RubyCMS includes Ahoy for visit and event tracking. The install generator sets up Ahoy with server-side tracking (no JavaScript required).
165
+
166
+ #### Tracking page views
167
+
168
+ Include `RubyCms::PageTracking` in your public controllers to automatically track page views:
169
+
170
+ ```ruby
171
+ class PagesController < ApplicationController
172
+ include RubyCms::PageTracking
173
+
174
+ def home
175
+ # @page_name is set to controller_name by default
176
+ # Override if needed: @page_name = "home"
177
+ end
178
+ end
179
+ ```
180
+
181
+ Page views are stored in `ahoy_events` with:
182
+
183
+ - `name: "page_view"`
184
+ - `page_name`: Controller-specific identifier
185
+ - `request_path`: Full request path
186
+ - `visit`: Associated Ahoy visit (includes IP, user agent, browser, etc.)
187
+
188
+ #### What Ahoy tracks
189
+
190
+ **Visits** (`ahoy_visits` table):
191
+
192
+ - Visit token (unique per session)
193
+ - IP address and user agent
194
+ - Browser, OS, device type
195
+ - Landing page and referrer
196
+ - UTM parameters
197
+ - User ID (when authenticated)
198
+
199
+ **Events** (`ahoy_events` table):
200
+
201
+ - Event name (e.g., "page_view")
202
+ - Timestamp
203
+ - Associated visit
204
+ - Custom properties (page_name, request_path, etc.)
205
+ - User ID (when authenticated)
206
+
207
+ #### Accessing analytics data
208
+
209
+ Query the Ahoy tables directly or use the Ahoy gem's built-in methods:
210
+
211
+ ```ruby
212
+ # Get all page views
213
+ Ahoy::Event.where(name: "page_view")
214
+
215
+ # Page views for a specific page
216
+ Ahoy::Event.where(name: "page_view", page_name: "home")
217
+
218
+ # Unique visitors (visits)
219
+ Ahoy::Visit.count
220
+
221
+ # Page views by page
222
+ Ahoy::Event.where(name: "page_view")
223
+ .group(:page_name)
224
+ .count
225
+ ```
226
+
227
+ #### Architecture notes
228
+
229
+ **Visitor Errors** and **Ahoy** are independent systems:
230
+
231
+ - Visitor Errors log exceptions via `ApplicationController#rescue_from`
232
+ - Ahoy tracks visits/events via Rack middleware and controller callbacks
233
+ - No direct relationship between the two systems
234
+
235
+ ---
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ # CSS compile (no Rails needed - for gem development)
13
+ namespace :ruby_cms do
14
+ namespace :css do
15
+ desc "Compile RubyCMS admin.css from component files (for gem development)"
16
+ task compile_gem: :environment do
17
+ require_relative "lib/ruby_cms/css_compiler"
18
+ gem_root = __dir__
19
+ dest = File.join(gem_root, "app/assets/stylesheets/ruby_cms/admin.css")
20
+ RubyCms::CssCompiler.compile(gem_root, dest)
21
+ puts "✓ Compiled admin.css in gem"
22
+ end
23
+ end
24
+ end
25
+
26
+ task :environment do
27
+ # No-op for gem Rakefile; Rails app Rakefiles load full env
28
+ end
29
+
30
+ task default: %i[spec rubocop]
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ class AdminPage
6
+ # Optional wrapper for pages that want a consistent Turbo Frame target
7
+ # for table/content updates (pagination, filters, search, etc).
8
+ #
9
+ # Default ID matches RubyCMS convention: "admin_table_content".
10
+ class AdminTableContent < BaseComponent
11
+ def initialize(id: "admin_table_content", **attrs)
12
+ super()
13
+ @id = id
14
+ @attrs = attrs
15
+ end
16
+
17
+ def view_template(&)
18
+ turbo_frame_tag(@id, **default_attrs.merge(@attrs), &)
19
+ end
20
+
21
+ private
22
+
23
+ def default_attrs
24
+ {
25
+ class: "flex-1 flex flex-col min-h-0",
26
+ data: { turbo_action: "advance" }
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ # Admin page wrapper component (Tailwind-first)
6
+ #
7
+ # NOTE: This file must exist (at this path) so Zeitwerk autoloads
8
+ # `RubyCms::Admin::AdminPage` as a CLASS (not a module inferred from the
9
+ # `admin_page/` directory).
10
+ class AdminPage < BaseComponent
11
+ def initialize(title: nil, footer: nil, **options)
12
+ super()
13
+ @title = title
14
+ @footer = footer
15
+
16
+ assign_options(options)
17
+ @user_attrs = extract_user_attrs(options)
18
+ end
19
+
20
+ def extract_user_attrs(options)
21
+ excluded_keys = %i[
22
+ title subtitle actions action_icons search breadcrumbs padding overflow
23
+ content_card turbo_frame turbo_frame_options
24
+ ]
25
+ options.except(*excluded_keys)
26
+ end
27
+
28
+ def view_template(&)
29
+ content = build_page_content(&)
30
+ wrap_with_turbo_frame(content)
31
+ end
32
+
33
+ private
34
+
35
+ def build_page_content(&block)
36
+ lambda do
37
+ div(class: build_classes("flex flex-col gap-4", @user_attrs[:class]),
38
+ **@user_attrs.except(:class)) do
39
+ render_breadcrumbs if @breadcrumbs&.any?
40
+ render_header
41
+ render_content(&block)
42
+ render_footer if @footer.present?
43
+ end
44
+ end
45
+ end
46
+
47
+ def wrap_with_turbo_frame(content)
48
+ if @turbo_frame
49
+ turbo_frame_tag(@turbo_frame, **default_turbo_frame_options, &content)
50
+ else
51
+ content.call
52
+ end
53
+ end
54
+
55
+ def default_turbo_frame_options
56
+ { class: "flex-1 flex flex-col min-h-0", data: { turbo_action: "advance" } }
57
+ .merge(@turbo_frame_options || {})
58
+ end
59
+
60
+ def turbo_frame_tag(id, **attrs, &)
61
+ if respond_to?(:helpers) && helpers.respond_to?(:turbo_frame_tag)
62
+ helpers.turbo_frame_tag(id, **attrs, &)
63
+ elsif respond_to?(:turbo_frame_tag, true)
64
+ super
65
+ else
66
+ div(id: id, data: { turbo_frame: id, turbo_action: "advance" }, **attrs, &)
67
+ end
68
+ end
69
+
70
+ def render_breadcrumbs
71
+ nav(class: "text-sm text-gray-500", aria_label: "Breadcrumb") do
72
+ ol(class: "flex items-center flex-wrap gap-x-2 gap-y-1") do
73
+ @breadcrumbs.each_with_index do |crumb, index|
74
+ render_breadcrumb_item(crumb, index == @breadcrumbs.size - 1)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def render_breadcrumb_item(crumb, last)
81
+ li(class: "flex items-center") do
82
+ last ? render_breadcrumb_current(crumb) : render_breadcrumb_link(crumb)
83
+ end
84
+ end
85
+
86
+ def render_breadcrumb_current(crumb)
87
+ span(class: "font-medium text-gray-900", aria_current: "page") do
88
+ crumb[:label] || crumb[:text]
89
+ end
90
+ end
91
+
92
+ def render_breadcrumb_link(crumb)
93
+ a(href: crumb[:url] || crumb[:path], class: "hover:text-gray-700") do
94
+ span { crumb[:label] || crumb[:text] }
95
+ span(class: "px-2 text-gray-300") { "/" }
96
+ end
97
+ end
98
+
99
+ def render_header
100
+ return unless @title || @action_icons.any? || @actions.any? || @search
101
+
102
+ div(class: "flex flex-col gap-3") { render_header_rows }
103
+ end
104
+
105
+ def render_header_rows
106
+ div(class: "flex flex-wrap items-start justify-between gap-4") do
107
+ render_header_title_group
108
+ render_header_actions_icons
109
+ end
110
+
111
+ div(class: "flex flex-wrap items-center justify-between gap-3") do
112
+ render_search if @search
113
+ div(class: "flex items-center gap-2 flex-wrap") { render_header_action_buttons }
114
+ end
115
+ end
116
+
117
+ def render_header_title_group
118
+ return unless @title || @subtitle
119
+
120
+ div(class: "min-w-0") do
121
+ h1(class: "text-lg font-semibold text-gray-900 truncate") { @title } if @title
122
+ p(class: "text-sm text-gray-500 mt-0.5") { @subtitle } if @subtitle
123
+ end
124
+ end
125
+
126
+ def render_header_actions_icons
127
+ return unless @action_icons.any?
128
+
129
+ div(class: "flex items-center gap-2 flex-wrap") do
130
+ @action_icons.each {|icon_action| render_icon_action(icon_action) }
131
+ end
132
+ end
133
+
134
+ def render_header_action_buttons
135
+ @actions.each {|action| render_action_button(action) }
136
+ end
137
+
138
+ def render_icon_action(action)
139
+ if get_method?(action[:method])
140
+ render_icon_link(action)
141
+ else
142
+ render_icon_form(action)
143
+ end
144
+ end
145
+
146
+ def render_icon_link(action)
147
+ a(href: action_url(action), **icon_attrs(action)) { render_icon(action[:icon]) }
148
+ end
149
+
150
+ def render_icon_form(action)
151
+ form_with(url: action_url(action), method: action[:method],
152
+ class: "inline") do
153
+ button(type: "submit", **icon_attrs(action)) { render_icon(action[:icon]) }
154
+ end
155
+ end
156
+
157
+ def icon_attrs(action)
158
+ attrs = base_icon_attrs(action)
159
+ data = action[:data]
160
+ attrs[:data] = data if data
161
+ attrs
162
+ end
163
+
164
+ def action_url(action)
165
+ action[:url] || action[:path] || "#"
166
+ end
167
+
168
+ def render_icon(icon)
169
+ case icon
170
+ when String then svg_icon_path(icon)
171
+ when Hash then svg_icon_hash(icon)
172
+ else sanitize(
173
+ icon.to_s,
174
+ tags: %w[svg path g circle rect line polygon polyline ellipse text],
175
+ attributes: %w[
176
+ fill stroke stroke-linecap stroke-linejoin stroke-width d
177
+ class viewBox cx cy r x y points x1 y1 x2 y2 aria-current id title aria-label
178
+ ]
179
+ )
180
+ end
181
+ end
182
+
183
+ def svg_icon_path(path)
184
+ svg(class: "w-5 h-5", fill: "none", stroke: "currentColor",
185
+ viewBox: "0 0 24 24") do |s|
186
+ s.path(stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2", d: path)
187
+ end
188
+ end
189
+
190
+ def svg_icon_hash(icon)
191
+ svg(class: "w-5 h-5",
192
+ fill: icon[:fill] || "none",
193
+ stroke: icon[:stroke] || "currentColor",
194
+ viewBox: icon[:viewBox] || "0 0 24 24") do |s|
195
+ Array(icon[:paths] || icon[:path]).compact.each {|p| s.path(**p) }
196
+ end
197
+ end
198
+
199
+ def render_action_button(action)
200
+ if action[:html].present?
201
+ render_safe_html_action(action)
202
+ else
203
+ render_standard_action(action)
204
+ end
205
+ end
206
+
207
+ def render_safe_html_action(action)
208
+ content = action[:html].to_s
209
+ sanitize(content)
210
+ end
211
+
212
+ def render_standard_action(action)
213
+ label = action[:label] || action[:text] || action[:name]&.humanize || "Action"
214
+ render_action_element(action_url(action), action[:method] || :get,
215
+ build_action_attributes(action), label)
216
+ end
217
+
218
+ def build_action_attributes(action)
219
+ base = "inline-flex items-center justify-center rounded-lg px-3 py-2 " \
220
+ "text-sm font-medium transition"
221
+ secondary = "bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50"
222
+ variant = action_primary?(action) ? primary_action_classes : secondary
223
+ attrs = { class: build_classes(base, variant, action[:class]) }
224
+ attrs[:data] = action[:data] if action[:data]
225
+ attrs
226
+ end
227
+
228
+ def action_primary?(action)
229
+ action[:primary] != false && action[:style] != "secondary"
230
+ end
231
+
232
+ def action_secondary?(action)
233
+ action[:style] == "secondary" || action[:primary] == false
234
+ end
235
+
236
+ def render_action_element(url, method, attrs, label)
237
+ if get_method?(method)
238
+ a(href: url, **attrs) { label }
239
+ else
240
+ render_form_action(url, method, attrs, label)
241
+ end
242
+ end
243
+
244
+ def render_form_action(url, method, attrs, label)
245
+ form_with(url: url, method: method, class: "inline") do
246
+ button(type: "submit", **attrs) { label }
247
+ end
248
+ end
249
+
250
+ def render_content(&)
251
+ div(class: "flex-1 flex flex-col min-h-0") do
252
+ if @content_card
253
+ div(
254
+ class: "bg-white rounded-lg border border-gray-200/80 shadow-sm " \
255
+ "p-5 sm:p-6 flex-1 flex flex-col min-h-0"
256
+ ) { yield if block_given? }
257
+ elsif block_given?
258
+ yield
259
+ end
260
+ end
261
+ end
262
+
263
+ def render_footer
264
+ div(class: "mt-4") do
265
+ case @footer
266
+ when Proc
267
+ instance_exec(&@footer)
268
+ else
269
+ plain @footer
270
+ end
271
+ end
272
+ end
273
+
274
+ def render_search
275
+ opts = @search.kind_of?(Hash) ? @search : { placeholder: "Search" }
276
+ form_with(url: opts[:url] || "#", method: :get, class: "w-full sm:w-auto",
277
+ data: { turbo_frame: opts[:turbo_frame] || "admin_table_content" }) do
278
+ div(class: "relative flex items-center") do
279
+ span(class: "absolute left-3 text-gray-400 pointer-events-none") do
280
+ svg_icon_path("M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z")
281
+ end
282
+ input(
283
+ type: "search",
284
+ name: opts[:name] || "q",
285
+ placeholder: opts[:placeholder] || "Search",
286
+ class: "w-full sm:w-72 pl-10 pr-3 py-2 text-sm rounded-lg bg-white ring-1 " \
287
+ "ring-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-200",
288
+ value: opts[:value],
289
+ data: { action: "input->turbo-frame#submit" }
290
+ )
291
+ end
292
+ end
293
+ end
294
+
295
+ def get_method?(method)
296
+ [:get, "get"].include?(method)
297
+ end
298
+
299
+ def assign_options(options)
300
+ @subtitle = options[:subtitle]
301
+ @actions = options[:actions] || []
302
+ @action_icons = options[:action_icons] || []
303
+ @search = options[:search]
304
+ @breadcrumbs = options[:breadcrumbs]
305
+ @padding = options.fetch(:padding, false)
306
+ @overflow = options.fetch(:overflow, true)
307
+ @content_card = options.fetch(:content_card, true)
308
+ @turbo_frame = options[:turbo_frame]
309
+ @turbo_frame_options = options[:turbo_frame_options]
310
+ end
311
+
312
+ def base_icon_attrs(action)
313
+ label = action[:title] || action[:label] || ""
314
+ {
315
+ class: build_classes(icon_base_classes, icon_color_classes(action[:color])),
316
+ title: label,
317
+ aria_label: label
318
+ }
319
+ end
320
+
321
+ def icon_color_classes(color)
322
+ icon_color_class_map.fetch((color || "blue").to_s, icon_color_class_map["blue"])
323
+ end
324
+
325
+ def icon_color_class_map
326
+ {
327
+ "blue" => "text-blue-600 hover:bg-blue-50",
328
+ "green" => "text-green-600 hover:bg-green-50",
329
+ "red" => "text-red-600 hover:bg-red-50",
330
+ "purple" => "text-purple-600 hover:bg-purple-50",
331
+ "gray" => "text-gray-700 hover:bg-gray-50",
332
+ "teal" => "text-teal-600 hover:bg-teal-50"
333
+ }
334
+ end
335
+
336
+ def icon_base_classes
337
+ "inline-flex items-center justify-center w-9 h-9 rounded-lg"
338
+ end
339
+
340
+ def primary_action_classes
341
+ "bg-teal-600 text-white hover:bg-teal-700"
342
+ end
343
+ end
344
+ end
345
+ end