tabular front page interface
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<% 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="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-4">
|
||||
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
|
||||
<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>
|
||||
</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="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" />
|
||||
@@ -29,16 +30,10 @@
|
||||
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">
|
||||
<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),
|
||||
@@ -50,8 +45,8 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
||||
<% all_language_params = base_params.except(:language, :starts_with) %>
|
||||
<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" %>
|
||||
@@ -64,7 +59,7 @@
|
||||
|
||||
<% if @language_code.present? %>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<% alphabet_params = base_params.merge(language: @language_code).except(:starts_with) %>
|
||||
<% 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'}" %>
|
||||
@@ -75,90 +70,121 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<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">Total Entries</div>
|
||||
<div class="text-2xl font-bold text-slate-900"><%= number_with_delimiter(@entry_count) %></div>
|
||||
<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">Verified</div>
|
||||
<div class="text-2xl font-bold text-emerald-600"><%= number_with_delimiter(@verified_count) %></div>
|
||||
<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">Recent Additions</div>
|
||||
<div class="text-sm text-slate-600">Updated with the latest translations</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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? %>
|
||||
<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>
|
||||
<tr>
|
||||
<td colspan="<%= @display_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
|
||||
No entries matched your filters.
|
||||
</td>
|
||||
</tr>
|
||||
<% 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>
|
||||
<% 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? %>
|
||||
<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>
|
||||
<span class="text-emerald-600 font-semibold">Verified</span>
|
||||
<% else %>
|
||||
<span class="text-xs font-semibold text-amber-600">Unverified</span>
|
||||
<span class="text-amber-600 font-semibold">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",
|
||||
<%= link_to "View",
|
||||
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>
|
||||
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 %>
|
||||
</div>
|
||||
</td>
|
||||
<% end %>
|
||||
</section>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 %>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user