gemini suggestion front page

This commit is contained in:
2026-01-22 19:29:25 +01:00
parent 985d8a7169
commit 8453801820
9 changed files with 334 additions and 3 deletions
+2
View File
@@ -18,6 +18,8 @@ gem "turbo-rails"
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
gem "caxlsx"
gem "caxlsx_rails"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"
+14
View File
@@ -100,6 +100,14 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
caxlsx (4.4.1)
htmlentities (~> 4.3, >= 4.3.4)
marcel (~> 1.0)
nokogiri (~> 1.10, >= 1.10.4)
rubyzip (>= 2.4, < 4)
caxlsx_rails (0.6.4)
actionpack (>= 3.1)
caxlsx (>= 3.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
@@ -120,6 +128,7 @@ GEM
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@@ -362,6 +371,8 @@ DEPENDENCIES
brakeman
bundler-audit
capybara
caxlsx
caxlsx_rails
debug
image_processing (~> 1.2)
importmap-rails
@@ -408,6 +419,8 @@ CHECKSUMS
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
caxlsx (4.4.1) sha256=c23e756a73737250d1571b51ed5c91a830178c60444da2728d345cdc7c9f272f
caxlsx_rails (0.6.4) sha256=27e1ebb4617473f49a7956214e352b485beb080d590769ef5a826c410c7d2276
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
@@ -422,6 +435,7 @@ CHECKSUMS
ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
+43
View File
@@ -0,0 +1,43 @@
class EntriesController < ApplicationController
before_action :set_entry, only: [:show]
def index
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
@language_code = params[:language].presence
@category = params[:category].presence
@query = params[:q].to_s.strip
@starts_with = params[:starts_with].presence
entries_scope = Entry.all
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?
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)
@entry_count = Entry.count
@verified_count = Entry.where(verified: true).count
@recent_entries = Entry.order(created_at: :desc).limit(6)
end
def show
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
end
def download
@entries = Entry.order(:id)
respond_to do |format|
format.xlsx do
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
response.headers["Content-Disposition"] = "attachment; filename=\"#{filename}\""
end
end
end
private
def set_entry
@entry = Entry.find(params[:id])
end
end
+16
View File
@@ -0,0 +1,16 @@
module EntriesHelper
def alphabet_letters
("A".."Z").to_a
end
def entry_translation_for(entry, language_code)
language_column = language_code.to_s
return unless entry.has_attribute?(language_column)
entry.public_send(language_column)
end
def format_entry_category(entry)
entry.category.to_s.tr("_", " ").capitalize
end
end
+33
View File
@@ -0,0 +1,33 @@
workbook = xlsx_package.workbook
workbook.add_worksheet(name: "Entries") do |sheet|
sheet.add_row [
"Category",
"Finnish (fi)",
"English (en)",
"Swedish (sv)",
"Norwegian (no)",
"Russian (ru)",
"German (de)",
"Notes",
"Verified",
"Created At",
"Updated At"
]
@entries.find_each do |entry|
sheet.add_row [
entry.category,
entry.fi,
entry.en,
entry.sv,
entry.no,
entry.ru,
entry.de,
entry.notes,
entry.verified? ? "Yes" : "No",
entry.created_at&.to_date,
entry.updated_at&.to_date
]
end
end
+164
View File
@@ -0,0 +1,164 @@
<% 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>
<%= 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'}" %>
<% 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>
<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>
<% 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>
</div>
<% end %>
</div>
<% end %>
</section>
<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>
</section>
</main>
+54
View File
@@ -0,0 +1,54 @@
<% content_for :title, "Entry" %>
<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 "Download XLSX", download_entries_path(format: :xlsx), class: "text-sm font-semibold text-indigo-700 px-3 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
</div>
</div>
</nav>
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
<div>
<%= link_to "← Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
</div>
<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-2xl font-semibold text-slate-800"><%= 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>
</main>
+2 -1
View File
@@ -20,9 +20,10 @@
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<body class="bg-slate-50 text-slate-900 font-sans antialiased">
<%= yield %>
</body>
</html>
+6 -2
View File
@@ -10,9 +10,13 @@ Rails.application.routes.draw do
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
# Defines the root path route ("/")
# root "posts#index"
root "entries#index"
resources :entries
resources :entries do
collection do
get :download
end
end
resources :suggested_meanings
resources :comments, only: [:create, :update, :destroy]
resources :entry_versions, only: [:index, :show]