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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class FooterComponent < BetterPage::ApplicationViewComponent
6
+ def initialize(actions: [], info: nil)
7
+ @actions = actions
8
+ @info = info
9
+ end
10
+
11
+ attr_reader :actions, :info
12
+
13
+ def actions? = actions.any?
14
+ def info? = info.present?
15
+
16
+ def action_classes(style)
17
+ base = "inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2"
18
+
19
+ case style&.to_sym
20
+ when :primary
21
+ "#{base} border-transparent text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500"
22
+ when :secondary
23
+ "#{base} border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-blue-500"
24
+ when :danger
25
+ "#{base} border-transparent text-white bg-red-600 hover:bg-red-700 focus:ring-red-500"
26
+ else
27
+ "#{base} border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-blue-500"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ <header class="bg-white shadow rounded-xl p-4">
2
+ <%% if breadcrumbs? %>
3
+ <nav class="flex mb-4" aria-label="Breadcrumb">
4
+ <ol class="inline-flex items-center space-x-1 md:space-x-3">
5
+ <%% breadcrumbs.each_with_index do |crumb, i| %>
6
+ <li class="inline-flex items-center">
7
+ <%% if crumb[:path] %>
8
+ <%%= link_to crumb[:label], crumb[:path], class: "text-sm text-gray-500 hover:text-gray-700" %>
9
+ <%% else %>
10
+ <span class="text-sm font-medium text-gray-500"><%%= crumb[:label] %></span>
11
+ <%% end %>
12
+ <%% unless i == breadcrumbs.size - 1 %>
13
+ <svg class="w-4 h-4 mx-2 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
14
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
15
+ </svg>
16
+ <%% end %>
17
+ </li>
18
+ <%% end %>
19
+ </ol>
20
+ </nav>
21
+ <%% end %>
22
+
23
+ <div class="flex items-center justify-between">
24
+ <div>
25
+ <h1 class="text-2xl font-bold text-gray-900"><%%= title %></h1>
26
+ <%% if metadata? %>
27
+ <div class="mt-1 flex items-center space-x-4">
28
+ <%% metadata.each do |meta| %>
29
+ <span class="text-sm text-gray-500">
30
+ <%% if meta[:icon] %>
31
+ <span class="mr-1"><%%= meta[:icon] %></span>
32
+ <%% end %>
33
+ <%%= meta[:value] %>
34
+ </span>
35
+ <%% end %>
36
+ </div>
37
+ <%% end %>
38
+ </div>
39
+
40
+ <%% if actions? %>
41
+ <div class="flex space-x-3">
42
+ <%% actions.each do |action| %>
43
+ <%%= link_to action[:path],
44
+ data: { turbo_method: action[:method], turbo_confirm: action[:confirm] }.compact,
45
+ class: action_classes(action[:style]) do %>
46
+ <%% if action[:icon] %>
47
+ <span class="mr-2"><%%= action[:icon] %></span>
48
+ <%% end %>
49
+ <%%= action[:label] %>
50
+ <%% end %>
51
+ <%% end %>
52
+ </div>
53
+ <%% end %>
54
+ </div>
55
+ </header>
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class HeaderComponent < BetterPage::ApplicationViewComponent
6
+ def initialize(title:, breadcrumbs: [], actions: [], metadata: [])
7
+ @title = title
8
+ @breadcrumbs = breadcrumbs
9
+ @actions = actions
10
+ @metadata = metadata
11
+ end
12
+
13
+ attr_reader :title, :breadcrumbs, :actions, :metadata
14
+
15
+ def breadcrumbs? = breadcrumbs.any?
16
+ def actions? = actions.any?
17
+ def metadata? = metadata.any?
18
+
19
+ def action_classes(style)
20
+ base = "inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2"
21
+
22
+ case style&.to_sym
23
+ when :primary
24
+ "#{base} bg-blue-600 text-white ring-blue-600 hover:bg-blue-700 focus:ring-blue-500"
25
+ when :secondary
26
+ "#{base} bg-white text-gray-900 ring-gray-300 hover:bg-gray-50 focus:ring-blue-500"
27
+ when :danger
28
+ "#{base} bg-red-600 text-white ring-red-600 hover:bg-red-700 focus:ring-red-500"
29
+ when :success
30
+ "#{base} bg-green-600 text-white ring-green-600 hover:bg-green-700 focus:ring-green-500"
31
+ when :warning
32
+ "#{base} bg-yellow-600 text-white ring-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500"
33
+ else
34
+ "#{base} bg-white text-gray-900 ring-gray-300 hover:bg-gray-50 focus:ring-blue-500"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,70 @@
1
+ <div id="<%%= id %>"
2
+ data-controller="modal"
3
+ data-modal-confirm-close-value="<%%= confirm_close %>">
4
+
5
+ <%%# Trigger slot - bottone per aprire il modal %>
6
+ <%% if trigger? %>
7
+ <%%= trigger %>
8
+ <%% end %>
9
+
10
+ <%%# Container del modal (nascosto di default) %>
11
+ <div data-modal-target="container"
12
+ class="hidden relative z-50"
13
+ aria-modal="true"
14
+ role="dialog">
15
+
16
+ <%%# Backdrop %>
17
+ <div data-modal-target="backdrop"
18
+ data-action="click->modal#backdropClick"
19
+ class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300 ease-out opacity-0">
20
+ </div>
21
+
22
+ <%%# Modal panel container %>
23
+ <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
24
+ <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
25
+ <div data-modal-target="panel"
26
+ class="<%%= panel_classes %> opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
27
+
28
+ <%%# Header %>
29
+ <%% if show_header? %>
30
+ <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
31
+ <div class="flex items-center gap-4">
32
+ <%% if title? %>
33
+ <h3 class="text-lg font-semibold leading-6 text-gray-900"><%%= title %></h3>
34
+ <%% end %>
35
+ <%% if header_actions? %>
36
+ <div class="flex items-center gap-2">
37
+ <%%= actions %>
38
+ </div>
39
+ <%% end %>
40
+ </div>
41
+ <%% if closable? %>
42
+ <button type="button"
43
+ data-action="click->modal#requestClose"
44
+ class="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500">
45
+ <span class="sr-only">Close</span>
46
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
47
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
48
+ </svg>
49
+ </button>
50
+ <%% end %>
51
+ </div>
52
+ <%% end %>
53
+
54
+ <%%# Content %>
55
+ <div class="px-4 py-4">
56
+ <%%= content %>
57
+ </div>
58
+
59
+ <%%# Footer %>
60
+ <%% if footer_actions? %>
61
+ <div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-200 bg-gray-50">
62
+ <%%= actions %>
63
+ </div>
64
+ <%% end %>
65
+
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class ModalComponent < BetterPage::ApplicationViewComponent
6
+ renders_one :trigger
7
+ renders_one :actions
8
+
9
+ SIZES = {
10
+ normal: "max-w-md",
11
+ large: "max-w-2xl"
12
+ }.freeze
13
+
14
+ def initialize(id:, title: nil, size: :normal, closable: true, actions_position: :footer, confirm_close: false)
15
+ @id = id
16
+ @title = title
17
+ @size = size.to_sym
18
+ @closable = closable
19
+ @actions_position = actions_position.to_sym
20
+ @confirm_close = confirm_close
21
+ end
22
+
23
+ attr_reader :id, :title, :size, :closable, :actions_position, :confirm_close
24
+
25
+ def closable?
26
+ @closable
27
+ end
28
+
29
+ def title?
30
+ title.present?
31
+ end
32
+
33
+ def show_header?
34
+ title? || closable?
35
+ end
36
+
37
+ def header_actions?
38
+ actions? && actions_position == :header
39
+ end
40
+
41
+ def footer_actions?
42
+ actions? && actions_position == :footer
43
+ end
44
+
45
+ def size_class
46
+ SIZES.fetch(size, SIZES[:normal])
47
+ end
48
+
49
+ def panel_classes
50
+ "relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full #{size_class}"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,22 @@
1
+ <%% if fields? %>
2
+ <div class="bg-white shadow overflow-hidden rounded-lg mb-6">
3
+ <div class="px-4 py-5 sm:p-6">
4
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-8 <%%= grid_classes %>">
5
+ <%% fields.each do |field| %>
6
+ <div class="<%%= field[:span] ? "sm:col-span-#{field[:span]}" : '' %>">
7
+ <dt class="text-sm font-medium text-gray-500"><%%= field[:label] %></dt>
8
+ <dd class="mt-1 text-sm text-gray-900">
9
+ <%% if field[:format] == :badge && field[:color] %>
10
+ <span class="<%%= badge_classes(field[:color]) %>"><%%= format_value(field) %></span>
11
+ <%% elsif field[:link] %>
12
+ <%%= link_to format_value(field), field[:link], class: "text-blue-600 hover:text-blue-500" %>
13
+ <%% else %>
14
+ <%%= format_value(field) %>
15
+ <%% end %>
16
+ </dd>
17
+ </div>
18
+ <%% end %>
19
+ </dl>
20
+ </div>
21
+ </div>
22
+ <%% end %>
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class OverviewComponent < BetterPage::ApplicationViewComponent
6
+ def initialize(fields:, columns: 2)
7
+ @fields = fields
8
+ @columns = columns
9
+ end
10
+
11
+ attr_reader :fields, :columns
12
+
13
+ def fields? = fields.any?
14
+
15
+ def grid_classes
16
+ case columns
17
+ when 1 then "sm:grid-cols-1"
18
+ when 2 then "sm:grid-cols-2"
19
+ when 3 then "sm:grid-cols-3"
20
+ when 4 then "sm:grid-cols-4"
21
+ else "sm:grid-cols-2"
22
+ end
23
+ end
24
+
25
+ def format_value(field)
26
+ value = field[:value]
27
+ return field[:empty] || "-" if value.blank?
28
+
29
+ case field[:format]&.to_sym
30
+ when :currency
31
+ number_to_currency(value)
32
+ when :date
33
+ value.respond_to?(:strftime) ? value.strftime("%B %d, %Y") : value
34
+ when :datetime
35
+ value.respond_to?(:strftime) ? value.strftime("%B %d, %Y %H:%M") : value
36
+ when :boolean
37
+ value ? "Yes" : "No"
38
+ when :percentage
39
+ "#{value}%"
40
+ when :badge
41
+ value
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ def badge_classes(color)
48
+ base = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
49
+
50
+ case color&.to_sym
51
+ when :green
52
+ "#{base} bg-green-100 text-green-800"
53
+ when :red
54
+ "#{base} bg-red-100 text-red-800"
55
+ when :yellow
56
+ "#{base} bg-yellow-100 text-yellow-800"
57
+ when :blue
58
+ "#{base} bg-blue-100 text-blue-800"
59
+ when :blue
60
+ "#{base} bg-blue-100 text-blue-800"
61
+ when :purple
62
+ "#{base} bg-purple-100 text-purple-800"
63
+ when :pink
64
+ "#{base} bg-pink-100 text-pink-800"
65
+ else
66
+ "#{base} bg-gray-100 text-gray-800"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,63 @@
1
+ <%% if paginated? || show_info? %>
2
+ <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" aria-label="Pagination">
3
+ <%% if show_info? %>
4
+ <div class="hidden sm:block">
5
+ <p class="text-sm text-gray-700">
6
+ Showing
7
+ <span class="font-medium"><%%= from_count %></span>
8
+ to
9
+ <span class="font-medium"><%%= to_count %></span>
10
+ of
11
+ <span class="font-medium"><%%= total_count %></span>
12
+ results
13
+ </p>
14
+ </div>
15
+ <%% end %>
16
+
17
+ <%% if paginated? %>
18
+ <div class="flex flex-1 justify-between sm:justify-end">
19
+ <div class="isolate inline-flex -space-x-px rounded-md shadow-sm">
20
+ <%% if current_page > 1 %>
21
+ <%%= link_to url_for(page: current_page - 1), class: "relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0" do %>
22
+ <span class="sr-only">Previous</span>
23
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
24
+ <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
25
+ </svg>
26
+ <%% end %>
27
+ <%% else %>
28
+ <span class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-300 ring-1 ring-inset ring-gray-300 cursor-not-allowed">
29
+ <span class="sr-only">Previous</span>
30
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
31
+ <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
32
+ </svg>
33
+ </span>
34
+ <%% end %>
35
+
36
+ <%% page_numbers.each do |page| %>
37
+ <%% if page == :ellipsis %>
38
+ <span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300">...</span>
39
+ <%% else %>
40
+ <%%= link_to page, url_for(page: page), class: page_link_classes(page) %>
41
+ <%% end %>
42
+ <%% end %>
43
+
44
+ <%% if current_page < total_pages %>
45
+ <%%= link_to url_for(page: current_page + 1), class: "relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0" do %>
46
+ <span class="sr-only">Next</span>
47
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
48
+ <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
49
+ </svg>
50
+ <%% end %>
51
+ <%% else %>
52
+ <span class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-300 ring-1 ring-inset ring-gray-300 cursor-not-allowed">
53
+ <span class="sr-only">Next</span>
54
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
55
+ <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
56
+ </svg>
57
+ </span>
58
+ <%% end %>
59
+ </div>
60
+ </div>
61
+ <%% end %>
62
+ </nav>
63
+ <%% end %>
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class PaginationComponent < BetterPage::ApplicationViewComponent
6
+ def initialize(items:, info: true)
7
+ @items = items
8
+ @show_info = info
9
+ end
10
+
11
+ attr_reader :items
12
+
13
+ def show_info? = @show_info
14
+ def paginated? = items.respond_to?(:total_pages) && items.total_pages > 1
15
+
16
+ def current_page = items.respond_to?(:current_page) ? items.current_page : 1
17
+ def total_pages = items.respond_to?(:total_pages) ? items.total_pages : 1
18
+ def total_count = items.respond_to?(:total_count) ? items.total_count : items.size
19
+
20
+ def from_count
21
+ return 0 if total_count.zero?
22
+
23
+ per_page = items.respond_to?(:limit_value) ? items.limit_value : 25
24
+ ((current_page - 1) * per_page) + 1
25
+ end
26
+
27
+ def to_count
28
+ per_page = items.respond_to?(:limit_value) ? items.limit_value : 25
29
+ [current_page * per_page, total_count].min
30
+ end
31
+
32
+ def page_numbers
33
+ return [] unless paginated?
34
+
35
+ pages = []
36
+ window = 2
37
+
38
+ # Always show first page
39
+ pages << 1
40
+
41
+ # Add ellipsis if needed
42
+ pages << :ellipsis if current_page > window + 2
43
+
44
+ # Add pages around current
45
+ ((current_page - window)..(current_page + window)).each do |page|
46
+ pages << page if page > 1 && page < total_pages
47
+ end
48
+
49
+ # Add ellipsis if needed
50
+ pages << :ellipsis if current_page < total_pages - window - 1
51
+
52
+ # Always show last page
53
+ pages << total_pages if total_pages > 1
54
+
55
+ pages.uniq
56
+ end
57
+
58
+ def page_link_classes(page)
59
+ base = "relative inline-flex items-center px-4 py-2 text-sm font-medium"
60
+
61
+ if page == current_page
62
+ "#{base} z-10 bg-blue-50 border-blue-500 text-blue-600"
63
+ else
64
+ "#{base} bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,31 @@
1
+ <div class="bg-white shadow rounded-lg overflow-hidden mb-6" <%% if collapsible? %>data-controller="panel"<%% end %>>
2
+ <%% if title? %>
3
+ <div class="px-4 py-5 sm:px-6 border-b border-gray-200 <%%= collapsible? ? 'cursor-pointer' : '' %>" <%% if collapsible? %>data-action="click->panel#toggle"<%% end %>>
4
+ <div class="flex items-center justify-between">
5
+ <div>
6
+ <h3 class="text-lg font-medium leading-6 text-gray-900"><%%= title %></h3>
7
+ <%% if description? %>
8
+ <p class="mt-1 text-sm text-gray-500"><%%= description %></p>
9
+ <%% end %>
10
+ </div>
11
+ <%% if collapsible? %>
12
+ <div class="ml-4 flex-shrink-0">
13
+ <svg class="h-5 w-5 text-gray-400 transition-transform" data-panel-target="icon" <%%= collapsed? ? '' : 'style="transform: rotate(180deg)"' %> fill="currentColor" viewBox="0 0 20 20">
14
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
15
+ </svg>
16
+ </div>
17
+ <%% end %>
18
+ </div>
19
+ </div>
20
+ <%% end %>
21
+
22
+ <%% if fields? %>
23
+ <div class="px-4 py-5 sm:p-6 <%%= collapsed? ? 'hidden' : '' %>" <%% if collapsible? %>data-panel-target="content"<%% end %>>
24
+ <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
25
+ <%% fields.each do |field| %>
26
+ <%%= render BetterPage::Ui::FieldComponent.new(**field) %>
27
+ <%% end %>
28
+ </div>
29
+ </div>
30
+ <%% end %>
31
+ </div>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class PanelComponent < BetterPage::ApplicationViewComponent
6
+ def initialize(title: nil, description: nil, fields: [], collapsible: false, collapsed: false)
7
+ @title = title
8
+ @description = description
9
+ @fields = fields
10
+ @collapsible = collapsible
11
+ @collapsed = collapsed
12
+ end
13
+
14
+ attr_reader :title, :description, :fields, :collapsible, :collapsed
15
+
16
+ def title? = title.present?
17
+ def description? = description.present?
18
+ def fields? = fields.any?
19
+ def collapsible? = collapsible
20
+ def collapsed? = collapsed
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,33 @@
1
+ <%% if stats? %>
2
+ <div class="mb-6">
3
+ <dl class="grid gap-5 <%%= grid_classes %>">
4
+ <%% stats.each do |stat| %>
5
+ <div class="relative bg-white pt-5 px-4 pb-12 sm:pt-6 sm:px-6 shadow rounded-lg overflow-hidden">
6
+ <dt>
7
+ <%% if stat[:icon] %>
8
+ <div class="absolute bg-blue-500 rounded-md p-3">
9
+ <span class="h-6 w-6 text-white"><%%= stat[:icon] %></span>
10
+ </div>
11
+ <%% end %>
12
+ <p class="<%%= stat[:icon] ? 'ml-16' : '' %> text-sm font-medium text-gray-500 truncate"><%%= stat[:label] %></p>
13
+ </dt>
14
+ <dd class="<%%= stat[:icon] ? 'ml-16' : '' %> flex items-baseline pb-6 sm:pb-7">
15
+ <p class="text-2xl font-semibold text-gray-900"><%%= stat[:value] %></p>
16
+ <%% if stat[:change] %>
17
+ <p class="ml-2 flex items-baseline text-sm font-semibold <%%= trend_classes(stat[:trend]) %>">
18
+ <%%= trend_icon(stat[:trend]) %>
19
+ <span class="sr-only"><%%= stat[:trend] == :up ? 'Increased' : 'Decreased' %> by</span>
20
+ <%%= stat[:change] %>
21
+ </p>
22
+ <%% end %>
23
+ <%% if stat[:link] %>
24
+ <div class="absolute bottom-0 inset-x-0 bg-gray-50 px-4 py-4 sm:px-6">
25
+ <%%= link_to stat[:link][:label], stat[:link][:path], class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
26
+ </div>
27
+ <%% end %>
28
+ </dd>
29
+ </div>
30
+ <%% end %>
31
+ </dl>
32
+ </div>
33
+ <%% end %>
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterPage
4
+ module Ui
5
+ class StatisticsComponent < BetterPage::ApplicationViewComponent
6
+ def initialize(stats:, columns: 4)
7
+ @stats = Array(stats)
8
+ @columns = columns
9
+ end
10
+
11
+ attr_reader :stats, :columns
12
+
13
+ def stats? = stats.any?
14
+
15
+ def grid_classes
16
+ case columns
17
+ when 2
18
+ "grid-cols-1 sm:grid-cols-2"
19
+ when 3
20
+ "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
21
+ when 4
22
+ "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
23
+ when 5
24
+ "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5"
25
+ else
26
+ "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
27
+ end
28
+ end
29
+
30
+ def trend_classes(trend)
31
+ case trend&.to_sym
32
+ when :up
33
+ "text-green-600"
34
+ when :down
35
+ "text-red-600"
36
+ else
37
+ "text-gray-500"
38
+ end
39
+ end
40
+
41
+ def trend_icon(trend)
42
+ case trend&.to_sym
43
+ when :up
44
+ '<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>'.html_safe
45
+ when :down
46
+ '<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>'.html_safe
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end