Compare commits

...

55 Commits

Author SHA1 Message Date
Runar Ingebrigtsen
7b6e059da6 logo update
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 53s
2026-02-20 22:02:26 +01:00
Runar Ingebrigtsen
e9f8f03db2 URL update
CI / scan_ruby (push) Successful in 39s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 48s
2026-02-20 21:43:40 +01:00
Runar Ingebrigtsen
b289cdc320 update + parallel tests 2026-02-20 21:43:25 +01:00
Runar Ingebrigtsen
8bb410dcfa yaml syntax
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 1m1s
2026-02-06 02:35:56 +01:00
Runar Ingebrigtsen
ce67776eec sanasto.wiki
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 50s
2026-02-06 02:34:44 +01:00
Runar Ingebrigtsen
9f71fe65e5 test corst, fix footer
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 48s
2026-02-06 02:01:57 +01:00
Runar Ingebrigtsen
e15835bda9 support browser cors request
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 47s
2026-02-05 23:59:11 +01:00
Runar Ingebrigtsen
83320d4c9a add CORS access for sanasto.app
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Failing after 20s
CI / test (push) Successful in 48s
2026-02-05 23:52:21 +01:00
Runar Ingebrigtsen
a2008e2ae3 update vulnerability scan
CI / scan_ruby (push) Successful in 24s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 52s
2026-02-04 09:02:38 +01:00
Runar Ingebrigtsen
b45a451748 fix pagination test
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 51s
2026-02-04 08:59:04 +01:00
Runar Ingebrigtsen
441caabb98 complete API swagger documentation
CI / scan_ruby (push) Failing after 29s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 21s
CI / test (push) Failing after 48s
2026-02-04 01:33:02 +01:00
Runar Ingebrigtsen
a139bde102 switch to pagy for pagination 2026-02-03 21:21:53 +01:00
Runar Ingebrigtsen
f35a09f07a lint 2026-02-01 05:32:30 +01:00
Runar Ingebrigtsen
1a10e3c784 api completion 2026-02-01 05:29:16 +01:00
Runar Ingebrigtsen
4fe95ca538 add sync API with swagger documentation at /api
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 47s
2026-01-31 22:39:12 +01:00
Runar Ingebrigtsen
fa36305244 document deployment 2026-01-31 16:05:44 +01:00
Runar Ingebrigtsen
9acdc4e6db actually, include notifications
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 50s
2026-01-31 15:55:24 +01:00
Runar Ingebrigtsen
e48b386b54 update todos 2026-01-31 15:55:02 +01:00
Runar Ingebrigtsen
d183fb4b53 normalize emails 2026-01-31 15:51:01 +01:00
Runar Ingebrigtsen
9c6714e97c shared flash notifications 2026-01-31 15:50:31 +01:00
Runar Ingebrigtsen
227ab744b5 fix logout 2026-01-31 15:49:49 +01:00
Runar Ingebrigtsen
4bc393887b 96.99% test coverage 2026-01-31 15:48:32 +01:00
Runar Ingebrigtsen
8ec8f15857 run some lint and security check before pushing code 2026-01-31 11:49:25 +01:00
Runar Ingebrigtsen
803c1371b7 lint
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Failing after 36s
2026-01-30 10:39:53 +01:00
Runar Ingebrigtsen
46e4f808e7 refactor comments, select language
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 12s
CI / lint (push) Failing after 19s
CI / test (push) Failing after 36s
2026-01-30 10:37:56 +01:00
Runar Ingebrigtsen
8ce7f1b913 rate limiter
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Failing after 35s
2026-01-30 10:09:49 +01:00
Runar Ingebrigtsen
c407ee3530 shared header, responsive 2026-01-30 10:09:38 +01:00
Runar Ingebrigtsen
32a4ffa70e rate limiting sesisons 2026-01-30 10:08:57 +01:00
Runar Ingebrigtsen
20ce18ca74 remember me, password reset 2026-01-30 10:08:41 +01:00
Runar Ingebrigtsen
4e5c25adbf fix mail server and lint removal
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 34s
2026-01-30 08:56:12 +01:00
Runar Ingebrigtsen
7118f1ea45 block bots
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Successful in 36s
2026-01-30 08:52:17 +01:00
Runar Ingebrigtsen
f31a25fb03 stop bots crawling
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 39s
2026-01-30 08:43:59 +01:00
Runar Ingebrigtsen
7c7bdf7e65 lint this, check todo
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 33s
2026-01-30 01:47:41 +01:00
Runar Ingebrigtsen
21e7e65dfb update gems
CI / scan_ruby (push) Successful in 33s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Successful in 36s
2026-01-30 01:45:01 +01:00
Runar Ingebrigtsen
3e36821e51 edit entries 2026-01-30 01:43:58 +01:00
Runar Ingebrigtsen
530021960e add entry requests, invite new users
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Successful in 11s
CI / lint (push) Failing after 19s
CI / test (push) Successful in 34s
2026-01-30 01:28:53 +01:00
Runar Ingebrigtsen
b64ad52d30 use docs/TODO.md 2026-01-30 01:20:45 +01:00
Runar Ingebrigtsen
4a6388ade6 favion 2026-01-29 16:03:11 +01:00
Runar Ingebrigtsen
e7f2215be4 resend invitations 2026-01-29 15:47:03 +01:00
Runar Ingebrigtsen
887d52c447 fix mail from address
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 19s
CI / test (push) Successful in 29s
2026-01-29 14:04:42 +01:00
Runar Ingebrigtsen
e9295dc278 add solid_queue schema
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 28s
2026-01-29 11:37:07 +01:00
Runar Ingebrigtsen
001d63c513 install solid queue
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 18s
CI / test (push) Successful in 29s
2026-01-27 10:03:40 +01:00
Runar Ingebrigtsen
d6ba730d4a update credentials
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 29s
2026-01-27 09:47:12 +01:00
Runar Ingebrigtsen
8a4c146117 use rails credentials for smtp creds
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 30s
2026-01-27 09:23:11 +01:00
Runar Ingebrigtsen
b54db723c5 remove system tests, nothing to see
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 29s
2026-01-26 23:50:19 +01:00
Runar Ingebrigtsen
de52fe9b93 install sqlite in CI
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Successful in 30s
CI / system-test (push) Failing after 21s
2026-01-26 23:45:27 +01:00
Runar Ingebrigtsen
a4d5a676d6 db:setup db:migrate
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Failing after 13s
CI / system-test (push) Failing after 14s
2026-01-26 23:38:17 +01:00
Runar Ingebrigtsen
b3c37cca13 db:setup
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Failing after 13s
CI / system-test (push) Failing after 14s
2026-01-26 23:36:02 +01:00
Runar Ingebrigtsen
be0ddcc89e db before test
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Failing after 12s
CI / system-test (push) Failing after 13s
2026-01-26 22:54:23 +01:00
Runar Ingebrigtsen
654ec39f36 remove lint 2026-01-26 21:56:55 +01:00
Runar Ingebrigtsen
f42e9da504 add importmap bin
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Failing after 18s
CI / test (push) Failing after 17s
CI / system-test (push) Failing after 18s
2026-01-26 21:53:49 +01:00
Runar Ingebrigtsen
a69be52b72 fix vulnerabilities
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Failing after 10s
CI / lint (push) Failing after 19s
CI / test (push) Failing after 16s
CI / system-test (push) Failing after 15s
2026-01-26 21:38:17 +01:00
Runar Ingebrigtsen
35f10c4bda use mise, upgrade roo 2026-01-26 21:34:44 +01:00
Runar Ingebrigtsen
b943b4c8bd ruby verision
CI / scan_ruby (push) Failing after 41s
CI / scan_js (push) Failing after 12s
CI / lint (push) Failing after 20s
CI / test (push) Failing after 13s
CI / system-test (push) Failing after 14s
2026-01-26 13:25:38 +01:00
Runar Ingebrigtsen
a680ae7275 deployment setup
CI / scan_ruby (push) Failing after 30s
CI / scan_js (push) Failing after 7s
CI / lint (push) Failing after 42s
CI / test (push) Failing after 7s
CI / system-test (push) Failing after 17s
2026-01-26 13:20:07 +01:00
134 changed files with 7264 additions and 480 deletions
+49 -33
View File
@@ -83,42 +83,58 @@ jobs:
with: with:
bundler-cache: true bundler-cache: true
- name: Install SQLite3 CLI
run: sudo apt-get update && sudo apt-get install -y sqlite3
- name: Set up database
env:
RAILS_ENV: test
run: bin/rails db:create db:migrate
- name: Run tests - name: Run tests
env: env:
RAILS_ENV: test RAILS_ENV: test
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0 # REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test run: bin/rails test
system-test: # system-test:
runs-on: ubuntu-latest # runs-on: ubuntu-latest
#
# services: # # services:
# redis: # # redis:
# image: valkey/valkey:8 # # image: valkey/valkey:8
# ports: # # ports:
# - 6379:6379 # # - 6379:6379
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 # # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps: # steps:
- name: Checkout code # - name: Checkout code
uses: actions/checkout@v6 # uses: actions/checkout@v6
#
- name: Set up Ruby # - name: Set up Ruby
uses: ruby/setup-ruby@v1 # uses: ruby/setup-ruby@v1
with: # with:
bundler-cache: true # bundler-cache: true
#
- name: Run System Tests # - name: Install SQLite3 CLI
env: # run: sudo apt-get update && sudo apt-get install -y sqlite3
RAILS_ENV: test #
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # - name: Set up database
# REDIS_URL: redis://localhost:6379/0 # env:
run: bin/rails db:test:prepare test:system # RAILS_ENV: test
# run: bin/rails db:create db:migrate
- name: Keep screenshots from failed system tests #
uses: actions/upload-artifact@v4 # - name: Run System Tests
if: failure() # env:
with: # RAILS_ENV: test
name: screenshots # # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
path: ${{ github.workspace }}/tmp/screenshots # # REDIS_URL: redis://localhost:6379/0
if-no-files-found: ignore # run: bin/rails test:system
#
# - name: Keep screenshots from failed system tests
# uses: actions/upload-artifact@v4
# if: failure()
# with:
# name: screenshots
# path: ${{ github.workspace }}/tmp/screenshots
# if-no-files-found: ignore
Vendored
+5 -1
View File
@@ -2,7 +2,6 @@
.byebug_history .byebug_history
.claude .claude
.env* .env*
.ruby-version
.tool-versions .tool-versions
.yarn-integrity .yarn-integrity
.DS_Store .DS_Store
@@ -24,3 +23,8 @@
/db/*.sqlite3-* /db/*.sqlite3-*
/db/schema.rb /db/schema.rb
!.keep !.keep
# Kamal deployment secrets (DO NOT COMMIT!)
/.kamal/secrets
/vendor/
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
hosts = ENV["KAMAL_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_COMMAND
# KAMAL_SUBCOMMAND
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = github_repo_from_remote_url
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
private
def github_repo_from_remote_url
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
if url.start_with?("https://github.com/")
url.delete_prefix("https://github.com/")
elsif url.start_with?("git@github.com:")
url.delete_prefix("git@github.com:")
else
url
end
end
end
$stdout.sync = true
begin
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
+2
View File
@@ -0,0 +1,2 @@
[tools]
ruby = "4"
+1
View File
@@ -0,0 +1 @@
markup: markdown
+1
View File
@@ -0,0 +1 @@
4.0.1
+11 -2
View File
@@ -3,7 +3,7 @@ source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2" gem "rails", "~> 8.1.2"
# Excel import for seeds # Excel import for seeds
gem "roo", "~> 2.10" gem "roo", "~> 3.0"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft" gem "propshaft"
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record
@@ -18,6 +18,8 @@ gem "turbo-rails"
gem "stimulus-rails" gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder] # Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder" gem "jbuilder"
gem "grape"
gem "rswag-ui"
gem "caxlsx" gem "caxlsx"
gem "caxlsx_rails" gem "caxlsx_rails"
@@ -44,6 +46,9 @@ gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2" gem "image_processing", "~> 1.2"
# Pagination [https://github.com/ddnexus/pagy]
gem "pagy", "~> 8.0"
group :development, :test do group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
@@ -56,16 +61,20 @@ group :development, :test do
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false gem "rubocop-rails-omakase", require: false
# Parallel test runner [https://github.com/grosser/parallel_tests]
gem "parallel_tests", require: false
end end
group :development do group :development do
# Use console on exceptions pages [https://github.com/rails/web-console] # Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console" gem "web-console"
gem "mailcatcher"
end end
group :test do group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "benchmark", require: false
gem "simplecov", require: false
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
end end
+111 -92
View File
@@ -81,11 +81,12 @@ GEM
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.21) bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1) bigdecimal (4.0.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.21.1) bootsnap (1.23.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.2) brakeman (8.0.2)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -111,29 +112,52 @@ GEM
concurrent-ruby (1.3.6) concurrent-ruby (1.3.6)
connection_pool (3.0.2) connection_pool (3.0.2)
crass (1.0.6) crass (1.0.6)
daemons (1.4.1) csv (3.3.5)
date (3.5.1) date (3.5.1)
debug (1.11.1) debug (1.11.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
docile (1.4.1)
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.2.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.3.1)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.1) erb (6.0.1)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
eventmachine (1.2.7)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-gnu)
fugit (1.12.1) fugit (1.12.1)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
haml (7.2.0) grape (3.1.1)
temple (>= 0.8.2) activesupport (>= 7.1)
thor dry-configurable
tilt dry-types (>= 1.1)
mustermann-grape (~> 1.1.0)
rack (>= 2)
zeitwerk
htmlentities (4.4.2) htmlentities (4.4.2)
i18n (1.14.8) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -145,14 +169,15 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.2) io-console (0.8.2)
irb (1.16.0) irb (1.17.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.18.0) json (2.18.1)
kamal (2.10.1) kamal (2.10.1)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -176,16 +201,6 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
mailcatcher (0.2.4)
eventmachine
haml
i18n
json
mail
sinatra
skinny (>= 0.1.2)
sqlite3-ruby
thin
marcel (1.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1) mini_magick (5.3.1)
@@ -196,7 +211,9 @@ GEM
msgpack (1.8.0) msgpack (1.8.0)
mustermann (3.0.4) mustermann (3.0.4)
ruby2_keywords (~> 0.0.1) ruby2_keywords (~> 0.0.1)
net-imap (0.6.2) mustermann-grape (1.1.0)
mustermann (>= 1.0.0)
net-imap (0.6.3)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -211,17 +228,20 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.0-x86_64-linux-gnu) nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
ostruct (0.6.3) ostruct (0.6.3)
pagy (8.6.3)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.10.1) parallel_tests (5.6.0)
parallel
parser (3.3.10.2)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.8.0) prism (1.9.0)
propshaft (1.3.1) propshaft (1.3.1)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
@@ -230,15 +250,11 @@ GEM
date date
stringio stringio
public_suffix (7.0.2) public_suffix (7.0.2)
puma (7.1.0) puma (7.2.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.2.5)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@@ -278,7 +294,7 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.3.1)
rdoc (7.1.0) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@@ -286,10 +302,16 @@ GEM
reline (0.6.3) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.4) rexml (3.4.4)
roo (2.10.1) roo (3.0.0)
base64 (~> 0.2)
csv (~> 3)
logger (~> 1)
nokogiri (~> 1) nokogiri (~> 1)
rubyzip (>= 1.3.0, < 3.0.0) rubyzip (>= 3.0.0, < 4.0.0)
rubocop (1.82.1) rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.84.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -297,7 +319,7 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0) rubocop-ast (1.49.0)
@@ -322,24 +344,20 @@ GEM
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (3.2.2)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.40.0) selenium-webdriver (4.41.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sinatra (4.2.1) simplecov (0.22.0)
logger (>= 1.6.0) docile (~> 1.1)
mustermann (~> 3.0) simplecov-html (~> 0.11)
rack (>= 3.0.0, < 4) simplecov_json_formatter (~> 0.1)
rack-protection (= 4.2.1) simplecov-html (0.13.2)
rack-session (>= 2.0.0, < 3) simplecov_json_formatter (0.1.4)
tilt (~> 2.0)
skinny (0.2.2)
eventmachine (~> 1.0)
thin
solid_cable (3.0.12) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
@@ -349,7 +367,7 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_queue (1.3.1) solid_queue (1.3.2)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
@@ -357,8 +375,6 @@ GEM
railties (>= 7.1) railties (>= 7.1)
thor (>= 1.3.1) thor (>= 1.3.1)
sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3-ruby (1.3.3)
sqlite3 (>= 1.3.3)
sshkit (1.25.0) sshkit (1.25.0)
base64 base64
logger logger
@@ -369,18 +385,11 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.2.0) stringio (3.2.0)
temple (0.10.4)
thin (2.0.1)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
logger
rack (>= 1, < 4)
thor (1.5.0) thor (1.5.0)
thruster (0.1.17-x86_64-linux) thruster (0.1.18-x86_64-linux)
tilt (2.7.0)
timeout (0.6.0) timeout (0.6.0)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.21) turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -402,13 +411,14 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.4) zeitwerk (2.7.5)
PLATFORMS PLATFORMS
x86_64-linux-gnu x86_64-linux-gnu
DEPENDENCIES DEPENDENCIES
bcrypt (~> 3.1.7) bcrypt (~> 3.1.7)
benchmark
bootsnap bootsnap
brakeman brakeman
bundler-audit bundler-audit
@@ -416,17 +426,21 @@ DEPENDENCIES
caxlsx caxlsx
caxlsx_rails caxlsx_rails
debug debug
grape
image_processing (~> 1.2) image_processing (~> 1.2)
importmap-rails importmap-rails
jbuilder jbuilder
kamal kamal
mailcatcher pagy (~> 8.0)
parallel_tests
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.2) rails (~> 8.1.2)
roo (~> 2.10) roo (~> 3.0)
rswag-ui
rubocop-rails-omakase rubocop-rails-omakase
selenium-webdriver selenium-webdriver
simplecov
solid_cable solid_cable
solid_cache solid_cache
solid_queue solid_queue
@@ -455,10 +469,11 @@ CHECKSUMS
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214
brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
@@ -467,35 +482,39 @@ CHECKSUMS
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
daemons (1.4.1) sha256=8fc76d76faec669feb5e455d72f35bd4c46dc6735e28c420afb822fac1fa9a1d csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8
dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1
dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2
dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a grape (3.1.1) sha256=774f16782d917a90e69de0499dfaab571e5ad967569ac066a2b0b918af12de69
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3 htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42
json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
mailcatcher (0.2.4) sha256=ba1d6f23d32f69929dce332d0aa7aeabddadd15507de474754e593807111dda9
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
@@ -503,7 +522,8 @@ CHECKSUMS
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
mustermann (3.0.4) sha256=85fadcb6b3c6493a8b511b42426f904b7f27b282835502233dd154daab13aa22 mustermann (3.0.4) sha256=85fadcb6b3c6493a8b511b42426f904b7f27b282835502233dd154daab13aa22
net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5
net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d
@@ -511,21 +531,22 @@ CHECKSUMS
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
pagy (8.6.3) sha256=537b2ee3119f237dd6c4a0d0a35c67a77b9d91ebb9d4f85e31407c2686774fb2
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 parallel_tests (5.6.0) sha256=b2d7af382d1c0289daba65308d9143b3ad82d817d500c0ad1b4bde468beb13af
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
puma (7.1.0) sha256=e45c10cb124f224d448c98db653a75499794edbecadc440ad616cf50f2fd49dd puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
@@ -535,12 +556,13 @@ CHECKSUMS
railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055 railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363 rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1
rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212
rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
@@ -548,27 +570,24 @@ CHECKSUMS
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
skinny (0.2.2) sha256=f40caceccfe3e1d9826f60195a090f43ea7c1130c36b3170887db69a9fb52102 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 solid_queue (1.3.2) sha256=44a53047be4255f616ff13fa5d35980e7b3eee6e31d957eadb88fbe8e0db4509
sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5
sqlite3-ruby (1.3.3) sha256=140b6742875dd5afc3f30ab95720fe60d38e154ae1f4d0728e250778a04094e7
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
thin (2.0.1) sha256=5bbde5648377f5c3864b5da7cd89a23b5c2d8d8bb9435719f6db49644bcdade9
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 thruster (0.1.18-x86_64-linux) sha256=0ec1ff5f12289c1ac10cf8e28ce6b5266f4e73416b34a664b79d037c7d955c40
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7 turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
@@ -579,7 +598,7 @@ CHECKSUMS
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
BUNDLED WITH BUNDLED WITH
4.0.4 4.0.4
+18 -8
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
"Sanasto Wiki" is a web-based dictionary application for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors. "Sanasto Wiki" is a web-based glossary for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors.
--- ---
@@ -188,15 +188,25 @@ See 'public/Kristillisyyden sanasto ver 23.5.2013.xlsx'
--- ---
## API (Optional Future) ## API (Public Sync)
REST API for potential mobile app or integration: Public JSON endpoint for syncing entries:
``` ```
GET /api/entries GET /api/entries
GET /api/entries/:id GET /api/entries?since=2026-01-01T12:00:00Z
GET /api/entries/search?q=:query&lang=:code ```
POST /api/entries (authenticated)
PATCH /api/entries/:id (authenticated) Responses include all language columns, category and `updated_at`. The optional `since`
parameter filters by `updated_at` (ISO8601).
Swagger docs:
```
GET /api/swagger
```
Swagger UI:
```
GET /api
``` ```
## Deployment ## Deployment
-53
View File
@@ -1,53 +0,0 @@
# Sanasto Wiki TODO List
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
---
## High Priority
### Bugs
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
### Improvements
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
---
## Medium Priority
### New Features
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
### Discussion
- [x] **Add comments to entries**: Users can now add comments to entries.
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
### Refactoring
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
---
## Low Priority
### New Features
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
### Improvements
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
@pending_suggestions_count = SuggestedMeaning.pending.count @pending_suggestions_count = SuggestedMeaning.pending.count
@accepted_suggestions_count = SuggestedMeaning.accepted.count @accepted_suggestions_count = SuggestedMeaning.accepted.count
@rejected_suggestions_count = SuggestedMeaning.rejected.count @rejected_suggestions_count = SuggestedMeaning.rejected.count
@requested_entries_count = Entry.requested.count
@comment_count = Comment.count @comment_count = Comment.count
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
def create def create
@invitation = User.new(invitation_params) @invitation = User.new(invitation_params)
@invitation.invitation_token = SecureRandom.urlsafe_base64(32) @invitation.invite_by(current_user)
@invitation.invitation_sent_at = Time.current
@invitation.invited_by = current_user
@invitation.password = SecureRandom.urlsafe_base64(16) @invitation.password = SecureRandom.urlsafe_base64(16)
if @invitation.save if @invitation.save
@@ -29,6 +27,21 @@ class Admin::InvitationsController < Admin::BaseController
end end
end end
def resend
@invitation = User.find(params[:id])
if @invitation.invitation_accepted_at.present?
redirect_to admin_invitations_path, alert: "Cannot resend an accepted invitation."
return
end
@invitation.invite_by!(current_user)
InvitationMailer.invite(@invitation).deliver_later
redirect_to admin_invitations_path, notice: "Invitation resent to #{@invitation.email}"
end
def destroy def destroy
@invitation = User.find(params[:id]) @invitation = User.find(params[:id])
@@ -44,6 +57,12 @@ class Admin::InvitationsController < Admin::BaseController
private private
def invitation_params def invitation_params
params.require(:user).permit(:email, :name, :role, :primary_language) permitted = params.require(:user).permit(:email, :name, :primary_language)
if params[:user][:role].present? && User.roles.key?(params[:user][:role])
permitted[:role] = params[:user][:role]
end
permitted
end end
end end
@@ -0,0 +1,60 @@
class Admin::RequestsController < Admin::BaseController
def index
@requested_entries = Entry.requested
.includes(:requested_by)
.order(created_at: :desc)
@approved_entries = Entry.approved
.includes(:requested_by)
.order(updated_at: :desc)
end
def show
@entry = Entry.find(params[:id])
end
def edit
@entry = Entry.find(params[:id])
end
def update
@entry = Entry.find(params[:id])
if @entry.update(entry_params)
redirect_to admin_request_path(@entry), notice: "Request updated successfully."
else
flash.now[:alert] = "Error updating request."
render :edit, status: :unprocessable_entity
end
end
def approve
@entry = Entry.find(params[:id])
@user = @entry.requested_by
@user.invite_by!(current_user)
@entry.update!(status: :approved)
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
redirect_to admin_requests_path, notice: "Request approved and invitation sent to #{@user.email}."
end
def reject
@entry = Entry.find(params[:id])
@user = @entry.requested_by
entry_preview = [ @entry.fi, @entry.en, @entry.sv, @entry.no, @entry.ru, @entry.de ].compact.first || "Entry"
@entry.destroy!
@user.destroy! if @user.requested_entries.count.zero?
redirect_to admin_requests_path, notice: "Request '#{entry_preview}' has been rejected and deleted."
end
private
def entry_params
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
end
+16 -3
View File
@@ -3,14 +3,20 @@ class Admin::UsersController < Admin::BaseController
def index def index
@users = User.order(created_at: :desc) @users = User.order(created_at: :desc)
@users = @users.where(role: params[:role]) if params[:role].present? .by_role(params[:role])
@users = @users.where("email LIKE ?", "%#{params[:q]}%") if params[:q].present? .search_email(params[:q])
end end
def edit def edit
end end
def update def update
# Prevent users from modifying their own role
if @user == current_user && user_params[:role].present?
redirect_to admin_users_path, alert: "You cannot modify your own role."
return
end
if @user.update(user_params) if @user.update(user_params)
redirect_to admin_users_path, notice: "User updated successfully." redirect_to admin_users_path, notice: "User updated successfully."
else else
@@ -40,6 +46,13 @@ class Admin::UsersController < Admin::BaseController
end end
def user_params def user_params
params.require(:user).permit(:name, :email, :role, :primary_language) permitted = params.require(:user).permit(:name, :email, :primary_language)
# Only allow role if it's a valid role enum value
if params[:user][:role].present? && User.roles.key?(params[:user][:role])
permitted[:role] = params[:user][:role]
end
permitted
end end
end end
+48
View File
@@ -0,0 +1,48 @@
require "grape"
require "ostruct"
class Api::Base < Grape::API
format :json
default_format :json
content_type :json, "application/json"
helpers do
def parse_since_param(raw_since)
return nil if raw_since.blank?
Time.iso8601(raw_since)
rescue ArgumentError
error!({ error: "Invalid since parameter. Use ISO8601 timestamp." }, 400)
end
end
resource :entries do
desc "Return public entries in all languages",
attributes: OpenStruct.new(success: nil, produces: nil)
params do
optional :since,
type: String,
desc: "ISO8601 timestamp. Returns entries updated after this time."
end
get do
since_time = parse_since_param(params[:since])
entries_scope = Entry.active_entries
entries_scope = entries_scope.where("updated_at > ?", since_time) if since_time
entries_scope
.order(:updated_at, :id)
.select(
:id,
:category,
:fi,
:en,
:sv,
:no,
:ru,
:de,
:updated_at
)
end
end
end
+119
View File
@@ -0,0 +1,119 @@
# config/routes.rb
# app/controllers/api/swagger_controller.rb
module Api
class SwaggerController < ApplicationController
def index
render json: {
openapi: "3.0.0",
info: {
title: "Sanasto Wiki API",
description: "Public sync API for Sanasto Wiki glossary entries.",
version: "1.0.0"
},
servers: [
{
url: "https://#{request.host}",
description: "Production server"
}
],
paths: {
"/api/entries": {
get: {
summary: "Return public entries in all languages",
description: "Retrieve all active glossary entries with optional filtering by update timestamp",
tags: [ "Entries" ],
parameters: [
{
name: "since",
in: "query",
description: "ISO8601 timestamp. Returns entries updated after this time.",
required: false,
schema: {
type: "string",
format: "date-time",
example: "2024-01-01T00:00:00Z"
}
}
],
responses: {
"200": {
description: "List of entries",
content: {
"application/json": {
schema: {
type: "array",
items: {
"$ref": "#/components/schemas/Entry"
}
}
}
}
},
"400": {
description: "Invalid since parameter",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: { type: "string" }
}
}
}
}
}
}
}
}
},
components: {
schemas: {
Entry: {
type: "object",
properties: {
id: {
type: "integer",
description: "Entry ID"
},
category: {
type: "string",
description: "Entry category"
},
fi: {
type: "string",
description: "Finnish translation"
},
en: {
type: "string",
description: "English translation"
},
sv: {
type: "string",
description: "Swedish translation"
},
no: {
type: "string",
description: "Norwegian translation"
},
ru: {
type: "string",
description: "Russian translation"
},
de: {
type: "string",
description: "German translation"
},
updated_at: {
type: "string",
format: "date-time",
description: "Last update timestamp (ISO8601)"
}
}
}
}
}
}
end
end
end
+41 -1
View File
@@ -1,7 +1,14 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include BotBlocker
include Pagy::Backend
# Changes to the importmap will invalidate the etag for HTML responses # Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes stale_when_importmap_changes
SESSION_TIMEOUT = 3.days
before_action :check_session_timeout
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?, helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
:contributor_or_above?, :setup_completed? :contributor_or_above?, :setup_completed?
@@ -12,7 +19,40 @@ class ApplicationController < ActionController::Base
end end
def current_user def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] return @current_user if defined?(@current_user)
# First check session
if session[:user_id]
@current_user = User.find_by(id: session[:user_id])
# Then check remember me cookie
elsif cookies.signed[:remember_token]
user = User.find_by_valid_remember_token(cookies.signed[:remember_token])
if user
session[:user_id] = user.id
@current_user = user
else
# Invalid or expired remember token, clear it
cookies.delete(:remember_token)
end
end
@current_user
end
def check_session_timeout
return unless logged_in?
return if cookies.signed[:remember_token].present?
if session[:last_activity_at].present?
last_activity = Time.parse(session[:last_activity_at])
if last_activity < SESSION_TIMEOUT.ago
reset_session
redirect_to login_path, alert: "Your session has expired. Please sign in again."
return
end
end
session[:last_activity_at] = Time.current.to_s
end end
def logged_in? def logged_in?
+56
View File
@@ -0,0 +1,56 @@
module BotBlocker
extend ActiveSupport::Concern
included do
before_action :block_bots
end
private
def block_bots
return unless bot_request?
render plain: "Bot access is not allowed", status: :forbidden
end
def bot_request?
user_agent = request.user_agent.to_s.downcase
# List of known bot user agents
bot_patterns = [
"gptbot", # OpenAI GPTBot
"chatgpt", # ChatGPT
"claude-web", # Anthropic Claude
"bingbot", # Microsoft Bing
"googlebot", # Google
"baiduspider", # Baidu
"yandexbot", # Yandex
"duckduckbot", # DuckDuckGo
"slurp", # Yahoo
"facebookexternalhit", # Facebook
"twitterbot", # Twitter
"linkedinbot", # LinkedIn
"whatsapp", # WhatsApp
"telegrambot", # Telegram
"slackbot", # Slack
"discordbot", # Discord
"applebot", # Apple
"ia_archiver", # Alexa/Internet Archive
"petalbot", # Huawei
"seznambot", # Seznam
"ahrefsbot", # Ahrefs
"semrushbot", # SEMrush
"mj12bot", # Majestic
"dotbot", # OpenSiteExplorer
"rogerbot", # Moz
"exabot", # Exalead
"facebot", # Facebook
"spider", # Generic spiders
"crawler", # Generic crawlers
"scraper", # Generic scrapers
"bot" # Generic bots (last resort)
]
bot_patterns.any? { |pattern| user_agent.include?(pattern) }
end
end
+45
View File
@@ -0,0 +1,45 @@
module RateLimiter
extend ActiveSupport::Concern
included do
before_action :check_rate_limit, only: [ :create ]
end
private
def check_rate_limit
identifier = request.ip
cache_key = "rate_limit:#{controller_name}:#{identifier}"
# Get current attempt count
attempts = Rails.cache.read(cache_key) || 0
if attempts >= max_attempts
@rate_limited = true
render_rate_limit_error
return
end
# Increment attempt count with expiry
Rails.cache.write(cache_key, attempts + 1, expires_in: lockout_period)
end
def reset_rate_limit
identifier = request.ip
cache_key = "rate_limit:#{controller_name}:#{identifier}"
Rails.cache.delete(cache_key)
end
def render_rate_limit_error
flash.now[:alert] = "Too many failed attempts. Please try again in #{lockout_period / 60} minutes."
render action_name == "create" ? :new : action_name, status: :too_many_requests
end
def max_attempts
5
end
def lockout_period
15.minutes
end
end
+17 -13
View File
@@ -2,35 +2,33 @@ class EntriesController < ApplicationController
before_action :set_entry, only: [ :show, :edit, :update ] before_action :set_entry, only: [ :show, :edit, :update ]
def index def index
@language_code = params[:language].presence @language_code = validate_language_code(params[:language].presence)
@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.active_entries
entries_scope = entries_scope.with_category(@category) entries_scope = entries_scope.with_category(@category)
entries_scope = entries_scope.search(@query, language_code: @language_code) 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.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.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?
@total_entries = entries_scope.count @pagy, @entries = pagy(entries_scope, items: 25)
@total_pages = (@total_entries.to_f / @per_page).ceil @total_entries = @pagy.count
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
@entry_count = Entry.count @entry_count = Entry.active_entries.count
@verified_count = Entry.where(verified: true).count @requested_count = Entry.requested.count
@verified_count = Entry.active_entries.where(verified: true).count
@needs_review_count = @entry_count - @verified_count @needs_review_count = @entry_count - @verified_count
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language| @complete_entries_count = supported_languages.reduce(Entry.active_entries) do |scope, language|
scope.where.not(language.code => [ nil, "" ]) scope.where.not(language.code => [ nil, "" ])
end.count end.count
@missing_entries_count = @entry_count - @complete_entries_count @missing_entries_count = @entry_count - @complete_entries_count
@language_completion = supported_languages.index_with do |language| @language_completion = supported_languages.index_with do |language|
next 0 if @entry_count.zero? next 0 if @entry_count.zero?
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round (Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
end end
if @language_code.present? if @language_code.present?
@@ -61,7 +59,7 @@ class EntriesController < ApplicationController
end end
def download def download
@entries = Entry.order(:id) @entries = Entry.active_entries.order(:id)
respond_to do |format| respond_to do |format|
format.xlsx do format.xlsx do
filename = "sanasto-entries-#{Time.zone.today}.xlsx" filename = "sanasto-entries-#{Time.zone.today}.xlsx"
@@ -77,6 +75,12 @@ class EntriesController < ApplicationController
end end
def entry_params def entry_params
params.require(:entry).permit(:category) params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
def validate_language_code(code)
return nil if code.blank?
SupportedLanguage.valid_codes.include?(code) ? code : nil
end end
end end
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
invitation_token: nil invitation_token: nil
) )
# Activate approved entries by this user
Entry.where(requested_by: @user, status: :approved).update_all(status: :active)
session[:user_id] = @user.id session[:user_id] = @user.id
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!" redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
else else
@@ -0,0 +1,79 @@
class PasswordResetsController < ApplicationController
RESET_TOKEN_EXPIRY = 1.hour
def new
# Show request password reset form
end
def create
@user = User.find_by(email: params[:email]&.downcase&.strip)
if @user&.invitation_accepted_at.present?
@user.update!(
reset_password_token: SecureRandom.urlsafe_base64(32),
reset_password_sent_at: Time.current
)
PasswordResetMailer.reset(@user).deliver_later
elsif @user.present?
@user.invite_by!
InvitationMailer.invite(@user).deliver_later
end
redirect_to login_path, notice: "If that email address is in our system, you will receive password reset instructions."
end
def edit
@user = User.find_by(reset_password_token: params[:token])
if @user.nil?
redirect_to login_path, alert: "Invalid password reset link."
elsif password_reset_expired?(@user)
redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
end
end
def update
@user = User.find_by(reset_password_token: params[:token])
if @user.nil?
redirect_to login_path, alert: "Invalid password reset link."
return
end
if password_reset_expired?(@user)
redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
return
end
if params[:password].blank?
flash.now[:alert] = "Password cannot be blank."
render :edit, status: :unprocessable_entity
return
end
if params[:password] != params[:password_confirmation]
flash.now[:alert] = "Password confirmation doesn't match."
render :edit, status: :unprocessable_entity
return
end
@user.password = params[:password]
@user.password_confirmation = params[:password_confirmation]
@user.reset_password_token = nil
@user.reset_password_sent_at = nil
if @user.save
session[:user_id] = @user.id
redirect_to root_path, notice: "Your password has been reset successfully."
else
flash.now[:alert] = @user.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
private
def password_reset_expired?(user)
user.reset_password_sent_at < RESET_TOKEN_EXPIRY.ago
end
end
+75
View File
@@ -0,0 +1,75 @@
class RequestsController < ApplicationController
def new
@entry = Entry.new
if current_user
@pending_count = current_user.requested_entries.where(status: [ :requested, :approved ]).count
elsif params[:email].present?
@pending_count = User.find_by(email: params[:email])&.requested_entries&.where(status: [ :requested, :approved ])&.count || 0
else
@pending_count = 0
end
end
def create
# If user is logged in, use their account
if current_user
@user = current_user
else
# Anonymous submission - need to find or create user
email = request_params[:email]
existing_user = User.find_by(email: email)
# Check if user has already accepted an invitation
if existing_user&.invitation_accepted_at.present?
redirect_to login_path, alert: "An account with this email already exists. Please log in."
return
end
# Use existing pending user or create new one
@user = existing_user || User.new(
name: request_params[:name],
email: email,
password: SecureRandom.alphanumeric(32),
role: :contributor
)
end
# Create entry in a transaction
ActiveRecord::Base.transaction do
# Save user only if it's a new record
if @user.new_record? && !@user.save
@pending_count = 0
@entry = Entry.new(entry_params)
flash.now[:alert] = "There was an error submitting your request. Please check the form."
render :new, status: :unprocessable_entity
raise ActiveRecord::Rollback
return
end
# Create entry
@entry = Entry.new(entry_params)
@entry.status = :requested
@entry.requested_by = @user
if @entry.save
redirect_to root_path, notice: "Thank you for your request! We'll review it and get back to you soon."
else
@pending_count = 0
flash.now[:alert] = "There was an error submitting your request. Please check the form."
render :new, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
end
private
def request_params
params.require(:entry).permit(:name, :email, :category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
def entry_params
request_params.except(:name, :email)
end
end
+22 -2
View File
@@ -1,4 +1,6 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
include RateLimiter
def new def new
# Redirect to admin if already logged in # Redirect to admin if already logged in
if logged_in? if logged_in?
@@ -17,7 +19,23 @@ class SessionsController < ApplicationController
return return
end end
# Reset rate limit on successful login
reset_rate_limit
session[:user_id] = user.id session[:user_id] = user.id
session[:last_activity_at] = Time.current.to_s
# Handle remember me
if params[:remember_me] == "1"
token = user.remember_me
cookies.signed[:remember_token] = {
value: token,
expires: User::REMEMBER_TOKEN_EXPIRY.from_now,
httponly: true,
secure: Rails.env.production?
}
end
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome back, #{user.name}!" redirect_to admin? ? admin_root_path : root_path, notice: "Welcome back, #{user.name}!"
else else
flash.now[:alert] = "Invalid email or password." flash.now[:alert] = "Invalid email or password."
@@ -26,7 +44,9 @@ class SessionsController < ApplicationController
end end
def destroy def destroy
session[:user_id] = nil current_user&.forget_me if cookies.signed[:remember_token]
redirect_to root_path, notice: "You have been logged out." reset_session
cookies.delete(:remember_token)
redirect_to root_path, notice: "You have been logged out.", status: :see_other
end end
end end
+2
View File
@@ -1,4 +1,6 @@
module ApplicationHelper module ApplicationHelper
include Pagy::Frontend
def language_name(code) def language_name(code)
supported_languages.find { |l| l.code == code }&.name supported_languages.find { |l| l.code == code }&.name
end end
+11
View File
@@ -28,4 +28,15 @@ module EntriesHelper
def format_entry_category(entry) def format_entry_category(entry)
entry.category.to_s.tr("_", " ").capitalize entry.category.to_s.tr("_", " ").capitalize
end end
def format_entry_status(entry)
case entry.status
when "requested"
content_tag(:span, "Requested", class: "px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800")
when "approved"
content_tag(:span, "Approved", class: "px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800")
when "active"
content_tag(:span, "Active", class: "px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800")
end
end
end end
@@ -1,25 +1,46 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static targets = ["modal", "button"] static targets = ["modal", "languageSelect", "form"]
connect() { connect() {
this.modalTarget.classList.add("hidden") this.modalTarget.classList.add("hidden")
this.buttonTarget.classList.remove("hidden")
} }
open(event) { open(event) {
event.preventDefault() event.preventDefault()
// Get the language code from the button that was clicked
const languageCode = event.currentTarget.dataset.languageCode
// Set the language select value if provided
if (languageCode && this.hasLanguageSelectTarget) {
this.languageSelectTarget.value = languageCode
}
this.modalTarget.classList.remove("hidden") this.modalTarget.classList.remove("hidden")
} }
close(event) { close(event) {
if (event.target === this.modalTarget) { if (event.target === this.modalTarget) {
this.modalTarget.classList.add("hidden") this.modalTarget.classList.add("hidden")
this.resetForm()
} }
} }
closeWithButton() { closeWithButton(event) {
event.preventDefault()
this.modalTarget.classList.add("hidden") this.modalTarget.classList.add("hidden")
this.resetForm()
}
stopPropagation(event) {
event.stopPropagation()
}
resetForm() {
if (this.hasFormTarget) {
this.formTarget.reset()
}
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: "from@example.com" default from: Rails.application.credentials.dig(:mail, :from)
layout "mailer" layout "mailer"
end end
+9 -2
View File
@@ -1,12 +1,19 @@
class InvitationMailer < ApplicationMailer class InvitationMailer < ApplicationMailer
def invite(user) def invite(user, approved_entry: nil)
@user = user @user = user
@approved_entry = approved_entry
@invitation_url = invitation_url(@user.invitation_token) @invitation_url = invitation_url(@user.invitation_token)
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY @expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
subject = if @approved_entry
"Your entry request has been approved - Join Sanasto Wiki"
else
"You've been invited to join Sanasto Wiki"
end
mail( mail(
to: @user.email, to: @user.email,
subject: "You've been invited to join Sanasto Wiki" subject: subject
) )
end end
end end
+9
View File
@@ -0,0 +1,9 @@
class PasswordResetMailer < ApplicationMailer
def reset(user)
@user = user
@reset_url = edit_password_reset_url(@user.reset_password_token)
@expires_at = @user.reset_password_sent_at + PasswordResetsController::RESET_TOKEN_EXPIRY
mail(to: @user.email, subject: "Reset your Sanasto Wiki password")
end
end
+13 -1
View File
@@ -1,15 +1,21 @@
class Entry < ApplicationRecord class Entry < ApplicationRecord
belongs_to :created_by, class_name: "User", optional: true belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true
belongs_to :requested_by, class_name: "User", optional: true
has_many :suggested_meanings, dependent: :destroy has_many :suggested_meanings, dependent: :destroy
has_many :comments, as: :commentable, dependent: :destroy has_many :comments, as: :commentable, dependent: :destroy
enum :category, %i[word phrase proper_name title reference other] enum :category, %i[word phrase proper_name title reference other]
enum :status, %i[requested approved active], default: :active
validates :category, presence: true validates :category, presence: true
validate :at_least_one_translation
scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all } scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all }
scope :requested, -> { where(status: :requested) }
scope :approved, -> { where(status: :approved) }
scope :active_entries, -> { where(status: :active) }
def self.search(query, language_code: nil) def self.search(query, language_code: nil)
return all if query.blank? return all if query.blank?
@@ -35,7 +41,7 @@ class Entry < ApplicationRecord
return none unless valid_lang?(language_code) return none unless valid_lang?(language_code)
where.not(language_code => [ nil, "" ]) where.not(language_code => [ nil, "" ])
.order(Arel.sql("#{language_code} ASC")) .order(arel_table[language_code].asc)
end end
private private
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
def self.valid_lang?(code) def self.valid_lang?(code)
SupportedLanguage.valid_codes.include?(code.to_s) SupportedLanguage.valid_codes.include?(code.to_s)
end end
def at_least_one_translation
if [ fi, en, sv, no, ru, de ].all?(&:blank?)
errors.add(:base, "At least one language translation is required")
end
end
end end
+48 -1
View File
@@ -6,6 +6,7 @@ class User < ApplicationRecord
has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify
has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify
has_many :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify
has_many :submitted_suggested_meanings, has_many :submitted_suggested_meanings,
class_name: "SuggestedMeaning", class_name: "SuggestedMeaning",
foreign_key: :submitted_by_id, foreign_key: :submitted_by_id,
@@ -18,11 +19,18 @@ class User < ApplicationRecord
enum :role, %i[contributor reviewer admin] enum :role, %i[contributor reviewer admin]
validates :email, presence: true, uniqueness: true validates :email, presence: true, uniqueness: { case_sensitive: false }
validates :password, length: { minimum: 12 }, if: -> { password.present? } validates :password, length: { minimum: 12 }, if: -> { password.present? }
before_validation :normalize_email
scope :by_role, ->(role) { where(role: role) if role.present? }
scope :search_email, ->(q) { where("email LIKE ?", "%#{sanitize_sql_like(q)}%") if q.present? }
# Invitation token expires after 14 days # Invitation token expires after 14 days
INVITATION_TOKEN_EXPIRY = 14.days INVITATION_TOKEN_EXPIRY = 14.days
# Remember me token expires after 2 weeks
REMEMBER_TOKEN_EXPIRY = 2.weeks
def invitation_expired? def invitation_expired?
return false if invitation_sent_at.nil? return false if invitation_sent_at.nil?
@@ -33,10 +41,49 @@ class User < ApplicationRecord
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired? invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
end end
def invite_by(invitee)
self.invited_by = invitee if invitee && invited_by.nil?
self.invitation_token = SecureRandom.urlsafe_base64(32)
self.invitation_sent_at = Time.current
end
def invite_by!(invitee = nil)
invite_by(invitee)
save!
end
def self.find_by_valid_invitation_token(token) def self.find_by_valid_invitation_token(token)
where(invitation_token: token) where(invitation_token: token)
.where(invitation_accepted_at: nil) .where(invitation_accepted_at: nil)
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago) .where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
.first .first
end end
def remember_me
self.remember_token = SecureRandom.urlsafe_base64(32)
self.remember_created_at = Time.current
save(validate: false)
remember_token
end
def forget_me
update_columns(remember_token: nil, remember_created_at: nil)
end
def remember_token_expired?
return true if remember_created_at.nil?
remember_created_at < REMEMBER_TOKEN_EXPIRY.ago
end
def self.find_by_valid_remember_token(token)
user = find_by(remember_token: token)
return nil if user.nil? || user.remember_token_expired?
user
end
private
def normalize_email
self.email = email.downcase.strip if email.present?
end
end end
+24 -16
View File
@@ -18,10 +18,12 @@
</svg> </svg>
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <%= link_to admin_users_path do %>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt> <dl>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
</dl> <dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl>
<% end %>
</div> </div>
</div> </div>
</div> </div>
@@ -44,10 +46,12 @@
</svg> </svg>
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <%= link_to root_path do %>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt> <dl>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
</dl> <dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl>
<% end %>
</div> </div>
</div> </div>
</div> </div>
@@ -69,10 +73,12 @@
</svg> </svg>
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <%= link_to admin_requests_path do %>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt> <dl>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
</dl> <dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
</dl>
<% end %>
</div> </div>
</div> </div>
</div> </div>
@@ -94,10 +100,12 @@
</svg> </svg>
</div> </div>
<div class="ml-5 w-0 flex-1"> <div class="ml-5 w-0 flex-1">
<dl> <%= link_to admin_invitations_path do %>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt> <dl>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
</dl> <dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl>
<% end %>
</div> </div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -65,7 +65,8 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %> <%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-4">
<%= button_to "Re-send", resend_admin_invitation_path(invitation), method: :put, class: "text-blue-600 hover:text-blue-900" %>
<%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %> <%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
</td> </td>
</tr> </tr>
+38
View File
@@ -0,0 +1,38 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Entry Request</h1>
<p class="text-gray-600 mt-2">Modify the entry details before approval.</p>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %>
<% if @entry.errors.any? %>
<div class="mx-6 mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="px-6 pt-6 space-y-4">
<%= render 'entries/form_fields', f: f %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Requester (Read-only)</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-1 text-sm">
<div><span class="font-medium">Name:</span> <%= @entry.requested_by&.name %></div>
<div><span class="font-medium">Email:</span> <%= @entry.requested_by&.email %></div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex gap-3">
<%= f.submit "Save Changes", class: "px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition" %>
<%= link_to "Cancel", admin_request_path(@entry), class: "px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
</div>
<% end %>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Requests</h1>
<p class="text-gray-600 mt-2">Review and manage entry requests from public users.</p>
</div>
<!-- Requested Entries Section -->
<div class="mb-12">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-yellow-50 border-b border-yellow-100 px-6 py-4">
<h2 class="text-xl font-bold text-yellow-900 flex items-center gap-2">
<span>⏳</span> Pending Review
<span class="text-sm font-normal text-yellow-700">(<%= @requested_entries.count %> total)</span>
</h2>
</div>
<% if @requested_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @requested_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.created_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
<%= link_to "Edit", edit_admin_request_path(entry), class: "text-blue-600 hover:text-blue-900" %>
<%= button_to "Approve", approve_admin_request_path(entry), method: :post, class: "inline text-green-600 hover:text-green-900", form: { data: { turbo_confirm: "Send invitation to #{entry.requested_by&.email}?" } } %>
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this request?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No pending requests at the moment.
</div>
<% end %>
</div>
</div>
<!-- Approved Entries Section -->
<div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4">
<h2 class="text-xl font-bold text-blue-900 flex items-center gap-2">
<span>✅</span> Approved (Awaiting User Acceptance)
<span class="text-sm font-normal text-blue-700">(<%= @approved_entries.count %> total)</span>
</h2>
</div>
<% if @approved_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @approved_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.updated_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this approved request?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No approved entries awaiting user acceptance.
</div>
<% end %>
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Request Details</h1>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="border-b border-gray-200 px-6 py-4 bg-gray-50">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">Entry Information</h2>
<%= content_tag(:span, @entry.status.titleize, class: "px-3 py-1 text-sm font-semibold rounded-full #{@entry.requested? ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}") %>
</div>
</div>
<div class="px-6 py-6 space-y-6">
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-2">Category</h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= @entry.category.humanize %>
</span>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇫🇮 Finnish</div>
<div class="text-gray-900"><%= @entry.fi.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇬🇧 English</div>
<div class="text-gray-900"><%= @entry.en.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇸🇪 Swedish</div>
<div class="text-gray-900"><%= @entry.sv.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇳🇴 Norwegian</div>
<div class="text-gray-900"><%= @entry.no.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇷🇺 Russian</div>
<div class="text-gray-900"><%= @entry.ru.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇩🇪 German</div>
<div class="text-gray-900"><%= @entry.de.presence || "—" %></div>
</div>
</div>
</div>
<% if @entry.notes.present? %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
<div class="bg-gray-50 rounded-lg p-4 text-gray-900 whitespace-pre-wrap">
<%= @entry.notes %>
</div>
</div>
<% end %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Requester Information</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div>
<span class="text-sm font-medium text-gray-700">Name:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.name %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Email:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.email %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Submitted:</span>
<span class="text-gray-900 ml-2"><%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex flex-wrap gap-3">
<%= link_to "← Back to Requests", admin_requests_path, class: "px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
<% if @entry.requested? %>
<%= link_to "Edit", edit_admin_request_path(@entry), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition" %>
<%= button_to "Approve & Send Invitation", approve_admin_request_path(@entry), method: :post, class: "px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition", form: { data: { turbo_confirm: "Send invitation to #{@entry.requested_by&.email}?" } } %>
<% end %>
<%= button_to "Reject", reject_admin_request_path(@entry), method: :delete, class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition ml-auto", form: { data: { turbo_confirm: "Are you sure you want to delete this request?" } } %>
</div>
</div>
</div>
+10 -6
View File
@@ -2,12 +2,16 @@
<%= render "entries/comment", comment: @comment %> <%= render "entries/comment", comment: @comment %>
<% end %> <% end %>
<% if @comment.language_code.present? %>
<%= turbo_stream.replace "comment-details-#{@comment.language_code}" do %>
<%= render "entries/language_comment_details", entry: @commentable, language_code: @comment.language_code %>
<% end %>
<% end %>
<%= turbo_stream.replace "comment_tabs" do %> <%= turbo_stream.replace "comment_tabs" do %>
<%= render "entries/comment_tabs", entry: @commentable %> <%= render "entries/comment_tabs", entry: @commentable %>
<% end %> <% end %>
<%= turbo_stream.update "comment_form_modal" do %>
<%= render "entries/comment_modal_content", entry: @commentable %>
<% end %>
<%= turbo_stream.append "comment_form_modal", target: "comment_form_modal" do %>
<script>
document.getElementById('comment_form_modal').classList.add('hidden');
</script>
<% end %>
+20 -5
View File
@@ -1,12 +1,27 @@
<%= form_with(model: [entry, Comment.new(commentable: entry)], <%= form_with(model: [entry, Comment.new(commentable: entry)],
data: { turbo_stream: true }, data: { turbo_stream: true, comments_target: "form" },
html: { class: "space-y-4" }) do |form| %> html: { class: "space-y-4" }) do |form| %>
<%= form.hidden_field :language_code, value: (local_assigns[:language_code].presence || nil) %>
<div> <div>
<%= form.label :body, "Comment", class: "sr-only" %> <%= form.label :language_code, "Language", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %> <%= form.select :language_code,
options_for_select(
[["All languages", nil]] + supported_languages.map { |lang| ["#{lang.name} (#{lang.code.upcase})", lang.code] },
local_assigns[:language_code].presence
),
{},
{
data: { comments_target: "languageSelect" },
class: "block w-full px-3 py-2 border border-slate-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm"
} %>
</div> </div>
<div class="flex justify-end"> <div>
<%= form.label :body, "Comment", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-lg shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
</div>
<div class="flex justify-end gap-2">
<button type="button" data-action="click->comments#closeWithButton" class="px-4 py-2 text-sm font-medium text-slate-700 hover:text-slate-900 transition">
Cancel
</button>
<%= form.submit "Submit", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> <%= form.submit "Submit", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</div> </div>
<% end %> <% end %>
@@ -0,0 +1,9 @@
<div data-action="click->comments#stopPropagation" class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg">
<div class="flex justify-between items-center mb-4">
<h4 class="text-lg font-bold">Add a Comment</h4>
<button data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
&times;
</button>
</div>
<%= render "entries/comment_form", entry: entry %>
</div>
+3 -11
View File
@@ -1,8 +1,8 @@
<% if current_user %> <% if current_user %>
<div class="mt-8" data-controller="comments"> <div class="mt-8">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-slate-900">Discussion</h3> <h3 class="text-lg font-bold text-slate-900">Discussion</h3>
<button id="add_comment_button" data-action="click->comments#open" data-comments-target="button" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold"> <button data-action="click->comments#open" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
Add Comment Add Comment
</button> </button>
</div> </div>
@@ -10,15 +10,7 @@
<%= render "entries/comment_tabs", entry: entry %> <%= render "entries/comment_tabs", entry: entry %>
<div id="comment_form_modal" data-comments-target="modal" data-action="click->comments#close" class="hidden fixed inset-0 bg-slate-900 bg-opacity-50 z-40 flex items-center justify-center"> <div id="comment_form_modal" data-comments-target="modal" data-action="click->comments#close" class="hidden fixed inset-0 bg-slate-900 bg-opacity-50 z-40 flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg"> <%= render "entries/comment_modal_content", entry: entry %>
<div class="flex justify-between items-center">
<h4 class="text-lg font-bold">Add a Comment</h4>
<button id="close_comment_form" data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
&times;
</button>
</div>
<%= render "entries/comment_form", entry: entry %>
</div>
</div> </div>
</div> </div>
+62
View File
@@ -0,0 +1,62 @@
<%# Local variables expected:
# - f: form builder object
# - show_category: boolean (default: true) - whether to show category field
# - show_notes: boolean (default: true) - whether to show notes field
# - category_prompt: string or false (default: "Select a category") - prompt for category select, or false for no prompt
%>
<% show_category = local_assigns.fetch(:show_category, true) %>
<% show_notes = local_assigns.fetch(:show_notes, true) %>
<% category_prompt = local_assigns.fetch(:category_prompt, "Select a category") %>
<% if show_category %>
<div>
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.select :category,
Entry.categories.keys.map { |cat| [cat.humanize, cat] },
category_prompt ? { prompt: category_prompt } : {},
{ required: category_prompt.present?, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
</div>
<% end %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations (at least one required)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
</div>
</div>
<% if show_notes %>
<div>
<%= f.label :notes, "Additional Notes (optional)", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
</div>
<% end %>
@@ -1,8 +0,0 @@
<details class="text-xs">
<summary class="inline-flex items-center gap-1 text-indigo-600 font-semibold cursor-pointer">
Add comment
</summary>
<div class="mt-3">
<%= render "entries/comment_form", entry: entry, language_code: language_code %>
</div>
</details>
+15 -10
View File
@@ -42,6 +42,9 @@
<tr> <tr>
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500"> <td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
No entries matched your filters. No entries matched your filters.
<br><br>
<%= link_to "Request a new entry", new_request_path,
class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %>
</td> </td>
</tr> </tr>
<% else %> <% else %>
@@ -83,17 +86,19 @@
</div> </div>
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"> <div class="flex items-center justify-between mt-4 text-sm text-slate-600">
<div>Page <%= @page %> of <%= [@total_pages, 1].max %></div> <div><%= pagy_info(@pagy).html_safe %></div>
<div class="flex items-center gap-2"> <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
<% pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %> prev_url = @pagy.prev ? entries_path(pagination_params.merge(page: @pagy.prev)) : nil
<%= link_to "Previous", previous_page ? entries_path(pagination_params.merge(page: previous_page)) : "#", next_url = @pagy.next ? entries_path(pagination_params.merge(page: @pagy.next)) : nil
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_stream: true } %> <%= link_to "Previous", prev_url || "#",
<%= 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 #{'opacity-50 pointer-events-none' unless prev_url}",
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: (prev_url ? { turbo_stream: true } : {}) %>
data: { turbo_stream: true } %> <%= link_to "Next", next_url || "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{'opacity-50 pointer-events-none' unless next_url}",
data: (next_url ? { turbo_stream: true } : {}) %>
</div> </div>
</div> </div>
</div> </div>
+42 -57
View File
@@ -1,70 +1,55 @@
<% content_for :title, "Edit Entry" %> <% content_for :title, "Edit Entry" %>
<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"> <%= render "shared/header", show_request_button: false, show_browse_button: true %>
<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"> <%= render "shared/notifications" %>
<div class="flex items-center justify-between">
<%= link_to "← Back to entry", entry_path(@entry), class: "text-sm text-slate-500 hover:text-indigo-600" %>
<%= 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="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center"> <div class="max-w-2xl w-full">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Edit Category</span> <div class="bg-white rounded-2xl shadow-xl p-8">
<% if @entry.verified? %> <div class="text-center mb-8">
<div class="flex items-center gap-1.5 text-emerald-600"> <div class="flex items-center justify-center gap-3 mb-2">
<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> <h1 class="text-3xl font-bold text-gray-900">Edit Entry</h1>
<span class="text-xs font-bold">Verified</span> <% if @entry.verified? %>
<div class="flex items-center gap-1.5 text-emerald-600">
<svg class="w-5 h-5" 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-sm font-bold">Verified</span>
</div>
<% else %>
<span class="text-sm font-semibold text-amber-600 px-3 py-1 rounded-full bg-amber-50">Unverified</span>
<% end %>
</div>
<p class="text-gray-600">Update the translations and details for this entry.</p>
</div> </div>
<% else %>
<span class="text-xs font-semibold text-amber-600">Unverified</span>
<% end %>
</div>
<div class="p-6 space-y-6"> <%= form_with model: @entry, class: "space-y-6" do |f| %>
<%= form_with model: @entry, class: "space-y-4" do |form| %> <% if @entry.errors.any? %>
<div> <div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<%= form.label :category, "Category", class: "block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2" %> <h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<%= form.select :category, <ul class="list-disc list-inside text-red-700 text-sm space-y-1">
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] }, <% @entry.errors.full_messages.each do |message| %>
{}, <li><%= message %></li>
class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %> <% end %>
</div> </ul>
<div class="flex justify-end">
<%= form.submit "Save Category", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
</div>
<% end %>
<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-2">
<div class="grid grid-cols-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
</div> </div>
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p> <% end %>
<div class="space-y-6">
<%= render 'entries/form_fields', f: f, category_prompt: false %>
</div>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<%= f.submit "Save Changes", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
<%= link_to "Cancel", entry_path(@entry), class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
</div> </div>
<% end %> <% end %>
</div>
<% if @entry.notes.present? %> <div class="mt-6 text-center text-sm text-gray-600">
<div class="mt-6 pt-5 border-t border-slate-100"> <%= link_to "← Back to entry", entry_path(@entry), class: "text-indigo-600 hover:text-indigo-800 font-semibold" %> •
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4> <%= link_to "Back to search", entries_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
<p class="text-sm text-slate-600 italic"><%= @entry.notes %></p>
</div> </div>
<% end %> </div>
</div> </div>
</div> </div>
</main> </div>
+2 -19
View File
@@ -1,26 +1,9 @@
<% content_for :title, "Sanasto Wiki" %> <% content_for :title, "Sanasto Wiki" %>
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200"> <%= render "shared/header" %>
<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">
<%= 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" %>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% else %>
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
</div>
</div>
</div>
</header>
<%= render "shared/notifications" %>
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<section id="search"> <section id="search">
+8 -6
View File
@@ -13,7 +13,7 @@
</div> </div>
</nav> </nav>
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6"> <main class="max-w-5xl mx-auto px-4 py-8 space-y-6" data-controller="comments">
<div> <div>
<%= link_to "← Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %> <%= link_to "← Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
</div> </div>
@@ -39,14 +39,16 @@
<% next if translation.blank? %> <% next if translation.blank? %>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-2"> <div class="grid grid-cols-2">
<div>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span> <span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
<% if current_user %>
<button data-action="click->comments#open" data-language-code="<%= language.code %>" class="text-[10px] ml-3 font-bold uppercase text-indigo-400 hover:text-indigo-600 transition">
Add Comment
</button>
<% end %>
</div>
</div> </div>
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p> <p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
<% if current_user %>
<div id="comment-details-<%= language.code %>">
<%= render "entries/language_comment_details", entry: @entry, language_code: language.code %>
</div>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
+72 -4
View File
@@ -79,6 +79,32 @@
font-size: 14px; font-size: 14px;
border-radius: 4px; border-radius: 4px;
} }
.entry-box {
background: #f0fdf4;
border-left: 4px solid #10b981;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.entry-box h3 {
margin: 0 0 12px 0;
color: #065f46;
font-size: 16px;
}
.entry-translations {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
margin-top: 12px;
}
.entry-translations dt {
font-weight: 600;
color: #064e3b;
}
.entry-translations dd {
margin: 0;
color: #1e293b;
}
</style> </style>
</head> </head>
<body> <body>
@@ -90,11 +116,53 @@
<div class="content"> <div class="content">
<p class="greeting">Hello <%= @user.name %>,</p> <p class="greeting">Hello <%= @user.name %>,</p>
<p> <% if @approved_entry %>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity. <p>
</p> Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
</p>
<p>With a login account, you can contribute to this work.</p> <div class="entry-box">
<h3>✓ Your Approved Entry</h3>
<p style="margin: 0 0 4px 0;"><strong>Category:</strong> <%= @approved_entry.category.to_s.humanize %></p>
<dl class="entry-translations">
<% if @approved_entry.fi.present? %>
<dt>🇫🇮 Finnish:</dt>
<dd><%= @approved_entry.fi %></dd>
<% end %>
<% if @approved_entry.en.present? %>
<dt>🇬🇧 English:</dt>
<dd><%= @approved_entry.en %></dd>
<% end %>
<% if @approved_entry.sv.present? %>
<dt>🇸🇪 Swedish:</dt>
<dd><%= @approved_entry.sv %></dd>
<% end %>
<% if @approved_entry.no.present? %>
<dt>🇳🇴 Norwegian:</dt>
<dd><%= @approved_entry.no %></dd>
<% end %>
<% if @approved_entry.ru.present? %>
<dt>🇷🇺 Russian:</dt>
<dd><%= @approved_entry.ru %></dd>
<% end %>
<% if @approved_entry.de.present? %>
<dt>🇩🇪 German:</dt>
<dd><%= @approved_entry.de %></dd>
<% end %>
</dl>
</div>
<p>
To complete the process and publish your entry, please accept this invitation to create your account on <strong>Sanasto Wiki</strong>.
</p>
<% else %>
<p>
The <strong>Sanasto Wiki</strong> let you search words and expressions you might need for interpretation work in the living Christianity.
</p>
<p>With a login account, you can contribute to this work.</p>
<% end %>
<div class="info-box"> <div class="info-box">
<p style="margin: 0;"><strong>Your Account Details:</strong></p> <p style="margin: 0;"><strong>Your Account Details:</strong></p>
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
Hello <%= @user.name %>, Hello <%= @user.name %>,
<% if @approved_entry %>
Great news! Your entry request has been APPROVED and is ready to be published.
YOUR APPROVED ENTRY
-------------------
Category: <%= @approved_entry.category.to_s.humanize %>
Translations:
<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
<% end %>
To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
<% else %>
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity. The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
With a login account, you can contribute to this work. With a login account, you can contribute to this work.
<% end %>
YOUR ACCOUNT DETAILS YOUR ACCOUNT DETAILS
-------------------- --------------------
+1 -1
View File
@@ -36,7 +36,7 @@
</div> </div>
<% end %> <% end %>
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %> <%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div> <div>
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %> <%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password, <%= form.password_field :password,
+92 -14
View File
@@ -16,31 +16,109 @@
<header class="bg-white border-b border-slate-200"> <header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4"> <div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between"> <div class="h-16 flex items-center justify-between">
<div class="flex items-center gap-2"> <%= link_to admin_dashboard_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span> <span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Admin</span> <span class="text-xl font-light text-slate-400">Admin</span>
</div> <% end %>
<nav class="flex items-center gap-3">
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-3">
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<% requested_count = Entry.requested.count %>
<% gap = requested_count.zero? ? '' : 'pr-4' %>
<%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
Requests
<% if requested_count > 0 %>
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> <%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</nav>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center gap-3">
<span class="text-sm text-slate-600">
<%= current_user.name.split.first %>
</span>
<button type="button" id="admin-mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="admin-mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
<div class="py-2 px-2 border-b border-slate-200 mb-2">
<span class="text-sm font-medium text-slate-900">
<%= current_user.name %>
</span>
</div>
<nav class="flex flex-col space-y-1">
<%= link_to "Dashboard", admin_dashboard_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= link_to "Users", admin_users_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<% requested_count = Entry.requested.count %>
<%= link_to admin_requests_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition flex items-center justify-between" do %>
<span>Requests</span>
<% if requested_count > 0 %>
<span class="bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { class: "w-full" }, class: "w-full text-left px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition cursor-pointer" %>
</nav> </nav>
</div> </div>
</div> </div>
</header> </header>
<!-- Flash messages --> <script>
<% if flash.any? %> function setupAdminMobileMenu() {
<div class="max-w-7xl mx-auto px-4 mt-4"> const menuButton = document.getElementById('admin-mobile-menu-button');
<% flash.each do |type, message| %> const mobileMenu = document.getElementById('admin-mobile-menu');
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4" role="alert">
<span class="block sm:inline"><%= message %></span> if (menuButton && mobileMenu) {
</div> // Remove existing listeners to avoid duplicates
<% end %> const newMenuButton = menuButton.cloneNode(true);
</div> menuButton.parentNode.replaceChild(newMenuButton, menuButton);
<% end %>
newMenuButton.addEventListener('click', function(e) {
e.stopPropagation();
mobileMenu.classList.toggle('hidden');
});
// Close menu when clicking outside
document.addEventListener('click', function(event) {
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
// Close menu when navigating with Turbo
document.addEventListener('turbo:click', function() {
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
}
}
// Run on initial load and on Turbo navigation
document.addEventListener('DOMContentLoaded', setupAdminMobileMenu);
document.addEventListener('turbo:load', setupAdminMobileMenu);
</script>
<%= render "shared/notifications" %>
<!-- Main content --> <!-- Main content -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8"> <main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
+4 -3
View File
@@ -14,9 +14,10 @@
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png"> <link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/icon.svg" type="image/svg+xml"> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/icon.png"> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon-512.png" />
<%# Includes all stylesheet files in app/assets/stylesheets %> <%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #334155;
max-width: 640px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
}
.header p {
margin: 8px 0 0 0;
opacity: 0.9;
}
.content {
background: white;
border: 1px solid #e2e8f0;
border-top: none;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.greeting {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #1e293b;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 14px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 24px 0;
text-align: center;
}
.button:hover {
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 14px;
color: #64748b;
}
.expiry-notice {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
font-size: 14px;
border-radius: 4px;
}
.warning-box {
background: #fef2f2;
border-left: 4px solid #ef4444;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="header">
<h1>Sanasto Wiki</h1>
<p>Password Reset Request</p>
</div>
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<p>
We received a request to reset your password for your Sanasto Wiki account.
</p>
<p>
If you made this request, click the button below to set a new password:
</p>
<div style="text-align: center;">
<%= link_to "Reset My Password", @reset_url, class: "button" %>
</div>
<div class="expiry-notice">
This password reset link will expire on <strong><%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %></strong>.
</div>
<p>
You can also copy and paste this link into your browser:
</p>
<p style="word-break: break-all; color: #6366f1; font-size: 14px;">
<%= @reset_url %>
</p>
<div class="warning-box">
<strong>Didn't request a password reset?</strong>
<p style="margin: 8px 0 0 0;">
If you didn't make this request, you can safely ignore this email. Your password will remain unchanged.
</p>
</div>
<div class="footer">
<p>
For security reasons, this link will only work once and will expire in 1 hour.
</p>
<p style="margin-top: 12px;">
Questions? Reply to this email.
</p>
</div>
</div>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<% content_for :title, "Set New Password" %>
<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">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
</div>
</div>
</header>
<%= render "shared/notifications" %>
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 mb-2">Set new password</h1>
<p class="text-sm text-slate-600">Enter your new password below.</p>
</div>
<%= form_with url: password_reset_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div>
<%= form.label :password, "New Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password,
autofocus: true,
autocomplete: "new-password",
required: true,
minlength: 8,
placeholder: "••••••••••••",
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
<p class="mt-1 text-xs text-slate-500">Minimum 8 characters</p>
</div>
<div>
<%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password_confirmation,
autocomplete: "new-password",
required: true,
minlength: 8,
placeholder: "••••••••••••",
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
</div>
<div class="pt-2">
<%= form.submit "Reset Password",
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
<% content_for :title, "Reset Password" %>
<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">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
</div>
</div>
</header>
<%= render "shared/notifications" %>
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 mb-2">Reset your password</h1>
<p class="text-sm text-slate-600">Enter your email address and we'll send you a link to reset your password.</p>
</div>
<%= form_with url: password_resets_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div>
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.email_field :email,
autofocus: true,
autocomplete: "email",
required: true,
placeholder: "you@example.com",
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
</div>
<div class="pt-2">
<%= form.submit "Send Reset Instructions",
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</div>
<% end %>
<div class="mt-6 text-center space-y-3">
<%= link_to login_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Sign In
<% end %>
</div>
</div>
</div>
</div>
</div>
+2 -2
View File
@@ -2,12 +2,12 @@
"name": "SanastoWiki", "name": "SanastoWiki",
"icons": [ "icons": [
{ {
"src": "/icon.png", "src": "/icon-512.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
}, },
{ {
"src": "/icon.png", "src": "/icon-512.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512", "sizes": "512x512",
"purpose": "maskable" "purpose": "maskable"
+71
View File
@@ -0,0 +1,71 @@
<% content_for :title, "Request a New Entry" %>
<div class="min-h-screen flex flex-col">
<%= render "shared/header", show_request_button: false %>
<%= render "shared/notifications" %>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Request a New Entry</h1>
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
</div>
<% if @pending_count && @pending_count > 0 %>
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
</div>
<% end %>
<%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
<% if @entry.errors.any? %>
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="space-y-4">
<% if current_user %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p class="text-sm text-blue-900">
<span class="font-semibold">Submitting as:</span> <%= current_user.name %> (<%= current_user.email %>)
</p>
</div>
<% else %>
<div>
<%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<% end %>
</div>
<%= render 'entries/form_fields', f: f %>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
<%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
</div>
<% end %>
<% unless current_user %>
<div class="mt-6 text-center text-sm text-gray-600">
Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
+12 -7
View File
@@ -12,6 +12,8 @@
</div> </div>
</header> </header>
<%= render "shared/notifications" %>
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50"> <div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8"> <div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
@@ -20,13 +22,8 @@
<p class="text-sm text-slate-600">Enter your credentials to continue</p> <p class="text-sm text-slate-600">Enter your credentials to continue</p>
</div> </div>
<% if flash[:alert] %>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
<%= flash[:alert] %>
</div>
<% end %>
<%= form_with url: login_path, method: :post, local: true, class: "space-y-5" do |form| %> <%= form_with url: login_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div> <div>
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %> <%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.email_field :email, <%= form.email_field :email,
@@ -38,7 +35,10 @@
</div> </div>
<div> <div>
<%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700 mb-2" %> <div class="flex justify-between items-center mb-2">
<%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700" %>
<%= link_to "Forgot password?", new_password_reset_path, class: "text-xs text-indigo-600 hover:text-indigo-700 font-medium" %>
</div>
<%= form.password_field :password, <%= form.password_field :password,
autocomplete: "current-password", autocomplete: "current-password",
required: true, required: true,
@@ -46,6 +46,11 @@
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %> class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
</div> </div>
<div class="flex items-center">
<%= check_box_tag :remember_me, "1", false, class: "w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-2 focus:ring-indigo-500" %>
<%= label_tag :remember_me, "Remember me for 2 weeks", class: "ml-2 text-sm text-slate-600" %>
</div>
<div class="pt-2"> <div class="pt-2">
<%= form.submit "Sign In", <%= form.submit "Sign In",
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
+122
View File
@@ -0,0 +1,122 @@
<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">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-3">
<% if local_assigns[:show_request_button] != false %>
<%= link_to "Request Entry", new_request_path,
class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %>
<% end %>
<% if local_assigns[:show_download_button] != false %>
<%= 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" %>
<% end %>
<% if local_assigns[:show_browse_button] %>
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<% end %>
<% if logged_in? %>
<div class="flex items-center gap-3 ml-2 pl-3 border-l border-slate-200">
<span class="text-sm text-slate-600">
<%= current_user.name %>
</span>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
<%= link_to "Sign Out", logout_path, data: { turbo_method: :delete },
class: "text-sm font-medium text-slate-600 hover:text-red-600 transition" %>
</div>
<% else %>
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center gap-3">
<% if logged_in? %>
<span class="text-sm text-slate-600">
<%= current_user.name.split.first %>
</span>
<% end %>
<button type="button" id="mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
<% if logged_in? %>
<div class="py-2 px-2 border-b border-slate-200 mb-2">
<span class="text-sm font-medium text-slate-900">
<%= current_user.name %>
</span>
</div>
<% end %>
<nav class="flex flex-col space-y-1">
<% if local_assigns[:show_request_button] != false %>
<%= link_to "Request Entry", new_request_path,
class: "px-2 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 rounded transition" %>
<% end %>
<% if local_assigns[:show_browse_button] %>
<%= link_to "Browse", entries_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<% end %>
<% if logged_in? %>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "px-2 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 rounded transition" %>
<% end %>
<%= link_to "Sign Out", logout_path, data: { turbo_method: :delete },
class: "block px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition" %>
<% else %>
<%= link_to "Sign In", login_path, class: "px-2 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 rounded transition" %>
<% end %>
</nav>
</div>
</div>
</header>
<script>
function setupMobileMenu() {
const menuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
if (menuButton && mobileMenu) {
// Remove existing listeners to avoid duplicates
const newMenuButton = menuButton.cloneNode(true);
menuButton.parentNode.replaceChild(newMenuButton, menuButton);
newMenuButton.addEventListener('click', function(e) {
e.stopPropagation();
mobileMenu.classList.toggle('hidden');
});
// Close menu when clicking outside
document.addEventListener('click', function(event) {
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
// Close menu when navigating with Turbo
document.addEventListener('turbo:click', function() {
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
}
}
// Run on initial load and on Turbo navigation
document.addEventListener('DOMContentLoaded', setupMobileMenu);
document.addEventListener('turbo:load', setupMobileMenu);
</script>
+16
View File
@@ -0,0 +1,16 @@
<% if flash.any? %>
<div class="flex justify-center px-4 mt-4">
<div class="space-y-2">
<% flash.each do |type, message| %>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-6 py-3 rounded-lg relative shadow-sm" role="alert">
<span class="block pr-8 text-sm font-medium"><%= message %></span>
<button type="button" class="absolute top-0 right-0 mt-2.5 mr-2 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<% end %>
</div>
</div>
<% end %>
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
require_relative "../config/application"
require "importmap/commands"
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue::Cli.start(ARGV)
+3
View File
@@ -1,6 +1,7 @@
require_relative "boot" require_relative "boot"
require "rails/all" require "rails/all"
require_relative "../lib/middleware/sanasto_cors"
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production. # you've limited to :test, :development, or :production.
@@ -24,5 +25,7 @@ module SanastoWiki
# config.time_zone = "Central Time (US & Canada)" # config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras") # config.eager_load_paths << Rails.root.join("extras")
config.active_record.schema_format = :sql config.active_record.schema_format = :sql
config.middleware.insert_before 0, Middleware::SanastoCors
end end
end end
+1 -1
View File
@@ -1 +1 @@
1frgw1tdeAMChiF5O/Lr3rhpz/EKVHFcl3fUFqpqRy6m31gTG4B58nR8FCm1rBujjNKZnGRvuGzxkw8rlwJKbSCtmFSQK3XIPQ5PW6XvL8n/9WVR9VBbSQxVQinXol+xp6tKHqUv6/fJRnIcvH0AR38tbeA4a6ByTG1bz62R0YVBCVdPkvB9QvS0TIy4Ks04YTJQvi/WIuBzQ0/2++v9rO9KLlabhDwLnOTYrjRgewlFAIlI8IlRZr6NO3m4yq6LTXD8gJ9rjXLE8Ajh9BFShlXXgAXXN0UsfetSseROvCYXhsFZAAR9pIX65zh8+edtxREmFL2yTXzNwTNpUq788VLGiHBK6mQQxviZGi4URj5zfZ+piXU3GWYcpU3txNGG0vVZYhyvs2sSFk+CQnmFojCuvvq9oNjrtTgE1yi/17SNq93YG7E7m+sFh2Jmc3cN04SPv7XLixKzFAK9028M7uQZvOC6R7Pb2DySvTILbe56CDCvBiTpTCB2--hkQcz/vYM834FRs4--BcXqGn/gjm7peH3bOzjLdA== JC3W3xoTxCvfRD1b4tx56WaFXVOpIE0Vfnd0Pt6KUiCEYgMIcPi5KXYKWGsUIZG3PgWOx4BhZ1HR1TXfm/kB7o1fVdPuiLeWpf+3JKwjg9dU1GBwPSwpH4t4uRgWPAE48h6WCkcy22gRuCWr3LmPcZa3EV7u9tZwjWDwOS/RW2FZkkOu7HGFQKwwcGZKVy0TF6hXxGfJEZMLF+5kWgjii2a1VAI0Dy53UBwlxQ+hSilYlkAEQTYmuRzCUExjQuPB77vdZZb/Fumuqp6GrP8xubczunzaePAmv5EBREUy3xfV71/hwDwZFZDCRQQ4PbVBlTYh/3upfUtjOtG2amnqwOGq2oWP1sFVNZy6ZyicMTTjrVf7oh96vR+tHr3OBg+ZEjYS9nHhUUyUv/OjXh35nHQRH0zbbGqCTngOpyOa6IRZuC83bybNQ3+qXY9lau22LoIc8sxJdSHLQ8ZMEXPYm5f8mo3xTJ0SlY8/3QGW0VcWrEp63gqP0k/3GuoHJbjGWT08kFgh8ynAOaORZyZdVkCdS3KbUeyiNdQV0UgowqwBz4cj9pWQGjY3zU0ngTyTD9fIZ6cfyjDpWHPaXeol+X8=--Jl8cRsZDKG3chvBd--MG6KloWhjRJgvvY/pq/fHQ==
+8 -3
View File
@@ -10,15 +10,20 @@ default: &default
timeout: 5000 timeout: 5000
development: development:
<<: *default primary:
database: storage/development.sqlite3 <<: *default
database: storage/development.sqlite3
queue:
<<: *default
database: storage/development_queue.sqlite3
migrations_paths: db/queue_migrate
# Warning: The database defined as "test" will be erased and # Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake". # re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production. # Do not set this db to the same as development or production.
test: test:
<<: *default <<: *default
database: storage/test.sqlite3 database: storage/test<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3
# Store production database in the storage/ directory, which by default # Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml. # is mounted as a persistent Docker volume in config/deploy.yml.
+62
View File
@@ -0,0 +1,62 @@
# Name of your application. Used to uniquely configure containers.
service: sanasto-wiki
# Name of the container image.
image: soverein/sanasto-wiki
# Deploy to these servers.
servers:
web:
- app.rin.no
# Uncomment when you want to run background jobs in a separate container
# job:
# hosts:
# - your-server-ip
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
proxy:
ssl: true
hosts:
- sanasto.rin.no
- sanasto.wiki
# Kamal proxy will forward to your app on port 3000
# Credentials for your image host.
registry:
# Use Docker Hub (default), GitHub Container Registry, or another registry
# For Docker Hub: username/image-name
# For GitHub: ghcr.io/username/image-name
server: git.rin.no
username: deploybot
# Always use an access token rather than real password
password:
- KAMAL_REGISTRY_PASSWORD
# Configure builder setup.
builder:
arch: amd64
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
clear:
RAILS_LOG_TO_STDOUT: true
RAILS_SERVE_STATIC_FILES: true
SOLID_QUEUE_IN_PUMA: true
secret:
- RAILS_MASTER_KEY
# Use persistent storage volume for SQLite database and uploads
volumes:
- "sanasto_storage:/rails/storage"
# Bridge fingerprinted assets between versions
asset_path: /rails/public/assets
# Aliases for common tasks
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs --follow
dbconsole: app exec --interactive --reuse "bin/rails dbconsole"
+3 -2
View File
@@ -31,12 +31,13 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options). # Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local config.active_storage.service = :local
# Don't care if the mailer can't send. # Raise delivery errors to see what's happening with email
config.action_mailer.raise_delivery_errors = false config.action_mailer.raise_delivery_errors = true
# Make template changes take effect immediately. # Make template changes take effect immediately.
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
# You can use Mailpit for SMTP in development - https://github.com/axllent/mailpit
config.action_mailer.delivery_method = :smtp config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = { config.action_mailer.smtp_settings = {
address: "localhost", address: "localhost",
+10 -9
View File
@@ -50,23 +50,24 @@ Rails.application.configure do
# config.cache_store = :mem_cache_store # config.cache_store = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job. # Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors. # Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false # config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" } config.action_mailer.default_url_options = { host: "sanasto.wiki" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = { config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name), user_name: Rails.application.credentials.dig(:mail, :username),
# password: Rails.application.credentials.dig(:smtp, :password), password: Rails.application.credentials.dig(:mail, :password),
# address: "smtp.example.com", address: Rails.application.credentials.dig(:mail, :server),
# port: 587, port: 587,
# authentication: :plain authentication: :plain
# } }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found). # the I18n.default_locale when a translation cannot be found).
+1 -1
View File
@@ -20,7 +20,7 @@ Rails.application.configure do
# Show full error reports. # Show full error reports.
config.consider_all_requests_local = true config.consider_all_requests_local = true
config.cache_store = :null_store config.cache_store = :memory_store
# Render exception templates for rescuable exceptions and raise for other exceptions. # Render exception templates for rescuable exceptions and raise for other exceptions.
config.action_dispatch.show_exceptions = :rescuable config.action_dispatch.show_exceptions = :rescuable
+6
View File
@@ -0,0 +1,6 @@
# Pagy Configuration
require "pagy/extras/overflow"
Pagy::DEFAULT[:items] = 25 # Match current 25 items per page
Pagy::DEFAULT[:page_param] = :page
Pagy::DEFAULT[:overflow] = :last_page
+3
View File
@@ -0,0 +1,3 @@
Rswag::Ui.configure do |config|
config.swagger_endpoint "/api/swagger", "Sanasto Wiki API"
end
+18
View File
@@ -0,0 +1,18 @@
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
production:
<<: *default
+15
View File
@@ -0,0 +1,15 @@
# examples:
# periodic_cleanup:
# class: CleanSoftDeletedRecordsJob
# queue: background
# args: [ 1000, { batch_size: 500 } ]
# schedule: every hour
# periodic_cleanup_with_command:
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
+26 -4
View File
@@ -1,6 +1,10 @@
Rails.application.routes.draw do Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
mount Api::Base => "/api"
get "/api/swagger", to: "api/swagger#index"
mount Rswag::Ui::Engine => "/api"
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live. # Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check get "up" => "rails/health#show", as: :rails_health_check
@@ -21,26 +25,44 @@ Rails.application.routes.draw do
post "login", to: "sessions#create" post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy", as: :logout delete "logout", to: "sessions#destroy", as: :logout
# Password reset routes
resources :password_resets, only: [ :new, :create ]
get "password_resets/:token/edit", to: "password_resets#edit", as: :edit_password_reset
patch "password_resets/:token", to: "password_resets#update", as: :password_reset
# Invitation acceptance routes # Invitation acceptance routes
get "invitations/:token", to: "invitations#show", as: :invitation get "invitations/:token", to: "invitations#show", as: :invitation
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
# Public entry request routes
resources :requests, only: [ :new, :create ]
# Admin namespace # Admin namespace
namespace :admin do namespace :admin do
root "dashboard#index" root "dashboard#index"
get "dashboard", to: "dashboard#index" get "dashboard", to: "dashboard#index"
resources :users, only: [ :index, :edit, :update, :destroy ] resources :users, only: [ :index, :edit, :update, :destroy ]
resources :invitations, only: [ :index, :new, :create, :destroy ] resources :invitations, only: [ :index, :new, :create, :destroy ] do
member do
put :resend
end
end
resources :requests, only: [ :index, :show, :edit, :update ] do
member do
post :approve
delete :reject
end
end
end end
resources :entries do resources :entries do
resources :comments, only: [:create] resources :comments, only: [ :create ]
collection do collection do
get :download get :download
end end
end end
resources :suggested_meanings resources :suggested_meanings
resources :comments, only: [:create, :update, :destroy] resources :comments, only: [ :create, :update, :destroy ]
resources :supported_languages, only: [:index, :show] resources :supported_languages, only: [ :index, :show ]
resources :users resources :users
end end
@@ -18,6 +18,5 @@ class CreateSuggestedMeanings < ActiveRecord::Migration[8.1]
t.timestamps t.timestamps
end end
end end
end end
@@ -0,0 +1,13 @@
class AddStatusToEntries < ActiveRecord::Migration[8.1]
def change
add_column :entries, :status, :integer, default: 2, null: false
add_index :entries, :status
# Set all existing entries to status: 2 (active)
reversible do |dir|
dir.up do
execute "UPDATE entries SET status = 2"
end
end
end
end
@@ -0,0 +1,7 @@
class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
def change
add_column :entries, :requested_by_id, :integer
add_foreign_key :entries, :users, column: :requested_by_id
add_index :entries, :requested_by_id
end
end
@@ -0,0 +1,7 @@
class AddPasswordResetToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :reset_password_token, :string
add_column :users, :reset_password_sent_at, :datetime
add_index :users, :reset_password_token, unique: true
end
end
@@ -0,0 +1,7 @@
class AddRememberTokenToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :remember_token, :string
add_column :users, :remember_created_at, :datetime
add_index :users, :remember_token, unique: true
end
end
+129
View File
@@ -0,0 +1,129 @@
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.text "error"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
t.string "queue_name", null: false
t.string "class_name", null: false
t.text "arguments"
t.integer "priority", default: 0, null: false
t.string "active_job_id"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.string "concurrency_key"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", null: false
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
t.bigint "supervisor_id"
t.integer "pid", null: false
t.string "hostname"
t.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "task_key", null: false
t.datetime "run_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
t.string "key", null: false
t.string "schedule", null: false
t.string "command", limit: 2048
t.string "class_name"
t.text "arguments"
t.string "queue_name"
t.integer "priority", default: 0
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
t.string "key", null: false
t.integer "value", default: 1, null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end
+61
View File
@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS "solid_queue_jobs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "queue_name" varchar NOT NULL, "class_name" varchar NOT NULL, "arguments" text, "priority" integer DEFAULT 0 NOT NULL, "active_job_id" varchar, "scheduled_at" datetime(6), "finished_at" datetime(6), "concurrency_key" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE INDEX "index_solid_queue_jobs_on_active_job_id" ON "solid_queue_jobs" ("active_job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_on_class_name" ON "solid_queue_jobs" ("class_name") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_on_finished_at" ON "solid_queue_jobs" ("finished_at") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_for_filtering" ON "solid_queue_jobs" ("queue_name", "finished_at") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_for_alerting" ON "solid_queue_jobs" ("scheduled_at", "finished_at") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_pauses" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "queue_name" varchar NOT NULL, "created_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_solid_queue_pauses_on_queue_name" ON "solid_queue_pauses" ("queue_name") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_processes" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "kind" varchar NOT NULL, "last_heartbeat_at" datetime(6) NOT NULL, "supervisor_id" bigint, "pid" integer NOT NULL, "hostname" varchar, "metadata" text, "created_at" datetime(6) NOT NULL, "name" varchar NOT NULL);
CREATE INDEX "index_solid_queue_processes_on_last_heartbeat_at" ON "solid_queue_processes" ("last_heartbeat_at") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_processes_on_name_and_supervisor_id" ON "solid_queue_processes" ("name", "supervisor_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_processes_on_supervisor_id" ON "solid_queue_processes" ("supervisor_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_recurring_tasks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" varchar NOT NULL, "schedule" varchar NOT NULL, "command" varchar(2048), "class_name" varchar, "arguments" text, "queue_name" varchar, "priority" integer DEFAULT 0, "static" boolean DEFAULT TRUE NOT NULL, "description" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_solid_queue_recurring_tasks_on_key" ON "solid_queue_recurring_tasks" ("key") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_recurring_tasks_on_static" ON "solid_queue_recurring_tasks" ("static") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_semaphores" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" varchar NOT NULL, "value" integer DEFAULT 1 NOT NULL, "expires_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE INDEX "index_solid_queue_semaphores_on_expires_at" ON "solid_queue_semaphores" ("expires_at") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_semaphores_on_key_and_value" ON "solid_queue_semaphores" ("key", "value") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_semaphores_on_key" ON "solid_queue_semaphores" ("key") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_blocked_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "concurrency_key" varchar NOT NULL, "expires_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_4cd34e2228"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE INDEX "index_solid_queue_blocked_executions_for_release" ON "solid_queue_blocked_executions" ("concurrency_key", "priority", "job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_blocked_executions_for_maintenance" ON "solid_queue_blocked_executions" ("expires_at", "concurrency_key") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_blocked_executions_on_job_id" ON "solid_queue_blocked_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_claimed_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "process_id" bigint, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_9cfe4d4944"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_claimed_executions_on_job_id" ON "solid_queue_claimed_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_claimed_executions_on_process_id_and_job_id" ON "solid_queue_claimed_executions" ("process_id", "job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_failed_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "error" text, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_39bbc7a631"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_failed_executions_on_job_id" ON "solid_queue_failed_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_ready_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_81fcbd66af"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_ready_executions_on_job_id" ON "solid_queue_ready_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_poll_all" ON "solid_queue_ready_executions" ("priority", "job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_poll_by_queue" ON "solid_queue_ready_executions" ("queue_name", "priority", "job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_recurring_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "task_key" varchar NOT NULL, "run_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_318a5533ed"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_recurring_executions_on_job_id" ON "solid_queue_recurring_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_recurring_executions_on_task_key_and_run_at" ON "solid_queue_recurring_executions" ("task_key", "run_at") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_scheduled_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "scheduled_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c4316f352d"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_scheduled_executions_on_job_id" ON "solid_queue_scheduled_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_dispatch_all" ON "solid_queue_scheduled_executions" ("scheduled_at", "priority", "job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
INSERT INTO "schema_migrations" (version) VALUES
('1');
+22 -31
View File
@@ -1,15 +1,5 @@
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY); CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
FOREIGN KEY ("created_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_6f84c41258"
FOREIGN KEY ("updated_by_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c" CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
FOREIGN KEY ("user_id") FOREIGN KEY ("user_id")
REFERENCES "users" ("id") REFERENCES "users" ("id")
@@ -18,7 +8,7 @@ CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/; CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/; CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f" CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "reset_password_token" varchar /*application='SanastoWiki'*/, "reset_password_sent_at" datetime(6) /*application='SanastoWiki'*/, "remember_token" varchar /*application='SanastoWiki'*/, "remember_created_at" datetime(6) /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_ae14a5013f"
FOREIGN KEY ("invited_by_id") FOREIGN KEY ("invited_by_id")
REFERENCES "users" ("id") REFERENCES "users" ("id")
); );
@@ -59,28 +49,29 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
CREATE TRIGGER entries_fts_after_insert
AFTER INSERT ON entries
BEGIN
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
END;
CREATE TRIGGER entries_fts_after_update
AFTER UPDATE ON entries
BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
END;
CREATE TRIGGER entries_fts_after_delete
AFTER DELETE ON entries
BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
END;
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL); CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
FOREIGN KEY ("updated_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_367d1ab731"
FOREIGN KEY ("created_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_4d36fd8a36"
FOREIGN KEY ("requested_by_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/;
INSERT INTO "schema_migrations" (version) VALUES INSERT INTO "schema_migrations" (version) VALUES
('20260130080931'),
('20260130080745'),
('20260129204706'),
('20260129204705'),
('20260123130957'), ('20260123130957'),
('20260123125325'), ('20260123125325'),
('20260122131000'), ('20260122131000'),
+37
View File
@@ -194,3 +194,40 @@ for i in range(10):
``` ```
``` ```
</markdown_spec> </markdown_spec>
<test_coverage>
## Test Coverage Tracking
The project uses SimpleCov to track test coverage. Coverage data is stored in `coverage/.resultset.json` after each test run.
### Coverage Information
- **Location**: `coverage/.resultset.json`
- **Format**: JSON file with line-by-line coverage data for each Ruby file
- **Generated by**: SimpleCov gem (runs automatically with tests)
- **HTML Report**: `coverage/index.html` (open in browser for detailed report)
### Reading Coverage Data
When checking test coverage:
1. Run tests: `bin/rails test`
2. Check overall coverage in console output (e.g., "Line Coverage: 85.7% (707 / 825)")
3. For detailed per-file coverage: open `coverage/index.html` in a browser
4. The `.resultset.json` file contains raw coverage data if programmatic access is needed
### Coverage Goals
- Aim for **80%+ line coverage** for all models and helpers
- Controllers should have **all actions tested** (both success and error paths)
- Critical business logic should have **100% coverage**
### Files Excluded from Coverage
- `config/` - Configuration files
- `db/` - Database migrations and schema
- `test/` - Test files themselves
- `vendor/` - Third-party code
### Coverage Notes for Agents
- Always run tests after making changes to verify coverage isn't decreasing
- If adding new methods/classes, add corresponding tests
- Coverage report shows which lines are NOT covered (highlighted in red in HTML report)
- Missing coverage often indicates edge cases or error paths not tested
</test_coverage>
+75
View File
@@ -0,0 +1,75 @@
# Deployment (Kamal)
We deploy with Kamal. You do NOT need to manually set up web servers or Docker
on the VM. `kamal setup` provisions everything.
## Requirements
### Local machine
- Ruby + Bundler
- Docker (for building images)
- SSH key access to the VM
- Registry credentials (see `config/deploy.yml`)
### Remote VM
- Bare VM with SSH access
- Open ports: 22, 80, 443
- A domain name pointing at the VM (for SSL)
## Configure
Update `config/deploy.yml`:
```yaml
service: sanasto-wiki
image: your-registry/sanasto-wiki
servers:
web:
- your-server-ip
proxy:
ssl: true
host: sanasto.example.com
registry:
server: ghcr.io
username: your-github-username
ssh:
user: deploy
```
Set the registry password:
```bash
export KAMAL_REGISTRY_PASSWORD="your-token"
```
Make sure `config/master.key` is present locally.
## First deploy
```bash
bundle exec kamal setup
```
## Regular deploys
```bash
bundle exec kamal deploy
```
## Useful commands
```bash
bundle exec kamal app logs --follow
bundle exec kamal app details
bundle exec kamal app exec --interactive --reuse "bin/rails console"
bundle exec kamal rollback
```
## Initial app setup
Visit `https://your-domain/setup` once after the first deploy to create the
admin account.
+57 -31
View File
@@ -2,14 +2,15 @@
## Authentication & Authorization ## Authentication & Authorization
- [ ] **Authentication system** - [x] **Authentication system**
- [x] Sessions controller and views (login/logout) - [x] Sessions controller and views (login/logout)
- [x] Email/password authentication with session management - [x] Email/password authentication with session management
- [x] Login redirects (admin vs regular users) - [x] Login redirects (admin vs regular users)
- [x] Logout functionality - [x] Logout functionality
- [ ] Password reset flow - [x] Password reset flow (email-based, 1 hour expiry)
- [ ] Rate limiting on login attempts - [x] Rate limiting on login attempts (5 attempts, 15 minute lockout)
- [ ] Session management (remember me, session timeout) - [x] Session management (remember me for 2 weeks, 30 minute timeout)
- [x] Sign in status in the site header
- [x] **Invitation system** - [x] **Invitation system**
- [x] Invitations controller (create, list, cancel) - [x] Invitations controller (create, list, cancel)
- [x] Invitation token generation - [x] Invitation token generation
@@ -22,6 +23,24 @@
- [ ] Contributor permissions enforcement (for entry editing) - [ ] Contributor permissions enforcement (for entry editing)
- [ ] Reviewer permissions enforcement (for review queue) - [ ] Reviewer permissions enforcement (for review queue)
## Security & Vulnerabilities
- [x] **Fixed user-controlled method execution** (HIGH)
- Added language code validation in EntriesController
- Prevents arbitrary method execution via `public_send()`
- [x] **Fixed SQL injection in Entry model** (MEDIUM)
- Replaced string interpolation with Arel safe column references
- Changed `Arel.sql("#{language_code} ASC")` to `arel_table[language_code].asc`
- [x] **Fixed mass assignment vulnerabilities** (MEDIUM)
- Added role validation in admin invitations and user management
- Only allows valid enum role values
- Prevents users from modifying their own role
- [x] **Fixed SQL LIKE injection** (MEDIUM)
- Added `sanitize_sql_like()` for email search in UsersController
- Prevents wildcard injection attacks
**Status:** All Brakeman security warnings resolved ✓
## Core Features ## Core Features
### Search & Browse ### Search & Browse
@@ -31,11 +50,11 @@
- [x] **Live search** (AJAX updates without page reload) - [x] **Live search** (AJAX updates without page reload)
### Entry Management ### Entry Management
- [ ] **Create entry form** with all language fields - [x] **Create entry form** with all language fields
- [ ] **Edit entry form** (contributors only) - [x] **Edit entry form** (contributors only)
- [ ] **Delete entries** (admin only, with confirmation) - [ ] **Delete entries** (admin only, with confirmation)
- [ ] **Bulk actions** (select multiple, bulk edit/delete) - [ ] **Bulk actions** (select multiple, bulk edit/delete)
- [ ] **Entry validation** (at least one language required, category required) - [x] **Entry validation** (at least one language required, category required)
### Suggested Meanings ### Suggested Meanings
- [ ] **Submit alternative translation** form - [ ] **Submit alternative translation** form
@@ -45,7 +64,7 @@
- [ ] **Notifications** when suggestions are reviewed - [ ] **Notifications** when suggestions are reviewed
### Comments & Discussion ### Comments & Discussion
- [ ] **Add comment** form on entry view - [x] **Add comment** form on entry view
- [ ] **Edit/delete own comments** - [ ] **Edit/delete own comments**
- [ ] **Comment threading** (optional: replies to comments) - [ ] **Comment threading** (optional: replies to comments)
- [ ] **Comment notifications** for entry contributors - [ ] **Comment notifications** for entry contributors
@@ -90,22 +109,38 @@
## Testing ## Testing
- [ ] **Controller tests** for all actions - [x] **Controller tests** for all actions
- [ ] **System tests** for critical user flows - [x] EntriesController (index, show, edit, update, download, filters, search)
- [ ] Public browsing and search - [x] PasswordResetsController (new, create, edit, update, token validation)
- [ ] Contributor creates/edits entry - [x] Existing tests: Sessions, Invitations, Setup, Admin controllers, Comments, Requests
- [ ] Reviewer workflow - [x] **System tests** for critical user flows
- [ ] Admin user management - [x] Public browsing and search
- [ ] **Integration tests** for authentication flows - [x] Contributor creates/edits entry
- [ ] **Performance tests** for search queries - [ ] Reviewer workflow (pending feature implementation)
- [x] Admin user management
- [x] **Integration tests** for authentication flows
- [x] Sign in/sign out flows
- [x] Remember me functionality
- [x] Session timeout
- [x] Rate limiting
- [x] Password reset flow
- [x] Invitation acceptance flow
- [x] **Performance tests** for search queries
- [x] Full text search benchmarks
- [x] Language-specific search
- [x] Alphabetical browsing
- [x] Category filtering
- [x] Combined filters
- [x] XLSX download performance
## Deployment ## Deployment
- [ ] **Kamal configuration** - [x] **Kamal configuration** (see docs/DEPLOYMENT.md)
- [ ] **Production environment** setup - [x] **Production environment** setup (automated via Kamal)
- [ ] **SSL certificate** configuration - [x] **SSL certificate** configuration (Let's Encrypt via Kamal proxy)
- [x] **Backup automation** (documented: manual and cron strategies)
- [ ] **Monitoring** (error tracking, performance monitoring) - [ ] **Monitoring** (error tracking, performance monitoring)
- [ ] **Backup automation** (Litestream to S3 or similar) - [ ] **Litestream setup** (optional: SQLite replication to S3)
## Future Considerations ## Future Considerations
@@ -118,24 +153,15 @@
--- ---
## Completed ## Completed (Not Tracked Above)
- [x] **Invitation system** (complete flow with email, acceptance, and expiry validation)
- [x] **Invitation acceptance flow** (users can accept invitations and set passwords)
- [x] **Invitation mailer** (HTML and text email templates with styled design)
- [x] **Token expiry validation** (14-day expiration for invitation links)
- [x] **Controller tests** (40 tests with 160+ assertions for authentication)
- [x] **Authentication system** (login/logout with session management)
- [x] **Admin layout design** updated to match entries page style - [x] **Admin layout design** updated to match entries page style
- [x] **Dynamic navigation** (Admin button for logged-in admins, Sign In for guests) - [x] **Dynamic navigation** (Admin button for logged-in admins, Sign In for guests)
- [x] **Authorization middleware** (Admin::BaseController with role checks) - [x] **Controller tests** (40 tests with 160+ assertions for authentication)
- [x] **Invitation token generation** (secure token creation for new users)
- [x] **Search input loses focus on filter change** - [x] **Search input loses focus on filter change**
- [x] **Mismatched enum syntax** in models - [x] **Mismatched enum syntax** in models
- [x] **Replace hardcoded LANGUAGE_COLUMNS** with dynamic query - [x] **Replace hardcoded LANGUAGE_COLUMNS** with dynamic query
- [x] **Improve fixture quality** (resolved foreign key violations) - [x] **Improve fixture quality** (resolved foreign key violations)
- [x] **XLSX download** button for entries
- [x] **FTS5 integration** (migration added)
- [x] **Database schema** implementation (all models and migrations) - [x] **Database schema** implementation (all models and migrations)
- [x] **Supported languages** table with seed data - [x] **Supported languages** table with seed data
- [x] **Filters do not update with new search results** - [x] **Filters do not update with new search results**
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Pre-commit hook that runs rubocop on staged Ruby files
echo "Running rubocop on staged files..."
# Get list of staged Ruby files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.rb$|\.rake$')
# If no Ruby files are staged, exit successfully
if [ -z "$STAGED_FILES" ]; then
echo "No Ruby files staged, skipping rubocop."
exit 0
fi
# Run rubocop on staged files
echo "$STAGED_FILES" | xargs bundle exec rubocop --force-exclusion
RUBOCOP_EXIT=$?
# If rubocop failed, prevent commit
if [ $RUBOCOP_EXIT -ne 0 ]; then
echo ""
echo "❌ Rubocop found issues. Please fix them before committing."
echo " You can run 'bundle exec rubocop -A' to auto-fix some issues."
echo " To skip this hook, use 'git commit --no-verify'"
exit 1
fi
echo "✅ Rubocop passed!"
exit 0
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Pre-push hook that runs security scans (brakeman + bundler-audit)
echo "Running security scans..."
echo ""
# Run brakeman
echo "🔍 Running brakeman..."
bundle exec brakeman --no-pager --quiet
BRAKEMAN_EXIT=$?
if [ $BRAKEMAN_EXIT -ne 0 ]; then
echo ""
echo "❌ Brakeman found security issues."
echo " Run 'bundle exec brakeman' for detailed output."
echo ""
fi
# Run bundler-audit
echo "🔍 Running bundler-audit..."
bundle exec bundler-audit check --update
BUNDLER_AUDIT_EXIT=$?
if [ $BUNDLER_AUDIT_EXIT -ne 0 ]; then
echo ""
echo "❌ Bundler-audit found vulnerable dependencies."
echo " Run 'bundle exec bundler-audit check' for detailed output."
echo ""
fi
# If either scan failed, prevent push
if [ $BRAKEMAN_EXIT -ne 0 ] || [ $BUNDLER_AUDIT_EXIT -ne 0 ]; then
echo "❌ Security scans failed. Please fix the issues before pushing."
echo " To skip this hook, use 'git push --no-verify'"
exit 1
fi
echo ""
echo "✅ All security scans passed!"
exit 0
+86
View File
@@ -0,0 +1,86 @@
module Middleware
class SanastoCors
ALLOWED_APP_ID = ENV.fetch("SANASTO_APP_ID", "app.sanasto").freeze
APP_ID_HEADER = "HTTP_X_SANASTO_APP"
def initialize(app)
@app = app
end
def call(env)
if allow_cors_for?(env)
if env["REQUEST_METHOD"] == "OPTIONS"
return preflight_response(env["HTTP_ORIGIN"], allowed_request_headers(env))
end
end
status, headers, body = @app.call(env)
if allow_cors_for?(env)
apply_cors_headers(headers, env["HTTP_ORIGIN"])
end
[ status, headers, body ]
end
private
def allow_cors_for?(env)
origin = env["HTTP_ORIGIN"].to_s
return false if origin.empty?
if env["REQUEST_METHOD"] == "OPTIONS"
return preflight_includes_app_id_header?(env)
end
app_id = env[APP_ID_HEADER].to_s
return false if app_id.empty?
app_id == ALLOWED_APP_ID
end
def preflight_includes_app_id_header?(env)
access_control_headers = env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
return false if access_control_headers.empty?
access_control_headers
.split(",")
.map { |header_name| header_name.strip.downcase }
.include?("x-sanasto-app")
end
def allowed_request_headers(env)
access_control_headers = env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
return default_allowed_headers if access_control_headers.empty?
sanitized = access_control_headers
.split(",")
.map { |header_name| header_name.strip }
.reject(&:empty?)
.join(", ")
sanitized.empty? ? default_allowed_headers : sanitized
end
def preflight_response(origin, allowed_headers)
headers = {}
apply_cors_headers(headers, origin, allowed_headers)
headers["Access-Control-Max-Age"] = "86400"
headers["Vary"] = [
headers["Vary"],
"Access-Control-Request-Headers",
"Access-Control-Request-Method"
].compact.join(", ")
[ 204, headers, [] ]
end
def apply_cors_headers(headers, origin, allowed_headers = default_allowed_headers)
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
headers["Access-Control-Allow-Headers"] = allowed_headers
headers["Vary"] = [ headers["Vary"], "Origin, X-Sanasto-App" ].compact.join(", ")
end
def default_allowed_headers
"Origin, Content-Type, Accept, Authorization, X-Sanasto-App"
end
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Some files were not shown because too many files have changed in this diff Show More