admin_resources 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3be67639f99785b3922b74d53915eb671d2bd18e9f9f29e2f4b176229b8ec37e
4
+ data.tar.gz: 581deeb6b0c2202bf2db8bddb021606aa804ed9eb44f386958480c5d56c84e1d
5
+ SHA512:
6
+ metadata.gz: b27b897928284a64a6d8f5e4bf733feea84508d72f41235187e45fa048004d6ea1ac4b0f2732cbf64fc86a179ba6776b785cda9db3b2d7cf600a1f9255a9360a
7
+ data.tar.gz: a469f701fde5346d425bea3fa7a8fa4dff3a656006abdb4a822cc0747488f617655c8bf46d7b7143e1eb00acd39383c3b007cbb96781dbcd4866695e1a1f4d1c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Rosenberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # AdminResources
2
+
3
+ A mountable Rails engine that automatically generates a full admin dashboard for your models. Register the models you want to manage, mount the engine, and you get a complete CRUD interface with zero boilerplate controllers or views in your app.
4
+
5
+ **Features:**
6
+ - Auto-generated index, show, new, edit, and delete for every registered model
7
+ - Dashboard with record counts for all registered models
8
+ - Built-in admin authentication via Devise (own `AdminUser` model, own table)
9
+ - Smart field rendering: JSON/JSONB pretty-printed, foreign keys linked to related admin pages, booleans as Yes/No, datetimes formatted
10
+ - Smart form generation: checkboxes for booleans, dropdowns for foreign keys, textareas for JSON, etc.
11
+ - `has_one` and `has_many` associations shown inline on show pages
12
+ - Your own styling — dark sidebar, clean table layout, no external CSS dependencies
13
+
14
+ ---
15
+
16
+ ## Requirements
17
+
18
+ - Rails 7.0+
19
+ - Ruby 3.0+
20
+ - Devise 4.0+
21
+ - PostgreSQL (for array column support) — other databases work but array columns won't be handled
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ Add to your `Gemfile`:
28
+
29
+ ```ruby
30
+ gem "admin_resources", github: "doeswork/admin_resources"
31
+ ```
32
+
33
+ Or for local development:
34
+
35
+ ```ruby
36
+ gem "admin_resources", path: "../admin_resources"
37
+ ```
38
+
39
+ Then run:
40
+
41
+ ```bash
42
+ bundle install
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Setup
48
+
49
+ ### 1. Run the migration
50
+
51
+ Copy and run the engine's migration to create the `admin_resources_admin_users` table:
52
+
53
+ ```bash
54
+ rails admin_resources:install:migrations
55
+ rails db:migrate
56
+ ```
57
+
58
+ ### 2. Mount the engine
59
+
60
+ In `config/routes.rb`:
61
+
62
+ ```ruby
63
+ Rails.application.routes.draw do
64
+ mount AdminResources::Engine, at: "/admin"
65
+
66
+ # ... rest of your routes
67
+ end
68
+ ```
69
+
70
+ ### 3. Configure your models
71
+
72
+ Create `config/initializers/admin_resources.rb`:
73
+
74
+ ```ruby
75
+ AdminResources.configure do |config|
76
+ # Register with specific index columns:
77
+ config.register "User", columns: %w[id email created_at]
78
+ config.register "Post", columns: %w[id title user_id published_at]
79
+
80
+ # Register without columns — defaults to first 6 columns:
81
+ config.register "Comment"
82
+ config.register "Tag"
83
+ end
84
+ ```
85
+
86
+ ### 4. Create your first admin user
87
+
88
+ Open a Rails console and create an `AdminResources::AdminUser`:
89
+
90
+ ```ruby
91
+ AdminResources::AdminUser.create!(
92
+ email: "admin@example.com",
93
+ password: "yourpassword",
94
+ password_confirmation: "yourpassword"
95
+ )
96
+ ```
97
+
98
+ Then visit `/admin` and sign in.
99
+
100
+ ---
101
+
102
+ ## Configuration reference
103
+
104
+ `config.register` accepts:
105
+
106
+ | Option | Type | Default | Description |
107
+ |--------|------|---------|-------------|
108
+ | `columns` | `Array<String>` | first 6 columns | Column names to display in the index table |
109
+
110
+ ```ruby
111
+ AdminResources.configure do |config|
112
+ config.register "ModelName", columns: %w[col1 col2 col3]
113
+ end
114
+ ```
115
+
116
+ Model names must be strings matching the exact class name (e.g. `"WorkflowStep"`, not `"workflow_step"`).
117
+
118
+ ---
119
+
120
+ ## What gets generated automatically
121
+
122
+ For each registered model, the engine provides:
123
+
124
+ | Route | Description |
125
+ |-------|-------------|
126
+ | `GET /admin/users` | Index — paginated table of all records |
127
+ | `GET /admin/users/new` | New form |
128
+ | `POST /admin/users` | Create |
129
+ | `GET /admin/users/:id` | Show — all columns + associations |
130
+ | `GET /admin/users/:id/edit` | Edit form |
131
+ | `PATCH /admin/users/:id` | Update |
132
+ | `DELETE /admin/users/:id` | Destroy |
133
+
134
+ Route helpers follow the pattern `admin_resources_<plural>_path` and `admin_resources_<singular>_path`.
135
+
136
+ ---
137
+
138
+ ## How field rendering works
139
+
140
+ ### Index + show pages
141
+
142
+ | Value type | Rendered as |
143
+ |------------|-------------|
144
+ | `nil` | *nil* (italic, grey) |
145
+ | JSON / JSONB column | `<pre>` with pretty-printed JSON |
146
+ | Column named `params` or `data` | Same as JSON |
147
+ | Foreign key (`*_id`) pointing to a registered model | Clickable link to that record's admin show page |
148
+ | `Boolean` | Yes / No |
149
+ | `Time` / `DateTime` | `YYYY-MM-DD HH:MM:SS` |
150
+ | Everything else | Plain text (truncated to 50 chars on index) |
151
+
152
+ ### Forms
153
+
154
+ | Column type | Field rendered |
155
+ |-------------|----------------|
156
+ | `:boolean` | Checkbox |
157
+ | `:text` | Textarea (4 rows) |
158
+ | `:integer`, `:decimal`, `:float` | Number input |
159
+ | `:date` | Date picker |
160
+ | `:datetime` | Datetime-local picker |
161
+ | `:json`, `:jsonb` | Textarea (serialized to JSON) |
162
+ | `*_id` foreign key | Dropdown (`collection_select`) populated with all records of the associated model |
163
+ | Column name contains `password` | Password input |
164
+ | Column name contains `email` | Email input |
165
+ | Everything else | Text input |
166
+
167
+ ### Show page associations
168
+
169
+ The show page automatically renders:
170
+
171
+ - **`has_one`** associations: shown as a detail card below the main record, with View/Edit links if the associated model is also registered
172
+ - **`has_many`** associations: shown as a table (limited to 20 rows) with a count and "New" link if the associated model is registered
173
+
174
+ ---
175
+
176
+ ## Authentication
177
+
178
+ The engine bundles its own `AdminResources::AdminUser` model with Devise. It lives in a separate table (`admin_resources_admin_users`) and is completely independent from any `User` model in your app.
179
+
180
+ Devise modules included: `database_authenticatable`, `recoverable`, `rememberable`, `validatable`.
181
+
182
+ All admin routes require a signed-in `AdminResources::AdminUser`. Unauthenticated requests are redirected to the admin login page.
183
+
184
+ ---
185
+
186
+ ## Development
187
+
188
+ Clone the repo and install dependencies:
189
+
190
+ ```bash
191
+ git clone https://github.com/doeswork/admin_resources
192
+ cd admin_resources
193
+ bundle install
194
+ ```
195
+
196
+ To install onto a local Rails app for testing, add `gem "admin_resources", path: "/path/to/admin_resources"` to that app's Gemfile.
197
+
198
+ To release a new version, update `lib/admin_resources/version.rb` and run:
199
+
200
+ ```bash
201
+ bundle exec rake release
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Contributing
207
+
208
+ Bug reports and pull requests are welcome at https://github.com/doeswork/admin_resources.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,26 @@
1
+ module AdminResources
2
+ class ApplicationController < ActionController::Base
3
+ before_action :authenticate_admin_resources_admin_user!
4
+ layout "admin_resources/admin"
5
+
6
+ helper_method :admin_models, :admin_path_for
7
+
8
+ def admin_models
9
+ AdminResources.model_names
10
+ end
11
+
12
+ def admin_path_for(model_name, action = :index, resource = nil)
13
+ route_name = model_name.underscore.pluralize
14
+ case action
15
+ when :index
16
+ send("admin_resources_#{route_name}_path")
17
+ when :new
18
+ send("new_admin_resources_#{route_name.singularize}_path")
19
+ when :show
20
+ send("admin_resources_#{route_name.singularize}_path", resource)
21
+ when :edit
22
+ send("edit_admin_resources_#{route_name.singularize}_path", resource)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ module AdminResources
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ puts "[AdminResources::DashboardController] index - showing dashboard"
5
+ @model_counts = AdminResources.model_names.each_with_object({}) do |model_name, hash|
6
+ hash[model_name] = model_name.constantize.count
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,127 @@
1
+ module AdminResources
2
+ class ResourcesController < ApplicationController
3
+ before_action :set_model_class
4
+ before_action :set_resource, only: %i[show edit update destroy]
5
+
6
+ helper_method :model_class, :model_name, :index_columns, :form_columns, :admin_value_display
7
+
8
+ def index
9
+ puts "[AdminResources::ResourcesController] index for #{model_name}"
10
+ @resources = model_class.all.order(id: :desc)
11
+ end
12
+
13
+ def show
14
+ puts "[AdminResources::ResourcesController] show #{model_name}##{@resource.id}"
15
+ end
16
+
17
+ def new
18
+ puts "[AdminResources::ResourcesController] new #{model_name}"
19
+ @resource = model_class.new
20
+ end
21
+
22
+ def create
23
+ puts "[AdminResources::ResourcesController] create #{model_name}"
24
+ @resource = model_class.new(resource_params)
25
+ if @resource.save
26
+ redirect_to admin_path_for(model_name, :show, @resource), notice: "#{model_name} was successfully created."
27
+ else
28
+ render :new, status: :unprocessable_entity
29
+ end
30
+ end
31
+
32
+ def edit
33
+ puts "[AdminResources::ResourcesController] edit #{model_name}##{@resource.id}"
34
+ end
35
+
36
+ def update
37
+ puts "[AdminResources::ResourcesController] update #{model_name}##{@resource.id}"
38
+ if @resource.update(resource_params)
39
+ redirect_to admin_path_for(model_name, :show, @resource), notice: "#{model_name} was successfully updated."
40
+ else
41
+ render :edit, status: :unprocessable_entity
42
+ end
43
+ end
44
+
45
+ def destroy
46
+ puts "[AdminResources::ResourcesController] destroy #{model_name}##{@resource.id}"
47
+ @resource.destroy
48
+ redirect_to admin_path_for(model_name, :index), notice: "#{model_name} was successfully deleted."
49
+ end
50
+
51
+ private
52
+
53
+ def set_model_class
54
+ model_param = params[:model]
55
+ unless AdminResources.model_names.include?(model_param)
56
+ raise ActiveRecord::RecordNotFound, "Model '#{model_param}' not registered in AdminResources"
57
+ end
58
+ @model_class = model_param.constantize
59
+ end
60
+
61
+ def set_resource
62
+ @resource = model_class.find(params[:id])
63
+ end
64
+
65
+ def model_class
66
+ @model_class
67
+ end
68
+
69
+ def model_name
70
+ model_class.name
71
+ end
72
+
73
+ def index_columns
74
+ config = AdminResources.models[model_name]
75
+ config&.dig(:columns) || model_class.column_names.first(6)
76
+ end
77
+
78
+ def form_columns
79
+ model_class.column_names - %w[id created_at updated_at]
80
+ end
81
+
82
+ # Returns [display_text, link_path_or_nil]
83
+ def admin_value_display(resource, column)
84
+ unless resource.respond_to?(column)
85
+ return ["[invalid column: #{column}]", nil]
86
+ end
87
+
88
+ value = resource.send(column)
89
+ return [nil, nil] if value.nil?
90
+
91
+ if column.end_with?("_id") && value.present?
92
+ assoc_name = column.sub(/_id$/, "")
93
+ association = resource.class.reflect_on_association(assoc_name.to_sym)
94
+
95
+ if association && association.macro == :belongs_to
96
+ assoc_class = association.klass
97
+ assoc_model = assoc_class.name
98
+
99
+ if AdminResources.model_names.include?(assoc_model)
100
+ associated_record = assoc_class.find_by(id: value)
101
+ if associated_record
102
+ display = "#{assoc_model} ##{value}"
103
+ display = associated_record.name if associated_record.respond_to?(:name) && associated_record.name.present?
104
+ display = associated_record.version if associated_record.respond_to?(:version) && associated_record.version.present?
105
+ display = associated_record.email if associated_record.respond_to?(:email) && associated_record.email.present?
106
+ return [display, admin_path_for(assoc_model, :show, associated_record)]
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ [value, nil]
113
+ end
114
+
115
+ def resource_params
116
+ permitted = form_columns.map do |col|
117
+ column = model_class.columns_hash[col]
118
+ if column&.array?
119
+ { col.to_sym => [] }
120
+ else
121
+ col.to_sym
122
+ end
123
+ end
124
+ params.require(model_class.model_name.param_key).permit(*permitted)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,7 @@
1
+ module AdminResources
2
+ class AdminUser < ApplicationRecord
3
+ self.table_name = "admin_resources_admin_users"
4
+
5
+ devise :database_authenticatable, :recoverable, :rememberable, :validatable
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ <div class="admin-header">
2
+ <h1>Dashboard</h1>
3
+ </div>
4
+
5
+ <div class="admin-grid">
6
+ <% @model_counts.each do |model_name, count| %>
7
+ <div class="admin-card admin-stat">
8
+ <div class="admin-stat-value"><%= count %></div>
9
+ <div class="admin-stat-label"><%= model_name.pluralize %></div>
10
+ <%= link_to "View all", admin_path_for(model_name), class: "admin-btn admin-btn-secondary" %>
11
+ </div>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,54 @@
1
+ <%= form_with model: @resource, url: (@resource.new_record? ? admin_path_for(model_name, :index) : admin_path_for(model_name, :show, @resource)), class: "admin-form" do |form| %>
2
+ <% if @resource.errors.any? %>
3
+ <div class="admin-flash alert">
4
+ <strong><%= pluralize(@resource.errors.count, "error") %> prohibited this <%= model_name.downcase %> from being saved:</strong>
5
+ <ul>
6
+ <% @resource.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <% form_columns.each do |col| %>
14
+ <% column = model_class.columns_hash[col] %>
15
+ <div class="field">
16
+ <%= form.label col %>
17
+ <% case column&.type %>
18
+ <% when :boolean %>
19
+ <%= form.check_box col %>
20
+ <% when :text %>
21
+ <%= form.text_area col, rows: 4 %>
22
+ <% when :integer, :decimal, :float %>
23
+ <%= form.number_field col, step: (column.type == :integer ? 1 : 'any') %>
24
+ <% when :date %>
25
+ <%= form.date_field col %>
26
+ <% when :datetime %>
27
+ <%= form.datetime_local_field col %>
28
+ <% when :json, :jsonb %>
29
+ <%= form.text_area col, rows: 4, value: @resource.send(col)&.to_json %>
30
+ <% else %>
31
+ <% if col.end_with?('_id') %>
32
+ <% assoc_name = col.sub(/_id$/, '').classify %>
33
+ <% assoc_class = assoc_name.safe_constantize %>
34
+ <% if assoc_class && assoc_class < ActiveRecord::Base %>
35
+ <%= form.collection_select col, assoc_class.all, :id, :to_s, include_blank: true %>
36
+ <% else %>
37
+ <%= form.number_field col %>
38
+ <% end %>
39
+ <% elsif col.include?('password') %>
40
+ <%= form.password_field col %>
41
+ <% elsif col.include?('email') %>
42
+ <%= form.email_field col %>
43
+ <% else %>
44
+ <%= form.text_field col %>
45
+ <% end %>
46
+ <% end %>
47
+ </div>
48
+ <% end %>
49
+
50
+ <div class="field">
51
+ <%= form.submit class: "admin-btn admin-btn-primary" %>
52
+ <%= link_to "Cancel", (@resource.new_record? ? admin_path_for(model_name) : admin_path_for(model_name, :show, @resource)), class: "admin-btn admin-btn-secondary" %>
53
+ </div>
54
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <div class="admin-header">
2
+ <h1>Edit <%= model_name %> #<%= @resource.id %></h1>
3
+ </div>
4
+
5
+ <%= render "form" %>
@@ -0,0 +1,38 @@
1
+ <div class="admin-header">
2
+ <h1><%= model_name.pluralize %></h1>
3
+ <%= link_to "New #{model_name}", admin_path_for(model_name, :new), class: "admin-btn admin-btn-primary" %>
4
+ </div>
5
+
6
+ <table class="admin-table">
7
+ <thead>
8
+ <tr>
9
+ <% index_columns.each do |col| %>
10
+ <th><%= col.titleize %></th>
11
+ <% end %>
12
+ <th>Actions</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ <% @resources.each do |resource| %>
17
+ <tr>
18
+ <% index_columns.each do |col| %>
19
+ <td>
20
+ <% display, link = admin_value_display(resource, col) %>
21
+ <% column_type = model_class.columns_hash[col]&.type %>
22
+ <% if %w[params data].include?(col) || column_type == :json || column_type == :jsonb %>
23
+ <pre style="margin: 0; font-size: 0.75rem; max-width: 300px; overflow-x: auto; white-space: pre-wrap;"><%= JSON.pretty_generate(display.is_a?(String) ? JSON.parse(display) : display) rescue display %></pre>
24
+ <% elsif link %>
25
+ <%= link_to truncate(display.to_s, length: 50), link %>
26
+ <% else %>
27
+ <%= truncate(display.to_s, length: 50) %>
28
+ <% end %>
29
+ </td>
30
+ <% end %>
31
+ <td class="admin-actions">
32
+ <%= link_to "View", admin_path_for(model_name, :show, resource), class: "admin-btn admin-btn-secondary" %>
33
+ <%= link_to "Edit", admin_path_for(model_name, :edit, resource), class: "admin-btn admin-btn-secondary" %>
34
+ </td>
35
+ </tr>
36
+ <% end %>
37
+ </tbody>
38
+ </table>
@@ -0,0 +1,5 @@
1
+ <div class="admin-header">
2
+ <h1>New <%= model_name %></h1>
3
+ </div>
4
+
5
+ <%= render "form" %>
@@ -0,0 +1,133 @@
1
+ <div class="admin-header">
2
+ <h1><%= model_name %> #<%= @resource.id %></h1>
3
+ <div class="admin-actions">
4
+ <%= link_to "Edit", admin_path_for(model_name, :edit, @resource), class: "admin-btn admin-btn-primary" %>
5
+ <%= link_to "Back to list", admin_path_for(model_name), class: "admin-btn admin-btn-secondary" %>
6
+ <%= button_to "Delete", admin_path_for(model_name, :show, @resource),
7
+ method: :delete,
8
+ class: "admin-btn admin-btn-danger",
9
+ data: { turbo_confirm: "Are you sure you want to delete this #{model_name.downcase}?" } %>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="admin-card">
14
+ <% model_class.column_names.each do |col| %>
15
+ <div class="admin-detail-row">
16
+ <div class="admin-detail-label"><%= col.titleize %></div>
17
+ <div class="admin-detail-value">
18
+ <% display, link = admin_value_display(@resource, col) %>
19
+ <% column_type = model_class.columns_hash[col]&.type %>
20
+ <% if display.nil? %>
21
+ <em style="color: #999;">nil</em>
22
+ <% elsif %w[params data].include?(col) || column_type == :json || column_type == :jsonb %>
23
+ <pre style="margin: 0; font-size: 0.85rem; background: #f5f5f5; padding: 0.5rem; border-radius: 4px; overflow-x: auto;"><%= JSON.pretty_generate(display) rescue display %></pre>
24
+ <% elsif display.is_a?(Time) || display.is_a?(DateTime) %>
25
+ <%= display.strftime("%Y-%m-%d %H:%M:%S") %>
26
+ <% elsif display.is_a?(TrueClass) || display.is_a?(FalseClass) %>
27
+ <%= display ? "Yes" : "No" %>
28
+ <% elsif link %>
29
+ <%= link_to display, link %>
30
+ <% else %>
31
+ <%= display %>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ <% end %>
36
+ </div>
37
+
38
+ <%# Display has_one associations %>
39
+ <% model_class.reflect_on_all_associations(:has_one).each do |assoc| %>
40
+ <% record = @resource.send(assoc.name) %>
41
+ <% next if record.nil? %>
42
+
43
+ <div style="margin-top: 2rem;">
44
+ <div class="admin-header">
45
+ <h2><%= assoc.name.to_s.titleize %></h2>
46
+ <% if AdminResources.model_names.include?(assoc.klass.name) %>
47
+ <div class="admin-actions">
48
+ <%= link_to "View", admin_path_for(assoc.klass.name, :show, record), class: "admin-btn admin-btn-secondary" %>
49
+ <%= link_to "Edit", admin_path_for(assoc.klass.name, :edit, record), class: "admin-btn admin-btn-secondary" %>
50
+ </div>
51
+ <% end %>
52
+ </div>
53
+
54
+ <div class="admin-card">
55
+ <% assoc.klass.column_names.each do |col| %>
56
+ <div class="admin-detail-row">
57
+ <div class="admin-detail-label"><%= col.titleize %></div>
58
+ <div class="admin-detail-value">
59
+ <% display, link = admin_value_display(record, col) %>
60
+ <% if display.nil? %>
61
+ <em style="color: #999;">nil</em>
62
+ <% elsif display.is_a?(Time) || display.is_a?(DateTime) %>
63
+ <%= display.strftime("%Y-%m-%d %H:%M:%S") %>
64
+ <% elsif display.is_a?(TrueClass) || display.is_a?(FalseClass) %>
65
+ <%= display ? "Yes" : "No" %>
66
+ <% elsif link %>
67
+ <%= link_to display, link %>
68
+ <% else %>
69
+ <%= truncate(display.to_s, length: 100) %>
70
+ <% end %>
71
+ </div>
72
+ </div>
73
+ <% end %>
74
+ </div>
75
+ </div>
76
+ <% end %>
77
+
78
+ <%# Display has_many associations %>
79
+ <% model_class.reflect_on_all_associations(:has_many).each do |assoc| %>
80
+ <% associated_records = @resource.send(assoc.name) %>
81
+ <% next if associated_records.empty? %>
82
+
83
+ <div style="margin-top: 2rem;">
84
+ <div class="admin-header">
85
+ <h2><%= assoc.name.to_s.titleize %> (<%= associated_records.count %>)</h2>
86
+ <% assoc_model = assoc.klass.name %>
87
+ <% if AdminResources.model_names.include?(assoc_model) %>
88
+ <%= link_to "New #{assoc_model}", admin_path_for(assoc_model, :new), class: "admin-btn admin-btn-secondary" %>
89
+ <% end %>
90
+ </div>
91
+
92
+ <table class="admin-table">
93
+ <thead>
94
+ <tr>
95
+ <% assoc.klass.column_names.first(6).each do |col| %>
96
+ <th><%= col.titleize %></th>
97
+ <% end %>
98
+ <th>Actions</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ <% associated_records.limit(20).each do |record| %>
103
+ <tr>
104
+ <% assoc.klass.column_names.first(6).each do |col| %>
105
+ <td>
106
+ <% display, link = admin_value_display(record, col) %>
107
+ <% column_type = assoc.klass.columns_hash[col]&.type %>
108
+ <% if col == "id" && AdminResources.model_names.include?(assoc.klass.name) %>
109
+ <%= link_to record.id, admin_path_for(assoc.klass.name, :show, record) %>
110
+ <% elsif %w[params data].include?(col) || column_type == :json || column_type == :jsonb %>
111
+ <pre style="margin: 0; font-size: 0.75rem; max-width: 300px; overflow-x: auto; white-space: pre-wrap;"><%= JSON.pretty_generate(display) rescue display %></pre>
112
+ <% elsif link %>
113
+ <%= link_to truncate(display.to_s, length: 50), link %>
114
+ <% else %>
115
+ <%= truncate(display.to_s, length: 50) %>
116
+ <% end %>
117
+ </td>
118
+ <% end %>
119
+ <td class="admin-actions">
120
+ <% if AdminResources.model_names.include?(assoc.klass.name) %>
121
+ <%= link_to "View", admin_path_for(assoc.klass.name, :show, record), class: "admin-btn admin-btn-secondary" %>
122
+ <%= link_to "Edit", admin_path_for(assoc.klass.name, :edit, record), class: "admin-btn admin-btn-secondary" %>
123
+ <% end %>
124
+ </td>
125
+ </tr>
126
+ <% end %>
127
+ </tbody>
128
+ </table>
129
+ <% if associated_records.count > 20 %>
130
+ <p style="margin-top: 0.5rem; color: #666;">Showing 20 of <%= associated_records.count %> records</p>
131
+ <% end %>
132
+ </div>
133
+ <% end %>
@@ -0,0 +1,75 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Admin - <%= content_for(:title) || "Admin" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <style>
9
+ .admin-layout { display: flex; min-height: 100vh; }
10
+ .admin-sidebar { width: 220px; background: #1a1a2e; padding: 1rem; }
11
+ .admin-sidebar a { color: #eee; text-decoration: none; display: block; padding: 0.5rem; border-radius: 4px; }
12
+ .admin-sidebar a:hover { background: #16213e; }
13
+ .admin-sidebar a.active { background: #0f3460; }
14
+ .admin-sidebar h2 { color: #fff; font-size: 1.25rem; margin-bottom: 1rem; }
15
+ .admin-sidebar h3 { color: #888; font-size: 0.75rem; text-transform: uppercase; margin: 1rem 0 0.5rem; }
16
+ .admin-main { flex: 1; padding: 2rem; background: #f5f5f5; }
17
+ .admin-header { margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: center; }
18
+ .admin-header h1 { margin: 0; }
19
+ .admin-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
20
+ .admin-table th, .admin-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
21
+ .admin-table th { background: #fafafa; font-weight: 600; }
22
+ .admin-table tr:hover { background: #fafafa; }
23
+ .admin-btn { display: inline-block; padding: 0.5rem 1rem; border-radius: 4px; text-decoration: none; font-size: 0.875rem; cursor: pointer; border: none; }
24
+ .admin-btn-primary { background: #0f3460; color: #fff; }
25
+ .admin-btn-secondary { background: #eee; color: #333; }
26
+ .admin-btn-danger { background: #dc3545; color: #fff; }
27
+ .admin-btn:hover { opacity: 0.9; }
28
+ .admin-form { background: #fff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); max-width: 600px; }
29
+ .admin-form .field { margin-bottom: 1rem; }
30
+ .admin-form label { display: block; margin-bottom: 0.25rem; font-weight: 500; }
31
+ .admin-form input[type="text"], .admin-form input[type="email"], .admin-form input[type="number"], .admin-form input[type="password"], .admin-form textarea, .admin-form select { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
32
+ .admin-form input[type="checkbox"] { width: auto; }
33
+ .admin-flash { padding: 1rem; border-radius: 4px; margin-bottom: 1rem; }
34
+ .admin-flash.notice { background: #d4edda; color: #155724; }
35
+ .admin-flash.alert { background: #f8d7da; color: #721c24; }
36
+ .admin-card { background: #fff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
37
+ .admin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
38
+ .admin-stat { text-align: center; }
39
+ .admin-stat-value { font-size: 2rem; font-weight: bold; color: #0f3460; }
40
+ .admin-stat-label { color: #666; }
41
+ .admin-detail-row { display: flex; border-bottom: 1px solid #eee; padding: 0.75rem 0; }
42
+ .admin-detail-label { width: 200px; font-weight: 500; color: #666; }
43
+ .admin-detail-value { flex: 1; }
44
+ .admin-actions { display: flex; gap: 0.5rem; }
45
+ </style>
46
+ </head>
47
+
48
+ <body>
49
+ <div class="admin-layout">
50
+ <nav class="admin-sidebar">
51
+ <h2><%= link_to "Admin", admin_resources.root_path %></h2>
52
+
53
+ <h3>Resources</h3>
54
+ <% admin_models.each do |model_name| %>
55
+ <%= link_to model_name.pluralize, admin_path_for(model_name),
56
+ class: ('active' if params[:model] == model_name) %>
57
+ <% end %>
58
+
59
+ <h3>Account</h3>
60
+ <%= link_to "Sign Out", admin_resources.destroy_admin_user_session_path, data: { turbo_method: :delete } %>
61
+ </nav>
62
+
63
+ <main class="admin-main">
64
+ <% if notice %>
65
+ <div class="admin-flash notice"><%= notice %></div>
66
+ <% end %>
67
+ <% if alert %>
68
+ <div class="admin-flash alert"><%= alert %></div>
69
+ <% end %>
70
+
71
+ <%= yield %>
72
+ </main>
73
+ </div>
74
+ </body>
75
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,16 @@
1
+ AdminResources::Engine.routes.draw do
2
+ devise_for :admin_users,
3
+ class_name: "AdminResources::AdminUser",
4
+ module: :devise,
5
+ path: "",
6
+ path_names: { sign_in: "login", sign_out: "logout" }
7
+
8
+ # Dynamically generate routes for every registered model
9
+ AdminResources.model_names.each do |model_name|
10
+ resources model_name.underscore.pluralize.to_sym,
11
+ controller: "resources",
12
+ defaults: { model: model_name }
13
+ end
14
+
15
+ root to: "dashboard#index"
16
+ end
@@ -0,0 +1,16 @@
1
+ class CreateAdminResourcesAdminUsers < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :admin_resources_admin_users do |t|
4
+ t.string :email, null: false, default: ""
5
+ t.string :encrypted_password, null: false, default: ""
6
+ t.string :reset_password_token
7
+ t.datetime :reset_password_sent_at
8
+ t.datetime :remember_created_at
9
+
10
+ t.timestamps null: false
11
+ end
12
+
13
+ add_index :admin_resources_admin_users, :email, unique: true
14
+ add_index :admin_resources_admin_users, :reset_password_token, unique: true
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminResources
4
+ class Configuration
5
+ attr_reader :models
6
+
7
+ def initialize
8
+ @models = {}
9
+ end
10
+
11
+ # Register a model for admin management
12
+ # Usage: config.register "User", columns: %w[id email created_at]
13
+ # Usage: config.register "User" (defaults to first 6 columns)
14
+ def register(model_name, columns: nil)
15
+ name = model_name.to_s.classify
16
+ @models[name] = { columns: columns&.map(&:to_s) }
17
+ end
18
+
19
+ def model_names
20
+ @models.keys
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "devise"
5
+
6
+ module AdminResources
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace AdminResources
9
+
10
+ initializer "admin_resources.load_admin_user_migration" do |app|
11
+ # Add our migrations to the host app's migration path
12
+ config.paths["db/migrate"].expanded.each do |expanded_path|
13
+ app.config.paths["db/migrate"] << expanded_path
14
+ end
15
+ end
16
+
17
+ config.generators do |g|
18
+ g.test_framework nil
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminResources
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "admin_resources/version"
4
+ require_relative "admin_resources/configuration"
5
+ require_relative "admin_resources/engine"
6
+
7
+ module AdminResources
8
+ class << self
9
+ def configuration
10
+ @configuration ||= Configuration.new
11
+ end
12
+
13
+ def configure
14
+ yield configuration
15
+ end
16
+
17
+ # Shorthand accessors used throughout the engine
18
+ def models
19
+ configuration.models
20
+ end
21
+
22
+ def model_names
23
+ configuration.model_names
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,4 @@
1
+ module AdminResources
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: admin_resources
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mark rosenberg
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: devise
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
41
+ description: Mount AdminResources::Engine in your Rails app, configure which models
42
+ to expose, and get a full admin dashboard with zero boilerplate.
43
+ email:
44
+ - mark@does.work
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - Rakefile
52
+ - app/controllers/admin_resources/application_controller.rb
53
+ - app/controllers/admin_resources/dashboard_controller.rb
54
+ - app/controllers/admin_resources/resources_controller.rb
55
+ - app/models/admin_resources/admin_user.rb
56
+ - app/views/admin_resources/dashboard/index.html.erb
57
+ - app/views/admin_resources/resources/_form.html.erb
58
+ - app/views/admin_resources/resources/edit.html.erb
59
+ - app/views/admin_resources/resources/index.html.erb
60
+ - app/views/admin_resources/resources/new.html.erb
61
+ - app/views/admin_resources/resources/show.html.erb
62
+ - app/views/layouts/admin_resources/admin.html.erb
63
+ - config/routes.rb
64
+ - db/migrate/20240101000000_create_admin_resources_admin_users.rb
65
+ - lib/admin_resources.rb
66
+ - lib/admin_resources/configuration.rb
67
+ - lib/admin_resources/engine.rb
68
+ - lib/admin_resources/version.rb
69
+ - sig/admin_resources.rbs
70
+ homepage: https://github.com/doeswork/admin_resources
71
+ licenses: []
72
+ metadata: {}
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.0.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.16
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Mountable Rails engine that auto-generates admin CRUD UI for registered models.
92
+ test_files: []