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
@query = params[:q].to_s.strip
@starts_with = params[:starts_with].presence
@page = [params[:page].to_i, 1].max
@per_page = 25
entries_scope = Entry.all
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.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
@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
def show
+175 -149
View File
@@ -1,164 +1,190 @@
<% content_for :title, "Sanasto Wiki" %>
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200">
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-4">
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
<%= 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>
</nav>
<main class="max-w-5xl mx-auto px-4 py-8 space-y-10">
<% 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 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">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-3">
<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>
<%= 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" %>
<%= 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>
<%= 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 class="flex flex-wrap gap-3">
<%= form.submit "Search", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<%= link_to "Download XLSX",
download_entries_path(format: :xlsx),
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" %>
</div>
<% end %>
<div class="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
<% 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 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'}" %>
<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>
<%= 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>
<% end %>
</div>
<% end %>
</section>
<section 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">Total Entries</div>
<div class="text-2xl font-bold text-slate-900"><%= number_with_delimiter(@entry_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">Verified</div>
<div class="text-2xl font-bold text-emerald-600"><%= number_with_delimiter(@verified_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">Recent Additions</div>
<div class="text-sm text-slate-600">Updated with the latest translations</div>
</div>
</section>
<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>
<section class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-slate-900">Entries</h2>
<div class="text-xs text-slate-500"><%= @entries.size %> shown</div>
</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 @entries.empty? %>
<div class="bg-white border border-slate-200 rounded-xl p-6 text-slate-600">
No entries matched your filters. Try a different search or remove some filters.
</div>
<% else %>
<div class="space-y-4">
<% @entries.each do |entry| %>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400"><%= format_entry_category(entry) %></span>
<% if entry.verified? %>
<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>
<% 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.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>
<% end %>
</section>
</div>
</header>
<section class="bg-white border border-slate-200 rounded-xl p-6 shadow-sm">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest mb-4">Recent Entries</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<% @recent_entries.each do |entry| %>
<%= 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"><%= format_entry_category(entry) %></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 class="mt-1 text-xs text-slate-500"><%= entry.created_at.strftime("%B %-d, %Y") %></div>
<% end %>
<% end %>
<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>
<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>