From 530021960e212733018234cc35c6e7fc52eaa1d4 Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 30 Jan 2026 01:28:53 +0100 Subject: [PATCH] add entry requests, invite new users --- app/controllers/admin/dashboard_controller.rb | 1 + app/controllers/admin/requests_controller.rb | 64 +++++ app/controllers/entries_controller.rb | 13 +- app/controllers/invitations_controller.rb | 3 + app/controllers/requests_controller.rb | 75 ++++++ app/helpers/entries_helper.rb | 11 + app/mailers/invitation_mailer.rb | 11 +- app/models/entry.rb | 12 + app/models/user.rb | 1 + app/views/admin/dashboard/index.html.erb | 40 +-- app/views/admin/requests/edit.html.erb | 81 ++++++ app/views/admin/requests/index.html.erb | 126 +++++++++ app/views/admin/requests/show.html.erb | 96 +++++++ app/views/entries/_results.html.erb | 3 + app/views/entries/index.html.erb | 17 ++ app/views/invitation_mailer/invite.html.erb | 76 +++++- app/views/invitation_mailer/invite.text.erb | 19 ++ app/views/layouts/admin.html.erb | 19 +- app/views/requests/new.html.erb | 129 ++++++++++ config/routes.rb | 9 + .../20260129204705_add_status_to_entries.rb | 13 + ...60129204706_add_requested_by_to_entries.rb | 7 + db/structure.sql | 47 ++-- public/favicon.ico | Bin 4286 -> 146084 bytes public/icon.png | Bin 5012 -> 23435 bytes public/icon.svg | 74 ++---- test/README_REQUESTS_TESTING.md | 141 ++++++++++ .../admin/requests_controller_test.rb | 184 ++++++++++++++ test/controllers/requests_controller_test.rb | 223 ++++++++++++++++ test/fixtures/entries.yml | 32 +++ test/fixtures/users.yml | 11 + test/integration/entry_request_flow_test.rb | 240 ++++++++++++++++++ test/mailers/invitation_mailer_test.rb | 46 ++++ test/models/entry_request_test.rb | 128 ++++++++++ test/models/entry_test.rb | 4 +- 35 files changed, 1838 insertions(+), 118 deletions(-) create mode 100644 app/controllers/admin/requests_controller.rb create mode 100644 app/controllers/requests_controller.rb create mode 100644 app/views/admin/requests/edit.html.erb create mode 100644 app/views/admin/requests/index.html.erb create mode 100644 app/views/admin/requests/show.html.erb create mode 100644 app/views/requests/new.html.erb create mode 100644 db/migrate/20260129204705_add_status_to_entries.rb create mode 100644 db/migrate/20260129204706_add_requested_by_to_entries.rb create mode 100644 test/README_REQUESTS_TESTING.md create mode 100644 test/controllers/admin/requests_controller_test.rb create mode 100644 test/controllers/requests_controller_test.rb create mode 100644 test/integration/entry_request_flow_test.rb create mode 100644 test/models/entry_request_test.rb diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index bad4114..0d5fbb7 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController @pending_suggestions_count = SuggestedMeaning.pending.count @accepted_suggestions_count = SuggestedMeaning.accepted.count @rejected_suggestions_count = SuggestedMeaning.rejected.count + @requested_entries_count = Entry.requested.count @comment_count = Comment.count diff --git a/app/controllers/admin/requests_controller.rb b/app/controllers/admin/requests_controller.rb new file mode 100644 index 0000000..bc2df29 --- /dev/null +++ b/app/controllers/admin/requests_controller.rb @@ -0,0 +1,64 @@ +class Admin::RequestsController < Admin::BaseController + def index + @requested_entries = Entry.requested + .includes(:requested_by) + .order(created_at: :desc) + + @approved_entries = Entry.approved + .includes(:requested_by) + .order(updated_at: :desc) + end + + def show + @entry = Entry.find(params[:id]) + end + + def edit + @entry = Entry.find(params[:id]) + end + + def update + @entry = Entry.find(params[:id]) + + if @entry.update(entry_params) + redirect_to admin_request_path(@entry), notice: "Request updated successfully." + else + flash.now[:alert] = "Error updating request." + render :edit, status: :unprocessable_entity + end + end + + def approve + @entry = Entry.find(params[:id]) + @user = @entry.requested_by + + @user.update!( + invitation_token: SecureRandom.urlsafe_base64(32), + invitation_sent_at: Time.current, + invited_by: current_user + ) + + @entry.update!(status: :approved) + InvitationMailer.invite(@user, approved_entry: @entry).deliver_later + + redirect_to admin_requests_path, notice: "Request approved and invitation sent to #{@user.email}." + end + + def reject + @entry = Entry.find(params[:id]) + @user = @entry.requested_by + + entry_preview = [@entry.fi, @entry.en, @entry.sv, @entry.no, @entry.ru, @entry.de].compact.first || "Entry" + + @entry.destroy! + @user.destroy! if @user.requested_entries.count.zero? + + redirect_to admin_requests_path, notice: "Request '#{entry_preview}' has been rejected and deleted." + end + + private + + def entry_params + params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes) + end +end diff --git a/app/controllers/entries_controller.rb b/app/controllers/entries_controller.rb index d8d0060..1f6c7d5 100644 --- a/app/controllers/entries_controller.rb +++ b/app/controllers/entries_controller.rb @@ -9,7 +9,7 @@ class EntriesController < ApplicationController @page = [ params[:page].to_i, 1 ].max @per_page = 25 - entries_scope = Entry.all + entries_scope = Entry.active_entries entries_scope = entries_scope.with_category(@category) entries_scope = entries_scope.search(@query, language_code: @language_code) entries_scope = entries_scope.starts_with(@starts_with, language_code: @language_code) if @starts_with.present? @@ -20,17 +20,18 @@ class EntriesController < ApplicationController @total_pages = (@total_entries.to_f / @per_page).ceil @entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page) - @entry_count = Entry.count - @verified_count = Entry.where(verified: true).count + @entry_count = Entry.active_entries.count + @requested_count = Entry.requested.count + @verified_count = Entry.active_entries.where(verified: true).count @needs_review_count = @entry_count - @verified_count - @complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language| + @complete_entries_count = supported_languages.reduce(Entry.active_entries) do |scope, language| scope.where.not(language.code => [ nil, "" ]) end.count @missing_entries_count = @entry_count - @complete_entries_count @language_completion = supported_languages.index_with do |language| next 0 if @entry_count.zero? - (Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round + (Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round end if @language_code.present? @@ -61,7 +62,7 @@ class EntriesController < ApplicationController end def download - @entries = Entry.order(:id) + @entries = Entry.active_entries.order(:id) respond_to do |format| format.xlsx do filename = "sanasto-entries-#{Time.zone.today}.xlsx" diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 8f8c3e8..9e1d513 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -21,6 +21,9 @@ class InvitationsController < ApplicationController invitation_token: nil ) + # Activate approved entries by this user + Entry.where(requested_by: @user, status: :approved).update_all(status: :active) + session[:user_id] = @user.id redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!" else diff --git a/app/controllers/requests_controller.rb b/app/controllers/requests_controller.rb new file mode 100644 index 0000000..872194d --- /dev/null +++ b/app/controllers/requests_controller.rb @@ -0,0 +1,75 @@ +class RequestsController < ApplicationController + def new + @entry = Entry.new + + if current_user + @pending_count = current_user.requested_entries.where(status: [ :requested, :approved ]).count + elsif params[:email].present? + @pending_count = User.find_by(email: params[:email])&.requested_entries&.where(status: [ :requested, :approved ])&.count || 0 + else + @pending_count = 0 + end + end + + def create + # If user is logged in, use their account + if current_user + @user = current_user + else + # Anonymous submission - need to find or create user + email = request_params[:email] + existing_user = User.find_by(email: email) + + # Check if user has already accepted an invitation + if existing_user&.invitation_accepted_at.present? + redirect_to login_path, alert: "An account with this email already exists. Please log in." + return + end + + # Use existing pending user or create new one + @user = existing_user || User.new( + name: request_params[:name], + email: email, + password: SecureRandom.alphanumeric(32), + role: :contributor + ) + end + + # Create entry in a transaction + ActiveRecord::Base.transaction do + # Save user only if it's a new record + if @user.new_record? && !@user.save + @pending_count = 0 + @entry = Entry.new(entry_params) + flash.now[:alert] = "There was an error submitting your request. Please check the form." + render :new, status: :unprocessable_entity + raise ActiveRecord::Rollback + return + end + + # Create entry + @entry = Entry.new(entry_params) + @entry.status = :requested + @entry.requested_by = @user + + if @entry.save + redirect_to root_path, notice: "Thank you for your request! We'll review it and get back to you soon." + else + @pending_count = 0 + flash.now[:alert] = "There was an error submitting your request. Please check the form." + render :new, status: :unprocessable_entity + raise ActiveRecord::Rollback + end + end + end + + private + + def request_params + params.require(:entry).permit(:name, :email, :category, :fi, :en, :sv, :no, :ru, :de, :notes) + end + + def entry_params + request_params.except(:name, :email) + end +end diff --git a/app/helpers/entries_helper.rb b/app/helpers/entries_helper.rb index 4ae6bdc..ba48078 100644 --- a/app/helpers/entries_helper.rb +++ b/app/helpers/entries_helper.rb @@ -28,4 +28,15 @@ module EntriesHelper def format_entry_category(entry) entry.category.to_s.tr("_", " ").capitalize end + + def format_entry_status(entry) + case entry.status + when "requested" + content_tag(:span, "Requested", class: "px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800") + when "approved" + content_tag(:span, "Approved", class: "px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800") + when "active" + content_tag(:span, "Active", class: "px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800") + end + end end diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb index bcc635b..c9b33e7 100644 --- a/app/mailers/invitation_mailer.rb +++ b/app/mailers/invitation_mailer.rb @@ -1,12 +1,19 @@ class InvitationMailer < ApplicationMailer - def invite(user) + def invite(user, approved_entry: nil) @user = user + @approved_entry = approved_entry @invitation_url = invitation_url(@user.invitation_token) @expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY + subject = if @approved_entry + "Your entry request has been approved - Join Sanasto Wiki" + else + "You've been invited to join Sanasto Wiki" + end + mail( to: @user.email, - subject: "You've been invited to join Sanasto Wiki" + subject: subject ) end end diff --git a/app/models/entry.rb b/app/models/entry.rb index b1e37c3..c3c9d46 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -1,15 +1,21 @@ class Entry < ApplicationRecord belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true + belongs_to :requested_by, class_name: "User", optional: true has_many :suggested_meanings, dependent: :destroy has_many :comments, as: :commentable, dependent: :destroy enum :category, %i[word phrase proper_name title reference other] + enum :status, %i[requested approved active], default: :active validates :category, presence: true + validate :at_least_one_translation scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all } + scope :requested, -> { where(status: :requested) } + scope :approved, -> { where(status: :approved) } + scope :active_entries, -> { where(status: :active) } def self.search(query, language_code: nil) return all if query.blank? @@ -43,4 +49,10 @@ class Entry < ApplicationRecord def self.valid_lang?(code) SupportedLanguage.valid_codes.include?(code.to_s) end + + def at_least_one_translation + if [fi, en, sv, no, ru, de].all?(&:blank?) + errors.add(:base, "At least one language translation is required") + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 2da91f3..3099f30 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,7 @@ class User < ApplicationRecord has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify + has_many :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify has_many :submitted_suggested_meanings, class_name: "SuggestedMeaning", foreign_key: :submitted_by_id, diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb index 6d3a2f8..f53c7ac 100644 --- a/app/views/admin/dashboard/index.html.erb +++ b/app/views/admin/dashboard/index.html.erb @@ -18,10 +18,12 @@
-
-
Total Users
-
<%= @user_count %>
-
+ <%= link_to admin_users_path do %> +
+
Total Users
+
<%= @user_count %>
+
+ <% end %>
@@ -44,10 +46,12 @@
-
-
Total Entries
-
<%= @entry_count %>
-
+ <%= link_to root_path do %> +
+
Total Entries
+
<%= @entry_count %>
+
+ <% end %>
@@ -69,10 +73,12 @@
-
-
Suggestions
-
<%= @pending_suggestions_count %>
-
+ <%= link_to admin_requests_path do %> +
+
Suggestions / Requests
+
<%= @pending_suggestions_count %> / <%= @requested_entries_count %>
+
+ <% end %>
@@ -94,10 +100,12 @@
-
-
Pending Invites
-
<%= @pending_invitations %>
-
+ <%= link_to admin_invitations_path do %> +
+
Pending Invites
+
<%= @pending_invitations %>
+
+ <% end %>
diff --git a/app/views/admin/requests/edit.html.erb b/app/views/admin/requests/edit.html.erb new file mode 100644 index 0000000..e3d193c --- /dev/null +++ b/app/views/admin/requests/edit.html.erb @@ -0,0 +1,81 @@ +
+
+

Edit Entry Request

+

Modify the entry details before approval.

+
+ +
+ <%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %> + <% if @entry.errors.any? %> +
+

Please fix the following errors:

+
    + <% @entry.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+
+ <%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %> + <%= f.select :category, Entry.categories.keys.map { |cat| [cat.humanize, cat] }, {}, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+ +
+

Translations

+
+
+ <%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+ +
+ <%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+ +
+ <%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+ +
+ <%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+ +
+ <%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+ +
+ <%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %> +
+
+
+ +
+ <%= f.label :notes, "Additional Notes", class: "block text-sm font-semibold text-gray-700 mb-2" %> + <%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %> +
+ +
+

Requester (Read-only)

+
+
Name: <%= @entry.requested_by&.name %>
+
Email: <%= @entry.requested_by&.email %>
+
+
+
+ +
+ <%= f.submit "Save Changes", class: "px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition" %> + <%= link_to "Cancel", admin_request_path(@entry), class: "px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %> +
+ <% end %> +
+
diff --git a/app/views/admin/requests/index.html.erb b/app/views/admin/requests/index.html.erb new file mode 100644 index 0000000..837c31c --- /dev/null +++ b/app/views/admin/requests/index.html.erb @@ -0,0 +1,126 @@ +
+
+

Entry Requests

+

Review and manage entry requests from public users.

+
+ + +
+
+
+

+ Pending Review + (<%= @requested_entries.count %> total) +

+
+ + <% if @requested_entries.any? %> +
+ + + + + + + + + + + + <% @requested_entries.each do |entry| %> + + + + + + + + <% end %> + +
EntryCategoryRequesterDateActions
+
+ <%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %> +
+
+ + <%= entry.category.humanize %> + + +
<%= entry.requested_by&.name %>
+
<%= entry.requested_by&.email %>
+
+ <%= entry.created_at.strftime("%b %d, %Y") %> + + <%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %> + <%= link_to "Edit", edit_admin_request_path(entry), class: "text-blue-600 hover:text-blue-900" %> + <%= button_to "Approve", approve_admin_request_path(entry), method: :post, class: "inline text-green-600 hover:text-green-900", form: { data: { turbo_confirm: "Send invitation to #{entry.requested_by&.email}?" } } %> + <%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this request?" } } %> +
+
+ <% else %> +
+ No pending requests at the moment. +
+ <% end %> +
+
+ + +
+
+
+

+ Approved (Awaiting User Acceptance) + (<%= @approved_entries.count %> total) +

+
+ + <% if @approved_entries.any? %> +
+ + + + + + + + + + + + <% @approved_entries.each do |entry| %> + + + + + + + + <% end %> + +
EntryCategoryRequesterApprovedActions
+
+ <%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %> +
+
+ + <%= entry.category.humanize %> + + +
<%= entry.requested_by&.name %>
+
<%= entry.requested_by&.email %>
+
+ <%= entry.updated_at.strftime("%b %d, %Y") %> + + <%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %> + <%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this approved request?" } } %> +
+
+ <% else %> +
+ No approved entries awaiting user acceptance. +
+ <% end %> +
+
+
diff --git a/app/views/admin/requests/show.html.erb b/app/views/admin/requests/show.html.erb new file mode 100644 index 0000000..432730a --- /dev/null +++ b/app/views/admin/requests/show.html.erb @@ -0,0 +1,96 @@ +
+
+

Entry Request Details

+
+ +
+
+
+

Entry Information

+ <%= content_tag(:span, @entry.status.titleize, class: "px-3 py-1 text-sm font-semibold rounded-full #{@entry.requested? ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}") %> +
+
+ +
+
+

Category

+ + <%= @entry.category.humanize %> + +
+ +
+

Translations

+
+
+
🇫🇮 Finnish
+
<%= @entry.fi.presence || "—" %>
+
+ +
+
🇬🇧 English
+
<%= @entry.en.presence || "—" %>
+
+ +
+
🇸🇪 Swedish
+
<%= @entry.sv.presence || "—" %>
+
+ +
+
🇳🇴 Norwegian
+
<%= @entry.no.presence || "—" %>
+
+ +
+
🇷🇺 Russian
+
<%= @entry.ru.presence || "—" %>
+
+ +
+
🇩🇪 German
+
<%= @entry.de.presence || "—" %>
+
+
+
+ + <% if @entry.notes.present? %> +
+

Notes

+
+ <%= @entry.notes %> +
+
+ <% end %> + +
+

Requester Information

+
+
+ Name: + <%= @entry.requested_by&.name %> +
+
+ Email: + <%= @entry.requested_by&.email %> +
+
+ Submitted: + <%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %> +
+
+
+
+ +
+ <%= link_to "← Back to Requests", admin_requests_path, class: "px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %> + + <% if @entry.requested? %> + <%= link_to "Edit", edit_admin_request_path(@entry), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition" %> + <%= button_to "Approve & Send Invitation", approve_admin_request_path(@entry), method: :post, class: "px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition", form: { data: { turbo_confirm: "Send invitation to #{@entry.requested_by&.email}?" } } %> + <% end %> + + <%= button_to "Reject", reject_admin_request_path(@entry), method: :delete, class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition ml-auto", form: { data: { turbo_confirm: "Are you sure you want to delete this request?" } } %> +
+
+
diff --git a/app/views/entries/_results.html.erb b/app/views/entries/_results.html.erb index aeb6ba7..423f100 100644 --- a/app/views/entries/_results.html.erb +++ b/app/views/entries/_results.html.erb @@ -42,6 +42,9 @@ No entries matched your filters. +

+ <%= link_to "Request a new entry", new_request_path, + class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %> <% else %> diff --git a/app/views/entries/index.html.erb b/app/views/entries/index.html.erb index 969a679..238bfeb 100644 --- a/app/views/entries/index.html.erb +++ b/app/views/entries/index.html.erb @@ -9,6 +9,8 @@ Wiki
+ <%= link_to "Request Entry", new_request_path, + class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %> <%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %> <% if admin? %> @@ -21,6 +23,21 @@
+ + <% if flash.any? %> +
+ <% flash.each do |type, message| %> + + <% end %> +
+ <% end %>