ruby_ui_admin 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/MIT-LICENSE +20 -0
- data/README.md +165 -0
- data/app/assets/stylesheets/ruby_ui_admin/application.tailwind.css +111 -0
- data/app/components/ruby_ui_admin/ui/base.rb +26 -0
- data/app/components/ruby_ui_admin/ui/icon.rb +79 -0
- data/app/components/ruby_ui_admin/ui/native_select.rb +37 -0
- data/app/components/ruby_ui_admin/ui/native_select_group.rb +18 -0
- data/app/components/ruby_ui_admin/ui/native_select_icon.rb +42 -0
- data/app/components/ruby_ui_admin/ui/native_select_option.rb +18 -0
- data/app/components/ruby_ui_admin/ui/pagination.rb +62 -0
- data/app/components/ruby_ui_admin/ui/pagination_content.rb +18 -0
- data/app/components/ruby_ui_admin/ui/pagination_ellipsis.rb +45 -0
- data/app/components/ruby_ui_admin/ui/pagination_item.rb +32 -0
- data/app/components/ruby_ui_admin/ui/select.rb +29 -0
- data/app/components/ruby_ui_admin/ui/toast_action.rb +30 -0
- data/app/components/ruby_ui_admin/ui/toast_cancel.rb +30 -0
- data/app/components/ruby_ui_admin/ui/toast_close.rb +43 -0
- data/app/components/ruby_ui_admin/ui/toast_description.rb +21 -0
- data/app/components/ruby_ui_admin/ui/toast_icon.rb +69 -0
- data/app/components/ruby_ui_admin/ui/toast_item.rb +75 -0
- data/app/components/ruby_ui_admin/ui/toast_region.rb +147 -0
- data/app/components/ruby_ui_admin/ui/toast_title.rb +21 -0
- data/app/components/ruby_ui_admin/ui.rb +12 -0
- data/app/components/ruby_ui_admin/views/action.rb +37 -0
- data/app/components/ruby_ui_admin/views/action_form.rb +104 -0
- data/app/components/ruby_ui_admin/views/action_trigger.rb +148 -0
- data/app/components/ruby_ui_admin/views/actions_menu.rb +34 -0
- data/app/components/ruby_ui_admin/views/base.rb +301 -0
- data/app/components/ruby_ui_admin/views/card_component.rb +69 -0
- data/app/components/ruby_ui_admin/views/dashboard.rb +40 -0
- data/app/components/ruby_ui_admin/views/docs.rb +91 -0
- data/app/components/ruby_ui_admin/views/field_input.rb +156 -0
- data/app/components/ruby_ui_admin/views/field_value.rb +301 -0
- data/app/components/ruby_ui_admin/views/filter_bar.rb +111 -0
- data/app/components/ruby_ui_admin/views/form.rb +151 -0
- data/app/components/ruby_ui_admin/views/home.rb +35 -0
- data/app/components/ruby_ui_admin/views/index.rb +324 -0
- data/app/components/ruby_ui_admin/views/path_helpers.rb +36 -0
- data/app/components/ruby_ui_admin/views/rails_helpers.rb +15 -0
- data/app/components/ruby_ui_admin/views/show.rb +77 -0
- data/app/components/ruby_ui_admin/views/show_fields.rb +37 -0
- data/app/components/ruby_ui_admin/views/show_tab.rb +58 -0
- data/app/components/ruby_ui_admin/views/structure_renderer.rb +148 -0
- data/app/components/ruby_ui_admin/views/translation.rb +12 -0
- data/app/controllers/ruby_ui_admin/actions_controller.rb +162 -0
- data/app/controllers/ruby_ui_admin/application_controller.rb +81 -0
- data/app/controllers/ruby_ui_admin/dashboards_controller.rb +12 -0
- data/app/controllers/ruby_ui_admin/docs_controller.rb +166 -0
- data/app/controllers/ruby_ui_admin/home_controller.rb +18 -0
- data/app/controllers/ruby_ui_admin/resources_controller.rb +259 -0
- data/config/locales/ruby_ui_admin.en.yml +59 -0
- data/config/locales/ruby_ui_admin.pt-BR.yml +59 -0
- data/config/routes/dynamic_routes.rb +11 -0
- data/config/routes.rb +23 -0
- data/docs/README.md +53 -0
- data/docs/actions/overview.md +112 -0
- data/docs/authorization/action-policy.md +72 -0
- data/docs/authorization/adapters.md +101 -0
- data/docs/authorization/explicit-mode.md +45 -0
- data/docs/authorization/field-authorization.md +53 -0
- data/docs/authorization/policies.md +62 -0
- data/docs/authorization/scopes.md +48 -0
- data/docs/customization/controllers.md +62 -0
- data/docs/customization/ejecting.md +55 -0
- data/docs/customization/javascript.md +161 -0
- data/docs/customization/theming-rubyui.md +80 -0
- data/docs/dashboards/overview.md +65 -0
- data/docs/fields/overview.md +94 -0
- data/docs/filters/overview.md +73 -0
- data/docs/generators-and-tasks/overview.md +42 -0
- data/docs/getting-started/authentication.md +98 -0
- data/docs/getting-started/configuration.md +174 -0
- data/docs/getting-started/installation.md +201 -0
- data/docs/getting-started/internationalization.md +50 -0
- data/docs/getting-started/practical-guide.md +310 -0
- data/docs/resources/associations.md +66 -0
- data/docs/resources/index-customization.md +68 -0
- data/docs/resources/overview.md +50 -0
- data/docs/resources/scopes.md +54 -0
- data/docs/resources/tabs-panels.md +65 -0
- data/lib/generators/ruby_ui_admin/action/action_generator.rb +21 -0
- data/lib/generators/ruby_ui_admin/action/templates/action.rb.tt +24 -0
- data/lib/generators/ruby_ui_admin/assets/assets_generator.rb +71 -0
- data/lib/generators/ruby_ui_admin/card/card_generator.rb +28 -0
- data/lib/generators/ruby_ui_admin/card/templates/chart_card.rb.tt +14 -0
- data/lib/generators/ruby_ui_admin/card/templates/metric_card.rb.tt +14 -0
- data/lib/generators/ruby_ui_admin/card/templates/partial_card.rb.tt +13 -0
- data/lib/generators/ruby_ui_admin/components/components_generator.rb +43 -0
- data/lib/generators/ruby_ui_admin/controller/controller_generator.rb +30 -0
- data/lib/generators/ruby_ui_admin/controller/templates/controller.rb.tt +24 -0
- data/lib/generators/ruby_ui_admin/dashboard/dashboard_generator.rb +18 -0
- data/lib/generators/ruby_ui_admin/dashboard/templates/dashboard.rb.tt +14 -0
- data/lib/generators/ruby_ui_admin/eject/eject_generator.rb +55 -0
- data/lib/generators/ruby_ui_admin/filter/filter_generator.rb +28 -0
- data/lib/generators/ruby_ui_admin/filter/templates/boolean_filter.rb.tt +13 -0
- data/lib/generators/ruby_ui_admin/filter/templates/select_filter.rb.tt +19 -0
- data/lib/generators/ruby_ui_admin/filter/templates/text_filter.rb.tt +13 -0
- data/lib/generators/ruby_ui_admin/install/install_generator.rb +62 -0
- data/lib/generators/ruby_ui_admin/install/templates/initializer.rb.tt +41 -0
- data/lib/generators/ruby_ui_admin/locales/locales_generator.rb +22 -0
- data/lib/generators/ruby_ui_admin/policy/policy_generator.rb +18 -0
- data/lib/generators/ruby_ui_admin/policy/templates/policy.rb.tt +24 -0
- data/lib/generators/ruby_ui_admin/resource/resource_generator.rb +84 -0
- data/lib/generators/ruby_ui_admin/resource/templates/resource.rb.tt +15 -0
- data/lib/generators/ruby_ui_admin/scope/scope_generator.rb +18 -0
- data/lib/generators/ruby_ui_admin/scope/templates/scope.rb.tt +11 -0
- data/lib/ruby_ui_admin/authorization/action_policy_adapter.rb +69 -0
- data/lib/ruby_ui_admin/authorization/adapter.rb +80 -0
- data/lib/ruby_ui_admin/authorization/can_can_can_adapter.rb +70 -0
- data/lib/ruby_ui_admin/authorization/pundit_adapter.rb +86 -0
- data/lib/ruby_ui_admin/authorization.rb +40 -0
- data/lib/ruby_ui_admin/base_action.rb +211 -0
- data/lib/ruby_ui_admin/base_dashboard.rb +70 -0
- data/lib/ruby_ui_admin/base_policy.rb +38 -0
- data/lib/ruby_ui_admin/base_resource.rb +503 -0
- data/lib/ruby_ui_admin/cards/base_card.rb +59 -0
- data/lib/ruby_ui_admin/cards/chart_card.rb +16 -0
- data/lib/ruby_ui_admin/cards/metric_card.rb +22 -0
- data/lib/ruby_ui_admin/cards/partial_card.rb +14 -0
- data/lib/ruby_ui_admin/configuration.rb +177 -0
- data/lib/ruby_ui_admin/current.rb +20 -0
- data/lib/ruby_ui_admin/dashboard_manager.rb +52 -0
- data/lib/ruby_ui_admin/engine.rb +80 -0
- data/lib/ruby_ui_admin/execution_context.rb +36 -0
- data/lib/ruby_ui_admin/fields/association_field.rb +67 -0
- data/lib/ruby_ui_admin/fields/badge_field.rb +19 -0
- data/lib/ruby_ui_admin/fields/base_field.rb +208 -0
- data/lib/ruby_ui_admin/fields/belongs_to_field.rb +49 -0
- data/lib/ruby_ui_admin/fields/boolean_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/boolean_group_field.rb +54 -0
- data/lib/ruby_ui_admin/fields/code_field.rb +18 -0
- data/lib/ruby_ui_admin/fields/date_field.rb +34 -0
- data/lib/ruby_ui_admin/fields/date_time_field.rb +37 -0
- data/lib/ruby_ui_admin/fields/field_manager.rb +27 -0
- data/lib/ruby_ui_admin/fields/file_field.rb +67 -0
- data/lib/ruby_ui_admin/fields/files_field.rb +62 -0
- data/lib/ruby_ui_admin/fields/has_and_belongs_to_many_field.rb +9 -0
- data/lib/ruby_ui_admin/fields/has_many_field.rb +24 -0
- data/lib/ruby_ui_admin/fields/has_one_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/hidden_field.rb +14 -0
- data/lib/ruby_ui_admin/fields/id_field.rb +19 -0
- data/lib/ruby_ui_admin/fields/key_value_field.rb +41 -0
- data/lib/ruby_ui_admin/fields/number_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/password_field.rb +21 -0
- data/lib/ruby_ui_admin/fields/record_link_field.rb +45 -0
- data/lib/ruby_ui_admin/fields/select_field.rb +71 -0
- data/lib/ruby_ui_admin/fields/status_field.rb +21 -0
- data/lib/ruby_ui_admin/fields/text_field.rb +14 -0
- data/lib/ruby_ui_admin/fields/textarea_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/url_field.rb +21 -0
- data/lib/ruby_ui_admin/filters/base_filter.rb +81 -0
- data/lib/ruby_ui_admin/filters/boolean_filter.rb +9 -0
- data/lib/ruby_ui_admin/filters/multiple_select_filter.rb +11 -0
- data/lib/ruby_ui_admin/filters/select_filter.rb +9 -0
- data/lib/ruby_ui_admin/filters/text_filter.rb +9 -0
- data/lib/ruby_ui_admin/menu/builder.rb +94 -0
- data/lib/ruby_ui_admin/resource_manager.rb +82 -0
- data/lib/ruby_ui_admin/scopes/base_scope.rb +107 -0
- data/lib/ruby_ui_admin/services/authorization_service.rb +38 -0
- data/lib/ruby_ui_admin/structure.rb +40 -0
- data/lib/ruby_ui_admin/tailwind_source.rb +65 -0
- data/lib/ruby_ui_admin/version.rb +5 -0
- data/lib/ruby_ui_admin/view.rb +34 -0
- data/lib/ruby_ui_admin.rb +110 -0
- data/lib/tasks/ruby_ui_admin.rake +71 -0
- data/public/ruby-ui-admin-assets/application.css +2 -0
- data/public/ruby-ui-admin-assets/controllers/index.js +36 -0
- data/public/ruby-ui-admin-assets/controllers/rua--bulk-select_controller.js +19 -0
- data/public/ruby-ui-admin-assets/controllers/rua--confirm_controller.js +51 -0
- data/public/ruby-ui-admin-assets/controllers/rua--dialog_controller.js +55 -0
- data/public/ruby-ui-admin-assets/controllers/rua--row-link_controller.js +16 -0
- data/public/ruby-ui-admin-assets/controllers/rua--tabs_controller.js +36 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--combobox_controller.js +197 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_content_controller.js +7 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_controller.js +15 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--sidebar_controller.js +67 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--toast_controller.js +151 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--toaster_controller.js +314 -0
- data/public/ruby-ui-admin-assets/vendor/stimulus.js +2567 -0
- data/public/ruby-ui-admin-assets/vendor/turbo.js +7178 -0
- metadata +379 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIAdmin
|
|
4
|
+
module Views
|
|
5
|
+
# An action button that opens an inline modal with the action form. Without JS the
|
|
6
|
+
# button is a plain link to the action page (Views::Action), so it always works.
|
|
7
|
+
class ActionTrigger < Phlex::HTML
|
|
8
|
+
include RubyUIAdmin::UI
|
|
9
|
+
include RailsHelpers
|
|
10
|
+
include PathHelpers
|
|
11
|
+
|
|
12
|
+
# `bulk: true` renders a submit button tied (via the HTML `form` attribute) to the
|
|
13
|
+
# bulk-selection form `form_id`, so without JS it submits the checked rows to the
|
|
14
|
+
# action page. With JS the checked ids are injected into the modal form instead.
|
|
15
|
+
# `part:` controls what is rendered — `:both` (default), `:trigger` (just the button/link)
|
|
16
|
+
# or `:dialog` (just the modal). When triggers live inside the actions Combobox, the dialogs
|
|
17
|
+
# are rendered separately (outside the popover) so closing it doesn't hide the open modal.
|
|
18
|
+
# `as_menu_item: true` styles the trigger to fill a Combobox item row.
|
|
19
|
+
def initialize(resource:, action:, record_ids: [], bulk: false, form_id: nil, part: :both, as_menu_item: false)
|
|
20
|
+
@resource = resource
|
|
21
|
+
@action = action
|
|
22
|
+
@record_ids = record_ids
|
|
23
|
+
@bulk = bulk
|
|
24
|
+
@form_id = form_id
|
|
25
|
+
@part = part
|
|
26
|
+
@as_menu_item = as_menu_item
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def view_template
|
|
30
|
+
render_trigger unless @part == :dialog
|
|
31
|
+
render_dialog unless @part == :trigger
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def render_trigger
|
|
37
|
+
if @bulk
|
|
38
|
+
button(
|
|
39
|
+
type: "submit",
|
|
40
|
+
form: @form_id,
|
|
41
|
+
formaction: page_path,
|
|
42
|
+
formmethod: "get",
|
|
43
|
+
data: trigger_data,
|
|
44
|
+
class: trigger_classes
|
|
45
|
+
) { @action.name }
|
|
46
|
+
else
|
|
47
|
+
a(href: action_path, data: trigger_data, class: trigger_classes) { @action.name }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Data attributes for the trigger. The `rua--dialog#open` Stimulus action opens the lazy
|
|
52
|
+
# modal (passing the dialog id / bulk flag as action params). Inside the actions Combobox
|
|
53
|
+
# the item is also a combobox `input` target (so the search box filters it) and closes the
|
|
54
|
+
# popover on click — both actions chain on `click`.
|
|
55
|
+
def trigger_data
|
|
56
|
+
actions = ["click->rua--dialog#open"]
|
|
57
|
+
data = {rua__dialog_id_param: dialog_id}
|
|
58
|
+
# String "true" (not boolean) so Phlex renders `...-bulk-param="true"` and Stimulus
|
|
59
|
+
# type-casts the action param back to boolean `true` (a boolean would render an empty
|
|
60
|
+
# attribute, which Stimulus reads as the string "").
|
|
61
|
+
data[:rua__dialog_bulk_param] = "true" if @bulk
|
|
62
|
+
if @as_menu_item
|
|
63
|
+
data[:ruby_ui__combobox_target] = "input"
|
|
64
|
+
actions << "click->ruby-ui--combobox#closePopover"
|
|
65
|
+
end
|
|
66
|
+
data[:action] = actions.join(" ")
|
|
67
|
+
data
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Inside the actions Combobox the trigger fills the item row (the surrounding ComboboxItem
|
|
71
|
+
# supplies the hover/padding). Standalone it is a normal outline button.
|
|
72
|
+
def trigger_classes
|
|
73
|
+
if @as_menu_item
|
|
74
|
+
"block w-full whitespace-nowrap text-left text-sm"
|
|
75
|
+
else
|
|
76
|
+
"inline-flex items-center h-9 px-4 rounded-md border border-input bg-background text-sm font-medium shadow-sm hover:bg-accent"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def action_key
|
|
81
|
+
@action.class.action_key
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dialog_id
|
|
85
|
+
"rua-action-#{action_key}-#{@bulk ? "bulk" : (@record_ids.first || "all")}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def page_path
|
|
89
|
+
ruby_ui_admin.resource_action_path(resource_name: @resource.route_key, action_id: action_key)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def action_path
|
|
93
|
+
ids = Array(@record_ids).map { |id| "record_ids[]=#{id}" }
|
|
94
|
+
ids.any? ? "#{page_path}?#{ids.join("&")}" : page_path
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def frame_id
|
|
98
|
+
"#{dialog_id}-frame"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Fragment URL the modal's `<turbo-frame>` loads (so the action's `fields` are only
|
|
102
|
+
# evaluated when the modal opens). For non-bulk it's a static `src` the frame loads
|
|
103
|
+
# lazily on open; for bulk the live selection isn't known server-side, so the controller
|
|
104
|
+
# builds the `src` from this base + the checked ids when the modal opens.
|
|
105
|
+
def frame_src_base
|
|
106
|
+
return "#{page_path}?fragment=1&frame_id=#{frame_id}" if @bulk
|
|
107
|
+
|
|
108
|
+
sep = action_path.include?("?") ? "&" : "?"
|
|
109
|
+
"#{action_path}#{sep}fragment=1&frame_id=#{frame_id}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The form is loaded lazily inside a `<turbo-frame>` when the modal opens: non-bulk frames
|
|
113
|
+
# have a `src` + `loading="lazy"` (Turbo loads them when the dialog becomes visible); bulk
|
|
114
|
+
# frames get their `src` set by `rua--dialog#open` from the live selection. Without JS the
|
|
115
|
+
# trigger is a link/submit to the full action page, so it still works.
|
|
116
|
+
def render_dialog
|
|
117
|
+
div(data: {rua_dialog: dialog_id}, hidden: true, class: "fixed inset-0 z-50 flex items-center justify-center p-4") do
|
|
118
|
+
div(data: {action: "click->rua--dialog#close"}, class: "absolute inset-0 bg-black/50")
|
|
119
|
+
|
|
120
|
+
div(class: "relative z-10 w-full max-w-lg rounded-xl border border-border bg-background shadow-lg") do
|
|
121
|
+
div(class: "flex items-center justify-between p-6 pb-2") do
|
|
122
|
+
h2(class: "text-lg font-semibold") { @action.name }
|
|
123
|
+
button(type: "button", data: {action: "click->rua--dialog#close"}, class: "text-muted-foreground hover:text-foreground") { "✕" }
|
|
124
|
+
end
|
|
125
|
+
div(class: "p-6 pt-2") do
|
|
126
|
+
render_dialog_frame
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def render_dialog_frame
|
|
133
|
+
attrs = {id: frame_id}
|
|
134
|
+
if @bulk
|
|
135
|
+
# No static src — the controller sets it from the checked rows on open.
|
|
136
|
+
attrs[:data] = {rua_frame_base: frame_src_base}
|
|
137
|
+
else
|
|
138
|
+
attrs[:src] = frame_src_base
|
|
139
|
+
attrs[:loading] = "lazy"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
tag(:turbo_frame, **attrs) do
|
|
143
|
+
p(class: "text-sm text-muted-foreground") { "…" }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIAdmin
|
|
4
|
+
module Views
|
|
5
|
+
# Shared by Index and Show: collapses a set of action triggers into RubyUI's Combobox
|
|
6
|
+
# (searchable popover) so the actions never wrap/sprawl across the header.
|
|
7
|
+
module ActionsMenu
|
|
8
|
+
# Each action is a Combobox item (clickable, opens its modal and closes the popover). The
|
|
9
|
+
# modals are rendered after the combobox (outside the popover) so closing it doesn't hide
|
|
10
|
+
# an open modal. `bulk:` ties triggers to the bulk-selection form (`form_id`); `record_ids:`
|
|
11
|
+
# scopes them to specific records (the single-record show view).
|
|
12
|
+
def render_actions_menu(actions, bulk: false, form_id: nil, record_ids: [])
|
|
13
|
+
render RubyUI::Combobox.new do
|
|
14
|
+
render RubyUI::ComboboxTrigger.new(placeholder: rua_t("index.actions"))
|
|
15
|
+
render RubyUI::ComboboxPopover.new do
|
|
16
|
+
render RubyUI::ComboboxSearchInput.new(placeholder: rua_t("index.search"))
|
|
17
|
+
render RubyUI::ComboboxList.new do
|
|
18
|
+
render(RubyUI::ComboboxEmptyState.new) { rua_t("index.no_results") }
|
|
19
|
+
actions.each do |action|
|
|
20
|
+
render RubyUI::ComboboxItem.new do
|
|
21
|
+
render RubyUIAdmin::Views::ActionTrigger.new(resource: @resource, action: action, record_ids: record_ids, bulk: bulk, form_id: form_id, part: :trigger, as_menu_item: true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
actions.each do |action|
|
|
29
|
+
render RubyUIAdmin::Views::ActionTrigger.new(resource: @resource, action: action, record_ids: record_ids, bulk: bulk, form_id: form_id, part: :dialog)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIAdmin
|
|
4
|
+
module Views
|
|
5
|
+
# Full-page layout/chrome for every admin screen. Subclasses implement `content`.
|
|
6
|
+
class Base < Phlex::HTML
|
|
7
|
+
include RubyUIAdmin::UI
|
|
8
|
+
include RailsHelpers
|
|
9
|
+
include PathHelpers
|
|
10
|
+
include Translation
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
doctype
|
|
14
|
+
# `overflow-y: auto` keeps the admin scrollable even when the host's global CSS locks page
|
|
15
|
+
# scroll (e.g. `html, body { overflow: hidden }` for a fixed-viewport app shell) — the admin
|
|
16
|
+
# renders its own document, so it can't rely on the host layout's own scroll override.
|
|
17
|
+
html(lang: "en", style: "overflow-y: auto") do
|
|
18
|
+
head do
|
|
19
|
+
meta(charset: "utf-8")
|
|
20
|
+
meta(name: "viewport", content: "width=device-width, initial-scale=1")
|
|
21
|
+
title { page_title }
|
|
22
|
+
render_head_assets
|
|
23
|
+
end
|
|
24
|
+
# Turbo Drive is on (self-hosted, see render_head_assets) for SPA-style navigation — no
|
|
25
|
+
# full-page reloads. `rua--confirm` manages the shared delete-confirmation AlertDialog.
|
|
26
|
+
body(class: "min-h-screen bg-background text-foreground antialiased", data: {controller: "rua--confirm"}) do
|
|
27
|
+
render RubyUI::SidebarWrapper.new do
|
|
28
|
+
render_sidebar
|
|
29
|
+
render RubyUI::SidebarInset.new do
|
|
30
|
+
render_topbar
|
|
31
|
+
main(class: "flex-1 min-w-0 p-6 lg:p-8") do
|
|
32
|
+
# `rua--dialog` manages the lazy action modals rendered anywhere in the page body.
|
|
33
|
+
div(data: {controller: "rua--dialog"}, class: "w-full max-w-7xl mx-auto") { content }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
render_toaster
|
|
38
|
+
render_confirm_dialog
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Overridden by subclasses.
|
|
44
|
+
def content; end
|
|
45
|
+
|
|
46
|
+
# Whether the current user is allowed to perform `rule` on `record`.
|
|
47
|
+
def authorized_to?(rule, record, policy_class: nil)
|
|
48
|
+
RubyUIAdmin::Services::AuthorizationService
|
|
49
|
+
.new(RubyUIAdmin::Current.user, record, policy_class: policy_class)
|
|
50
|
+
.allowed?(rule)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def page_title
|
|
56
|
+
RubyUIAdmin.configuration.app_name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def navigation_resources
|
|
60
|
+
RubyUIAdmin.resource_manager.navigation_resources
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Top bar inside the inset: holds the sidebar trigger (toggles the desktop icon-rail
|
|
64
|
+
# collapse and opens the mobile drawer). Kept minimal — no app-name/user chrome.
|
|
65
|
+
def render_topbar
|
|
66
|
+
header(class: "sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4") do
|
|
67
|
+
render RubyUI::SidebarTrigger.new(class: "-ml-1")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The RubyUI Sidebar: fixed/expanded by default, collapses to an icon rail on user
|
|
72
|
+
# action (persisted via cookie), and becomes a drawer on mobile (<768px).
|
|
73
|
+
def render_sidebar
|
|
74
|
+
render RubyUI::Sidebar.new(collapsible: :icon, open: sidebar_open?) do
|
|
75
|
+
render RubyUI::SidebarHeader.new do
|
|
76
|
+
div(class: "flex items-center gap-2 px-2 py-1.5 font-semibold") do
|
|
77
|
+
render RubyUIAdmin::UI::Icon.new(:layout_dashboard, class: "size-5 shrink-0")
|
|
78
|
+
span(class: "truncate group-data-[collapsible=icon]:hidden") { RubyUIAdmin.configuration.app_name }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
render RubyUI::SidebarContent.new do
|
|
83
|
+
if (menu = main_menu_items)
|
|
84
|
+
render_menu(menu)
|
|
85
|
+
else
|
|
86
|
+
render_auto_menu
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
render_sidebar_footer
|
|
91
|
+
|
|
92
|
+
render RubyUI::SidebarRail.new
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Bottom of the sidebar: current user label + a sign-out item. Rendered only when
|
|
97
|
+
# `config.sign_out_path_name` is set (resolved via the host's `main_app` routes).
|
|
98
|
+
def render_sidebar_footer
|
|
99
|
+
path_name = RubyUIAdmin.configuration.sign_out_path_name
|
|
100
|
+
return if path_name.blank?
|
|
101
|
+
|
|
102
|
+
render RubyUI::SidebarFooter.new do
|
|
103
|
+
render RubyUI::SidebarMenu.new do
|
|
104
|
+
if (label = current_user_label)
|
|
105
|
+
render RubyUI::SidebarMenuItem.new do
|
|
106
|
+
div(class: "flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground truncate group-data-[collapsible=icon]:hidden") { plain label }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
render RubyUI::SidebarMenuItem.new do
|
|
111
|
+
render_sign_out_button(path_name)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render_sign_out_button(path_name)
|
|
118
|
+
action = main_app.public_send(path_name)
|
|
119
|
+
method = (RubyUIAdmin.configuration.sign_out_method || :delete).to_s
|
|
120
|
+
|
|
121
|
+
form(action: action, method: "post", class: "w-full") do
|
|
122
|
+
input(type: "hidden", name: "_method", value: method)
|
|
123
|
+
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
|
|
124
|
+
render RubyUI::SidebarMenuButton.new(as: :button, type: "submit", title: rua_t("nav.sign_out")) do
|
|
125
|
+
render RubyUIAdmin::UI::Icon.new(:log_out, class: "size-4 shrink-0")
|
|
126
|
+
span(class: "truncate") { rua_t("nav.sign_out") }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Best-effort label for the signed-in user (email → name → to_s).
|
|
132
|
+
def current_user_label
|
|
133
|
+
u = RubyUIAdmin::Current.user
|
|
134
|
+
return nil unless u
|
|
135
|
+
|
|
136
|
+
(u.respond_to?(:email) && u.email.presence) ||
|
|
137
|
+
(u.respond_to?(:name) && u.name.presence) ||
|
|
138
|
+
u.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Reads the persisted collapse state (set client-side by the sidebar controller) so
|
|
142
|
+
# the server renders the same state and there's no flash on navigation.
|
|
143
|
+
def sidebar_open?
|
|
144
|
+
request.cookies["sidebar_state"] != "false"
|
|
145
|
+
rescue
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The curated menu tree, or nil when no `config.main_menu` is set (auto navigation).
|
|
150
|
+
def main_menu_items
|
|
151
|
+
block = RubyUIAdmin.configuration.main_menu
|
|
152
|
+
return nil unless block
|
|
153
|
+
|
|
154
|
+
RubyUIAdmin::Menu::Builder.build(&block)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Curated menu: top-level leaves go into one group; each section becomes a group.
|
|
158
|
+
def render_menu(items)
|
|
159
|
+
leaves, sections = items.partition { |item| item.type != :section }
|
|
160
|
+
render_leaf_group(leaves) if leaves.any?
|
|
161
|
+
sections.each { |section| render_menu_section(section) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def render_menu_section(section)
|
|
165
|
+
render RubyUI::SidebarGroup.new do
|
|
166
|
+
if section.label.present?
|
|
167
|
+
render RubyUI::SidebarGroupLabel.new do
|
|
168
|
+
render_menu_icon(section.icon)
|
|
169
|
+
plain section.label.to_s
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
leaves = section.items.reject { |item| item.type == :section }
|
|
173
|
+
render RubyUI::SidebarMenu.new do
|
|
174
|
+
leaves.each { |item| render_menu_leaf(item) }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
# Nested sections (rare) render as their own sibling groups.
|
|
178
|
+
section.items.select { |item| item.type == :section }.each { |nested| render_menu_section(nested) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Auto navigation (no curated menu): a dashboards group (if any) + a resources group.
|
|
182
|
+
def render_auto_menu
|
|
183
|
+
dashboards = RubyUIAdmin.dashboard_manager.dashboards
|
|
184
|
+
render_leaf_group(dashboards.map { |d| RubyUIAdmin::Menu::DashboardItem.new(dashboard: d) }) if dashboards.any?
|
|
185
|
+
render_leaf_group(navigation_resources.map { |rc| RubyUIAdmin::Menu::ResourceItem.new(resource: rc) })
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_leaf_group(leaves)
|
|
189
|
+
render RubyUI::SidebarGroup.new do
|
|
190
|
+
render RubyUI::SidebarMenu.new do
|
|
191
|
+
leaves.each { |item| render_menu_leaf(item) }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_menu_leaf(item)
|
|
197
|
+
href, label, icon = menu_leaf_attrs(item)
|
|
198
|
+
return if href.nil?
|
|
199
|
+
|
|
200
|
+
# Resource items stay active across the whole resource (index/show/new/edit/actions),
|
|
201
|
+
# so the index path is treated as a prefix. Dashboards/links match the exact path only.
|
|
202
|
+
active = current_path?(href, prefix: item.type == :resource)
|
|
203
|
+
|
|
204
|
+
render RubyUI::SidebarMenuItem.new do
|
|
205
|
+
render RubyUI::SidebarMenuButton.new(as: :a, href: href, title: label.to_s, active: active) do
|
|
206
|
+
render RubyUIAdmin::UI::Icon.new(icon, class: "size-4 shrink-0")
|
|
207
|
+
span(class: "truncate") { plain label.to_s }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Returns [href, label, icon] for a leaf menu item, or nil href if unresolvable.
|
|
213
|
+
def menu_leaf_attrs(item)
|
|
214
|
+
case item.type
|
|
215
|
+
when :resource
|
|
216
|
+
[resource_index_path(item.resource), item.label || item.resource.navigation_label, :circle]
|
|
217
|
+
when :dashboard
|
|
218
|
+
[ruby_ui_admin.dashboard_path(dashboard_id: item.dashboard.id), item.label || item.dashboard.title, :layout_dashboard]
|
|
219
|
+
when :link
|
|
220
|
+
[item.path, item.label, :link]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Exact path match, or (when `prefix:`) any path nested under it — so a resource's nav
|
|
225
|
+
# entry stays active on its show/new/edit/action pages, not just the index.
|
|
226
|
+
def current_path?(href, prefix: false)
|
|
227
|
+
path = href.to_s.split("?").first
|
|
228
|
+
return false if path.nil? || path.empty?
|
|
229
|
+
return true if request.path == path
|
|
230
|
+
|
|
231
|
+
prefix && request.path.start_with?("#{path}/")
|
|
232
|
+
rescue
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Renders a menu section icon. A raw SVG/HTML string is rendered as-is; named icons
|
|
237
|
+
# (e.g. "heroicons/outline/table-cells") need a host-provided SVG and are skipped.
|
|
238
|
+
def render_menu_icon(icon)
|
|
239
|
+
return if icon.nil? || icon.to_s.strip.empty?
|
|
240
|
+
|
|
241
|
+
raw(safe(icon.to_s)) if icon.to_s.include?("<svg")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Flash messages render through RubyUI's Toast component (Stimulus-driven). A
|
|
245
|
+
# `<noscript>` fallback keeps them visible (as badges) when JS is off.
|
|
246
|
+
def render_toaster
|
|
247
|
+
messages = flash.to_hash
|
|
248
|
+
|
|
249
|
+
render RubyUIAdmin::UI::ToastRegion.new(
|
|
250
|
+
flash: messages,
|
|
251
|
+
position: :bottom_right,
|
|
252
|
+
close_button: true,
|
|
253
|
+
duration: 5000
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return if messages.empty?
|
|
257
|
+
|
|
258
|
+
noscript do
|
|
259
|
+
div(class: "fixed top-4 right-4 z-[100] flex flex-col gap-2") do
|
|
260
|
+
messages.each do |type, message|
|
|
261
|
+
next if message.blank?
|
|
262
|
+
|
|
263
|
+
variant = %w[alert error].include?(type.to_s) ? :destructive : :success
|
|
264
|
+
render RubyUI::Badge.new(variant: variant) { message.to_s }
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Shared confirmation AlertDialog, driven by the `rua--confirm` Stimulus controller (on
|
|
271
|
+
# <body>). A trigger (e.g. delete) opens it via `click->rua--confirm#request`. Without JS the
|
|
272
|
+
# trigger's form submits directly (the action still works); with JS, this dialog confirms first.
|
|
273
|
+
def render_confirm_dialog
|
|
274
|
+
div(data: {rua__confirm_target: "dialog"}, hidden: true, class: "fixed inset-0 z-50 flex items-center justify-center p-4") do
|
|
275
|
+
div(data: {action: "click->rua--confirm#cancel"}, class: "absolute inset-0 bg-black/50")
|
|
276
|
+
div(role: "alertdialog", "aria-modal": "true", class: "relative z-10 w-full max-w-md rounded-xl border border-border bg-background shadow-lg p-6") do
|
|
277
|
+
h2(data: {rua__confirm_target: "title"}, class: "text-lg font-semibold") { rua_t("confirm.title") }
|
|
278
|
+
p(data: {rua__confirm_target: "message"}, class: "text-sm text-muted-foreground mt-2") { rua_t("confirm.message") }
|
|
279
|
+
div(class: "flex items-center justify-end gap-3 mt-6") do
|
|
280
|
+
render(RubyUI::Button.new(type: :button, variant: :outline, data: {action: "click->rua--confirm#cancel"})) { rua_t("confirm.cancel") }
|
|
281
|
+
render(RubyUI::Button.new(type: :button, variant: :destructive, data: {action: "click->rua--confirm#confirm"})) { rua_t("confirm.confirm") }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The admin renders the host's RubyUI components, so the host owns the head assets — its
|
|
288
|
+
# Tailwind build (CSS, incl. the RubyUI design tokens) and its bundler/importmap (JS that
|
|
289
|
+
# registers the RubyUI Stimulus controllers and ours). The host supplies them via
|
|
290
|
+
# `config.head_assets` (a proc evaluated in the Rails view context). When unset, no assets
|
|
291
|
+
# are emitted (the page still renders; it just won't be styled/enhanced).
|
|
292
|
+
def render_head_assets
|
|
293
|
+
head_assets = RubyUIAdmin.configuration.head_assets
|
|
294
|
+
return unless head_assets
|
|
295
|
+
|
|
296
|
+
markup = view_context.instance_exec(&head_assets)
|
|
297
|
+
raw(safe(markup.to_s)) if markup
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIAdmin
|
|
4
|
+
module Views
|
|
5
|
+
# Renders a single dashboard card, dispatching on the card type.
|
|
6
|
+
class CardComponent < Phlex::HTML
|
|
7
|
+
include RubyUIAdmin::UI
|
|
8
|
+
|
|
9
|
+
def initialize(card:)
|
|
10
|
+
@card = card
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def view_template
|
|
14
|
+
render RubyUI::Card.new(class: span_class) do
|
|
15
|
+
render RubyUI::CardHeader.new do
|
|
16
|
+
render RubyUI::CardTitle.new(class: "text-sm font-medium text-muted-foreground") { @card.label }
|
|
17
|
+
end
|
|
18
|
+
render RubyUI::CardContent.new do
|
|
19
|
+
render_body
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def span_class
|
|
27
|
+
width = @card.width.to_i
|
|
28
|
+
width > 1 ? "md:col-span-#{width}" : ""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_body
|
|
32
|
+
case @card.type
|
|
33
|
+
when :metric then render_metric
|
|
34
|
+
when :chart then render_chart
|
|
35
|
+
when :partial then raw(safe(@card.body.to_s))
|
|
36
|
+
else plain(@card.query.to_s)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render_metric
|
|
41
|
+
div(class: "text-3xl font-semibold tracking-tight") do
|
|
42
|
+
plain [@card.prefix, @card.value, @card.suffix].compact.join
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render_chart
|
|
47
|
+
data = @card.data
|
|
48
|
+
return plain("—") if data.empty?
|
|
49
|
+
|
|
50
|
+
max = data.values.map(&:to_f).max
|
|
51
|
+
max = 1.0 if max.zero?
|
|
52
|
+
|
|
53
|
+
div(class: "space-y-2") do
|
|
54
|
+
data.each do |label, value|
|
|
55
|
+
div(class: "space-y-1") do
|
|
56
|
+
div(class: "flex justify-between text-xs text-muted-foreground") do
|
|
57
|
+
span { label.to_s }
|
|
58
|
+
span { value.to_s }
|
|
59
|
+
end
|
|
60
|
+
div(class: "h-2 rounded bg-muted") do
|
|
61
|
+
div(class: "h-2 rounded bg-primary", style: "width: #{(value.to_f / max * 100).round(1)}%")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIAdmin
|
|
4
|
+
module Views
|
|
5
|
+
class Dashboard < Base
|
|
6
|
+
def initialize(dashboard:)
|
|
7
|
+
@dashboard = dashboard
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def page_title
|
|
11
|
+
"#{@dashboard.class.title} · #{RubyUIAdmin.configuration.app_name}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def content
|
|
15
|
+
div(class: "mb-6") do
|
|
16
|
+
h1(class: "text-2xl font-semibold tracking-tight") { @dashboard.class.title }
|
|
17
|
+
if (description = @dashboard.class.description)
|
|
18
|
+
p(class: "text-sm text-muted-foreground mt-1") { description }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
cards = @dashboard.get_cards
|
|
23
|
+
if cards.empty?
|
|
24
|
+
p(class: "text-muted-foreground") { "No cards defined yet." }
|
|
25
|
+
else
|
|
26
|
+
div(class: grid_class) do
|
|
27
|
+
cards.each { |card| render RubyUIAdmin::Views::CardComponent.new(card: card) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def grid_class
|
|
35
|
+
columns = @dashboard.class.grid_columns.to_i.clamp(1, 4)
|
|
36
|
+
"grid gap-4 md:grid-cols-#{columns}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIAdmin
|
|
4
|
+
module Views
|
|
5
|
+
# Standalone, self-contained page for the dev-only docs browser. Deliberately does NOT use the
|
|
6
|
+
# admin layout or the host's RubyUI/Tailwind assets — it ships its own minimal CSS so it renders
|
|
7
|
+
# correctly even in a freshly-installed host where the admin's styling isn't wired up yet.
|
|
8
|
+
class Docs < Phlex::HTML
|
|
9
|
+
def initialize(body_html:, title:, nav_groups:, current_slug:)
|
|
10
|
+
@body_html = body_html
|
|
11
|
+
@title = title
|
|
12
|
+
@nav_groups = nav_groups
|
|
13
|
+
@current_slug = current_slug
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def view_template
|
|
17
|
+
doctype
|
|
18
|
+
html(lang: "en") do
|
|
19
|
+
head do
|
|
20
|
+
meta(charset: "utf-8")
|
|
21
|
+
meta(name: "viewport", content: "width=device-width, initial-scale=1")
|
|
22
|
+
title { "#{@title} · RubyUI Admin docs" }
|
|
23
|
+
style { raw(safe(STYLES)) }
|
|
24
|
+
end
|
|
25
|
+
body do
|
|
26
|
+
div(class: "rua-docs") do
|
|
27
|
+
render_sidebar
|
|
28
|
+
main(class: "rua-docs-main") do
|
|
29
|
+
article(class: "rua-prose") { raw(safe(@body_html)) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def render_sidebar
|
|
39
|
+
nav(class: "rua-docs-nav") do
|
|
40
|
+
div(class: "rua-docs-brand") { "RubyUI Admin · Docs" }
|
|
41
|
+
@nav_groups.each do |group|
|
|
42
|
+
div(class: "rua-docs-group") do
|
|
43
|
+
h3 { group[:label] } if group[:label]
|
|
44
|
+
ul do
|
|
45
|
+
group[:items].each do |item|
|
|
46
|
+
li do
|
|
47
|
+
a(href: item[:href], class: item[:slug] == @current_slug ? "active" : nil) { item[:title] }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
STYLES = <<~CSS
|
|
57
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
58
|
+
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2328; background: #fff; }
|
|
59
|
+
.rua-docs { display: flex; min-height: 100vh; align-items: stretch; }
|
|
60
|
+
.rua-docs-nav { flex: 0 0 17rem; border-right: 1px solid #e5e7eb; background: #f9fafb; padding: 1.25rem 1rem; overflow-y: auto; position: sticky; top: 0; height: 100vh; }
|
|
61
|
+
.rua-docs-brand { font-weight: 600; font-size: .9rem; margin-bottom: 1rem; color: #111827; }
|
|
62
|
+
.rua-docs-group { margin-bottom: 1rem; }
|
|
63
|
+
.rua-docs-group h3 { font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: #6b7280; margin: .75rem 0 .35rem; }
|
|
64
|
+
.rua-docs-nav ul { list-style: none; margin: 0; padding: 0; }
|
|
65
|
+
.rua-docs-nav li { margin: 0; }
|
|
66
|
+
.rua-docs-nav a { display: block; padding: .25rem .5rem; border-radius: .375rem; color: #374151; text-decoration: none; font-size: .85rem; }
|
|
67
|
+
.rua-docs-nav a:hover { background: #eef2ff; color: #1d4ed8; }
|
|
68
|
+
.rua-docs-nav a.active { background: #e0e7ff; color: #1d4ed8; font-weight: 600; }
|
|
69
|
+
.rua-docs-main { flex: 1 1 auto; min-width: 0; display: flex; justify-content: center; padding: 2rem 2.5rem 4rem; }
|
|
70
|
+
.rua-prose { width: 100%; max-width: 48rem; line-height: 1.65; }
|
|
71
|
+
.rua-prose h1, .rua-prose h2, .rua-prose h3, .rua-prose h4 { line-height: 1.25; font-weight: 600; margin-top: 1.75em; margin-bottom: .6em; }
|
|
72
|
+
.rua-prose h1 { font-size: 1.9rem; margin-top: 0; }
|
|
73
|
+
.rua-prose h2 { font-size: 1.45rem; padding-bottom: .3em; border-bottom: 1px solid #e5e7eb; }
|
|
74
|
+
.rua-prose h3 { font-size: 1.2rem; }
|
|
75
|
+
.rua-prose p, .rua-prose ul, .rua-prose ol, .rua-prose blockquote, .rua-prose table { margin: 0 0 1em; }
|
|
76
|
+
.rua-prose a { color: #1d4ed8; text-decoration: underline; text-underline-offset: 2px; }
|
|
77
|
+
.rua-prose ul, .rua-prose ol { padding-left: 1.5em; }
|
|
78
|
+
.rua-prose li { margin: .25em 0; }
|
|
79
|
+
.rua-prose code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: .875em; background: #f3f4f6; padding: .15em .35em; border-radius: .3em; }
|
|
80
|
+
.rua-prose pre { background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: .5rem; padding: 1rem; overflow-x: auto; }
|
|
81
|
+
.rua-prose pre code { background: none; padding: 0; font-size: .85rem; }
|
|
82
|
+
.rua-prose blockquote { border-left: 3px solid #d1d5db; padding: .1em 1em; color: #4b5563; background: #f9fafb; border-radius: 0 .375rem .375rem 0; }
|
|
83
|
+
.rua-prose table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; font-size: .9rem; }
|
|
84
|
+
.rua-prose th, .rua-prose td { border: 1px solid #e5e7eb; padding: .5em .75em; text-align: left; }
|
|
85
|
+
.rua-prose th { background: #f9fafb; font-weight: 600; }
|
|
86
|
+
.rua-prose img { max-width: 100%; }
|
|
87
|
+
.rua-prose hr { border: none; border-top: 1px solid #e5e7eb; margin: 2em 0; }
|
|
88
|
+
CSS
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|