tabular front page interface

This commit is contained in:
2026-01-22 20:35:45 +01:00
parent 8453801820
commit 95324a97cb
3 changed files with 227 additions and 151 deletions
+29
View File
@@ -0,0 +1,29 @@
draft: tmp/translation-page.html
Layout
Full-height design with the table taking up most of the viewport
Sticky header row and sticky key column for easy navigation when scrolling
Horizontal scroll for many languages, vertical scroll for many keys
Columns
Language columns: English (source), Norwegian, German, French, Spanish
Each language shows completion percentage in the header
Visual indicators
Green: source language
Blue: high completion (85%+)
Amber: moderate completion / needs review
Red row highlighting: missing translations
Features
Inline editing (click any cell to edit)
Search bar for finding anything
Stats bar showing translated/review/missing counts
Pagination for large translation sets
"All changes saved" status indicator
This should be a solid starting point for your translation work. Let me know if you want to adjust the languages, add interactivity (like saving to an API), or modify the styling.
+23 -2
View File
@@ -7,6 +7,8 @@ class EntriesController < ApplicationController
@category = params[:category].presence @category = params[:category].presence
@query = params[:q].to_s.strip @query = params[:q].to_s.strip
@starts_with = params[:starts_with].presence @starts_with = params[:starts_with].presence
@page = [params[:page].to_i, 1].max
@per_page = 25
entries_scope = Entry.all entries_scope = Entry.all
entries_scope = entries_scope.with_category(@category) entries_scope = entries_scope.with_category(@category)
@@ -15,10 +17,29 @@ class EntriesController < ApplicationController
entries_scope = entries_scope.alphabetical_for(@language_code) if @query.blank? && @starts_with.blank? && @language_code.present? entries_scope = entries_scope.alphabetical_for(@language_code) if @query.blank? && @starts_with.blank? && @language_code.present?
entries_scope = entries_scope.order(created_at: :desc) if entries_scope.order_values.empty? entries_scope = entries_scope.order(created_at: :desc) if entries_scope.order_values.empty?
@entries = entries_scope.limit(50) @total_entries = entries_scope.count
@total_pages = (@total_entries.to_f / @per_page).ceil
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
@entry_count = Entry.count @entry_count = Entry.count
@verified_count = Entry.where(verified: true).count @verified_count = Entry.where(verified: true).count
@recent_entries = Entry.order(created_at: :desc).limit(6) @needs_review_count = @entry_count - @verified_count
@complete_entries_count = @supported_languages.reduce(Entry.all) 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
end
if @language_code.present?
primary_language, other_languages = @supported_languages.partition { |language| language.code == @language_code }
@display_languages = primary_language + other_languages
else
@display_languages = @supported_languages
end
end end
def show def show
+175 -149
View File
@@ -1,164 +1,190 @@
<% content_for :title, "Sanasto Wiki" %> <% content_for :title, "Sanasto Wiki" %>
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200"> <div class="min-h-screen flex flex-col">
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between"> <header class="bg-white border-b border-slate-200">
<div class="flex items-center gap-2"> <div class="max-w-7xl mx-auto px-4">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span> <div class="h-16 flex items-center justify-between">
<span class="text-xl font-light text-slate-400">Wiki</span> <div class="flex items-center gap-2">
</div> <span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<div class="flex items-center gap-4"> <span class="text-xl font-light text-slate-400">Wiki</span>
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %> </div>
<%= 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 class="flex items-center gap-3">
</div> <span class="text-xs font-semibold text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-200">Read-only public view</span>
</div> <%= link_to "Download XLSX",
</nav> 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" %>
<main class="max-w-5xl mx-auto px-4 py-8 space-y-10"> <%= 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" %>
<% base_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<section class="space-y-5">
<%= form_with url: entries_path, method: :get, local: true, class: "space-y-4" do |form| %>
<div class="relative group">
<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> </div>
<%= form.text_field :q,
value: @query,
placeholder: "Search words, phrases, or biblical terms...",
class: "block w-full pl-11 pr-4 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> </div>
<div class="flex flex-wrap gap-3"> <div class="pb-6 space-y-4">
<%= form.submit "Search", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %> <%= form_with url: entries_path, method: :get, local: true do |form| %>
<%= link_to "Download XLSX", <div class="relative">
download_entries_path(format: :xlsx), <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
class: "text-sm font-semibold text-indigo-700 px-4 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %> <svg class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</div> <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" />
<% end %> </svg>
</div>
<div class="flex gap-2 overflow-x-auto pb-2 no-scrollbar"> <%= form.text_field :q,
<% all_category_params = base_params.except(:category) %> value: @query,
<%= link_to "All", placeholder: "Search words, phrases, or biblical terms...",
entries_path(all_category_params), class: "block w-full pl-11 pr-4 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
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" %> </div>
<% 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 gap-2 overflow-x-auto pb-2 no-scrollbar">
<% all_language_params = base_params.except(:language, :starts_with) %>
<%= 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) %>
<%= 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.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 %> <% end %>
</div>
<% end %>
</section>
<section class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="flex flex-wrap gap-2">
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm"> <% base_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<div class="text-xs uppercase tracking-widest text-slate-400">Total Entries</div> <% all_category_params = base_params.except(:category) %>
<div class="text-2xl font-bold text-slate-900"><%= number_with_delimiter(@entry_count) %></div> <%= link_to "All",
</div> entries_path(all_category_params),
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm"> 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" %>
<div class="text-xs uppercase tracking-widest text-slate-400">Verified</div> <% Entry.categories.keys.each do |category_name| %>
<div class="text-2xl font-bold text-emerald-600"><%= number_with_delimiter(@verified_count) %></div> <%= link_to category_name.tr('_', ' ').capitalize,
</div> entries_path(base_params.merge(category: category_name)),
<div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm"> 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" %>
<div class="text-xs uppercase tracking-widest text-slate-400">Recent Additions</div> <% end %>
<div class="text-sm text-slate-600">Updated with the latest translations</div> </div>
</div>
</section>
<section class="space-y-4"> <div class="flex flex-wrap gap-2">
<div class="flex items-center justify-between"> <% all_language_params = base_params.except(:language, :starts_with, :page) %>
<h2 class="text-lg font-bold text-slate-900">Entries</h2> <%= link_to "All Languages",
<div class="text-xs text-slate-500"><%= @entries.size %> shown</div> entries_path(all_language_params),
</div> 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 @entries.empty? %> <% if @language_code.present? %>
<div class="bg-white border border-slate-200 rounded-xl p-6 text-slate-600"> <div class="flex flex-wrap gap-2 text-xs">
No entries matched your filters. Try a different search or remove some filters. <% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %>
</div> <%= link_to "All",
<% else %> entries_path(alphabet_params),
<div class="space-y-4"> 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'}" %>
<% @entries.each do |entry| %> <% alphabet_letters.each do |letter| %>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden"> <%= link_to letter,
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center"> entries_path(alphabet_params.merge(starts_with: letter)),
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400"><%= format_entry_category(entry) %></span> 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'}" %>
<% if entry.verified? %> <% end %>
<div class="flex items-center gap-1.5 text-emerald-600">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="text-xs font-bold">Verified</span>
</div>
<% else %>
<span class="text-xs font-semibold text-amber-600">Unverified</span>
<% end %>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
<% @supported_languages.each do |language| %>
<% translation = entry_translation_for(entry, language.code) %>
<% next if translation.blank? %>
<div class="space-y-1">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
<p class="text-xl font-semibold text-slate-700"><%= translation %></p>
</div>
<% end %>
</div>
<% if entry.notes.present? %>
<div class="mt-6 pt-5 border-t border-slate-100">
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4>
<p class="text-sm text-slate-600 italic"><%= entry.notes %></p>
</div>
<% end %>
</div>
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex flex-wrap gap-3">
<%= link_to "View Entry",
entry_path(entry),
class: "text-xs font-bold text-indigo-600 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition ml-auto" %>
</div>
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %> </div>
</section> </header>
<section class="bg-white border border-slate-200 rounded-xl p-6 shadow-sm"> <section class="bg-slate-50 border-b border-slate-200">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest mb-4">Recent Entries</h3> <div class="max-w-7xl mx-auto px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<% @recent_entries.each do |entry| %> <div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<%= link_to entry_path(entry), class: "block p-4 rounded-lg border border-slate-200 hover:border-indigo-300 transition" do %> <div class="text-xs uppercase tracking-widest text-slate-400">Fully Translated</div>
<div class="text-xs uppercase tracking-widest text-slate-400"><%= format_entry_category(entry) %></div> <div class="text-2xl font-bold text-slate-900"><%= number_with_delimiter(@complete_entries_count) %></div>
<div class="mt-2 text-slate-900 font-semibold"><%= entry.fi.presence || entry.en.presence || entry.sv.presence || entry.no.presence || entry.ru.presence || entry.de.presence || "Untitled" %></div> </div>
<div class="mt-1 text-xs text-slate-500"><%= entry.created_at.strftime("%B %-d, %Y") %></div> <div class="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
<% end %> <div class="text-xs uppercase tracking-widest text-slate-400">Needs Review</div>
<% end %> <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> </div>
</section> </section>
</main>
<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>
<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 uppercase tracking-widest text-slate-400">Entry</div>
<div class="text-[10px] text-slate-400">Category / Status</div>
</th>
<% @display_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 %></div>
<div class="text-[10px] uppercase tracking-widest text-slate-400"><%= language.code.upcase %></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="<%= @display_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 = @display_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 = if @language_code.present?
entry.public_send(@language_code)
else
translation_values.find(&:present?)
end %>
<% primary_text = primary_text.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>
<% @display_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>
</div>