autosubmit, live search

This commit is contained in:
2026-01-22 23:52:53 +01:00
parent 34ab3d80ae
commit 28532fb109
12 changed files with 263 additions and 182 deletions
+11 -4
View File
@@ -1,5 +1,5 @@
class EntriesController < ApplicationController
before_action :set_entry, only: [:show]
before_action :set_entry, only: [ :show ]
def index
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
@@ -7,7 +7,7 @@ class EntriesController < ApplicationController
@category = params[:category].presence
@query = params[:q].to_s.strip
@starts_with = params[:starts_with].presence
@page = [params[:page].to_i, 1].max
@page = [ params[:page].to_i, 1 ].max
@per_page = 25
entries_scope = Entry.all
@@ -25,13 +25,13 @@ class EntriesController < ApplicationController
@verified_count = Entry.where(verified: true).count
@needs_review_count = @entry_count - @verified_count
@complete_entries_count = @supported_languages.reduce(Entry.all) do |scope, language|
scope.where.not(language.code => [nil, ""])
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.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
end
if @language_code.present?
@@ -40,6 +40,13 @@ class EntriesController < ApplicationController
else
@display_languages = @supported_languages
end
if turbo_frame_request?
turbo_frame_id = request.headers["Turbo-Frame"]
if turbo_frame_id == "entries_results"
render partial: "entries/results_frame", layout: false
end
end
end
def show
+2
View File
@@ -0,0 +1,2 @@
import "@hotwired/turbo-rails"
import "controllers"
@@ -0,0 +1,7 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
+4
View File
@@ -0,0 +1,4 @@
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
@@ -0,0 +1,23 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
debounce: { type: Number, default: 250 }
}
connect() {
console.log("Search controller connected", this.element)
this.submitTimeoutId = null
}
queueSubmit(event) {
console.log("queueSubmit fired", event.target.value)
clearTimeout(this.submitTimeoutId)
this.submitTimeoutId = setTimeout(() => this.submit(), this.debounceValue)
}
submit() {
console.log("submit() called")
this.element.requestSubmit()
}
}
+77
View File
@@ -0,0 +1,77 @@
<section class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 pb-6 space-y-4">
<%= form_with url: entries_path,
method: :get,
data: {
controller: "search",
turbo_frame: "entries_results"
} do |form| %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<% if @query.present? %>
<div class="absolute inset-y-0 right-0 pr-4 flex items-center">
<%= link_to "×",
entries_path(category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence),
class: "text-slate-400 hover:text-slate-600 text-2xl leading-none",
aria: { label: "Clear search" },
data: { turbo_frame: "entries_results" } %>
</div>
<% end %>
<%= form.text_field :q,
value: @query,
placeholder: "Search words, phrases, or biblical terms...",
class: "block w-full pl-11 pr-10 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition",
data: { action: "input->search#queueSubmit" } %>
</div>
<% end %>
<div class="flex flex-wrap gap-2">
<% base_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<% all_category_params = base_params.except(:category) %>
<%= link_to "All",
entries_path(all_category_params),
class: "px-4 py-1.5 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
data: { turbo_frame: "entries_results" } %>
<% Entry.categories.keys.each do |category_name| %>
<%= link_to category_name.tr('_', ' ').capitalize,
entries_path(base_params.merge(category: category_name)),
class: "px-4 py-1.5 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
data: { turbo_frame: "entries_results" } %>
<% end %>
</div>
<div class="flex flex-wrap gap-2">
<% all_language_params = base_params.except(:language, :starts_with, :page) %>
<%= link_to "All Languages",
entries_path(all_language_params),
class: "px-3 py-1.5 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
data: { turbo_frame: "entries_results" } %>
<% @supported_languages.each do |language| %>
<%= link_to "#{language.name} (#{language.code.upcase})",
entries_path(all_language_params.merge(language: language.code)),
class: "px-3 py-1.5 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
data: { turbo_frame: "entries_results" } %>
<% end %>
</div>
<% if @language_code.present? %>
<div class="flex flex-wrap gap-2 text-xs">
<% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %>
<%= link_to "All",
entries_path(alphabet_params),
class: "px-2.5 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
data: { turbo_frame: "entries_results" } %>
<% alphabet_letters(@language_code).each do |letter| %>
<%= link_to letter,
entries_path(alphabet_params.merge(starts_with: letter)),
class: "px-2.5 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
data: { turbo_frame: "entries_results" } %>
<% end %>
</div>
<% end %>
</div>
</section>
+117
View File
@@ -0,0 +1,117 @@
<section class="bg-slate-50 border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<div class="text-xs uppercase tracking-widest text-slate-400">Fully Translated</div>
<div class="text-2xl font-bold text-slate-900"><%= number_with_delimiter(@complete_entries_count) %></div>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<div class="text-xs uppercase tracking-widest text-slate-400">Needs Review</div>
<div class="text-2xl font-bold text-amber-600"><%= number_with_delimiter(@needs_review_count) %></div>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<div class="text-xs uppercase tracking-widest text-slate-400">Missing Translations</div>
<div class="text-2xl font-bold text-red-600"><%= number_with_delimiter(@missing_entries_count) %></div>
</div>
</div>
</div>
</section>
<main class="flex-1 overflow-hidden">
<div class="max-w-7xl mx-auto px-4 h-full flex flex-col py-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-bold text-slate-900">Translation Table</h2>
</div>
<div class="text-xs text-slate-500"><%= @total_entries %> entries</div>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="h-full overflow-auto">
<table class="min-w-full text-sm border-separate border-spacing-0">
<thead class="sticky top-0 z-20 bg-slate-50 border-b border-slate-200">
<tr>
<% preferred_language_code = @language_code.presence || "fi" %>
<% preferred_language = @display_languages.find { |language| language.code == preferred_language_code } %>
<th class="sticky left-0 z-30 bg-slate-50 text-left px-4 py-3 border-b border-slate-200">
<div class="text-xs font-semibold text-slate-700"><%= preferred_language&.name || "Finnish" %>
<span class="text-[10px] uppercase tracking-widest text-slate-400"><%= preferred_language_code.upcase %></span></div>
<div class="text-[10px] text-slate-400">Category / Status</div>
</th>
<% table_languages = @display_languages.reject { |language| language.code == preferred_language_code } %>
<% if preferred_language_code != "en" %>
<% english_language, other_languages = table_languages.partition { |language| language.code == "en" } %>
<% table_languages = english_language + other_languages %>
<% end %>
<% table_languages.each do |language| %>
<th class="px-4 py-3 text-left border-b border-slate-200">
<div class="text-xs font-semibold text-slate-700"><%= language.name %>
<span class="text-[10px] uppercase tracking-widest text-slate-400"><%= language.code.upcase %></span></div>
<div class="mt-1 text-[10px] font-semibold text-emerald-600">
<%= @language_completion.fetch(language, 0) %>% complete
</div>
</th>
<% end %>
</tr>
</thead>
<tbody class="bg-white text-slate-700">
<% if @entries.empty? %>
<tr>
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
No entries matched your filters.
</td>
</tr>
<% else %>
<% @entries.each do |entry| %>
<% translation_values = table_languages.map { |language| entry.public_send(language.code) } %>
<% missing_any = translation_values.any?(&:blank?) %>
<tr class="border-b border-slate-100 <%= missing_any ? 'bg-red-50/40' : '' %>">
<td class="sticky left-0 z-10 bg-white px-4 py-4 border-b border-slate-100 w-72">
<% primary_text = entry.public_send(preferred_language_code).presence || "Untitled" %>
<div class="font-semibold text-slate-900"><%= primary_text %></div>
<div class="mt-1 text-xs text-slate-500 flex items-center gap-2">
<span class="uppercase tracking-wider"><%= format_entry_category(entry) %></span>
<% if entry.verified? %>
<span class="text-emerald-600 font-semibold">Verified</span>
<% else %>
<span class="text-amber-600 font-semibold">Unverified</span>
<% end %>
<%= link_to "View",
entry_path(entry),
class: "text-indigo-600 font-semibold hover:underline" %>
</div>
</td>
<% table_languages.each do |language| %>
<% translation = entry.public_send(language.code) %>
<td class="px-4 py-4 border-b border-slate-100">
<% if translation.present? %>
<span class="text-slate-900"><%= translation %></span>
<% else %>
<span class="text-slate-400">—</span>
<% end %>
</td>
<% end %>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-slate-600">
<div>Page <%= @page %> of <%= [@total_pages, 1].max %></div>
<div class="flex items-center gap-2">
<% previous_page = @page > 1 ? @page - 1 : nil %>
<% next_page = @page < @total_pages ? @page + 1 : nil %>
<% pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<%= link_to "Previous", previous_page ? entries_path(pagination_params.merge(page: previous_page)) : "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{previous_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
data: { turbo_frame: "entries_results" } %>
<%= link_to "Next", next_page ? entries_path(pagination_params.merge(page: next_page)) : "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{next_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
data: { turbo_frame: "entries_results" } %>
</div>
</div>
</div>
</main>
@@ -0,0 +1,3 @@
<%= turbo_frame_tag "entries_results" do %>
<%= render "entries/results" %>
<% end %>
+7
View File
@@ -0,0 +1,7 @@
<div class="flex flex-col">
<%= render "entries/filters" %>
<%= turbo_frame_tag "entries_results" do %>
<%= render "entries/results" %>
<% end %>
</div>
+6 -178
View File
@@ -16,186 +16,14 @@
<%= link_to "Sign In", "#", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
</div>
</div>
<div class="pb-6 space-y-4">
<%= form_with url: entries_path, method: :get, local: true do |form| %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<% if @query.present? %>
<div class="absolute inset-y-0 right-0 pr-4 flex items-center">
<%= link_to "×",
entries_path(category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence),
class: "text-slate-400 hover:text-slate-600 text-2xl leading-none",
aria: { label: "Clear search" } %>
</div>
<% end %>
<%= form.text_field :q,
value: @query,
placeholder: "Search words, phrases, or biblical terms...",
class: "block w-full pl-11 pr-10 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
</div>
<% end %>
<div class="flex flex-wrap gap-2">
<% base_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<% all_category_params = base_params.except(:category) %>
<%= link_to "All",
entries_path(all_category_params),
class: "px-4 py-1.5 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider" %>
<% Entry.categories.keys.each do |category_name| %>
<%= link_to category_name.tr('_', ' ').capitalize,
entries_path(base_params.merge(category: category_name)),
class: "px-4 py-1.5 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider" %>
<% end %>
</div>
<div class="flex flex-wrap gap-2">
<% all_language_params = base_params.except(:language, :starts_with, :page) %>
<%= link_to "All Languages",
entries_path(all_language_params),
class: "px-3 py-1.5 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider" %>
<% @supported_languages.each do |language| %>
<%= link_to "#{language.name} (#{language.code.upcase})",
entries_path(all_language_params.merge(language: language.code)),
class: "px-3 py-1.5 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider" %>
<% end %>
</div>
<% if @language_code.present? %>
<div class="flex flex-wrap gap-2 text-xs">
<% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %>
<%= link_to "All",
entries_path(alphabet_params),
class: "px-2.5 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}" %>
<% alphabet_letters(@language_code).each do |letter| %>
<%= link_to letter,
entries_path(alphabet_params.merge(starts_with: letter)),
class: "px-2.5 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}" %>
<% end %>
</div>
<% end %>
</div>
</div>
</header>
<section class="bg-slate-50 border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<div class="text-xs uppercase tracking-widest text-slate-400">Fully Translated</div>
<div class="text-2xl font-bold text-slate-900"><%= number_with_delimiter(@complete_entries_count) %></div>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<div class="text-xs uppercase tracking-widest text-slate-400">Needs Review</div>
<div class="text-2xl font-bold text-amber-600"><%= number_with_delimiter(@needs_review_count) %></div>
</div>
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<div class="text-xs uppercase tracking-widest text-slate-400">Missing Translations</div>
<div class="text-2xl font-bold text-red-600"><%= number_with_delimiter(@missing_entries_count) %></div>
</div>
</div>
</div>
</section>
<div class="flex-1 flex flex-col">
<%= render "entries/filters" %>
<main class="flex-1 overflow-hidden">
<div class="max-w-7xl mx-auto px-4 h-full flex flex-col py-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-bold text-slate-900">Translation Table</h2>
<p class="text-sm text-slate-500">Public glossary entries with verified status and language coverage.</p>
</div>
<div class="text-xs text-slate-500"><%= @total_entries %> entries</div>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="h-full overflow-auto">
<table class="min-w-full text-sm border-separate border-spacing-0">
<thead class="sticky top-0 z-20 bg-slate-50 border-b border-slate-200">
<tr>
<% preferred_language_code = @language_code.presence || "fi" %>
<% preferred_language = @display_languages.find { |language| language.code == preferred_language_code } %>
<th class="sticky left-0 z-30 bg-slate-50 text-left px-4 py-3 border-b border-slate-200">
<div class="text-xs font-semibold text-slate-700"><%= preferred_language&.name || "Finnish" %>
<span class="text-[10px] uppercase tracking-widest text-slate-400"><%= preferred_language_code.upcase %></span></div>
<div class="text-[10px] text-slate-400">Category / Status</div>
</th>
<% table_languages = @display_languages.reject { |language| language.code == preferred_language_code } %>
<% if preferred_language_code != "en" %>
<% english_language, other_languages = table_languages.partition { |language| language.code == "en" } %>
<% table_languages = english_language + other_languages %>
<% end %>
<% table_languages.each do |language| %>
<th class="px-4 py-3 text-left border-b border-slate-200">
<div class="text-xs font-semibold text-slate-700"><%= language.name %>
<span class="text-[10px] uppercase tracking-widest text-slate-400"><%= language.code.upcase %></span></div>
<div class="mt-1 text-[10px] font-semibold text-emerald-600">
<%= @language_completion.fetch(language, 0) %>% complete
</div>
</th>
<% end %>
</tr>
</thead>
<tbody class="bg-white text-slate-700">
<% if @entries.empty? %>
<tr>
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
No entries matched your filters.
</td>
</tr>
<% else %>
<% @entries.each do |entry| %>
<% translation_values = table_languages.map { |language| entry.public_send(language.code) } %>
<% missing_any = translation_values.any?(&:blank?) %>
<tr class="border-b border-slate-100 <%= missing_any ? 'bg-red-50/40' : '' %>">
<td class="sticky left-0 z-10 bg-white px-4 py-4 border-b border-slate-100 w-72">
<% primary_text = entry.public_send(preferred_language_code).presence || "Untitled" %>
<div class="font-semibold text-slate-900"><%= primary_text %></div>
<div class="mt-1 text-xs text-slate-500 flex items-center gap-2">
<span class="uppercase tracking-wider"><%= format_entry_category(entry) %></span>
<% if entry.verified? %>
<span class="text-emerald-600 font-semibold">Verified</span>
<% else %>
<span class="text-amber-600 font-semibold">Unverified</span>
<% end %>
<%= link_to "View",
entry_path(entry),
class: "text-indigo-600 font-semibold hover:underline" %>
</div>
</td>
<% table_languages.each do |language| %>
<% translation = entry.public_send(language.code) %>
<td class="px-4 py-4 border-b border-slate-100">
<% if translation.present? %>
<span class="text-slate-900"><%= translation %></span>
<% else %>
<span class="text-slate-400">—</span>
<% end %>
</td>
<% end %>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-slate-600">
<div>Page <%= @page %> of <%= [@total_pages, 1].max %></div>
<div class="flex items-center gap-2">
<% previous_page = @page > 1 ? @page - 1 : nil %>
<% next_page = @page < @total_pages ? @page + 1 : nil %>
<% pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<%= link_to "Previous", previous_page ? entries_path(pagination_params.merge(page: previous_page)) : "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{previous_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}" %>
<%= link_to "Next", next_page ? entries_path(pagination_params.merge(page: next_page)) : "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{next_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}" %>
</div>
</div>
</div>
</main>
<%= turbo_frame_tag "entries_results" do %>
<%= render "entries/results" %>
<% end %>
</div>
</div>
+1
View File
@@ -20,6 +20,7 @@
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<script src="https://cdn.tailwindcss.com"></script>
</head>