ruby_cms 0.1.1 → 0.1.3

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +68 -164
  4. data/app/components/ruby_cms/admin/admin_page.rb +19 -19
  5. data/app/components/ruby_cms/admin/admin_page_header.rb +79 -0
  6. data/app/components/ruby_cms/admin/admin_resource_card.rb +55 -0
  7. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +4 -4
  8. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +5 -5
  9. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +1 -1
  10. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +15 -13
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +13 -11
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +9 -9
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +2 -2
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +8 -8
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +9 -9
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +3 -4
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +46 -32
  18. data/app/controllers/ruby_cms/admin/base_controller.rb +10 -4
  19. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +4 -3
  20. data/app/controllers/ruby_cms/admin/locale_controller.rb +2 -1
  21. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +25 -7
  22. data/app/helpers/ruby_cms/content_blocks_helper.rb +7 -3
  23. data/app/helpers/ruby_cms/settings_helper.rb +32 -20
  24. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +53 -12
  25. data/app/models/ruby_cms/permission.rb +38 -9
  26. data/app/models/ruby_cms/permittable.rb +0 -2
  27. data/app/services/ruby_cms/analytics/report.rb +37 -3
  28. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +2 -2
  29. data/app/views/layouts/ruby_cms/admin.html.erb +13 -17
  30. data/app/views/ruby_cms/admin/analytics/index.html.erb +103 -108
  31. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +28 -32
  32. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -2
  33. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +34 -20
  34. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +27 -27
  35. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +19 -19
  36. data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +21 -0
  37. data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +26 -0
  38. data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +34 -0
  39. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +16 -14
  40. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +3 -3
  41. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +14 -14
  42. data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +28 -0
  43. data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +21 -0
  44. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +44 -48
  45. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +0 -11
  46. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +204 -85
  47. data/app/views/ruby_cms/admin/settings/index.html.erb +214 -175
  48. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +32 -2
  49. data/app/views/ruby_cms/admin/users/_row.html.erb +4 -1
  50. data/config/locales/en.yml +4 -0
  51. data/lib/generators/ruby_cms/install_generator.rb +2 -1
  52. data/lib/ruby_cms/cli.rb +1 -1
  53. data/lib/ruby_cms/engine.rb +20 -12
  54. data/lib/ruby_cms/version.rb +1 -1
  55. data/lib/ruby_cms.rb +24 -0
  56. data/lib/tasks/admin.rake +120 -0
  57. data/log/test.log +7284 -0
  58. metadata +10 -4
  59. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +0 -17
  60. data/lib/tasks/ruby_cms.rake +0 -27
@@ -7,114 +7,110 @@
7
7
  show_security = high_volume || rapid || suspicious_ua
8
8
  %>
9
9
  <%= admin_page(
10
- title: t("ruby_cms.admin.analytics.visitor_details_title", default: "Visitor details"),
11
- subtitle: "#{@start_date} → #{@end_date}",
10
+ title: @ip_address,
11
+ subtitle: "Visitor analytics · #{@start_date} → #{@end_date}",
12
12
  content_card: false
13
13
  ) do %>
14
14
  <div class="space-y-6">
15
15
  <div class="flex items-start justify-between gap-4">
16
- <div class="min-w-0">
17
- <p class="text-sm font-medium text-gray-500">IP Address</p>
18
- <p class="mt-1 text-lg font-semibold tracking-tight text-gray-900 tabular-nums truncate"><%= @ip_address %></p>
19
- </div>
20
- <div class="flex items-center gap-2 flex-shrink-0">
16
+ <div class="flex items-center gap-2">
21
17
  <%= render "ruby_cms/admin/analytics/partials/security_alert" if show_security %>
22
- <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
23
18
  </div>
19
+ <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
24
20
  </div>
25
21
 
26
- <div class="grid grid-cols-4 gap-4">
27
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
28
- <p class="text-sm font-medium text-gray-500">Total views</p>
29
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@visitor_stats[:total_views]) %></p>
22
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
23
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
24
+ <p class="text-sm font-medium text-muted-foreground">Total views</p>
25
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@visitor_stats[:total_views]) %></p>
30
26
  </div>
31
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
32
- <p class="text-sm font-medium text-gray-500">Pages visited</p>
33
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@visitor_stats[:unique_pages]) %></p>
27
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
28
+ <p class="text-sm font-medium text-muted-foreground">Pages visited</p>
29
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@visitor_stats[:unique_pages]) %></p>
34
30
  </div>
35
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
36
- <p class="text-sm font-medium text-gray-500">First visit</p>
37
- <p class="mt-2 text-base font-semibold text-gray-900"><%= @visitor_stats[:first_visit].present? ? l(@visitor_stats[:first_visit], format: :short) : "—" %></p>
31
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
32
+ <p class="text-sm font-medium text-muted-foreground">First visit</p>
33
+ <p class="mt-2 text-base font-semibold text-foreground"><%= @visitor_stats[:first_visit].present? ? l(@visitor_stats[:first_visit], format: :short) : "—" %></p>
38
34
  </div>
39
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
40
- <p class="text-sm font-medium text-gray-500">Last visit</p>
41
- <p class="mt-2 text-base font-semibold text-gray-900"><%= @visitor_stats[:last_visit].present? ? l(@visitor_stats[:last_visit], format: :short) : "—" %></p>
35
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
36
+ <p class="text-sm font-medium text-muted-foreground">Last visit</p>
37
+ <p class="mt-2 text-base font-semibold text-foreground"><%= @visitor_stats[:last_visit].present? ? l(@visitor_stats[:last_visit], format: :short) : "—" %></p>
42
38
  </div>
43
39
  </div>
44
40
 
45
41
  <% first_visit_record = Ahoy::Visit.where(ip: @ip_address, started_at: @start_date.beginning_of_day..@end_date.end_of_day).order(started_at: :asc).first %>
46
42
  <% has_meta = first_visit_record && (first_visit_record.os.present? || first_visit_record.device_type.present? || first_visit_record.browser.present? || (first_visit_record.respond_to?(:landing_page) && first_visit_record.landing_page.present?)) %>
47
43
  <% if has_meta %>
48
- <div class="rounded-lg border border-gray-200/80 bg-white p-6 shadow-sm">
49
- <p class="text-sm font-semibold text-gray-900">Environment</p>
50
- <div class="mt-4 grid grid-cols-4 gap-4">
44
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
45
+ <p class="text-sm font-semibold text-foreground">Environment</p>
46
+ <div class="mt-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
51
47
  <% if first_visit_record.os.present? %>
52
48
  <div>
53
- <p class="text-xs font-medium text-gray-500">OS</p>
54
- <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.os %></p>
49
+ <p class="text-xs font-medium text-muted-foreground">OS</p>
50
+ <p class="mt-1 text-sm font-medium text-foreground"><%= first_visit_record.os %></p>
55
51
  </div>
56
52
  <% end %>
57
53
  <% if first_visit_record.device_type.present? %>
58
54
  <div>
59
- <p class="text-xs font-medium text-gray-500">Device</p>
60
- <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.device_type %></p>
55
+ <p class="text-xs font-medium text-muted-foreground">Device</p>
56
+ <p class="mt-1 text-sm font-medium text-foreground"><%= first_visit_record.device_type %></p>
61
57
  </div>
62
58
  <% end %>
63
59
  <% if first_visit_record.browser.present? %>
64
60
  <div>
65
- <p class="text-xs font-medium text-gray-500">Browser</p>
66
- <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.browser %></p>
61
+ <p class="text-xs font-medium text-muted-foreground">Browser</p>
62
+ <p class="mt-1 text-sm font-medium text-foreground"><%= first_visit_record.browser %></p>
67
63
  </div>
68
64
  <% end %>
69
65
  <% if first_visit_record.respond_to?(:landing_page) && first_visit_record.landing_page.present? %>
70
- <div class="col-span-4">
71
- <p class="text-xs font-medium text-gray-500">Landing</p>
72
- <p class="mt-1 text-sm font-medium text-gray-900 truncate" title="<%= first_visit_record.landing_page %>"><%= truncate(first_visit_record.landing_page, length: 120) %></p>
66
+ <div class="col-span-full">
67
+ <p class="text-xs font-medium text-muted-foreground">Landing page</p>
68
+ <p class="mt-1 text-sm font-medium text-foreground truncate" title="<%= first_visit_record.landing_page %>"><%= truncate(first_visit_record.landing_page, length: 120) %></p>
73
69
  </div>
74
70
  <% end %>
75
71
  </div>
76
72
  </div>
77
73
  <% end %>
78
74
 
79
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
80
- <div class="px-6 py-4 border-b border-gray-100">
81
- <p class="text-sm font-semibold text-gray-900">Activity</p>
82
- <p class="text-sm text-gray-500">Pages visited by this IP.</p>
75
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm overflow-hidden">
76
+ <div class="px-6 py-4 border-b border-border/40">
77
+ <p class="text-sm font-semibold text-foreground">Activity</p>
78
+ <p class="text-sm text-muted-foreground">Pages visited by this IP.</p>
83
79
  </div>
84
80
 
85
81
  <div class="overflow-x-auto">
86
82
  <table class="min-w-full text-sm">
87
- <thead class="bg-gray-50">
88
- <tr class="text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
83
+ <thead class="bg-muted/30">
84
+ <tr class="text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
89
85
  <th class="px-6 py-3">Page</th>
90
86
  <th class="px-6 py-3">Browser</th>
91
87
  <th class="px-6 py-3">Referrer</th>
92
88
  <th class="px-6 py-3">Time</th>
93
89
  </tr>
94
90
  </thead>
95
- <tbody class="divide-y divide-gray-100">
91
+ <tbody class="divide-y divide-border/40">
96
92
  <% if @visitor_views.any? %>
97
93
  <% @visitor_views.each do |event| %>
98
94
  <% visit = event.visit %>
99
- <tr class="hover:bg-gray-50 transition-colors">
100
- <td class="px-6 py-3 font-medium text-gray-900">
95
+ <tr class="hover:bg-muted/30 transition-colors">
96
+ <td class="px-6 py-3 font-medium text-foreground">
101
97
  <% page_name = event.respond_to?(:page_name) ? event.page_name : nil %>
102
98
  <% if page_name.present? %>
103
99
  <%= link_to page_name,
104
100
  page_details_ruby_cms_admin_analytics_path(page_name:, start_date: @start_date, end_date: @end_date, period: @period),
105
- class: "text-gray-900 hover:underline" %>
101
+ class: "text-primary hover:underline" %>
106
102
  <% else %>
107
- <span class="text-gray-500">Unknown</span>
103
+ <span class="text-muted-foreground">Unknown</span>
108
104
  <% end %>
109
105
  </td>
110
- <td class="px-6 py-3 text-gray-700"><%= visit&.browser || "Unknown" %></td>
111
- <td class="px-6 py-3 text-gray-700 truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
112
- <td class="px-6 py-3 text-gray-700"><%= time_ago_in_words(event.time) %> ago</td>
106
+ <td class="px-6 py-3 text-foreground"><%= visit&.browser || "Unknown" %></td>
107
+ <td class="px-6 py-3 text-foreground truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
108
+ <td class="px-6 py-3 text-muted-foreground"><%= time_ago_in_words(event.time) %> ago</td>
113
109
  </tr>
114
110
  <% end %>
115
111
  <% else %>
116
112
  <tr>
117
- <td colspan="4" class="px-6 py-10 text-center text-sm text-gray-500">No activity recorded for this visitor</td>
113
+ <td colspan="4" class="px-6 py-10 text-center text-sm text-muted-foreground">No activity recorded for this visitor</td>
118
114
  </tr>
119
115
  <% end %>
120
116
  </tbody>
@@ -29,18 +29,7 @@
29
29
  end
30
30
  %>
31
31
 
32
- <% locale_filter_url = ruby_cms_admin_content_blocks_path %>
33
- <% locale_filter_html = capture do %>
34
- <%= tag.div class: "flex items-center gap-1" do %>
35
- <%= tag.span "Locale:", class: "text-xs font-medium text-gray-500 mr-1" %>
36
- <% ([nil] + I18n.available_locales.to_a).each do |loc| %>
37
- <% is_active = (params[:locale].blank? && loc.nil?) || (loc && params[:locale].to_s == loc.to_s) %>
38
- <%= link_to (loc ? ruby_cms_locale_display_name(loc) : "All"), (loc ? "#{locale_filter_url}?locale=#{loc}" : locale_filter_url), class: "inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors no-underline #{is_active ? 'bg-gray-900 text-white' : 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'}", data: { turbo_frame: "admin_table_content" } %>
39
- <% end %>
40
- <% end %>
41
- <% end %>
42
32
  <%= render partial: "ruby_cms/admin/shared/bulk_action_table_index", locals: {
43
- header_filter: locale_filter_html,
44
33
  title: "Content blocks",
45
34
  collection: @content_blocks || [],
46
35
  headers: ["Key", "Locale", "Title", "Type", "Published", { text: "Actions", class: "text-right" }],
@@ -1,110 +1,229 @@
1
- <div class="flex flex-wrap items-center justify-between gap-4 mb-4">
2
- <h1 class="text-lg font-semibold tracking-tight text-gray-900">Content block</h1>
3
- <div class="flex items-center gap-2">
4
- <%= link_to "Edit",
5
- edit_ruby_cms_admin_content_block_path(@content_block),
6
- class: "inline-flex h-9 items-center justify-center rounded-md bg-gray-900 px-4 text-sm font-medium text-white shadow-sm hover:bg-gray-800 transition-colors no-underline" %>
7
- <%= link_to "Back",
8
- ruby_cms_admin_content_blocks_path,
9
- class: "inline-flex h-9 items-center justify-center rounded-md border border-gray-200 bg-white px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors no-underline" %>
10
- </div>
11
- </div>
1
+ <%= render RubyCms::Admin::AdminPageHeader.new(
2
+ title: @content_block.key,
3
+ subtitle: ruby_cms_locale_display_name(@content_block.locale),
4
+ breadcrumbs: [
5
+ { label: t("ruby_cms.admin.nav.admin", default: "Admin"), url: ruby_cms_admin_root_path },
6
+ { label: t("ruby_cms.admin.nav.content_blocks", default: "Content Blocks"), url: ruby_cms_admin_content_blocks_path },
7
+ { label: @content_block.key }
8
+ ]
9
+ ) do %>
10
+ <% if @content_block.published? %>
11
+ <span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full border bg-emerald-100 text-emerald-700 border-emerald-200">Published</span>
12
+ <% else %>
13
+ <span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full border bg-muted text-muted-foreground border-border/60">Draft</span>
14
+ <% end %>
12
15
 
13
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
16
+ <%= link_to ruby_cms_admin_content_block_path(@content_block),
17
+ data: { turbo_method: :delete, turbo_confirm: t("ruby_cms.admin.content_blocks.confirm_delete", default: "Are you sure?") },
18
+ class: "inline-flex items-center justify-center size-8 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" do %>
19
+ <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
20
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
21
+ </svg>
22
+ <% end %>
23
+ <% end %>
14
24
 
15
- <div class="grid grid-cols-2 sm:grid-cols-4 gap-6 p-6 border-b border-gray-100">
16
- <div>
17
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">ID</div>
18
- <div class="text-sm text-gray-900"><%= @content_block.id %></div>
19
- </div>
25
+ <%= form_with model: @content_block,
26
+ url: ruby_cms_admin_content_block_path(@content_block),
27
+ method: :patch,
28
+ id: "content-block-form",
29
+ data: { turbo: false } do |f| %>
30
+ <div class="<%= RubyCms::Admin::AdminResourceCard::CARD_CLASS %>">
31
+ <div class="<%= RubyCms::Admin::AdminResourceCard::GRID_CLASS %>">
32
+ <%# ── Form Fields (Left 2/3) ── %>
33
+ <div class="<%= RubyCms::Admin::AdminResourceCard::MAIN_CLASS %>">
34
+ <% if @content_block.errors.any? %>
35
+ <div class="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
36
+ <div class="flex gap-3">
37
+ <svg class="size-5 text-destructive shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
38
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
39
+ </svg>
40
+ <div>
41
+ <p class="text-sm font-medium text-destructive"><%= t("ruby_cms.admin.content_blocks.errors_title", default: "Please fix the following errors:") %></p>
42
+ <ul class="mt-1.5 text-sm text-destructive/80 list-disc list-inside">
43
+ <% @content_block.errors.full_messages.each do |message| %>
44
+ <li><%= message %></li>
45
+ <% end %>
46
+ </ul>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ <% end %>
20
51
 
21
- <div>
22
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Key</div>
23
- <div class="text-sm font-mono text-gray-900"><%= @content_block.key %></div>
24
- </div>
52
+ <%# ── Key & Content Type ── %>
53
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
54
+ <div>
55
+ <label class="<%= RubyCms::Admin::AdminResourceCard::LABEL_CLASS %>"><%= t("ruby_cms.admin.content_blocks.key", default: "Key") %></label>
56
+ <span class="block text-sm font-mono text-foreground py-2"><%= @content_block.key %></span>
57
+ <%= f.hidden_field :key, name: nil, value: @content_block.key %>
58
+ </div>
25
59
 
26
- <div>
27
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Locale</div>
28
- <div class="text-sm text-gray-900"><%= ruby_cms_locale_display_name(@content_block.locale) %></div>
29
- </div>
60
+ <div>
61
+ <label class="<%= RubyCms::Admin::AdminResourceCard::LABEL_CLASS %>"><%= t("ruby_cms.admin.content_blocks.content_type", default: "Content Type") %></label>
62
+ <%= select_tag "content_block[content_type]",
63
+ options_for_select(::ContentBlock::CONTENT_TYPES, @content_block.content_type),
64
+ class: RubyCms::Admin::AdminResourceCard::INPUT_CLASS %>
65
+ </div>
66
+ </div>
30
67
 
31
- <div>
32
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Type</div>
33
- <div class="text-sm text-gray-900"><%= @content_block.content_type %></div>
34
- </div>
68
+ <%# ── Published checkbox ── %>
69
+ <% if @blocks_by_locale.present? %>
70
+ <label class="inline-flex items-center gap-3 cursor-pointer">
71
+ <%= hidden_field_tag "content_block[published]", "0" %>
72
+ <%= check_box_tag "content_block[published]", "1",
73
+ @blocks_by_locale.values.any?(&:published?),
74
+ class: "size-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer" %>
75
+ <span class="text-sm font-medium text-foreground"><%= t("ruby_cms.admin.content_blocks.published", default: "Published") %> (all translations)</span>
76
+ </label>
77
+ <% end %>
78
+
79
+ <%# ── Locale Tabs ── %>
80
+ <% if @blocks_by_locale.present? %>
81
+ <div class="rounded-xl border border-border/60 overflow-hidden" data-controller="ruby-cms--locale-tabs">
82
+ <div class="flex gap-1 p-2 bg-muted/30 border-b border-border/60" role="tablist">
83
+ <% @blocks_by_locale.each_with_index do |(locale_s, _), idx| %>
84
+ <button type="button"
85
+ role="tab"
86
+ aria-selected="<%= idx == 0 %>"
87
+ aria-controls="locale-panel-<%= locale_s %>"
88
+ id="locale-tab-<%= locale_s %>"
89
+ data-ruby-cms--locale-tabs-target="tab"
90
+ data-panel-id="locale-panel-<%= locale_s %>"
91
+ data-action="click->ruby-cms--locale-tabs#switchTab"
92
+ class="px-3 py-1.5 rounded-md text-xs font-medium border-none cursor-pointer transition-colors <%= idx == 0 ? 'bg-background text-primary shadow-sm ring-1 ring-border/60' : 'bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground' %>">
93
+ <%= ruby_cms_locale_display_name(locale_s) %>
94
+ </button>
95
+ <% end %>
96
+ </div>
97
+
98
+ <% @blocks_by_locale.each_with_index do |(locale_s, block), idx| %>
99
+ <div id="locale-panel-<%= locale_s %>"
100
+ role="tabpanel"
101
+ aria-labelledby="locale-tab-<%= locale_s %>"
102
+ class="p-5 <%= idx > 0 ? 'hidden' : '' %>"
103
+ data-locale-panel>
104
+
105
+ <%= hidden_field_tag "content_block[locales][#{locale_s}][locale]", locale_s %>
106
+
107
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
108
+ <div>
109
+ <label class="<%= RubyCms::Admin::AdminResourceCard::LABEL_CLASS %>"><%= t("ruby_cms.admin.content_blocks.title", default: "Title") %></label>
110
+ <%= text_field_tag "content_block[locales][#{locale_s}][title]",
111
+ block.title,
112
+ class: RubyCms::Admin::AdminResourceCard::INPUT_CLASS %>
113
+ </div>
35
114
 
36
- <div>
37
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Published</div>
38
- <div class="text-sm text-gray-900">
39
- <% if @content_block.published? %>
40
- <span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-200">Published</span>
41
- <% else %>
42
- <span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-200">Draft</span>
115
+ <div class="sm:col-span-2">
116
+ <label class="<%= RubyCms::Admin::AdminResourceCard::LABEL_CLASS %>"><%= t("ruby_cms.admin.content_blocks.content", default: "Content") %></label>
117
+ <%= text_area_tag "content_block[locales][#{locale_s}][content]",
118
+ block.content,
119
+ rows: 5,
120
+ class: "#{RubyCms::Admin::AdminResourceCard::INPUT_CLASS} resize-y" %>
121
+ </div>
122
+
123
+ <% if ::ContentBlock.respond_to?(:action_text_available?) && ::ContentBlock.action_text_available? && block.persisted? && block.respond_to?(:rich_content) %>
124
+ <div class="sm:col-span-2">
125
+ <label class="<%= RubyCms::Admin::AdminResourceCard::LABEL_CLASS %>"><%= t("ruby_cms.admin.content_blocks.rich_content", default: "Rich Content") %></label>
126
+ <%= rich_text_area_tag "content_block[locales][#{locale_s}][rich_content]",
127
+ block.rich_content.present? ? block.rich_content.to_s : "",
128
+ class: "rounded-lg border border-border bg-background text-sm shadow-sm focus:outline-none" %>
129
+ </div>
130
+ <% end %>
131
+ </div>
132
+ </div>
133
+ <% end %>
134
+ </div>
43
135
  <% end %>
44
136
  </div>
45
- </div>
46
137
 
47
- <div>
48
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Created At</div>
49
- <div class="text-sm text-gray-900"><%= @content_block.created_at.strftime("%B %d, %Y at %I:%M %p") %></div>
50
- </div>
138
+ <%# ── Details Sidebar (Right 1/3) ── %>
139
+ <div class="<%= RubyCms::Admin::AdminResourceCard::SIDEBAR_CLASS %>">
140
+ <div>
141
+ <h3 class="<%= RubyCms::Admin::AdminResourceCard::SECTION_TITLE_CLASS %>">Details</h3>
142
+ <dl class="space-y-4">
143
+ <div>
144
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">ID</dt>
145
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %> font-mono"><%= @content_block.id %></dd>
146
+ </div>
51
147
 
52
- <div>
53
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Last Updated</div>
54
- <div class="text-sm text-gray-900"><%= @content_block.updated_at.strftime("%B %d, %Y at %I:%M %p") %></div>
55
- </div>
148
+ <div>
149
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Key</dt>
150
+ <dd class="mt-1">
151
+ <code class="text-xs bg-muted px-2 py-1 rounded-md font-mono text-foreground"><%= @content_block.key %></code>
152
+ </dd>
153
+ </div>
56
154
 
57
- <% if @content_block.updated_by.present? %>
58
- <div>
59
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Updated By</div>
60
- <div class="text-sm text-gray-900"><%= ruby_cms_user_display(@content_block.updated_by) %></div>
61
- </div>
62
- <% end %>
63
- </div>
155
+ <div>
156
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Locale</dt>
157
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"><%= ruby_cms_locale_display_name(@content_block.locale) %></dd>
158
+ </div>
64
159
 
65
- <% if @blocks_by_locale.present? %>
66
- <div class="grid gap-4 p-6" style="grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));">
67
- <% @blocks_by_locale.each do |locale_s, block| %>
68
- <div class="rounded-md border border-gray-200 bg-gray-50 overflow-hidden">
69
- <div class="px-3 py-2 border-b border-gray-200 bg-gray-100">
70
- <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-white text-gray-600 ring-1 ring-gray-200">
71
- <%= ruby_cms_locale_display_name(locale_s) %>
72
- </span>
73
- </div>
160
+ <div>
161
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Type</dt>
162
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"><%= @content_block.content_type %></dd>
163
+ </div>
74
164
 
75
- <div class="p-4 grid grid-cols-2 gap-4">
76
165
  <div>
77
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Title</div>
78
- <div class="text-sm text-gray-900"><%= block.title.presence || "—" %></div>
166
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Status</dt>
167
+ <dd class="mt-1">
168
+ <% if @content_block.published? %>
169
+ <span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-200">Published</span>
170
+ <% else %>
171
+ <span class="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-inset ring-border/60">Draft</span>
172
+ <% end %>
173
+ </dd>
79
174
  </div>
175
+ </dl>
176
+ </div>
80
177
 
81
- <div class="col-span-2">
82
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Content</div>
83
- <div class="text-sm text-gray-900 whitespace-pre-wrap"><%= block.content_body.presence || "—" %></div>
178
+ <div class="border-t border-border/60 pt-5">
179
+ <h3 class="<%= RubyCms::Admin::AdminResourceCard::SECTION_TITLE_CLASS %> mb-3">Timestamps</h3>
180
+ <dl class="space-y-4">
181
+ <div>
182
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Created</dt>
183
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"><%= @content_block.created_at.strftime("%b %d, %Y") %></dd>
84
184
  </div>
85
185
 
86
- <% if block.respond_to?(:rich_content) && block.rich_content.present? %>
87
- <div class="col-span-2">
88
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Rich content</div>
89
- <div class="text-sm text-gray-900 whitespace-pre-wrap"><%= block.rich_content %></div>
186
+ <div>
187
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Last Updated</dt>
188
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"><%= @content_block.updated_at.strftime("%b %d, %Y") %></dd>
189
+ </div>
190
+
191
+ <% if @content_block.updated_by.present? %>
192
+ <div>
193
+ <dt class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_LABEL_CLASS %>">Updated By</dt>
194
+ <dd class="<%= RubyCms::Admin::AdminResourceCard::DETAIL_VALUE_CLASS %>"><%= ruby_cms_user_display(@content_block.updated_by) %></dd>
90
195
  </div>
91
196
  <% end %>
92
- </div>
197
+ </dl>
93
198
  </div>
94
- <% end %>
95
- </div>
96
- <% else %>
97
- <div class="grid grid-cols-2 gap-6 p-6">
98
- <div>
99
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Title</div>
100
- <div class="text-sm text-gray-900"><%= @content_block.title %></div>
101
- </div>
102
199
 
103
- <div>
104
- <div class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Content</div>
105
- <div class="text-sm text-gray-900 whitespace-pre-wrap"><%= @content_block.content_body %></div>
200
+ <%# ── Preview per locale ── %>
201
+ <% if @blocks_by_locale.present? && @blocks_by_locale.values.any? { |b| b.title.present? || b.content_body.present? } %>
202
+ <div class="border-t border-border/60 pt-5">
203
+ <h3 class="<%= RubyCms::Admin::AdminResourceCard::SECTION_TITLE_CLASS %> mb-3">Content Preview</h3>
204
+ <div class="space-y-3">
205
+ <% @blocks_by_locale.each do |locale_s, block| %>
206
+ <% next if block.title.blank? && block.content_body.blank? %>
207
+ <div class="rounded-lg border border-border/60 bg-muted/30 p-3">
208
+ <span class="inline-flex items-center rounded-md bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground ring-1 ring-border/60 mb-2"><%= ruby_cms_locale_display_name(locale_s) %></span>
209
+ <% if block.title.present? %>
210
+ <p class="text-xs font-medium text-foreground"><%= block.title %></p>
211
+ <% end %>
212
+ <% if block.content_body.present? %>
213
+ <p class="text-xs text-muted-foreground mt-1 line-clamp-3"><%= block.content_body %></p>
214
+ <% end %>
215
+ </div>
216
+ <% end %>
217
+ </div>
218
+ </div>
219
+ <% end %>
106
220
  </div>
107
221
  </div>
108
- <% end %>
109
222
 
110
- </div>
223
+ <%# ── Actions Footer ── %>
224
+ <div class="<%= RubyCms::Admin::AdminResourceCard::ACTIONS_CLASS %>">
225
+ <%= link_to t("ruby_cms.admin.common.cancel", default: "Cancel"), ruby_cms_admin_content_blocks_path, class: RubyCms::Admin::AdminResourceCard::CANCEL_CLASS %>
226
+ <%= f.submit t("ruby_cms.admin.common.save", default: "Save Changes"), class: RubyCms::Admin::AdminResourceCard::SUBMIT_CLASS %>
227
+ </div>
228
+ </div>
229
+ <% end %>