backstage 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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/CONTRIBUTING.md +70 -0
  4. data/LICENSE +21 -0
  5. data/README.md +195 -0
  6. data/app/assets/stylesheets/backstage/backstage.css +5 -0
  7. data/app/controllers/backstage/actions_controller.rb +27 -0
  8. data/app/controllers/backstage/application_controller.rb +21 -0
  9. data/app/controllers/backstage/dashboards_controller.rb +15 -0
  10. data/app/controllers/backstage/home_controller.rb +14 -0
  11. data/app/controllers/backstage/resources_controller.rb +94 -0
  12. data/app/views/backstage/dashboards/show.html.erb +36 -0
  13. data/app/views/backstage/fields/_belongs_to.html.erb +7 -0
  14. data/app/views/backstage/fields/_boolean.html.erb +1 -0
  15. data/app/views/backstage/fields/_date.html.erb +1 -0
  16. data/app/views/backstage/fields/_datetime.html.erb +1 -0
  17. data/app/views/backstage/fields/_enum.html.erb +4 -0
  18. data/app/views/backstage/fields/_has_many.html.erb +19 -0
  19. data/app/views/backstage/fields/_image_url.html.erb +5 -0
  20. data/app/views/backstage/fields/_integer.html.erb +1 -0
  21. data/app/views/backstage/fields/_string.html.erb +1 -0
  22. data/app/views/backstage/fields/_text.html.erb +1 -0
  23. data/app/views/backstage/fields/_thumbnails.html.erb +13 -0
  24. data/app/views/backstage/home/index.html.erb +24 -0
  25. data/app/views/backstage/resources/edit.html.erb +18 -0
  26. data/app/views/backstage/resources/index.html.erb +54 -0
  27. data/app/views/backstage/resources/new.html.erb +13 -0
  28. data/app/views/layouts/backstage/backstage.html.erb +59 -0
  29. data/config/routes.rb +15 -0
  30. data/lib/backstage/association_config.rb +31 -0
  31. data/lib/backstage/auto_discovery.rb +58 -0
  32. data/lib/backstage/configuration.rb +31 -0
  33. data/lib/backstage/dashboard_config.rb +15 -0
  34. data/lib/backstage/engine.rb +11 -0
  35. data/lib/backstage/field.rb +39 -0
  36. data/lib/backstage/registry.rb +32 -0
  37. data/lib/backstage/resource_config.rb +91 -0
  38. data/lib/backstage/sidebar_config.rb +15 -0
  39. data/lib/backstage/version.rb +3 -0
  40. data/lib/backstage.rb +56 -0
  41. data/lib/generators/backstage/install/install_generator.rb +29 -0
  42. data/lib/generators/backstage/install/templates/SKILL.md +221 -0
  43. data/lib/generators/backstage/install/templates/backstage.yml +16 -0
  44. metadata +95 -0
@@ -0,0 +1,54 @@
1
+ <h1><%= @resource_config.model_class.model_name.human.pluralize %></h1>
2
+
3
+ <form method="get" action="">
4
+ <input type="search" name="q" value="<%= params[:q] %>" placeholder="Search…">
5
+ <button type="submit">Search</button>
6
+ </form>
7
+
8
+ <% @resource_config.index_fields.select(&:enum?).each do |field| %>
9
+ <nav class="enum-filters">
10
+ <%= link_to "All", url_for(params.permit!.except(:status, :page)) %>
11
+ <% field.enum_values.each do |label, value| %>
12
+ <%= link_to label, url_for(params.permit!.merge(field.name => value, page: nil)) %>
13
+ <% end %>
14
+ </nav>
15
+ <% end %>
16
+
17
+ <%= link_to "New #{@resource_config.model_class.model_name.human}", new_resource_path(resource: params[:resource]) %>
18
+
19
+ <table>
20
+ <thead>
21
+ <tr>
22
+ <% @resource_config.index_fields.each do |field| %>
23
+ <% col = field.name.to_s
24
+ next_dir = (@sort == col && @dir == "asc") ? "desc" : "asc" %>
25
+ <th><%= link_to field.name.to_s.humanize, url_for(sort: col, dir: next_dir) %></th>
26
+ <% end %>
27
+ <th>Actions</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ <% @records.each do |record| %>
32
+ <tr>
33
+ <% @resource_config.index_fields.each do |field| %>
34
+ <td>
35
+ <% if field.enum? %>
36
+ <%= record.public_send(field.name).to_s.humanize %>
37
+ <% else %>
38
+ <%= record.public_send(field.name) %>
39
+ <% end %>
40
+ </td>
41
+ <% end %>
42
+ <td><%= link_to "Edit", edit_resource_path(resource: params[:resource], id: record.id) %></td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+
48
+ <% if @total_pages > 1 %>
49
+ <nav>
50
+ <% (1..@total_pages).each do |p| %>
51
+ <%= link_to p, url_for(page: p), class: (p == @page ? "current" : nil) %>
52
+ <% end %>
53
+ </nav>
54
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <h1>New <%= @resource_config.model_class.model_name.human %></h1>
2
+
3
+ <%= form_with model: @record, url: resources_path(resource: params[:resource]), method: :post do |f| %>
4
+ <% @resource_config.edit_fields.each do |field| %>
5
+ <div>
6
+ <%= f.label field.name %>
7
+ <%= render partial: field.partial_path, locals: { f: f, field: field, record: @record } %>
8
+ </div>
9
+ <% end %>
10
+ <%= f.submit "Create" %>
11
+ <% end %>
12
+
13
+ <%= link_to "Cancel", resources_path(resource: params[:resource]) %>
@@ -0,0 +1,59 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Backstage</title>
7
+ <%= csrf_meta_tags %>
8
+ <%= stylesheet_link_tag "backstage/backstage", media: "all" %>
9
+ <script>
10
+ document.addEventListener("DOMContentLoaded", function() {
11
+ document.querySelectorAll("[data-confirm-message]").forEach(function(btn) {
12
+ btn.addEventListener("click", function(e) {
13
+ if (!window.confirm(btn.dataset.confirmMessage)) {
14
+ e.preventDefault()
15
+ }
16
+ })
17
+ })
18
+ document.querySelectorAll("[data-multi-select-search]").forEach(function(input) {
19
+ input.addEventListener("input", function() {
20
+ var q = input.value.toLowerCase()
21
+ input.closest("[data-multi-select]")
22
+ .querySelectorAll("[data-multi-select-item]")
23
+ .forEach(function(item) {
24
+ item.hidden = q.length > 0 && !item.textContent.toLowerCase().includes(q)
25
+ })
26
+ })
27
+ })
28
+ })
29
+ </script>
30
+ </head>
31
+ <body>
32
+ <header>
33
+ <%= link_to "Backstage", root_path %>
34
+ </header>
35
+ <nav>
36
+ <ul>
37
+ <% nav_resources.each do |config| %>
38
+ <li><%= link_to config.model_class.model_name.human.pluralize,
39
+ resources_path(resource: config.model_name_param) %></li>
40
+ <% end %>
41
+ </ul>
42
+ </nav>
43
+ <main>
44
+ <%= yield %>
45
+ </main>
46
+ <% sidebar = defined?(@resource_config) && @resource_config&.sidebar_config
47
+ record = defined?(@record) ? @record : nil %>
48
+ <% if sidebar&.links&.any? %>
49
+ <aside>
50
+ <ul>
51
+ <% sidebar.links.each do |link| %>
52
+ <% url = link.url_or_proc.respond_to?(:call) ? link.url_or_proc.call(record) : link.url_or_proc %>
53
+ <li><%= link_to link.label, url %></li>
54
+ <% end %>
55
+ </ul>
56
+ </aside>
57
+ <% end %>
58
+ </body>
59
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,15 @@
1
+ Backstage::Engine.routes.draw do
2
+ root to: "home#index"
3
+
4
+ get "dashboards/:name", to: "dashboards#show", as: :dashboard
5
+
6
+ scope ":resource" do
7
+ get "/", to: "resources#index", as: :resources
8
+ get "/new", to: "resources#new", as: :new_resource
9
+ post "/", to: "resources#create"
10
+ get "/:id/edit", to: "resources#edit", as: :edit_resource
11
+ patch "/:id", to: "resources#update", as: :resource
12
+ delete "/:id", to: "resources#destroy"
13
+ post "/:id/:action_name", to: "actions#create", as: :resource_action
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ module Backstage
2
+ class AssociationConfig
3
+ attr_reader :name, :kind, :options
4
+
5
+ def initialize(name, kind, options = {})
6
+ @name = name.to_sym
7
+ @kind = kind.to_sym
8
+ @options = options
9
+ end
10
+
11
+ def display_column
12
+ options[:display_column] || :id
13
+ end
14
+
15
+ def foreign_key
16
+ options[:foreign_key] || :"#{name}_id"
17
+ end
18
+
19
+ def class_name
20
+ options[:class_name] || name.to_s.classify
21
+ end
22
+
23
+ def associated_class
24
+ class_name.constantize
25
+ end
26
+
27
+ def image_col
28
+ options[:image_col] || :url
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ module Backstage
2
+ class AutoDiscovery
3
+ SYSTEM_COLUMNS = %i[id created_at updated_at].freeze
4
+
5
+ COLUMN_TYPE_MAP = {
6
+ string: :string,
7
+ text: :text,
8
+ integer: :integer,
9
+ decimal: :integer,
10
+ float: :integer,
11
+ boolean: :boolean,
12
+ date: :date,
13
+ datetime: :datetime
14
+ }.freeze
15
+
16
+ def self.build(model_class)
17
+ new(model_class).build
18
+ end
19
+
20
+ def initialize(model_class)
21
+ @model_class = model_class
22
+ end
23
+
24
+ def build
25
+ config = ResourceConfig.new(@model_class)
26
+ config.display_column = detect_display_column
27
+ fields = column_fields + enum_fields
28
+ config.index_fields = fields
29
+ config.edit_fields = fields
30
+ config
31
+ end
32
+
33
+ private
34
+
35
+ def column_fields
36
+ @model_class.columns
37
+ .reject { |col| SYSTEM_COLUMNS.include?(col.name.to_sym) }
38
+ .reject { |col| enum_column_names.include?(col.name.to_sym) }
39
+ .map { |col| Field.new(col.name, COLUMN_TYPE_MAP.fetch(col.type, :string)) }
40
+ end
41
+
42
+ def enum_fields
43
+ @model_class.defined_enums.map do |name, values|
44
+ enum_values = values.keys.map { |k| [k.humanize, k] }
45
+ Field.new(name, :enum, enum_values: enum_values)
46
+ end
47
+ end
48
+
49
+ def enum_column_names
50
+ @enum_column_names ||= @model_class.defined_enums.keys.map(&:to_sym)
51
+ end
52
+
53
+ def detect_display_column
54
+ column_names = @model_class.column_names.map(&:to_sym)
55
+ %i[name title email id].find { |col| column_names.include?(col) }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,31 @@
1
+ require "yaml"
2
+
3
+ module Backstage
4
+ class ConfigurationError < StandardError; end
5
+
6
+ class Configuration
7
+ attr_reader :admin_user_method, :redirect_on_failure, :per_page,
8
+ :model_names, :dashboard_configs
9
+
10
+ def initialize(hash)
11
+ @admin_user_method = (hash["admin_user_method"] || "is_admin?").to_sym
12
+ @redirect_on_failure = hash["redirect_on_failure"] || "/"
13
+ @per_page = hash["per_page"] || 25
14
+ @model_names = hash["models"] || []
15
+ @dashboard_configs = hash["dashboards"] || []
16
+
17
+ validate!
18
+ end
19
+
20
+ private
21
+
22
+ def validate!
23
+ unless @model_names.is_a?(Array)
24
+ raise ConfigurationError, "config/backstage.yml: 'models' must be a list, got #{@model_names.inspect}"
25
+ end
26
+ unless @per_page.is_a?(Integer)
27
+ raise ConfigurationError, "config/backstage.yml: 'per_page' must be an integer, got #{@per_page.inspect}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ module Backstage
2
+ class DashboardConfig
3
+ attr_reader :name, :model_name, :scope
4
+
5
+ def initialize(hash)
6
+ @name = hash["name"]
7
+ @model_name = hash["model"]
8
+ @scope = hash["scope"] || {}
9
+ end
10
+
11
+ def resource_config
12
+ Backstage.registry.resource_for(model_name)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module Backstage
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Backstage
4
+
5
+ initializer "backstage.configuration" do |app|
6
+ app.config.to_prepare do
7
+ Backstage.load_configuration!(app.root)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ module Backstage
2
+ class Field
3
+ attr_reader :name, :type, :options
4
+
5
+ def initialize(name, type, options = {})
6
+ @name = name.to_sym
7
+ @type = type.to_sym
8
+ @options = options
9
+ end
10
+
11
+ def partial_path
12
+ options[:partial] || "backstage/fields/#{type}"
13
+ end
14
+
15
+ def readonly?
16
+ options.fetch(:readonly, false)
17
+ end
18
+
19
+ def enum?
20
+ type == :enum
21
+ end
22
+
23
+ def enum_values
24
+ options[:enum_values] || []
25
+ end
26
+
27
+ def belongs_to?
28
+ type == :belongs_to
29
+ end
30
+
31
+ def has_many?
32
+ type == :has_many
33
+ end
34
+
35
+ def association
36
+ options[:association]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,32 @@
1
+ module Backstage
2
+ class Registry
3
+ def initialize
4
+ @resources = {}
5
+ @dashboards = {}
6
+ end
7
+
8
+ def register(model_name, resource_config)
9
+ @resources[model_name] = resource_config
10
+ end
11
+
12
+ def resource_for(model_name)
13
+ @resources.fetch(model_name) { raise KeyError, "Backstage: no resource registered for '#{model_name}'" }
14
+ end
15
+
16
+ def all_resources
17
+ @resources.values
18
+ end
19
+
20
+ def register_dashboard(dashboard_config)
21
+ @dashboards[dashboard_config.name] = dashboard_config
22
+ end
23
+
24
+ def dashboard_for(name)
25
+ @dashboards.fetch(name) { raise KeyError, "Backstage: no dashboard registered for '#{name}'" }
26
+ end
27
+
28
+ def all_dashboards
29
+ @dashboards.values
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,91 @@
1
+ module Backstage
2
+ class ResourceConfig
3
+ attr_accessor :model_class, :index_fields, :edit_fields,
4
+ :associations, :sidebar_links, :custom_actions, :excluded_columns
5
+ attr_writer :display_column
6
+
7
+ def initialize(model_class)
8
+ @model_class = model_class
9
+ @index_fields = []
10
+ @edit_fields = []
11
+ @associations = []
12
+ @sidebar_links = []
13
+ @custom_actions = []
14
+ @excluded_columns = []
15
+ end
16
+
17
+ def display_column(value = nil)
18
+ value ? @display_column = value.to_sym : @display_column
19
+ end
20
+
21
+ def model_name_param
22
+ model_class.model_name.plural
23
+ end
24
+
25
+ def sidebar(&block)
26
+ @sidebar_config ||= SidebarConfig.new
27
+ block&.call(@sidebar_config)
28
+ @sidebar_config
29
+ end
30
+
31
+ attr_reader :sidebar_config
32
+
33
+ def fields(*names)
34
+ @index_fields = names.map { |n| find_or_build_field(n) }
35
+ end
36
+
37
+ def exclude(*names)
38
+ names.map!(&:to_sym)
39
+ @index_fields = @index_fields.reject { |f| names.include?(f.name) }
40
+ @edit_fields = @edit_fields.reject { |f| names.include?(f.name) }
41
+ end
42
+
43
+ def has_many(name, **opts)
44
+ display_as = opts.delete(:as) || :has_many
45
+ assoc = AssociationConfig.new(name, :has_many, opts)
46
+ @associations << assoc
47
+ if display_as == :thumbnails
48
+ field_obj = Field.new(name.to_sym, :thumbnails, association: assoc, readonly: true)
49
+ @edit_fields.reject! { |f| f.name == field_obj.name }
50
+ @edit_fields << field_obj
51
+ else
52
+ ids_field = Field.new(:"#{name.to_s.singularize}_ids", :has_many, association: assoc)
53
+ @edit_fields.reject! { |f| f.name == ids_field.name }
54
+ @edit_fields << ids_field
55
+ end
56
+ end
57
+
58
+ def belongs_to(name, **opts)
59
+ assoc = AssociationConfig.new(name, :belongs_to, opts)
60
+ @associations << assoc
61
+ fk_field = Field.new(assoc.foreign_key, :belongs_to, association: assoc)
62
+ @edit_fields.reject! { |f| f.name == fk_field.name }
63
+ @index_fields.reject! { |f| f.name == fk_field.name }
64
+ @edit_fields << fk_field
65
+ end
66
+
67
+ def field(name, **opts)
68
+ type = opts.delete(:as) || opts.delete(:type)
69
+ sym = name.to_sym
70
+ existing = find_field(sym)
71
+ if existing
72
+ existing.options.merge!(opts)
73
+ existing.instance_variable_set(:@type, type.to_sym) if type
74
+ else
75
+ new_field = Field.new(sym, type || :string, opts)
76
+ @edit_fields << new_field
77
+ @index_fields << new_field
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def find_field(name)
84
+ (@index_fields + @edit_fields).uniq.find { |f| f.name == name }
85
+ end
86
+
87
+ def find_or_build_field(name)
88
+ find_field(name.to_sym) || Field.new(name.to_sym, :string)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,15 @@
1
+ module Backstage
2
+ class SidebarConfig
3
+ SidebarLink = Struct.new(:label, :url_or_proc)
4
+
5
+ attr_reader :links
6
+
7
+ def initialize
8
+ @links = []
9
+ end
10
+
11
+ def link(label, url_or_proc)
12
+ @links << SidebarLink.new(label, url_or_proc)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module Backstage
2
+ VERSION = "0.1.0"
3
+ end
data/lib/backstage.rb ADDED
@@ -0,0 +1,56 @@
1
+ require "backstage/version"
2
+ require "backstage/configuration"
3
+ require "backstage/field"
4
+ require "backstage/association_config"
5
+ require "backstage/resource_config"
6
+ require "backstage/auto_discovery"
7
+ require "backstage/dashboard_config"
8
+ require "backstage/sidebar_config"
9
+ require "backstage/registry"
10
+ require "backstage/engine"
11
+
12
+ module Backstage
13
+ class << self
14
+ attr_accessor :configuration, :registry
15
+
16
+ def load_configuration!(root)
17
+ path = File.join(root, "config", "backstage.yml")
18
+ hash = File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
19
+ self.configuration = Configuration.new(hash)
20
+
21
+ self.registry = Registry.new
22
+ configuration.model_names.each do |name|
23
+ begin
24
+ model_class = name.constantize
25
+ rescue NameError
26
+ raise ConfigurationError, "config/backstage.yml: unknown model '#{name}'"
27
+ end
28
+ registry.register(name, AutoDiscovery.build(model_class))
29
+ end
30
+
31
+ load_dsl_files!(root)
32
+
33
+ configuration.dashboard_configs.each do |hash|
34
+ registry.register_dashboard(DashboardConfig.new(hash))
35
+ end
36
+
37
+ configuration
38
+ end
39
+
40
+ def resource(model_name)
41
+ name = model_name.to_s
42
+ config = registry.resource_for(name)
43
+ yield config if block_given?
44
+ rescue KeyError
45
+ raise ConfigurationError, "config/backstage/*.rb: no registered model '#{name}'"
46
+ end
47
+
48
+ private
49
+
50
+ def load_dsl_files!(root)
51
+ dir = File.join(root, "config", "backstage")
52
+ return unless File.directory?(dir)
53
+ Dir.glob(File.join(dir, "*.rb")).sort.each { |f| load f }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ require "rails/generators/base"
2
+
3
+ module Backstage
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def copy_config
9
+ copy_file "backstage.yml", "config/backstage.yml"
10
+ end
11
+
12
+ def copy_skill
13
+ copy_file "SKILL.md", ".claude/skills/backstage-install.md"
14
+ end
15
+
16
+ def mount_engine
17
+ route 'mount Backstage::Engine, at: "/admin"'
18
+ end
19
+
20
+ def print_instructions
21
+ say "\nBackstage installed!", :green
22
+ say " 1. Edit config/backstage.yml to list your models"
23
+ say " 2. Wire up current_user in config/initializers/backstage.rb"
24
+ say " 3. Visit /admin"
25
+ say "\n Tip: run /backstage-install in Claude Code for a guided setup walkthrough\n"
26
+ end
27
+ end
28
+ end
29
+ end