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.
- checksums.yaml +7 -0
- data/.cursor/dhh.mdc +698 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/README.md +235 -0
- data/Rakefile +30 -0
- data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
- data/app/components/ruby_cms/admin/admin_page.rb +345 -0
- data/app/components/ruby_cms/admin/base_component.rb +78 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
- data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
- data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
- data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
- data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
- data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
- data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
- data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
- data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
- data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
- data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
- data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
- data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
- data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
- data/app/controllers/ruby_cms/errors_controller.rb +35 -0
- data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
- data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
- data/app/helpers/ruby_cms/application_helper.rb +41 -0
- data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
- data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
- data/app/helpers/ruby_cms/settings_helper.rb +160 -0
- data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
- data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
- data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
- data/app/javascript/controllers/ruby_cms/index.js +104 -0
- data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
- data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
- data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
- data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
- data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
- data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
- data/app/models/concerns/content_block/publishable.rb +54 -0
- data/app/models/concerns/content_block/searchable.rb +22 -0
- data/app/models/content_block.rb +155 -0
- data/app/models/ruby_cms/content_block.rb +8 -0
- data/app/models/ruby_cms/permission.rb +28 -0
- data/app/models/ruby_cms/permittable.rb +39 -0
- data/app/models/ruby_cms/preference.rb +111 -0
- data/app/models/ruby_cms/user_permission.rb +12 -0
- data/app/models/ruby_cms/visitor_error.rb +109 -0
- data/app/services/ruby_cms/analytics/report.rb +362 -0
- data/app/services/ruby_cms/security_tracker.rb +92 -0
- data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
- data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
- data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
- data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
- data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
- data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
- data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
- data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
- data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
- data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
- data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
- data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
- data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
- data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
- data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
- data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
- data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
- data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
- data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
- data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
- data/config/database.yml +6 -0
- data/config/importmap.rb +36 -0
- data/config/locales/en.yml +101 -0
- data/config/routes.rb +65 -0
- data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
- data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
- data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
- data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
- data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
- data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
- data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
- data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
- data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
- data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
- data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
- data/exe/ruby_cms +25 -0
- data/lib/generators/ruby_cms/install_generator.rb +1062 -0
- data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
- data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
- data/lib/ruby_cms/app_integration.rb +82 -0
- data/lib/ruby_cms/cli.rb +169 -0
- data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
- data/lib/ruby_cms/content_blocks_sync.rb +329 -0
- data/lib/ruby_cms/css_compiler.rb +35 -0
- data/lib/ruby_cms/engine.rb +498 -0
- data/lib/ruby_cms/settings.rb +145 -0
- data/lib/ruby_cms/settings_registry.rb +289 -0
- data/lib/ruby_cms/version.rb +5 -0
- data/lib/ruby_cms.rb +195 -0
- data/lib/tasks/ruby_cms.rake +27 -0
- data/log/test.log +17875 -0
- data/sig/ruby_cms.rbs +4 -0
- 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
|