better_page 2.0.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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +357 -0
  5. data/Rakefile +3 -0
  6. data/docs/00-README.md +17 -0
  7. data/docs/01-getting-started.md +137 -0
  8. data/docs/02-component-registry.md +192 -0
  9. data/docs/03-base-pages.md +238 -0
  10. data/docs/04-schema-validation.md +180 -0
  11. data/docs/05-turbo-support.md +220 -0
  12. data/docs/06-compliance-analyzer.md +147 -0
  13. data/docs/07-configuration.md +157 -0
  14. data/guide/00-README.md +32 -0
  15. data/guide/01-quick-start.md +148 -0
  16. data/guide/02-building-index-page.md +258 -0
  17. data/guide/03-building-show-page.md +266 -0
  18. data/guide/04-building-form-page.md +309 -0
  19. data/guide/05-custom-pages.md +325 -0
  20. data/guide/06-best-practices.md +311 -0
  21. data/lib/better_page/base_page.rb +161 -0
  22. data/lib/better_page/compliance/analyzer.rb +409 -0
  23. data/lib/better_page/component_registry.rb +393 -0
  24. data/lib/better_page/config.rb +165 -0
  25. data/lib/better_page/configuration.rb +153 -0
  26. data/lib/better_page/custom_base_page.rb +85 -0
  27. data/lib/better_page/default_components.rb +200 -0
  28. data/lib/better_page/form_base_page.rb +170 -0
  29. data/lib/better_page/index_base_page.rb +69 -0
  30. data/lib/better_page/railtie.rb +34 -0
  31. data/lib/better_page/show_base_page.rb +120 -0
  32. data/lib/better_page/validation_error.rb +7 -0
  33. data/lib/better_page/version.rb +3 -0
  34. data/lib/better_page.rb +80 -0
  35. data/lib/generators/better_page/component_generator.rb +131 -0
  36. data/lib/generators/better_page/install_generator.rb +160 -0
  37. data/lib/generators/better_page/page_generator.rb +101 -0
  38. data/lib/generators/better_page/sync_generator.rb +109 -0
  39. data/lib/generators/better_page/templates/application_page.rb.tt +12 -0
  40. data/lib/generators/better_page/templates/better_page_initializer.rb.tt +53 -0
  41. data/lib/generators/better_page/templates/custom_base_page.rb.tt +83 -0
  42. data/lib/generators/better_page/templates/custom_page.rb.tt +31 -0
  43. data/lib/generators/better_page/templates/edit_page.rb.tt +46 -0
  44. data/lib/generators/better_page/templates/form_base_page.rb.tt +126 -0
  45. data/lib/generators/better_page/templates/index_base_page.rb.tt +65 -0
  46. data/lib/generators/better_page/templates/index_page.rb.tt +56 -0
  47. data/lib/generators/better_page/templates/javascript/controllers/app_nav_controller.js +57 -0
  48. data/lib/generators/better_page/templates/javascript/controllers/drawer_controller.js +99 -0
  49. data/lib/generators/better_page/templates/javascript/controllers/dropdown_controller.js +60 -0
  50. data/lib/generators/better_page/templates/javascript/controllers/index.js +36 -0
  51. data/lib/generators/better_page/templates/javascript/controllers/modal_controller.js +70 -0
  52. data/lib/generators/better_page/templates/javascript/controllers/sidebar_controller.js +152 -0
  53. data/lib/generators/better_page/templates/javascript/controllers/table_controller.js +60 -0
  54. data/lib/generators/better_page/templates/javascript/controllers/tabs_controller.js +89 -0
  55. data/lib/generators/better_page/templates/new_page.rb.tt +46 -0
  56. data/lib/generators/better_page/templates/show_base_page.rb.tt +117 -0
  57. data/lib/generators/better_page/templates/show_page.rb.tt +45 -0
  58. data/lib/generators/better_page/templates/view_components/application_view_component.rb.tt +7 -0
  59. data/lib/generators/better_page/templates/view_components/custom_view_component.html.erb.tt +21 -0
  60. data/lib/generators/better_page/templates/view_components/custom_view_component.rb.tt +21 -0
  61. data/lib/generators/better_page/templates/view_components/form_view_component.html.erb.tt +25 -0
  62. data/lib/generators/better_page/templates/view_components/form_view_component.rb.tt +23 -0
  63. data/lib/generators/better_page/templates/view_components/index_view_component.html.erb.tt +33 -0
  64. data/lib/generators/better_page/templates/view_components/index_view_component.rb.tt +29 -0
  65. data/lib/generators/better_page/templates/view_components/show_view_component.html.erb.tt +29 -0
  66. data/lib/generators/better_page/templates/view_components/show_view_component.rb.tt +25 -0
  67. data/lib/generators/better_page/templates/view_components/ui/alerts_component.html.erb.tt +47 -0
  68. data/lib/generators/better_page/templates/view_components/ui/alerts_component.rb.tt +47 -0
  69. data/lib/generators/better_page/templates/view_components/ui/content_section_component.html.erb.tt +42 -0
  70. data/lib/generators/better_page/templates/view_components/ui/content_section_component.rb.tt +34 -0
  71. data/lib/generators/better_page/templates/view_components/ui/drawer_component.html.erb.tt +73 -0
  72. data/lib/generators/better_page/templates/view_components/ui/drawer_component.rb.tt +78 -0
  73. data/lib/generators/better_page/templates/view_components/ui/errors_component.html.erb.tt +23 -0
  74. data/lib/generators/better_page/templates/view_components/ui/errors_component.rb.tt +18 -0
  75. data/lib/generators/better_page/templates/view_components/ui/field_component.html.erb.tt +65 -0
  76. data/lib/generators/better_page/templates/view_components/ui/field_component.rb.tt +91 -0
  77. data/lib/generators/better_page/templates/view_components/ui/footer_component.html.erb.tt +33 -0
  78. data/lib/generators/better_page/templates/view_components/ui/footer_component.rb.tt +32 -0
  79. data/lib/generators/better_page/templates/view_components/ui/header_component.html.erb.tt +55 -0
  80. data/lib/generators/better_page/templates/view_components/ui/header_component.rb.tt +39 -0
  81. data/lib/generators/better_page/templates/view_components/ui/modal_component.html.erb.tt +70 -0
  82. data/lib/generators/better_page/templates/view_components/ui/modal_component.rb.tt +54 -0
  83. data/lib/generators/better_page/templates/view_components/ui/overview_component.html.erb.tt +22 -0
  84. data/lib/generators/better_page/templates/view_components/ui/overview_component.rb.tt +71 -0
  85. data/lib/generators/better_page/templates/view_components/ui/pagination_component.html.erb.tt +63 -0
  86. data/lib/generators/better_page/templates/view_components/ui/pagination_component.rb.tt +69 -0
  87. data/lib/generators/better_page/templates/view_components/ui/panel_component.html.erb.tt +31 -0
  88. data/lib/generators/better_page/templates/view_components/ui/panel_component.rb.tt +23 -0
  89. data/lib/generators/better_page/templates/view_components/ui/statistics_component.html.erb.tt +33 -0
  90. data/lib/generators/better_page/templates/view_components/ui/statistics_component.rb.tt +51 -0
  91. data/lib/generators/better_page/templates/view_components/ui/table_component.html.erb.tt +112 -0
  92. data/lib/generators/better_page/templates/view_components/ui/table_component.rb.tt +88 -0
  93. data/lib/generators/better_page/templates/view_components/ui/tabs_component.html.erb.tt +52 -0
  94. data/lib/generators/better_page/templates/view_components/ui/tabs_component.rb.tt +76 -0
  95. data/lib/generators/better_page/templates/view_components/ui/widget_component.html.erb.tt +72 -0
  96. data/lib/generators/better_page/templates/view_components/ui/widget_component.rb.tt +34 -0
  97. data/lib/tasks/better_page.rake +70 -0
  98. data/lib/tasks/better_page_tasks.rake +4 -0
  99. metadata +188 -0
@@ -0,0 +1,70 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="modal"
4
+ export default class extends Controller {
5
+ static targets = ["container", "panel", "backdrop"]
6
+ static values = {
7
+ open: { type: Boolean, default: false },
8
+ confirmClose: { type: Boolean, default: false }
9
+ }
10
+
11
+ connect() {
12
+ this.boundKeydown = this.keydown.bind(this)
13
+ document.addEventListener("keydown", this.boundKeydown)
14
+ }
15
+
16
+ disconnect() {
17
+ document.removeEventListener("keydown", this.boundKeydown)
18
+ document.body.classList.remove("overflow-hidden")
19
+ }
20
+
21
+ open() {
22
+ this.openValue = true
23
+ this.containerTarget.classList.remove("hidden")
24
+ document.body.classList.add("overflow-hidden")
25
+
26
+ requestAnimationFrame(() => {
27
+ this.backdropTarget.classList.remove("opacity-0")
28
+ this.panelTarget.classList.remove("opacity-0", "translate-y-4", "sm:translate-y-0", "sm:scale-95")
29
+ this.panelTarget.classList.add("opacity-100", "translate-y-0", "sm:scale-100")
30
+ })
31
+ }
32
+
33
+ close() {
34
+ this.openValue = false
35
+ this.backdropTarget.classList.add("opacity-0")
36
+ this.panelTarget.classList.remove("opacity-100", "translate-y-0", "sm:scale-100")
37
+ this.panelTarget.classList.add("opacity-0", "translate-y-4", "sm:translate-y-0", "sm:scale-95")
38
+
39
+ setTimeout(() => {
40
+ this.containerTarget.classList.add("hidden")
41
+ document.body.classList.remove("overflow-hidden")
42
+ }, 300)
43
+ }
44
+
45
+ toggle() {
46
+ this.openValue ? this.close() : this.open()
47
+ }
48
+
49
+ requestClose() {
50
+ if (this.confirmCloseValue) {
51
+ if (confirm("Sei sicuro di voler chiudere? I dati non salvati andranno persi.")) {
52
+ this.close()
53
+ }
54
+ } else {
55
+ this.close()
56
+ }
57
+ }
58
+
59
+ keydown(event) {
60
+ if (event.key === "Escape" && this.openValue) {
61
+ this.requestClose()
62
+ }
63
+ }
64
+
65
+ backdropClick(event) {
66
+ if (event.target === this.backdropTarget) {
67
+ this.requestClose()
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,152 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Sidebar Controller
5
+ *
6
+ * A Stimulus controller for collapsible sidebar navigation.
7
+ * Connects to data-controller="sidebar"
8
+ *
9
+ * Features:
10
+ * - Toggle entire sidebar collapse/expand
11
+ * - Toggle individual menu groups
12
+ * - Persist collapse state in localStorage
13
+ *
14
+ * Usage:
15
+ * <aside data-controller="sidebar"
16
+ * data-sidebar-collapsed-value="false"
17
+ * data-sidebar-persist-value="true"
18
+ * data-sidebar-storage-key-value="main-sidebar-collapsed">
19
+ * <button data-action="click->sidebar#toggle">Toggle</button>
20
+ * <div data-sidebar-target="group">
21
+ * <button data-action="click->sidebar#toggleGroup" data-group-id="nav">
22
+ * <span data-sidebar-target="groupLabel">Navigation</span>
23
+ * <svg data-sidebar-target="groupIcon">...</svg>
24
+ * </button>
25
+ * <div data-sidebar-target="groupContent" data-group-id="nav">
26
+ * <!-- group items -->
27
+ * </div>
28
+ * </div>
29
+ * </aside>
30
+ */
31
+ export default class extends Controller {
32
+ static targets = [
33
+ "brand",
34
+ "brandText",
35
+ "toggleIcon",
36
+ "group",
37
+ "groupToggle",
38
+ "groupLabel",
39
+ "groupIcon",
40
+ "groupContent",
41
+ "item",
42
+ "itemLabel"
43
+ ]
44
+
45
+ static values = {
46
+ collapsed: Boolean,
47
+ persist: Boolean,
48
+ storageKey: String
49
+ }
50
+
51
+ connect() {
52
+ // Restore saved state if persistence is enabled
53
+ if (this.persistValue && this.storageKeyValue) {
54
+ const saved = localStorage.getItem(this.storageKeyValue)
55
+ if (saved !== null) {
56
+ this.collapsedValue = saved === "true"
57
+ }
58
+ }
59
+ this.updateUI()
60
+ }
61
+
62
+ /**
63
+ * Toggle the entire sidebar collapse state
64
+ */
65
+ toggle() {
66
+ this.collapsedValue = !this.collapsedValue
67
+
68
+ // Save state if persistence is enabled
69
+ if (this.persistValue && this.storageKeyValue) {
70
+ localStorage.setItem(this.storageKeyValue, this.collapsedValue.toString())
71
+ }
72
+
73
+ this.updateUI()
74
+ }
75
+
76
+ /**
77
+ * Toggle a specific menu group
78
+ * @param {Event} event - Click event from the group toggle button
79
+ */
80
+ toggleGroup(event) {
81
+ const button = event.currentTarget
82
+ const groupId = button.dataset.groupId
83
+
84
+ // Find the content container for this group
85
+ const content = this.groupContentTargets.find(
86
+ (el) => el.dataset.groupId === groupId
87
+ )
88
+
89
+ // Find the icon within the button
90
+ const icon = button.querySelector('[data-sidebar-target="groupIcon"]')
91
+
92
+ // Toggle aria-expanded
93
+ const isExpanded = button.getAttribute("aria-expanded") === "true"
94
+ button.setAttribute("aria-expanded", (!isExpanded).toString())
95
+
96
+ // Toggle content visibility
97
+ if (content) {
98
+ content.classList.toggle("hidden")
99
+ }
100
+
101
+ // Toggle icon rotation
102
+ if (icon) {
103
+ icon.classList.toggle("-rotate-90")
104
+ icon.classList.toggle("rotate-0")
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Update the UI based on current collapsed state
110
+ */
111
+ updateUI() {
112
+ const collapsed = this.collapsedValue
113
+
114
+ // Update width classes
115
+ this.element.classList.toggle("w-64", !collapsed)
116
+ this.element.classList.toggle("w-16", collapsed)
117
+
118
+ // Update toggle icon rotation
119
+ if (this.hasToggleIconTarget) {
120
+ this.toggleIconTarget.classList.toggle("rotate-180", collapsed)
121
+ }
122
+
123
+ // Collect all text elements to hide/show
124
+ const textTargets = [
125
+ ...(this.hasBrandTextTarget ? this.brandTextTargets : []),
126
+ ...(this.hasGroupLabelTarget ? this.groupLabelTargets : []),
127
+ ...(this.hasItemLabelTarget ? this.itemLabelTargets : [])
128
+ ]
129
+
130
+ // Toggle visibility of text elements
131
+ textTargets.forEach((el) => {
132
+ el.classList.toggle("opacity-0", collapsed)
133
+ el.classList.toggle("w-0", collapsed)
134
+ el.classList.toggle("overflow-hidden", collapsed)
135
+ })
136
+
137
+ // Hide badges when collapsed
138
+ if (collapsed) {
139
+ this.element.querySelectorAll("[class*='badge'], [class*='rounded-full']").forEach((badge) => {
140
+ if (badge.classList.contains("bg-blue-100") || badge.classList.contains("bg-red-500")) {
141
+ badge.classList.add("hidden")
142
+ }
143
+ })
144
+ } else {
145
+ this.element.querySelectorAll("[class*='badge'], [class*='rounded-full']").forEach((badge) => {
146
+ if (badge.classList.contains("bg-blue-100") || badge.classList.contains("bg-red-500")) {
147
+ badge.classList.remove("hidden")
148
+ }
149
+ })
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,60 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Table Controller
5
+ *
6
+ * A Stimulus controller for table row selection.
7
+ * Connects to data-controller="table"
8
+ *
9
+ * Usage:
10
+ * <div data-controller="table">
11
+ * <table>
12
+ * <thead>
13
+ * <tr>
14
+ * <th>
15
+ * <input type="checkbox"
16
+ * data-table-target="selectAll"
17
+ * data-action="change->table#selectAll">
18
+ * </th>
19
+ * </tr>
20
+ * </thead>
21
+ * <tbody>
22
+ * <tr>
23
+ * <td>
24
+ * <input type="checkbox"
25
+ * data-table-target="row"
26
+ * data-action="change->table#rowChanged">
27
+ * </td>
28
+ * </tr>
29
+ * </tbody>
30
+ * </table>
31
+ * </div>
32
+ */
33
+ export default class extends Controller {
34
+ static targets = ["selectAll", "row"]
35
+
36
+ /**
37
+ * Select or deselect all row checkboxes
38
+ * @param {Event} event - The change event from the select all checkbox
39
+ */
40
+ selectAll(event) {
41
+ const checked = event.target.checked
42
+ this.rowTargets.forEach(checkbox => {
43
+ checkbox.checked = checked
44
+ })
45
+ }
46
+
47
+ /**
48
+ * Update the select all checkbox state based on individual row selections
49
+ * Sets indeterminate state when some (but not all) rows are selected
50
+ */
51
+ rowChanged() {
52
+ const total = this.rowTargets.length
53
+ const checked = this.rowTargets.filter(cb => cb.checked).length
54
+
55
+ if (this.hasSelectAllTarget) {
56
+ this.selectAllTarget.checked = checked === total
57
+ this.selectAllTarget.indeterminate = checked > 0 && checked < total
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,89 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="tabs"
4
+ export default class extends Controller {
5
+ static targets = ["tab", "panel"]
6
+ static values = {
7
+ default: String,
8
+ style: { type: String, default: "underline" },
9
+ activeClass: String,
10
+ inactiveClass: String
11
+ }
12
+
13
+ connect() {
14
+ const defaultId = this.defaultValue || this.tabTargets[0]?.dataset.tabId
15
+ if (defaultId) {
16
+ this.selectTab(defaultId, false)
17
+ }
18
+ }
19
+
20
+ select(event) {
21
+ const tabId = event.currentTarget.dataset.tabId
22
+ this.selectTab(tabId)
23
+ }
24
+
25
+ selectTab(tabId, animate = true) {
26
+ // Update tabs
27
+ this.tabTargets.forEach(tab => {
28
+ const isActive = tab.dataset.tabId === tabId
29
+ tab.setAttribute("aria-selected", isActive)
30
+ tab.setAttribute("tabindex", isActive ? "0" : "-1")
31
+
32
+ // Update classes
33
+ const activeClasses = this.activeClassValue.split(" ").filter(c => c)
34
+ const inactiveClasses = this.inactiveClassValue.split(" ").filter(c => c)
35
+
36
+ if (isActive) {
37
+ tab.classList.remove(...inactiveClasses)
38
+ tab.classList.add(...activeClasses)
39
+ } else {
40
+ tab.classList.remove(...activeClasses)
41
+ tab.classList.add(...inactiveClasses)
42
+ }
43
+ })
44
+
45
+ // Update panels
46
+ this.panelTargets.forEach(panel => {
47
+ const isActive = panel.dataset.tabId === tabId
48
+ panel.classList.toggle("hidden", !isActive)
49
+ })
50
+
51
+ // Dispatch custom event
52
+ this.dispatch("changed", { detail: { tabId } })
53
+ }
54
+
55
+ keydown(event) {
56
+ const currentTab = event.currentTarget
57
+ const currentIndex = this.tabTargets.indexOf(currentTab)
58
+ let newIndex
59
+
60
+ switch (event.key) {
61
+ case "ArrowLeft":
62
+ event.preventDefault()
63
+ newIndex = currentIndex - 1
64
+ if (newIndex < 0) newIndex = this.tabTargets.length - 1
65
+ break
66
+ case "ArrowRight":
67
+ event.preventDefault()
68
+ newIndex = currentIndex + 1
69
+ if (newIndex >= this.tabTargets.length) newIndex = 0
70
+ break
71
+ case "Home":
72
+ event.preventDefault()
73
+ newIndex = 0
74
+ break
75
+ case "End":
76
+ event.preventDefault()
77
+ newIndex = this.tabTargets.length - 1
78
+ break
79
+ default:
80
+ return
81
+ }
82
+
83
+ const newTab = this.tabTargets[newIndex]
84
+ if (newTab) {
85
+ newTab.focus()
86
+ this.selectTab(newTab.dataset.tabId)
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% namespace_path.each_with_index do |ns, i| -%>
4
+ <%= " " * i %>module <%= ns.camelize %>
5
+ <% end -%>
6
+ <%= " " * namespace_path.length %>class NewPage < FormBasePage
7
+ <%= " " * namespace_path.length %> def initialize(<%= resource_singular %>, metadata = {})
8
+ <%= " " * namespace_path.length %> @<%= resource_singular %> = <%= resource_singular %>
9
+ <%= " " * namespace_path.length %> @user = metadata[:user]
10
+ <%= " " * namespace_path.length %> super(<%= resource_singular %>, metadata)
11
+ <%= " " * namespace_path.length %> end
12
+
13
+ <%= " " * namespace_path.length %> private
14
+
15
+ <%= " " * namespace_path.length %> # Required: Form header configuration
16
+ <%= " " * namespace_path.length %> def header
17
+ <%= " " * namespace_path.length %> {
18
+ <%= " " * namespace_path.length %> title: "New <%= resource_singular.humanize %>",
19
+ <%= " " * namespace_path.length %> description: "Create a new <%= resource_singular.humanize.downcase %>.",
20
+ <%= " " * namespace_path.length %> breadcrumbs: breadcrumbs_config
21
+ <%= " " * namespace_path.length %> }
22
+ <%= " " * namespace_path.length %> end
23
+
24
+ <%= " " * namespace_path.length %> # Required: Form panels with fields
25
+ <%= " " * namespace_path.length %> def panels
26
+ <%= " " * namespace_path.length %> [
27
+ <%= " " * namespace_path.length %> {
28
+ <%= " " * namespace_path.length %> title: "Basic Information",
29
+ <%= " " * namespace_path.length %> fields: [
30
+ <%= " " * namespace_path.length %> # { name: :name, type: :text, label: "Name", required: true },
31
+ <%= " " * namespace_path.length %> # { name: :email, type: :email, label: "Email" }
32
+ <%= " " * namespace_path.length %> ]
33
+ <%= " " * namespace_path.length %> }
34
+ <%= " " * namespace_path.length %> # NOTE: Checkbox and radio fields must be in separate panels
35
+ <%= " " * namespace_path.length %> # {
36
+ <%= " " * namespace_path.length %> # title: "Settings",
37
+ <%= " " * namespace_path.length %> # fields: [
38
+ <%= " " * namespace_path.length %> # { name: :is_active, type: :checkbox, label: "Active" }
39
+ <%= " " * namespace_path.length %> # ]
40
+ <%= " " * namespace_path.length %> # }
41
+ <%= " " * namespace_path.length %> ]
42
+ <%= " " * namespace_path.length %> end
43
+ <%= " " * namespace_path.length %>end
44
+ <% namespace_path.reverse.each_with_index do |_, i| -%>
45
+ <%= " " * (namespace_path.length - i - 1) %>end
46
+ <% end -%>
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for all show/detail pages in this application.
4
+ #
5
+ # Components available (from global configuration):
6
+ # Required: header
7
+ # Optional: alerts, statistics, overview, content_sections, footer
8
+ #
9
+ # Customize this class to:
10
+ # - Add custom components with register_component
11
+ # - Override helper methods
12
+ # - Change stream_components
13
+ #
14
+ # @example Add a custom component
15
+ # register_component :activity_feed, default: { events: [] }
16
+ #
17
+ class ShowBasePage < ApplicationPage
18
+ page_type :show
19
+
20
+ # ─────────────────────────────────────────────────────────────────
21
+ # CUSTOM COMPONENTS
22
+ # ─────────────────────────────────────────────────────────────────
23
+ # Add custom components for all show pages here:
24
+ #
25
+ # register_component :timeline, default: []
26
+ # register_component :comments, default: { enabled: false }
27
+
28
+ # Main method that builds the complete show page configuration
29
+ # @return [Hash] complete show page configuration with :klass for rendering
30
+ def show
31
+ build_page
32
+ end
33
+
34
+ # The ViewComponent class used to render this show page
35
+ # @return [Class] BetterPage::ShowViewComponent
36
+ def view_component_class
37
+ return BetterPage::ShowViewComponent if defined?(BetterPage::ShowViewComponent)
38
+
39
+ raise NotImplementedError, "BetterPage::ShowViewComponent not found. Run: rails g better_page:install"
40
+ end
41
+
42
+ # Components to include in stream updates by default
43
+ # Override to customize which components update via Turbo Streams
44
+ # @return [Array<Symbol>]
45
+ def stream_components
46
+ %i[alerts statistics overview content_sections]
47
+ end
48
+
49
+ protected
50
+
51
+ # Helper to convert hash to info grid format
52
+ # @param hash [Hash] key-value pairs to convert
53
+ # @return [Array<Hash>] formatted info grid items
54
+ def info_grid_content_format(hash)
55
+ hash.map { |name, value| { name: name, value: value } }
56
+ end
57
+
58
+ # Helper to build content section
59
+ # @param title [String] section title
60
+ # @param icon [String] section icon
61
+ # @param color [String] section color
62
+ # @param type [Symbol] section type (:info_grid, :text_content, :custom)
63
+ # @param content [Hash, Array, nil] section content
64
+ # @return [Hash] formatted content section
65
+ def content_section_format(title:, icon:, color:, type:, content: nil)
66
+ section = {
67
+ title: title,
68
+ icon: icon,
69
+ color: color,
70
+ type: type
71
+ }
72
+
73
+ if type == :info_grid && content.is_a?(Array) && content.first.is_a?(Hash) && content.first.key?(:name)
74
+ section[:items] = content
75
+ elsif type == :info_grid && content.is_a?(Hash)
76
+ section[:items] = info_grid_content_format(content)
77
+ elsif type == :info_grid
78
+ section[:items] = content
79
+ else
80
+ section[:content] = content
81
+ end
82
+
83
+ section
84
+ end
85
+
86
+ # Helper to build statistic card
87
+ # @param label [String] statistic label
88
+ # @param value [String, Numeric] statistic value
89
+ # @param icon [String] statistic icon
90
+ # @param color [String] statistic color
91
+ # @return [Hash] formatted statistic
92
+ def statistic_format(label:, value:, icon:, color:)
93
+ {
94
+ label: label,
95
+ value: value,
96
+ icon: icon,
97
+ color: color
98
+ }
99
+ end
100
+
101
+ # Helper to build header action
102
+ # @param path [String] action path
103
+ # @param label [String] action label
104
+ # @param icon [String] action icon
105
+ # @param style [String] action style
106
+ # @param method [Symbol] HTTP method
107
+ # @return [Hash] formatted action
108
+ def action_format(path:, label:, icon:, style:, method: :get)
109
+ {
110
+ path: path,
111
+ label: label,
112
+ icon: icon,
113
+ style: style,
114
+ method: method
115
+ }
116
+ end
117
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% namespace_path.each_with_index do |ns, i| -%>
4
+ <%= " " * i %>module <%= ns.camelize %>
5
+ <% end -%>
6
+ <%= " " * namespace_path.length %>class ShowPage < ShowBasePage
7
+ <%= " " * namespace_path.length %> def initialize(<%= resource_singular %>, metadata = {})
8
+ <%= " " * namespace_path.length %> @<%= resource_singular %> = <%= resource_singular %>
9
+ <%= " " * namespace_path.length %> @user = metadata[:user]
10
+ <%= " " * namespace_path.length %> super(<%= resource_singular %>, metadata)
11
+ <%= " " * namespace_path.length %> end
12
+
13
+ <%= " " * namespace_path.length %> private
14
+
15
+ <%= " " * namespace_path.length %> # Required: Page header configuration
16
+ <%= " " * namespace_path.length %> def header
17
+ <%= " " * namespace_path.length %> {
18
+ <%= " " * namespace_path.length %> title: @<%= resource_singular %>.to_s,
19
+ <%= " " * namespace_path.length %> breadcrumbs: breadcrumbs_config,
20
+ <%= " " * namespace_path.length %> metadata: [],
21
+ <%= " " * namespace_path.length %> actions: [
22
+ <%= " " * namespace_path.length %> # { label: "Edit", path: edit_<%= resource_singular %>_path(@<%= resource_singular %>), icon: "edit", style: "primary" }
23
+ <%= " " * namespace_path.length %> ]
24
+ <%= " " * namespace_path.length %> }
25
+ <%= " " * namespace_path.length %> end
26
+
27
+ <%= " " * namespace_path.length %> # Optional: Content sections
28
+ <%= " " * namespace_path.length %> def content_sections
29
+ <%= " " * namespace_path.length %> [
30
+ <%= " " * namespace_path.length %> # {
31
+ <%= " " * namespace_path.length %> # title: "Details",
32
+ <%= " " * namespace_path.length %> # icon: "info-circle",
33
+ <%= " " * namespace_path.length %> # color: "blue",
34
+ <%= " " * namespace_path.length %> # type: :info_grid,
35
+ <%= " " * namespace_path.length %> # items: [
36
+ <%= " " * namespace_path.length %> # { name: "ID", value: @<%= resource_singular %>.id },
37
+ <%= " " * namespace_path.length %> # { name: "Created", value: format_date(@<%= resource_singular %>.created_at) }
38
+ <%= " " * namespace_path.length %> # ]
39
+ <%= " " * namespace_path.length %> # }
40
+ <%= " " * namespace_path.length %> ]
41
+ <%= " " * namespace_path.length %> end
42
+ <%= " " * namespace_path.length %>end
43
+ <% namespace_path.reverse.each_with_index do |_, i| -%>
44
+ <%= " " * (namespace_path.length - i - 1) %>end
45
+ <% end -%>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ class ApplicationViewComponent < ViewComponent::Base
5
+ include Turbo::FramesHelper
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ <div class="better-page better-page--custom">
2
+ <%% if alerts? %>
3
+ <%%= render BetterPage::Ui::AlertsComponent.new(alerts: alerts) %>
4
+ <%% end %>
5
+
6
+ <%% if header? %>
7
+ <%%= render BetterPage::Ui::HeaderComponent.new(**header) %>
8
+ <%% end %>
9
+
10
+ <%% if content? %>
11
+ <div class="space-y-6">
12
+ <%% content.each do |widget| %>
13
+ <%%= render BetterPage::Ui::WidgetComponent.new(**widget) %>
14
+ <%% end %>
15
+ </div>
16
+ <%% end %>
17
+
18
+ <%% if footer? %>
19
+ <%%= render BetterPage::Ui::FooterComponent.new(**footer) %>
20
+ <%% end %>
21
+ </div>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ class CustomViewComponent < ApplicationViewComponent
5
+ def initialize(config:)
6
+ @config = config
7
+ end
8
+
9
+ # Component accessors
10
+ def header = @config[:header]
11
+ def alerts = @config[:alerts]
12
+ def content = @config[:content]
13
+ def footer = @config[:footer]
14
+
15
+ # Presence helpers
16
+ def header? = header.present?
17
+ def alerts? = alerts.present?
18
+ def content? = content.present?
19
+ def footer? = footer.present?
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ <div class="better-page better-page--form">
2
+ <%% if alerts? %>
3
+ <%%= render BetterPage::Ui::AlertsComponent.new(alerts: alerts) %>
4
+ <%% end %>
5
+
6
+ <%% if header? %>
7
+ <%%= render BetterPage::Ui::HeaderComponent.new(**header) %>
8
+ <%% end %>
9
+
10
+ <%% if errors? %>
11
+ <%%= render BetterPage::Ui::ErrorsComponent.new(**errors) %>
12
+ <%% end %>
13
+
14
+ <%% if panels? %>
15
+ <div class="space-y-6">
16
+ <%% panels.each do |panel| %>
17
+ <%%= render BetterPage::Ui::PanelComponent.new(**panel) %>
18
+ <%% end %>
19
+ </div>
20
+ <%% end %>
21
+
22
+ <%% if footer? %>
23
+ <%%= render BetterPage::Ui::FooterComponent.new(**footer) %>
24
+ <%% end %>
25
+ </div>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ class FormViewComponent < ApplicationViewComponent
5
+ def initialize(config:)
6
+ @config = config
7
+ end
8
+
9
+ # Component accessors
10
+ def header = @config[:header]
11
+ def alerts = @config[:alerts]
12
+ def errors = @config[:errors]
13
+ def panels = @config[:panels]
14
+ def footer = @config[:footer]
15
+
16
+ # Presence helpers
17
+ def header? = header.present?
18
+ def alerts? = alerts.present?
19
+ def errors? = errors.present?
20
+ def panels? = panels.present?
21
+ def footer? = footer.present?
22
+ end
23
+ end