add entry requests, invite new users
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Successful in 11s
CI / lint (push) Failing after 19s
CI / test (push) Successful in 34s

This commit is contained in:
2026-01-30 01:28:53 +01:00
parent b64ad52d30
commit 530021960e
35 changed files with 1838 additions and 118 deletions
@@ -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
@@ -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
+7 -6
View File
@@ -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"
@@ -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
+75
View File
@@ -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
+11
View File
@@ -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
+9 -2
View File
@@ -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
+12
View File
@@ -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
+1
View File
@@ -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,
+24 -16
View File
@@ -18,10 +18,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl>
<%= link_to admin_users_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -44,10 +46,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl>
<%= link_to root_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -69,10 +73,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
</dl>
<%= link_to admin_requests_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -94,10 +100,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl>
<%= link_to admin_invitations_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl>
<% end %>
</div>
</div>
</div>
+81
View File
@@ -0,0 +1,81 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Entry Request</h1>
<p class="text-gray-600 mt-2">Modify the entry details before approval.</p>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %>
<% if @entry.errors.any? %>
<div class="mx-6 mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="px-6 pt-6 space-y-4">
<div>
<%= 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" %>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
</div>
</div>
<div>
<%= 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..." %>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Requester (Read-only)</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-1 text-sm">
<div><span class="font-medium">Name:</span> <%= @entry.requested_by&.name %></div>
<div><span class="font-medium">Email:</span> <%= @entry.requested_by&.email %></div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex gap-3">
<%= 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" %>
</div>
<% end %>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Requests</h1>
<p class="text-gray-600 mt-2">Review and manage entry requests from public users.</p>
</div>
<!-- Requested Entries Section -->
<div class="mb-12">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-yellow-50 border-b border-yellow-100 px-6 py-4">
<h2 class="text-xl font-bold text-yellow-900 flex items-center gap-2">
<span>⏳</span> Pending Review
<span class="text-sm font-normal text-yellow-700">(<%= @requested_entries.count %> total)</span>
</h2>
</div>
<% if @requested_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @requested_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.created_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= 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?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No pending requests at the moment.
</div>
<% end %>
</div>
</div>
<!-- Approved Entries Section -->
<div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4">
<h2 class="text-xl font-bold text-blue-900 flex items-center gap-2">
<span>✅</span> Approved (Awaiting User Acceptance)
<span class="text-sm font-normal text-blue-700">(<%= @approved_entries.count %> total)</span>
</h2>
</div>
<% if @approved_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @approved_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.updated_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= 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?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No approved entries awaiting user acceptance.
</div>
<% end %>
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Request Details</h1>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="border-b border-gray-200 px-6 py-4 bg-gray-50">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">Entry Information</h2>
<%= 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'}") %>
</div>
</div>
<div class="px-6 py-6 space-y-6">
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-2">Category</h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= @entry.category.humanize %>
</span>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇫🇮 Finnish</div>
<div class="text-gray-900"><%= @entry.fi.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇬🇧 English</div>
<div class="text-gray-900"><%= @entry.en.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇸🇪 Swedish</div>
<div class="text-gray-900"><%= @entry.sv.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇳🇴 Norwegian</div>
<div class="text-gray-900"><%= @entry.no.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇷🇺 Russian</div>
<div class="text-gray-900"><%= @entry.ru.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇩🇪 German</div>
<div class="text-gray-900"><%= @entry.de.presence || "—" %></div>
</div>
</div>
</div>
<% if @entry.notes.present? %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
<div class="bg-gray-50 rounded-lg p-4 text-gray-900 whitespace-pre-wrap">
<%= @entry.notes %>
</div>
</div>
<% end %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Requester Information</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div>
<span class="text-sm font-medium text-gray-700">Name:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.name %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Email:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.email %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Submitted:</span>
<span class="text-gray-900 ml-2"><%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex flex-wrap gap-3">
<%= 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?" } } %>
</div>
</div>
</div>
+3
View File
@@ -42,6 +42,9 @@
<tr>
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
No entries matched your filters.
<br><br>
<%= link_to "Request a new entry", new_request_path,
class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %>
</td>
</tr>
<% else %>
+17
View File
@@ -9,6 +9,8 @@
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-3">
<%= 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 @@
</div>
</header>
<!-- Flash messages -->
<% if flash.any? %>
<div class="max-w-7xl mx-auto px-4 mt-4 w-full">
<% flash.each do |type, message| %>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4 relative" role="alert">
<span class="block sm:inline pr-8"><%= message %></span>
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<% end %>
</div>
<% end %>
<div class="flex-1 flex flex-col">
<section id="search">
+72 -4
View File
@@ -79,6 +79,32 @@
font-size: 14px;
border-radius: 4px;
}
.entry-box {
background: #f0fdf4;
border-left: 4px solid #10b981;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.entry-box h3 {
margin: 0 0 12px 0;
color: #065f46;
font-size: 16px;
}
.entry-translations {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
margin-top: 12px;
}
.entry-translations dt {
font-weight: 600;
color: #064e3b;
}
.entry-translations dd {
margin: 0;
color: #1e293b;
}
</style>
</head>
<body>
@@ -90,11 +116,53 @@
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<p>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
</p>
<% if @approved_entry %>
<p>
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
</p>
<p>With a login account, you can contribute to this work.</p>
<div class="entry-box">
<h3>✓ Your Approved Entry</h3>
<p style="margin: 0 0 4px 0;"><strong>Category:</strong> <%= @approved_entry.category.to_s.humanize %></p>
<dl class="entry-translations">
<% if @approved_entry.fi.present? %>
<dt>🇫🇮 Finnish:</dt>
<dd><%= @approved_entry.fi %></dd>
<% end %>
<% if @approved_entry.en.present? %>
<dt>🇬🇧 English:</dt>
<dd><%= @approved_entry.en %></dd>
<% end %>
<% if @approved_entry.sv.present? %>
<dt>🇸🇪 Swedish:</dt>
<dd><%= @approved_entry.sv %></dd>
<% end %>
<% if @approved_entry.no.present? %>
<dt>🇳🇴 Norwegian:</dt>
<dd><%= @approved_entry.no %></dd>
<% end %>
<% if @approved_entry.ru.present? %>
<dt>🇷🇺 Russian:</dt>
<dd><%= @approved_entry.ru %></dd>
<% end %>
<% if @approved_entry.de.present? %>
<dt>🇩🇪 German:</dt>
<dd><%= @approved_entry.de %></dd>
<% end %>
</dl>
</div>
<p>
To complete the process and publish your entry, please accept this invitation to create your account on <strong>Sanasto Wiki</strong>.
</p>
<% else %>
<p>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
</p>
<p>With a login account, you can contribute to this work.</p>
<% end %>
<div class="info-box">
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
Hello <%= @user.name %>,
<% if @approved_entry %>
Great news! Your entry request has been APPROVED and is ready to be published.
YOUR APPROVED ENTRY
-------------------
Category: <%= @approved_entry.category.to_s.humanize %>
Translations:
<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
<% end %>
To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
<% else %>
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
With a login account, you can contribute to this work.
<% end %>
YOUR ACCOUNT DETAILS
--------------------
+17 -2
View File
@@ -24,6 +24,16 @@
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<% requested_count = Entry.requested.count %>
<% gap = requested_count.zero? ? '' : 'pr-4' %>
<%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
Requests
<% if requested_count > 0 %>
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</nav>
@@ -35,8 +45,13 @@
<% if flash.any? %>
<div class="max-w-7xl mx-auto px-4 mt-4">
<% flash.each do |type, message| %>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4" role="alert">
<span class="block sm:inline"><%= message %></span>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4 relative" role="alert">
<span class="block sm:inline pr-8"><%= message %></span>
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<% end %>
</div>
+129
View File
@@ -0,0 +1,129 @@
<% content_for :title, "Request a New Entry" %>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
<div class="flex items-center gap-3">
<%= link_to "Sign In", login_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
</div>
</div>
</div>
</header>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Request a New Entry</h1>
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
</div>
<% if flash[:alert] %>
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<%= flash[:alert] %>
</div>
<% end %>
<% if @pending_count && @pending_count > 0 %>
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
</div>
<% end %>
<%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
<% if @entry.errors.any? %>
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="space-y-4">
<% if current_user %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p class="text-sm text-blue-900">
<span class="font-semibold">Submitting as:</span> <%= current_user.name %> (<%= current_user.email %>)
</p>
</div>
<% else %>
<div>
<%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<% end %>
<div>
<%= 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] }, { prompt: "Select a category" }, { required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
</div>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations (at least one required)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
<div>
<%= 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" %>
</div>
</div>
</div>
<div>
<%= f.label :notes, "Additional Notes (optional)", 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..." %>
</div>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
<%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
</div>
<% end %>
<% unless current_user %>
<div class="mt-6 text-center text-sm text-gray-600">
Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
</div>
<% end %>
</div>
</div>
</div>
</div>