Compare commits

..

65 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
Runar Ingebrigtsen
a79b27020a edit category
CI / scan_ruby (push) Failing after 37s
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 18s
2026-01-23 22:10:51 +01:00
Runar Ingebrigtsen
9a814f1aa1 add comments on entries 2026-01-23 21:55:54 +01:00
Runar Ingebrigtsen
b3726e0777 DRY supported_languages 2026-01-23 21:55:06 +01:00
Runar Ingebrigtsen
a7713b962f add todo 2026-01-23 21:52:45 +01:00
Runar Ingebrigtsen
4fdebc8bf8 where's my schema 2026-01-23 14:01:47 +01:00
Runar Ingebrigtsen
faf87fe44f remove versioning 2026-01-23 14:00:18 +01:00
Runar Ingebrigtsen
396e649960 invitation emails 2026-01-23 13:49:56 +01:00
Runar Ingebrigtsen
35c29749fb add controller tests 2026-01-23 12:20:31 +01:00
Runar Ingebrigtsen
dea0ef508a switch install state to db 2026-01-23 12:20:13 +01:00
Runar Ingebrigtsen
965e8cdffe less spacing in filters interface 2026-01-23 10:19:18 +01:00
154 changed files with 8603 additions and 589 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
+19 -113
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.
--- ---
@@ -53,7 +53,7 @@ When translators disagree on a translation or want to suggest alternatives (regi
## Setup / First User ## Setup / First User
When the file `.installed` is missing, the `/setup` route is accessible for creating the initial admin account. The first user created will be the system's default contact email (accessible via `User.first.email`). When setup has not been completed, the `/setup` route is accessible for creating the initial admin account. Completion is tracked in the database. The first user created will be the system's default contact email (accessible via `User.first.email`).
For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md). For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
@@ -94,112 +94,8 @@ For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
--- ---
## Database Schema ## Database Schema
```
# db/schema.rb
ActiveRecord::Schema[8.0].define(version: 2025_01_22_100000) do see `db/structure.sql`
create_table "entries", force: :cascade do |t|
t.integer "category", null: false # word, phrase, proper_name, title, reference, other
# Language columns
t.string "fi" # Finnish
t.string "en" # English
t.string "sv" # Swedish
t.string "no" # Norwegian
t.string "ru" # Russian
t.string "de" # German
t.text "notes"
t.boolean "verified", default: false
t.integer "created_by_id"
t.integer "updated_by_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["category"], name: "index_entries_on_category"
end
create_table "suggested_meanings", force: :cascade do |t|
t.integer "entry_id", null: false
t.string "language_code", null: false
t.string "alternative_translation", null: false
t.text "context"
t.text "reasoning"
t.string "source"
t.string "region"
t.integer "status", default: 0 # pending, accepted, rejected
t.integer "submitted_by_id"
t.integer "reviewed_by_id"
t.datetime "reviewed_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["entry_id"], name: "index_suggested_meanings_on_entry_id"
t.index ["language_code"], name: "index_suggested_meanings_on_language_code"
t.index ["status"], name: "index_suggested_meanings_on_status"
end
create_table "comments", force: :cascade do |t|
t.integer "user_id", null: false
t.string "commentable_type", null: false
t.integer "commentable_id", null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
end
create_table "users", force: :cascade do |t|
t.string "email", null: false
t.string "password_digest", null: false
t.string "name"
t.integer "role", default: 0 # contributor, reviewer, admin
t.string "primary_language"
t.string "invitation_token"
t.datetime "invitation_sent_at"
t.datetime "invitation_accepted_at"
t.integer "invited_by_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
end
create_table "entry_versions", force: :cascade do |t|
t.integer "entry_id", null: false
t.integer "user_id", null: false
t.json "changes_made", null: false
t.string "change_type" # create, update, verify
t.datetime "created_at", null: false
t.index ["entry_id"], name: "index_entry_versions_on_entry_id"
end
create_table "supported_languages", force: :cascade do |t|
t.string "code", null: false
t.string "name", null: false
t.string "native_name", null: false
t.integer "sort_order", default: 0
t.boolean "active", default: true
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_supported_languages_on_code", unique: true
end
add_foreign_key "suggested_meanings", "entries"
add_foreign_key "suggested_meanings", "supported_languages", column: "language_code", primary_key: "code"
add_foreign_key "suggested_meanings", "users", column: "submitted_by_id"
add_foreign_key "suggested_meanings", "users", column: "reviewed_by_id"
add_foreign_key "comments", "users"
add_foreign_key "entries", "users", column: "created_by_id"
add_foreign_key "entries", "users", column: "updated_by_id"
add_foreign_key "entry_versions", "entries"
add_foreign_key "entry_versions", "users"
end
```
--- ---
@@ -292,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
@@ -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
@@ -22,8 +23,7 @@ class Admin::DashboardController < Admin::BaseController
.where("invitation_sent_at > ?", 14.days.ago) .where("invitation_sent_at > ?", 14.days.ago)
.count .count
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order) @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.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
@@ -14,14 +14,12 @@ 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
# TODO: Send invitation email # Send invitation email
# InvitationMailer.invite(@invitation).deliver_later InvitationMailer.invite(@invitation).deliver_later
redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}" redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}"
else else
@@ -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
+47 -3
View File
@@ -1,14 +1,58 @@
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
helper_method :current_user, :logged_in?, :admin?, :reviewer_or_admin?, :contributor_or_above?, :setup_completed? SESSION_TIMEOUT = 3.days
before_action :check_session_timeout
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
:contributor_or_above?, :setup_completed?
private private
def supported_languages
@supported_languages ||= SupportedLanguage.where(active: true).order(:sort_order, :name)
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?
@@ -57,6 +101,6 @@ class ApplicationController < ActionController::Base
end end
def setup_completed? def setup_completed?
File.exist?(Rails.root.join(".installed")) SetupState.installed?
end end
end end
+31
View File
@@ -0,0 +1,31 @@
class CommentsController < ApplicationController
before_action :require_login
before_action :set_commentable
def create
@comment = @commentable.comments.build(comment_params)
@comment.user = current_user
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @commentable }
end
else
# Handle validation errors
redirect_to @commentable, alert: "Comment could not be created: #{@comment.errors.full_messages.to_sentence}"
end
end
private
def set_commentable
if params[:entry_id]
@commentable = Entry.find(params[:entry_id])
end
end
def comment_params
params.require(:comment).permit(:body, :language_code)
end
end
+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
+35 -18
View File
@@ -1,44 +1,41 @@
class EntriesController < ApplicationController class EntriesController < ApplicationController
before_action :set_entry, only: [ :show ] before_action :set_entry, only: [ :show, :edit, :update ]
def index def index
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name) @language_code = validate_language_code(params[:language].presence)
@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?
primary_language, other_languages = @supported_languages.partition { |language| language.code == @language_code } primary_language, other_languages = supported_languages.partition { |language| language.code == @language_code }
@display_languages = primary_language + other_languages @display_languages = primary_language + other_languages
else else
@display_languages = @supported_languages @display_languages = supported_languages
end end
respond_to do |format| respond_to do |format|
@@ -48,11 +45,21 @@ class EntriesController < ApplicationController
end end
def show def show
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name) end
def edit
end
def update
if @entry.update(entry_params)
redirect_to entry_path(@entry), notice: "Entry updated."
else
render :edit, status: :unprocessable_entity
end
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"
@@ -66,4 +73,14 @@ class EntriesController < ApplicationController
def set_entry def set_entry
@entry = Entry.find(params[:id]) @entry = Entry.find(params[:id])
end end
def entry_params
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
+39
View File
@@ -0,0 +1,39 @@
class InvitationsController < ApplicationController
def show
@user = User.find_by_valid_invitation_token(params[:token])
if @user.nil?
redirect_to root_path, alert: "Invalid or expired invitation link."
end
end
def update
@user = User.find_by_valid_invitation_token(params[:token])
if @user.nil?
redirect_to root_path, alert: "Invalid or expired invitation link."
return
end
if @user.update(invitation_params)
@user.update(
invitation_accepted_at: Time.current,
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
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
else
render :show, status: :unprocessable_entity
end
end
private
def invitation_params
params.require(:user).permit(:password, :password_confirmation)
end
end
@@ -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 -10
View File
@@ -11,7 +11,7 @@ class SetupController < ApplicationController
@user.invitation_accepted_at = Time.current @user.invitation_accepted_at = Time.current
if @user.save if @user.save
create_installed_marker SetupState.mark_installed!
session[:user_id] = @user.id session[:user_id] = @user.id
redirect_to admin_root_path, notice: "Setup complete! Welcome to Sanasto Wiki." redirect_to admin_root_path, notice: "Setup complete! Welcome to Sanasto Wiki."
else else
@@ -28,15 +28,7 @@ class SetupController < ApplicationController
end end
def setup_completed? def setup_completed?
File.exist?(installed_marker_path) SetupState.installed?
end
def installed_marker_path
Rails.root.join(".installed")
end
def create_installed_marker
FileUtils.touch(installed_marker_path)
end end
def user_params def user_params
+5
View File
@@ -1,2 +1,7 @@
module ApplicationHelper module ApplicationHelper
include Pagy::Frontend
def language_name(code)
supported_languages.find { |l| l.code == code }&.name
end
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
@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "languageSelect", "form"]
connect() {
this.modalTarget.classList.add("hidden")
}
open(event) {
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")
}
close(event) {
if (event.target === this.modalTarget) {
this.modalTarget.classList.add("hidden")
this.resetForm()
}
}
closeWithButton(event) {
event.preventDefault()
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
+19
View File
@@ -0,0 +1,19 @@
class InvitationMailer < ApplicationMailer
def invite(user, approved_entry: nil)
@user = user
@approved_entry = approved_entry
@invitation_url = invitation_url(@user.invitation_token)
@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(
to: @user.email,
subject: subject
)
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
+17
View File
@@ -1,6 +1,23 @@
class Comment < ApplicationRecord class Comment < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :commentable, polymorphic: true belongs_to :commentable, polymorphic: true
belongs_to :language,
class_name: "SupportedLanguage",
foreign_key: :language_code,
primary_key: :code,
optional: true
validates :body, presence: true validates :body, presence: true
after_create_commit :notify_users
private
def notify_users
return if language_code.blank?
# Placeholder for notification logic once we decide delivery channels.
users_to_notify = User.where(primary_language: language_code).where.not(id: user_id)
# puts "Notifying users: #{users_to_notify.pluck(:email).join(", ")}"
end
end end
+13 -2
View File
@@ -1,16 +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
has_many :entry_versions, 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?
@@ -36,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
@@ -44,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
-6
View File
@@ -1,6 +0,0 @@
class EntryVersion < ApplicationRecord
belongs_to :entry
belongs_to :user
validates :changes_made, presence: true
end
+16
View File
@@ -0,0 +1,16 @@
class SetupState < ApplicationRecord
def self.installed?
first&.installed? || false
end
def self.mark_installed!
record = first_or_initialize
record.installed = true
record.installed_at ||= Time.current
record.save!
end
def self.reset!
delete_all
end
end
+67 -2
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,
@@ -14,11 +15,75 @@ class User < ApplicationRecord
class_name: "SuggestedMeaning", class_name: "SuggestedMeaning",
foreign_key: :reviewed_by_id, foreign_key: :reviewed_by_id,
dependent: :nullify dependent: :nullify
has_many :entry_versions, dependent: :nullify
has_many :comments, dependent: :nullify has_many :comments, dependent: :nullify
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_EXPIRY = 14.days
# Remember me token expires after 2 weeks
REMEMBER_TOKEN_EXPIRY = 2.weeks
def invitation_expired?
return false if invitation_sent_at.nil?
invitation_sent_at < INVITATION_TOKEN_EXPIRY.ago
end
def invitation_pending?
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
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)
where(invitation_token: token)
.where(invitation_accepted_at: nil)
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
.first
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
+11 -3
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">
<%= link_to admin_users_path do %>
<dl> <dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt> <dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd> <dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl> </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">
<%= link_to root_path do %>
<dl> <dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt> <dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd> <dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl> </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">
<%= link_to admin_requests_path do %>
<dl> <dl>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt> <dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd> <dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
</dl> </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">
<%= link_to admin_invitations_path do %>
<dl> <dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt> <dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd> <dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl> </dl>
<% end %>
</div> </div>
</div> </div>
</div> </div>
@@ -115,7 +123,7 @@
<h3 class="text-lg leading-6 font-medium text-gray-900">Language Completion</h3> <h3 class="text-lg leading-6 font-medium text-gray-900">Language Completion</h3>
<div class="mt-5"> <div class="mt-5">
<div class="space-y-4"> <div class="space-y-4">
<% @supported_languages.each do |language| %> <% supported_languages.each do |language| %>
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700"><%= language.native_name %></span> <span class="text-sm font-medium text-gray-700"><%= language.native_name %></span>
+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>
@@ -0,0 +1,17 @@
<%= turbo_stream.append "comments-#{@comment.language_code.presence || 'all'}" do %>
<%= render "entries/comment", comment: @comment %>
<% end %>
<%= turbo_stream.replace "comment_tabs" do %>
<%= render "entries/comment_tabs", entry: @commentable %>
<% 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 %>
+24
View File
@@ -0,0 +1,24 @@
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<%# Add user avatars here if you have them %>
<div class="h-10 w-10 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 font-bold">
<%= comment.user&.name&.first || 'A' %>
</div>
</div>
<div class="flex-1">
<div class="bg-slate-100 rounded-lg p-3">
<div class="flex items-center justify-between">
<p class="text-sm text-slate-900">
<span class="font-semibold "><%= comment.user&.name || "Anonymous" %></span>
<% unless comment.language_code.blank? %>
<span class="italic">on the <%= language_name(comment.language_code) %> translation</span>
<% end -%>
</p>
<p class="text-xs text-slate-500">
<%= comment.created_at ? "#{time_ago_in_words(comment.created_at)} ago" : "just now" %>
</p>
</div>
<p class="text-sm text-slate-800 mt-1"><%= comment.body %></p>
</div>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
<%= form_with(model: [entry, Comment.new(commentable: entry)],
data: { turbo_stream: true, comments_target: "form" },
html: { class: "space-y-4" }) do |form| %>
<div>
<%= form.label :language_code, "Language", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= 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>
<%= 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" %>
</div>
<% 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>
+30
View File
@@ -0,0 +1,30 @@
<div id="comment_tabs" class="border-b border-slate-200">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<% grouped_comments = entry.comments.group_by(&:language) %>
<% language_groups = supported_languages.map { |language| [language, grouped_comments[language] || []] } %>
<% language_groups.unshift([:all, entry.comments]) %>
<% language_groups.each do |language, comments| %>
<% language_label = language == :all ? "All languages" : language&.name %>
<% language_code = language == :all ? "all" : language&.code %>
<a href="#"
class="comment-tab border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
data-lang="<%= language_code %>">
<%= language_label %> <span class="bg-slate-100 text-slate-600 ml-2 py-0.5 px-2.5 rounded-full text-xs font-medium"><%= comments.count %></span>
</a>
<% end %>
</nav>
</div>
<div class="mt-4">
<% if entry.comments.empty? %>
<p class="text-slate-500">No comments yet. Be the first to add one!</p>
<% end %>
<% language_groups.each do |language, comments| %>
<% language_code = language == :all ? "all" : language&.code %>
<div id="comments-<%= language_code %>" class="comment-group space-y-4 hidden">
<% comments.each do |comment| %>
<%= render "entries/comment", comment: comment %>
<% end %>
</div>
<% end %>
</div>
@@ -0,0 +1,49 @@
<% if current_user %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-slate-900">Discussion</h3>
<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
</button>
</div>
<%= 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">
<%= render "entries/comment_modal_content", entry: entry %>
</div>
</div>
<script>
function activateCommentTab(targetLanguageCode) {
const commentGroups = document.querySelectorAll(".comment-group");
commentGroups.forEach((group) => {
const isTarget = group.id === `comments-${targetLanguageCode}`;
group.classList.toggle("hidden", !isTarget);
});
const tabs = document.querySelectorAll(".comment-tab");
tabs.forEach((tab) => {
const isActive = tab.dataset.lang === targetLanguageCode;
tab.classList.toggle("text-slate-900", isActive);
tab.classList.toggle("border-indigo-600", isActive);
tab.classList.toggle("text-slate-500", !isActive);
tab.classList.toggle("border-transparent", !isActive);
});
}
document.addEventListener("click", (event) => {
const tab = event.target.closest(".comment-tab");
if (!tab) {
return;
}
event.preventDefault();
activateCommentTab(tab.dataset.lang);
});
document.addEventListener("turbo:load", () => {
activateCommentTab("all");
});
</script>
<% end %>
+10 -10
View File
@@ -1,43 +1,43 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<% base_params = { q: @query, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %> <% base_params = { q: @query, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<% all_category_params = base_params.except(:category) %> <% all_category_params = base_params.except(:category) %>
<%= link_to "All", <%= link_to "All",
entries_path(all_category_params), entries_path(all_category_params),
class: "px-4 py-1.5 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider", class: "px-3 py-1 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
data: { turbo_stream: true } %> data: { turbo_stream: true } %>
<% Entry.categories.keys.each do |category_name| %> <% Entry.categories.keys.each do |category_name| %>
<%= link_to category_name.tr('_', ' ').capitalize, <%= link_to category_name.tr('_', ' ').capitalize,
entries_path(base_params.merge(category: category_name)), entries_path(base_params.merge(category: category_name)),
class: "px-4 py-1.5 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider", class: "px-3 py-1 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
data: { turbo_stream: true } %> data: { turbo_stream: true } %>
<% end %> <% end %>
</div> </div>
<div class="flex flex-wrap gap-2 mt-2 mb-2"> <div class="flex flex-wrap gap-1 mt-2 mb-2">
<% all_language_params = base_params.except(:language, :starts_with, :page) %> <% all_language_params = base_params.except(:language, :starts_with, :page) %>
<%= link_to "All Languages", <%= link_to "All Languages",
entries_path(all_language_params), entries_path(all_language_params),
class: "px-3 py-1.5 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider", class: "px-3 py-1 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
data: { turbo_stream: true } %> data: { turbo_stream: true } %>
<% @supported_languages.each do |language| %> <% supported_languages.each do |language| %>
<%= link_to "#{language.name} (#{language.code.upcase})", <%= link_to "#{language.name} (#{language.code.upcase})",
entries_path(all_language_params.merge(language: language.code)), entries_path(all_language_params.merge(language: language.code)),
class: "px-3 py-1.5 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider", class: "px-3 py-1 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
data: { turbo_stream: true } %> data: { turbo_stream: true } %>
<% end %> <% end %>
</div> </div>
<% if @language_code.present? %> <% if @language_code.present? %>
<div class="flex flex-wrap gap-2 text-xs mb-2"> <div class="flex flex-wrap gap-1 text-xs mb-2">
<% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %> <% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %>
<%= link_to "All", <%= link_to "All",
entries_path(alphabet_params), entries_path(alphabet_params),
class: "px-2.5 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}", class: "px-2 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
data: { turbo_stream: true } %> data: { turbo_stream: true } %>
<% alphabet_letters(@language_code).each do |letter| %> <% alphabet_letters(@language_code).each do |letter| %>
<%= link_to letter, <%= link_to letter,
entries_path(alphabet_params.merge(starts_with: letter)), entries_path(alphabet_params.merge(starts_with: letter)),
class: "px-2.5 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}", class: "px-2 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
data: { turbo_stream: true } %> data: { turbo_stream: true } %>
<% end %> <% end %>
</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 %>
+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>
+55
View File
@@ -0,0 +1,55 @@
<% content_for :title, "Edit Entry" %>
<div class="min-h-screen flex flex-col">
<%= render "shared/header", show_request_button: false, show_browse_button: true %>
<%= 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">
<div class="flex items-center justify-center gap-3 mb-2">
<h1 class="text-3xl font-bold text-gray-900">Edit Entry</h1>
<% 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>
<%= form_with model: @entry, class: "space-y-6" 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-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>
<% end %>
<div class="mt-6 text-center text-sm text-gray-600">
<%= link_to "← Back to entry", entry_path(@entry), class: "text-indigo-600 hover:text-indigo-800 font-semibold" %> •
<%= link_to "Back to search", entries_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
</div>
</div>
</div>
</div>
</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">
+15 -3
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>
@@ -21,6 +21,7 @@
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center"> <div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400"><%= format_entry_category(@entry) %></span> <span class="text-[10px] font-black uppercase tracking-widest text-slate-400"><%= format_entry_category(@entry) %></span>
<%= link_to "Edit", edit_entry_path(@entry) if admin? %>
<% if @entry.verified? %> <% if @entry.verified? %>
<div class="flex items-center gap-1.5 text-emerald-600"> <div class="flex items-center gap-1.5 text-emerald-600">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg> <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>
@@ -33,11 +34,20 @@
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12"> <div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
<% @supported_languages.each do |language| %> <% supported_languages.each do |language| %>
<% translation = entry_translation_for(@entry, language.code) %> <% translation = entry_translation_for(@entry, language.code) %>
<% next if translation.blank? %> <% next if translation.blank? %>
<div class="space-y-1"> <div class="space-y-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>
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p> <p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
</div> </div>
<% end %> <% end %>
@@ -51,4 +61,6 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<%= render "entries/comments_section", entry: @entry %>
</main> </main>
+204
View File
@@ -0,0 +1,204 @@
<!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;
}
.info-box {
background: #f8fafc;
border-left: 4px solid #6366f1;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.info-box strong {
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;
}
.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>
</head>
<body>
<div class="header">
<h1>Sanasto Wiki</h1>
<p>Kristillisyyden sanasto</p>
</div>
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<% if @approved_entry %>
<p>
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
</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">
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
<p style="margin: 8px 0 0 0;">
Email: <%= @user.email %><br>
Role: <%= @user.role.titleize %>
</p>
</div>
<p>
To accept this invitation and set your password, click the button below:
</p>
<div style="text-align: center;">
<%= link_to "Accept Invitation", @invitation_url, class: "button" %>
</div>
<div class="expiry-notice">
This invitation 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;">
<%= @invitation_url %>
</p>
<div class="footer">
<p>
If you weren't expecting this invitation, you can safely ignore this email.
</p>
<p style="margin-top: 12px;">
Questions? Reply to this email.
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,45 @@
========================================
SANASTO WIKI - INVITATION
========================================
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.
With a login account, you can contribute to this work.
<% end %>
YOUR ACCOUNT DETAILS
--------------------
Email: <%= @user.email %>
Role: <%= @user.role.titleize %>
TO ACCEPT THIS INVITATION
--------------------------
Please visit the following link to set your password and complete your registration:
<%= @invitation_url %>
This invitation will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
If you weren't expecting this invitation, you can safely ignore this email.
Questions? Reply to this email.
+75
View File
@@ -0,0 +1,75 @@
<% content_for :title, "Accept Invitation" %>
<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>
<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">Accept Invitation</h1>
<p class="text-sm text-slate-600">
You've been invited to join Sanasto Wiki as <%= @user.name %> (<%= @user.email %>)
</p>
<p class="text-sm text-slate-600 mt-1">
Role: <span class="font-medium text-indigo-600"><%= @user.role.titleize %></span>
</p>
</div>
<% if @user.errors.any? %>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
<h3 class="font-medium mb-2"><%= pluralize(@user.errors.count, "error") %> prevented acceptance:</h3>
<ul class="list-disc pl-5 space-y-1 text-sm">
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div>
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password,
autofocus: true,
required: true,
placeholder: "Minimum 12 characters",
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">Choose a strong password with at least 12 characters.</p>
</div>
<div>
<%= form.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password_confirmation,
required: true,
placeholder: "Re-enter your password",
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 "Accept Invitation & Join",
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">
<%= link_to root_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 Wiki
<% end %>
</div>
</div>
</div>
</div>
</div>
+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==
+6 -1
View File
@@ -10,15 +10,20 @@ default: &default
timeout: 5000 timeout: 5000
development: development:
primary:
<<: *default <<: *default
database: storage/development.sqlite3 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
+28 -2
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,22 +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
get "invitations/:token", to: "invitations#show", as: :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 ]
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 :entry_versions, only: [:index, :show]
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,10 @@
class CreateSetupStates < ActiveRecord::Migration[8.1]
def change
create_table :setup_states do |t|
t.boolean :installed, null: false, default: false
t.datetime :installed_at
t.timestamps
end
end
end
@@ -0,0 +1,14 @@
class DropEntryVersions < ActiveRecord::Migration[8.1]
def change
drop_table :entry_versions do |t|
t.text :changes_made
t.string :change_type
t.datetime :created_at, null: false
t.integer :entry_id, null: false
t.datetime :updated_at, null: false
t.integer :user_id
t.index [ :entry_id ], name: "index_entry_versions_on_entry_id"
t.index [ :user_id ], name: "index_entry_versions_on_user_id"
end
end
end
@@ -0,0 +1,5 @@
class AddLanguageCodeToComments < ActiveRecord::Migration[8.1]
def change
add_column :comments, :language_code, :string
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');
+27 -41
View File
@@ -1,33 +1,14 @@
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" 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 ("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, CONSTRAINT "fk_rails_03de2dc08c"
FOREIGN KEY ("user_id") FOREIGN KEY ("user_id")
REFERENCES "users" ("id") REFERENCES "users" ("id")
); );
CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application='SanastoWiki'*/; CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application='SanastoWiki'*/;
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 "entry_versions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "entry_id" integer NOT NULL, "user_id" integer NOT NULL, "changes_made" json NOT NULL, "change_type" varchar, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_be24c8cfa1"
FOREIGN KEY ("entry_id")
REFERENCES "entries" ("id")
, CONSTRAINT "fk_rails_aaeb10db8b"
FOREIGN KEY ("user_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entry_versions_on_entry_id" ON "entry_versions" ("entry_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entry_versions_on_user_id" ON "entry_versions" ("user_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")
); );
@@ -68,27 +49,32 @@ 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 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);
AFTER INSERT ON entries 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"
BEGIN FOREIGN KEY ("updated_by_id")
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes) REFERENCES "users" ("id")
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes); , CONSTRAINT "fk_rails_367d1ab731"
END; FOREIGN KEY ("created_by_id")
CREATE TRIGGER entries_fts_after_update REFERENCES "users" ("id")
AFTER UPDATE ON entries , CONSTRAINT "fk_rails_4d36fd8a36"
BEGIN FOREIGN KEY ("requested_by_id")
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes) REFERENCES "users" ("id")
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) CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes); CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
END; CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE TRIGGER entries_fts_after_delete CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
AFTER DELETE ON entries CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
BEGIN CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/;
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes) CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/;
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
END;
INSERT INTO "schema_migrations" (version) VALUES INSERT INTO "schema_migrations" (version) VALUES
('20260130080931'),
('20260130080745'),
('20260129204706'),
('20260129204705'),
('20260123130957'),
('20260123125325'),
('20260122131000'),
('20260122130000'), ('20260122130000'),
('20260122124151'), ('20260122124151'),
('20260122123837'), ('20260122123837'),
+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>

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