rails-image-post-solution 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +42 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +264 -0
  7. data/Rakefile +8 -0
  8. data/app/controllers/admin/frozen_posts_controller.rb +79 -0
  9. data/app/controllers/admin/image_reports_controller.rb +81 -0
  10. data/app/controllers/admin/users_controller.rb +100 -0
  11. data/app/controllers/rails_image_post_solution/admin/image_reports_controller.rb +92 -0
  12. data/app/controllers/rails_image_post_solution/image_reports_controller.rb +87 -0
  13. data/app/jobs/rails_image_post_solution/image_moderation_job.rb +97 -0
  14. data/app/models/rails_image_post_solution/image_report.rb +57 -0
  15. data/app/services/rails_image_post_solution/openai_vision_service.rb +128 -0
  16. data/app/views/admin/frozen_posts/index.html.erb +128 -0
  17. data/app/views/admin/image_reports/index.html.erb +94 -0
  18. data/app/views/admin/image_reports/show.html.erb +138 -0
  19. data/app/views/admin/users/index.html.erb +93 -0
  20. data/app/views/admin/users/show.html.erb +198 -0
  21. data/app/views/rails_image_post_solution/admin/image_reports/index.html.erb +100 -0
  22. data/app/views/rails_image_post_solution/admin/image_reports/show.html.erb +154 -0
  23. data/app/views/shared/_image_report_button.html.erb +26 -0
  24. data/app/views/shared/_image_report_modal.html.erb +45 -0
  25. data/config/locales/en.yml +260 -0
  26. data/config/locales/ja.yml +259 -0
  27. data/config/routes.rb +16 -0
  28. data/db/migrate/20250101000001_create_rails_image_post_solution_image_reports.rb +20 -0
  29. data/db/migrate/20250101000002_add_ai_moderation_fields_to_image_reports.rb +12 -0
  30. data/lib/generators/rails_image_post_solution/install/README +27 -0
  31. data/lib/generators/rails_image_post_solution/install/install_generator.rb +47 -0
  32. data/lib/generators/rails_image_post_solution/install/templates/add_ai_moderation_fields.rb.erb +12 -0
  33. data/lib/generators/rails_image_post_solution/install/templates/create_image_reports.rb.erb +19 -0
  34. data/lib/generators/rails_image_post_solution/install/templates/rails_image_post_solution.rb +15 -0
  35. data/lib/rails_image_post_solution/engine.rb +22 -0
  36. data/lib/rails_image_post_solution/version.rb +5 -0
  37. data/lib/rails_image_post_solution.rb +28 -0
  38. metadata +150 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsImagePostSolution
4
+ module Admin
5
+ class ImageReportsController < ApplicationController
6
+ before_action :require_login
7
+ before_action :require_admin
8
+ before_action :set_report, only: %i[show confirm dismiss]
9
+
10
+ def index
11
+ @status_filter = params[:status] || "all"
12
+
13
+ @reports = ImageReport.includes(:user, :active_storage_attachment, :reviewed_by)
14
+ .recent
15
+
16
+ # Filter by status
17
+ case @status_filter
18
+ when "pending"
19
+ @reports = @reports.pending
20
+ when "confirmed"
21
+ @reports = @reports.confirmed
22
+ when "dismissed"
23
+ @reports = @reports.dismissed
24
+ when "reviewed"
25
+ @reports = @reports.reviewed
26
+ end
27
+
28
+ @reports = @reports.limit(100)
29
+
30
+ # Statistics
31
+ @stats = {
32
+ total: ImageReport.count,
33
+ pending: ImageReport.pending.count,
34
+ confirmed: ImageReport.confirmed.count,
35
+ dismissed: ImageReport.dismissed.count,
36
+ reviewed: ImageReport.reviewed.count
37
+ }
38
+ end
39
+
40
+ def show
41
+ @attachment = @report.active_storage_attachment
42
+ @reported_user = @attachment.record.user if @attachment.record.respond_to?(:user)
43
+ @all_reports = ImageReport.where(active_storage_attachment_id: @attachment.id)
44
+ .includes(:user)
45
+ .recent
46
+ end
47
+
48
+ def confirm
49
+ @report.update!(
50
+ status: ImageReport::STATUSES[:confirmed],
51
+ reviewed_by: current_user,
52
+ reviewed_at: Time.current
53
+ )
54
+
55
+ redirect_to admin_image_reports_path, notice: I18n.t("rails_image_post_solution.flash.admin.report_confirmed")
56
+ end
57
+
58
+ def dismiss
59
+ @report.update!(
60
+ status: ImageReport::STATUSES[:dismissed],
61
+ reviewed_by: current_user,
62
+ reviewed_at: Time.current
63
+ )
64
+
65
+ redirect_to admin_image_reports_path, notice: I18n.t("rails_image_post_solution.flash.admin.report_dismissed")
66
+ end
67
+
68
+ private
69
+
70
+ def set_report
71
+ @report = ImageReport.find(params[:id])
72
+ rescue ActiveRecord::RecordNotFound
73
+ redirect_to admin_image_reports_path, alert: I18n.t("rails_image_post_solution.flash.admin.report_not_found")
74
+ end
75
+
76
+ def require_admin
77
+ admin_check = RailsImagePostSolution.configuration&.admin_check_method || :admin?
78
+
79
+ return if current_user.respond_to?(admin_check) && current_user.public_send(admin_check)
80
+
81
+ redirect_to main_app.root_path, alert: I18n.t("rails_image_post_solution.flash.admin.admin_access_only")
82
+ end
83
+
84
+ def require_login
85
+ # Call authentication method implemented in host application
86
+ return if respond_to?(:current_user) && current_user
87
+
88
+ redirect_to main_app.root_path, alert: I18n.t("rails_image_post_solution.flash.admin.login_required")
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsImagePostSolution
4
+ class ImageReportsController < ApplicationController
5
+ before_action :require_login
6
+ before_action :set_attachment, only: :create
7
+
8
+ def create
9
+ # Check if already reported
10
+ existing_report = ImageReport.find_by(
11
+ active_storage_attachment_id: @attachment.id,
12
+ user_id: current_user.id
13
+ )
14
+
15
+ if existing_report
16
+ respond_to do |format|
17
+ format.json do
18
+ render json: { error: I18n.t("rails_image_post_solution.flash.already_reported") },
19
+ status: :unprocessable_entity
20
+ end
21
+ format.html do
22
+ redirect_back fallback_location: root_path,
23
+ alert: I18n.t("rails_image_post_solution.flash.already_reported")
24
+ end
25
+ end
26
+ return
27
+ end
28
+
29
+ @report = ImageReport.new(
30
+ active_storage_attachment_id: @attachment.id,
31
+ user_id: current_user.id,
32
+ reason: params[:reason],
33
+ status: ImageReport::STATUSES[:pending]
34
+ )
35
+
36
+ if @report.save
37
+ # Run image moderation only on first report
38
+ if ImageReport.where(active_storage_attachment_id: @attachment.id).count == 1
39
+ ImageModerationJob.perform_later(@attachment.id)
40
+ end
41
+
42
+ respond_to do |format|
43
+ format.json do
44
+ render json: { success: true, message: I18n.t("rails_image_post_solution.flash.report_received") },
45
+ status: :created
46
+ end
47
+ format.html do
48
+ redirect_back fallback_location: root_path,
49
+ notice: I18n.t("rails_image_post_solution.flash.report_received")
50
+ end
51
+ end
52
+ else
53
+ respond_to do |format|
54
+ format.json { render json: { error: @report.errors.full_messages.join(", ") }, status: :unprocessable_entity }
55
+ format.html { redirect_back fallback_location: root_path, alert: @report.errors.full_messages.join(", ") }
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def set_attachment
63
+ @attachment = ActiveStorage::Attachment.find(params[:attachment_id])
64
+ rescue ActiveRecord::RecordNotFound
65
+ respond_to do |format|
66
+ format.json do
67
+ render json: { error: I18n.t("rails_image_post_solution.flash.image_not_found") }, status: :not_found
68
+ end
69
+ format.html do
70
+ redirect_back fallback_location: root_path, alert: I18n.t("rails_image_post_solution.flash.image_not_found")
71
+ end
72
+ end
73
+ end
74
+
75
+ def require_login
76
+ # Call authentication method implemented in host application
77
+ return if respond_to?(:current_user) && current_user
78
+
79
+ respond_to do |format|
80
+ format.json do
81
+ render json: { error: I18n.t("rails_image_post_solution.flash.login_required") }, status: :unauthorized
82
+ end
83
+ format.html { redirect_to main_app.root_path, alert: I18n.t("rails_image_post_solution.flash.login_required") }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsImagePostSolution
4
+ class ImageModerationJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ # Job to perform automatic image moderation
8
+ # Uses OpenAI Vision API to detect R18/R18G content
9
+ def perform(attachment_id)
10
+ attachment = ActiveStorage::Attachment.find_by(id: attachment_id)
11
+ return unless attachment&.blob
12
+
13
+ # Analyze image using OpenAI Vision Service
14
+ result = OpenaiVisionService.new.moderate_image(attachment)
15
+
16
+ # Process results
17
+ if result[:flagged]
18
+ # Create automatic report when inappropriate content is detected
19
+ create_auto_report(attachment, result)
20
+
21
+ # Temporarily freeze post (if enabled in config)
22
+ freeze_post(attachment, result) if auto_freeze_enabled?
23
+ end
24
+
25
+ Rails.logger.info "Image moderation completed for attachment ##{attachment_id}: #{result[:flagged] ? 'FLAGGED' : 'OK'}"
26
+ rescue StandardError => e
27
+ Rails.logger.error "Image moderation failed for attachment ##{attachment_id}: #{e.message}"
28
+ # Don't fail the job on error (leave for manual review)
29
+ end
30
+
31
+ private
32
+
33
+ def auto_freeze_enabled?
34
+ RailsImagePostSolution.configuration&.auto_freeze_on_flag != false
35
+ end
36
+
37
+ def create_auto_report(attachment, result)
38
+ # Create automatic report as system user (nil user_id)
39
+ # Skip if automatic report already exists
40
+ existing_report = ImageReport.find_by(
41
+ active_storage_attachment_id: attachment.id,
42
+ user_id: nil # Automatic report by system
43
+ )
44
+
45
+ return if existing_report
46
+
47
+ ImageReport.create!(
48
+ active_storage_attachment: attachment,
49
+ user_id: nil, # Automatic report by system
50
+ reason: build_auto_report_reason(result),
51
+ status: ImageReport::STATUSES[:confirmed], # Automatically set to confirmed (inappropriate)
52
+ reviewed_at: Time.current,
53
+ ai_flagged: result[:flagged],
54
+ ai_confidence: result[:confidence],
55
+ ai_categories: result[:categories].to_json,
56
+ ai_detected_at: Time.current
57
+ )
58
+ end
59
+
60
+ def build_auto_report_reason(result)
61
+ reasons = []
62
+ reasons << "Auto-detected: Inappropriate content detected"
63
+
64
+ if result[:categories]
65
+ flagged_categories = result[:categories].select { |_, flagged| flagged }
66
+ if flagged_categories.any?
67
+ reasons << "\nDetected categories:"
68
+ flagged_categories.each_key do |category|
69
+ reasons << " - #{category}"
70
+ end
71
+ end
72
+ end
73
+
74
+ reasons << "\nConfidence: #{(result[:confidence] * 100).round(1)}%" if result[:confidence]
75
+
76
+ reasons.join("\n")
77
+ end
78
+
79
+ # Temporarily freeze post
80
+ # Only works if host application implements freeze_post! method
81
+ def freeze_post(attachment, result)
82
+ record = attachment.record
83
+ return unless record
84
+ return unless record.respond_to?(:freeze_post!)
85
+
86
+ # Skip if already frozen
87
+ return if record.respond_to?(:frozen?) && record.frozen?
88
+
89
+ reason = "AI Auto-moderation: Post has been temporarily frozen due to inappropriate content detection.\n#{build_auto_report_reason(result)}"
90
+
91
+ record.freeze_post!(type: :temporary, reason: reason)
92
+ Rails.logger.info "Post #{record.class.name}##{record.id} has been temporarily frozen due to inappropriate content"
93
+ rescue StandardError => e
94
+ Rails.logger.error "Failed to freeze post for attachment ##{attachment.id}: #{e.message}"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsImagePostSolution
4
+ class ImageReport < ApplicationRecord
5
+ self.table_name = "image_reports"
6
+
7
+ # Status constants
8
+ STATUSES = {
9
+ pending: "pending", # Not reviewed yet
10
+ reviewed: "reviewed", # Reviewed (no action needed)
11
+ confirmed: "confirmed", # Confirmed (flagged as inappropriate)
12
+ dismissed: "dismissed" # Dismissed (no issues found)
13
+ }.freeze
14
+
15
+ # Report reason category keys (translations via I18n.t("rails_image_post_solution.image_report.categories.#{key}"))
16
+ REASON_CATEGORIES = %i[r18 r18g copyright spam harassment other].freeze
17
+
18
+ # Get category translation text
19
+ def self.category_text(key)
20
+ I18n.t("rails_image_post_solution.image_report.categories.#{key}")
21
+ end
22
+
23
+ # Get category list (for use in views)
24
+ def self.categories_for_select
25
+ REASON_CATEGORIES.map { |key| [ key, category_text(key) ] }
26
+ end
27
+
28
+ # Associations
29
+ belongs_to :active_storage_attachment, class_name: "ActiveStorage::Attachment"
30
+ belongs_to :user
31
+ belongs_to :reviewed_by, class_name: "User", optional: true
32
+
33
+ # Validations
34
+ validates :status, presence: true, inclusion: { in: STATUSES.values }
35
+ validates :user_id, uniqueness: { scope: :active_storage_attachment_id,
36
+ message: "has already reported this image" }
37
+
38
+ # Scopes
39
+ scope :pending, -> { where(status: STATUSES[:pending]) }
40
+ scope :reviewed, -> { where(status: STATUSES[:reviewed]) }
41
+ scope :confirmed, -> { where(status: STATUSES[:confirmed]) }
42
+ scope :dismissed, -> { where(status: STATUSES[:dismissed]) }
43
+ scope :recent, -> { order(created_at: :desc) }
44
+
45
+ # Check if image has been reported as inappropriate
46
+ def self.image_reported?(attachment_id)
47
+ where(active_storage_attachment_id: attachment_id)
48
+ .where(status: [ STATUSES[:pending], STATUSES[:confirmed] ])
49
+ .exists?
50
+ end
51
+
52
+ # Get report count for image
53
+ def self.report_count(attachment_id)
54
+ where(active_storage_attachment_id: attachment_id).count
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsImagePostSolution
4
+ class OpenaiVisionService
5
+ # Perform image content moderation using OpenAI Vision API
6
+
7
+ def initialize
8
+ api_key = RailsImagePostSolution.configuration&.openai_api_key || ENV["OPENAI_API_KEY"]
9
+ @client = OpenAI::Client.new(access_token: api_key) if api_key.present?
10
+ end
11
+
12
+ # Analyze image to detect R18/R18G content
13
+ # @param attachment [ActiveStorage::Attachment] Image to analyze
14
+ # @return [Hash] Analysis result { flagged: Boolean, categories: Hash, confidence: Float, raw_response: Hash }
15
+ def moderate_image(attachment)
16
+ # Skip if OpenAI API key is not configured
17
+ return safe_result unless @client
18
+
19
+ # Get image URL (generate temporary URL)
20
+ image_url = get_image_url(attachment)
21
+ return safe_result unless image_url
22
+
23
+ # Call OpenAI Vision API
24
+ response = call_vision_api(image_url)
25
+
26
+ # Parse response
27
+ parse_response(response)
28
+ rescue StandardError => e
29
+ Rails.logger.error "OpenAI Vision API error: #{e.message}"
30
+ safe_result # Fail safe on error (no flag)
31
+ end
32
+
33
+ private
34
+
35
+ def get_image_url(attachment)
36
+ # Rails.application.routes.url_helpers.rails_blob_url(attachment, only_path: false)
37
+ # Use Base64 encoding as local URLs don't work in development environment
38
+ return nil unless attachment.blob
39
+
40
+ # Encode image as Base64
41
+ image_data = attachment.blob.download
42
+ base64_image = Base64.strict_encode64(image_data)
43
+ "data:#{attachment.blob.content_type};base64,#{base64_image}"
44
+ rescue StandardError => e
45
+ Rails.logger.error "Failed to get image data: #{e.message}"
46
+ nil
47
+ end
48
+
49
+ def call_vision_api(image_url)
50
+ @client.chat(
51
+ parameters: {
52
+ model: "gpt-4o-mini", # Model capable of image analysis
53
+ messages: [
54
+ {
55
+ role: "user",
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: moderation_prompt
60
+ },
61
+ {
62
+ type: "image_url",
63
+ image_url: {
64
+ url: image_url
65
+ }
66
+ }
67
+ ]
68
+ }
69
+ ],
70
+ max_tokens: 500
71
+ }
72
+ )
73
+ end
74
+
75
+ def moderation_prompt
76
+ <<~PROMPT
77
+ Analyze this image and determine if it falls into any of the following categories:
78
+
79
+ 1. R18 (Adult Content): Contains sexual expression or nudity
80
+ 2. R18G (Grotesque Content): Contains violent, cruel, or gory imagery
81
+ 3. Illegal Content: Illegal drugs, inappropriate use of weapons, etc.
82
+
83
+ Please respond in the following JSON format:
84
+ {
85
+ "flagged": true/false,
86
+ "categories": {
87
+ "r18": true/false,
88
+ "r18g": true/false,
89
+ "illegal": true/false
90
+ },
91
+ "confidence": 0.0-1.0,
92
+ "reason": "Brief explanation of the determination"
93
+ }
94
+
95
+ **Important**: Respond only in JSON format, do not include any other text.
96
+ PROMPT
97
+ end
98
+
99
+ def parse_response(response)
100
+ content = response.dig("choices", 0, "message", "content")
101
+ return safe_result unless content
102
+
103
+ # Parse JSON response
104
+ result = JSON.parse(content)
105
+
106
+ {
107
+ flagged: result["flagged"] || false,
108
+ categories: result["categories"] || {},
109
+ confidence: result["confidence"] || 0.0,
110
+ reason: result["reason"],
111
+ raw_response: response
112
+ }
113
+ rescue JSON::ParserError => e
114
+ Rails.logger.error "Failed to parse OpenAI response: #{e.message}, Content: #{content}"
115
+ safe_result
116
+ end
117
+
118
+ def safe_result
119
+ {
120
+ flagged: false,
121
+ categories: {},
122
+ confidence: 0.0,
123
+ reason: nil,
124
+ raw_response: nil
125
+ }
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,128 @@
1
+ <div class="admin-container">
2
+ <h1 class="admin-header"><%= t('admin.frozen_posts.index.title') %></h1>
3
+
4
+ <!-- Statistics -->
5
+ <div class="admin-stats">
6
+ <div class="stat-card">
7
+ <div class="stat-label"><%= t('admin.frozen_posts.index.stats.total') %></div>
8
+ <div class="stat-value"><%= @stats[:total] %></div>
9
+ </div>
10
+ <div class="stat-card temporary">
11
+ <div class="stat-label"><%= t('admin.frozen_posts.index.stats.temporary') %></div>
12
+ <div class="stat-value"><%= @stats[:temporary] %></div>
13
+ </div>
14
+ <div class="stat-card permanent">
15
+ <div class="stat-label"><%= t('admin.frozen_posts.index.stats.permanent') %></div>
16
+ <div class="stat-value"><%= @stats[:permanent] %></div>
17
+ </div>
18
+ </div>
19
+
20
+ <!-- Filters -->
21
+ <div class="admin-filters">
22
+ <%= link_to t('admin.frozen_posts.index.filters.all'), admin_frozen_posts_path, class: "filter-btn #{'active' if @filter == 'all'}" %>
23
+ <%= link_to t('admin.frozen_posts.index.filters.temporary'), admin_frozen_posts_path(filter: "temporary"), class: "filter-btn #{'active' if @filter == 'temporary'}" %>
24
+ <%= link_to t('admin.frozen_posts.index.filters.permanent'), admin_frozen_posts_path(filter: "permanent"), class: "filter-btn #{'active' if @filter == 'permanent'}" %>
25
+ </div>
26
+
27
+ <!-- Frozen Posts List -->
28
+ <div class="admin-reports-list">
29
+ <% if @frozen_posts.any? %>
30
+ <table class="admin-table">
31
+ <thead>
32
+ <tr>
33
+ <th><%= t('admin.frozen_posts.index.table.type') %></th>
34
+ <th><%= t('admin.frozen_posts.index.table.content') %></th>
35
+ <th><%= t('admin.frozen_posts.index.table.poster') %></th>
36
+ <th><%= t('admin.frozen_posts.index.table.freeze_reason') %></th>
37
+ <th><%= t('admin.frozen_posts.index.table.status') %></th>
38
+ <th><%= t('admin.frozen_posts.index.table.frozen_at') %></th>
39
+ <th><%= t('admin.frozen_posts.index.table.actions') %></th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ <% @frozen_posts.each do |post| %>
44
+ <tr class="report-row status-<%= post.frozen_type %>">
45
+ <td>
46
+ <% if post.is_a?(Stage) %>
47
+ <span class="post-type-badge stage"><%= t('admin.frozen_posts.index.post_types.stage') %></span>
48
+ <% else %>
49
+ <span class="post-type-badge comment"><%= t('admin.frozen_posts.index.post_types.comment') %></span>
50
+ <% end %>
51
+ </td>
52
+ <td class="content-cell">
53
+ <% if post.is_a?(Stage) %>
54
+ <div class="post-title"><%= link_to post.title, stage_path(post), target: "_blank", class: "user-link" %></div>
55
+ <div class="post-excerpt"><%= truncate(post.description, length: 80) %></div>
56
+ <% else %>
57
+ <div class="post-title">
58
+ <%= link_to "#{t('admin.frozen_posts.index.comment_prefix')}: #{post.multiplay_recruitment.title}", multiplay_recruitment_path(post.multiplay_recruitment), target: "_blank", class: "user-link" %>
59
+ </div>
60
+ <div class="post-excerpt"><%= truncate(post.content, length: 80) %></div>
61
+ <% end %>
62
+ </td>
63
+ <td>
64
+ <%= link_to post.user.display_name, admin_user_path(post.user), class: "user-link" %>
65
+ </td>
66
+ <td class="reason-cell">
67
+ <%= truncate(post.frozen_reason, length: 100) %>
68
+ </td>
69
+ <td>
70
+ <% if post.temporarily_frozen? %>
71
+ <span class="status-badge status-pending"><%= t('admin.frozen_posts.index.status_badges.temporary') %></span>
72
+ <% elsif post.permanently_frozen? %>
73
+ <span class="status-badge status-confirmed"><%= t('admin.frozen_posts.index.status_badges.permanent') %></span>
74
+ <% end %>
75
+ </td>
76
+ <td>
77
+ <%= l(post.frozen_at, format: :long) %>
78
+ </td>
79
+ <td class="actions-cell">
80
+ <div class="action-buttons">
81
+ <% if post.is_a?(Stage) %>
82
+ <% unless post.permanently_frozen? %>
83
+ <%= button_to t('admin.frozen_posts.index.actions.permanent_freeze'), admin_frozen_posts_permanent_freeze_stage_path(post),
84
+ method: :patch,
85
+ class: "btn-action btn-warning",
86
+ data: { confirm: t('admin.frozen_posts.index.confirm.permanent_freeze') } %>
87
+ <% end %>
88
+ <%= button_to t('admin.frozen_posts.index.actions.unfreeze'), admin_frozen_posts_unfreeze_stage_path(post),
89
+ method: :patch,
90
+ class: "btn-action btn-success",
91
+ data: { confirm: t('admin.frozen_posts.index.confirm.unfreeze') } %>
92
+ <%= button_to t('admin.frozen_posts.index.actions.delete'), admin_frozen_posts_destroy_stage_path(post),
93
+ method: :delete,
94
+ class: "btn-action btn-danger",
95
+ data: { confirm: t('admin.frozen_posts.index.confirm.delete') } %>
96
+ <% else %>
97
+ <% unless post.permanently_frozen? %>
98
+ <%= button_to t('admin.frozen_posts.index.actions.permanent_freeze'), admin_frozen_posts_permanent_freeze_comment_path(post),
99
+ method: :patch,
100
+ class: "btn-action btn-warning",
101
+ data: { confirm: t('admin.frozen_posts.index.confirm.permanent_freeze') } %>
102
+ <% end %>
103
+ <%= button_to t('admin.frozen_posts.index.actions.unfreeze'), admin_frozen_posts_unfreeze_comment_path(post),
104
+ method: :patch,
105
+ class: "btn-action btn-success",
106
+ data: { confirm: t('admin.frozen_posts.index.confirm.unfreeze') } %>
107
+ <%= button_to t('admin.frozen_posts.index.actions.delete'), admin_frozen_posts_destroy_comment_path(post),
108
+ method: :delete,
109
+ class: "btn-action btn-danger",
110
+ data: { confirm: t('admin.frozen_posts.index.confirm.delete') } %>
111
+ <% end %>
112
+ </div>
113
+ </td>
114
+ </tr>
115
+ <% end %>
116
+ </tbody>
117
+ </table>
118
+ <% else %>
119
+ <div class="empty-state">
120
+ <p><%= t('admin.frozen_posts.index.no_frozen_posts') %></p>
121
+ </div>
122
+ <% end %>
123
+ </div>
124
+
125
+ <div class="admin-actions">
126
+ <%= link_to t('admin.frozen_posts.index.back'), root_path, class: "btn btn-secondary" %>
127
+ </div>
128
+ </div>