ruby_cms 0.1.1 → 0.1.2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -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 +81 -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 +25 -24
  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/settings_helper.rb +19 -9
  23. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +53 -12
  24. data/app/models/ruby_cms/permission.rb +38 -9
  25. data/app/models/ruby_cms/permittable.rb +0 -2
  26. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +2 -2
  27. data/app/views/layouts/ruby_cms/admin.html.erb +13 -17
  28. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +0 -11
  29. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +204 -85
  30. data/app/views/ruby_cms/admin/settings/index.html.erb +214 -175
  31. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +32 -2
  32. data/app/views/ruby_cms/admin/users/_row.html.erb +4 -1
  33. data/config/locales/en.yml +4 -0
  34. data/lib/ruby_cms/engine.rb +20 -12
  35. data/lib/ruby_cms/version.rb +1 -1
  36. data/lib/ruby_cms.rb +24 -0
  37. metadata +4 -3
  38. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +0 -17
@@ -9,20 +9,49 @@ module RubyCms
9
9
 
10
10
  validates :key, presence: true, uniqueness: true
11
11
 
12
- DEFAULT_KEYS = %w[
13
- manage_admin
14
- manage_permissions
15
- manage_content_blocks
16
- manage_visitor_errors
17
- manage_analytics
18
- ].freeze
12
+ DEFAULT_KEYS = RubyCms::DEFAULT_PERMISSION_KEYS
19
13
 
20
14
  class << self
21
15
  def ensure_defaults!
22
- DEFAULT_KEYS.each do |k|
23
- find_or_create_by!(key: k) {|p| p.name = k.humanize }
16
+ all_keys.each do |k|
17
+ find_or_create_by!(key: k) {|p| p.name = k.titleize }
24
18
  end
25
19
  end
20
+
21
+ def all_keys
22
+ (DEFAULT_KEYS + RubyCms.extra_permission_keys.map(&:to_s)).uniq.freeze
23
+ end
24
+
25
+ def templates
26
+ RubyCms.permission_templates
27
+ end
28
+
29
+ def register_keys(*keys)
30
+ RubyCms.register_permission_keys(*keys)
31
+ end
32
+
33
+ def register_template(name, label:, keys:, description: nil)
34
+ RubyCms.register_permission_template(name, label:, keys:, description:)
35
+ end
36
+
37
+ def apply_template!(user, template_name)
38
+ tmpl = templates[template_name.to_sym]
39
+ raise ArgumentError, "Unknown template: #{template_name}" unless tmpl
40
+
41
+ ensure_defaults!
42
+ perms = where(key: tmpl[:keys])
43
+ perms.each do |perm|
44
+ RubyCms::UserPermission.find_or_create_by!(user: user, permission: perm)
45
+ end
46
+ end
47
+
48
+ def matching_templates(user)
49
+ user_keys = RubyCms::UserPermission.where(user: user)
50
+ .joins(:permission)
51
+ .pluck("permissions.key")
52
+ templates.select {|_, tmpl| (tmpl[:keys] - user_keys).empty? }
53
+ .keys
54
+ end
26
55
  end
27
56
  end
28
57
  end
@@ -12,9 +12,7 @@ module RubyCms
12
12
  k = permission_key.to_s
13
13
  return false unless RubyCms::Permission.exists?(key: k)
14
14
 
15
- # Treat manage_admin as a super-permission for admin features.
16
15
  cms_permission_keys_cached.include?(k) ||
17
- cms_permission_keys_cached.include?("manage_admin") ||
18
16
  record&.can_edit?(self)
19
17
  end
20
18
 
@@ -76,7 +76,7 @@
76
76
 
77
77
  <div class="px-3 py-3 flex-shrink-0 bg-[#FAF9F5]" data-ruby-cms--mobile-menu-target="sidebarContent">
78
78
  <div class="mb-2">
79
- <%= form_with url: ruby_cms_admin_locale_path, method: :patch, scope: nil, data: { turbo: true } do |form| %>
79
+ <%= form_with url: ruby_cms_admin_locale_path, method: :patch, scope: nil, data: { turbo: false } do |form| %>
80
80
  <div class="relative">
81
81
  <div class="flex items-center gap-3 w-full px-3 py-2.5 text-sm font-medium text-gray-600 rounded-md no-underline transition-colors hover:bg-blue-500 hover:text-white">
82
82
  <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@@ -94,7 +94,7 @@
94
94
  {
95
95
  class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer",
96
96
  aria: { label: "Locale" },
97
- onchange: "this.form.requestSubmit()"
97
+ onchange: "this.form.submit()"
98
98
  } %>
99
99
  </div>
100
100
  <% end %>
@@ -39,34 +39,30 @@
39
39
  <% else %>
40
40
  <div class="flex-1 flex flex-col min-h-0 p-6 overflow-y-auto bg-gray-50">
41
41
  <div class="mx-auto w-full max-w-7xl">
42
+ <%# Page header – prefer AdminPageHeader component rendered by views.
43
+ Legacy content_for fallback kept for pages that haven't migrated. %>
42
44
  <% if content_for?(:title) || content_for?(:header_actions) %>
43
- <header class="flex-shrink-0 mb-4 <%= yield(:header_styles) if content_for?(:header_styles) %>">
45
+ <header class="flex-shrink-0 mb-4">
46
+ <% if content_for?(:breadcrumbs) %>
47
+ <nav class="mb-1 text-sm text-muted-foreground" aria-label="Breadcrumb">
48
+ <ol class="flex items-center flex-wrap gap-y-1">
49
+ <%= yield :breadcrumbs %>
50
+ </ol>
51
+ </nav>
52
+ <% end %>
44
53
  <div class="flex flex-wrap items-center justify-between gap-4">
45
- <div>
54
+ <div class="min-w-0">
46
55
  <% if content_for?(:title) %>
47
- <h1 class="text-lg font-semibold tracking-tight text-gray-900"><%= yield :title %></h1>
48
- <% end %>
49
- <% if content_for?(:breadcrumbs) %>
50
- <nav class="mt-0.5 text-sm text-gray-500" aria-label="Breadcrumb">
51
- <ol class="flex items-center flex-wrap gap-x-2 gap-y-1">
52
- <%= yield :breadcrumbs %>
53
- </ol>
54
- </nav>
56
+ <h1 class="text-lg font-semibold tracking-tight text-foreground"><%= yield :title %></h1>
55
57
  <% end %>
56
58
  </div>
57
59
  <% if content_for?(:header_actions) %>
58
- <div class="flex items-center flex-shrink-0">
60
+ <div class="flex items-center gap-3 flex-shrink-0">
59
61
  <%= yield :header_actions %>
60
62
  </div>
61
63
  <% end %>
62
64
  </div>
63
65
  </header>
64
- <% elsif content_for?(:breadcrumbs) %>
65
- <nav class="mb-4" aria-label="Breadcrumb">
66
- <ol class="flex items-center space-x-2 text-sm text-gray-600">
67
- <%= content_for :breadcrumbs %>
68
- </ol>
69
- </nav>
70
66
  <% end %>
71
67
 
72
68
  <%= render "layouts/ruby_cms/admin_flash_messages" %>
@@ -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 %>