Compare commits
65 Commits
114b7a51d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6e059da6 | ||
|
|
e9f8f03db2 | ||
|
|
b289cdc320 | ||
|
|
8bb410dcfa | ||
|
|
ce67776eec | ||
|
|
9f71fe65e5 | ||
|
|
e15835bda9 | ||
|
|
83320d4c9a | ||
|
|
a2008e2ae3 | ||
|
|
b45a451748 | ||
|
|
441caabb98 | ||
|
|
a139bde102 | ||
|
|
f35a09f07a | ||
|
|
1a10e3c784 | ||
|
|
4fe95ca538 | ||
|
|
fa36305244 | ||
|
|
9acdc4e6db | ||
|
|
e48b386b54 | ||
|
|
d183fb4b53 | ||
|
|
9c6714e97c | ||
|
|
227ab744b5 | ||
|
|
4bc393887b | ||
|
|
8ec8f15857 | ||
|
|
803c1371b7 | ||
|
|
46e4f808e7 | ||
|
|
8ce7f1b913 | ||
|
|
c407ee3530 | ||
|
|
32a4ffa70e | ||
|
|
20ce18ca74 | ||
|
|
4e5c25adbf | ||
|
|
7118f1ea45 | ||
|
|
f31a25fb03 | ||
|
|
7c7bdf7e65 | ||
|
|
21e7e65dfb | ||
|
|
3e36821e51 | ||
|
|
530021960e | ||
|
|
b64ad52d30 | ||
|
|
4a6388ade6 | ||
|
|
e7f2215be4 | ||
|
|
887d52c447 | ||
|
|
e9295dc278 | ||
|
|
001d63c513 | ||
|
|
d6ba730d4a | ||
|
|
8a4c146117 | ||
|
|
b54db723c5 | ||
|
|
de52fe9b93 | ||
|
|
a4d5a676d6 | ||
|
|
b3c37cca13 | ||
|
|
be0ddcc89e | ||
|
|
654ec39f36 | ||
|
|
f42e9da504 | ||
|
|
a69be52b72 | ||
|
|
35f10c4bda | ||
|
|
b943b4c8bd | ||
|
|
a680ae7275 | ||
|
|
a79b27020a | ||
|
|
9a814f1aa1 | ||
|
|
b3726e0777 | ||
|
|
a7713b962f | ||
|
|
4fdebc8bf8 | ||
|
|
faf87fe44f | ||
|
|
396e649960 | ||
|
|
35c29749fb | ||
|
|
dea0ef508a | ||
|
|
965e8cdffe |
Vendored
+49
-33
@@ -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
@@ -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/
|
||||||
|
|||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Docker set up on $KAMAL_HOSTS..."
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
||||||
Executable
+14
@@ -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"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
||||||
Executable
+51
@@ -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
|
||||||
Executable
+47
@@ -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 ]
|
||||||
Executable
+122
@@ -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
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[tools]
|
||||||
|
ruby = "4"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
markup: markdown
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4.0.1
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
class EntryVersion < ApplicationRecord
|
|
||||||
belongs_to :entry
|
|
||||||
belongs_to :user
|
|
||||||
|
|
||||||
validates :changes_made, presence: true
|
|
||||||
end
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 %>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<%= render "entries/comment_form", entry: entry %>
|
||||||
|
</div>
|
||||||
@@ -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 %>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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,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" %>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require_relative "../config/application"
|
||||||
|
require "importmap/commands"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require_relative "../config/environment"
|
||||||
|
require "solid_queue/cli"
|
||||||
|
|
||||||
|
SolidQueue::Cli.start(ARGV)
|
||||||
@@ -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 @@
|
|||||||
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
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Rswag::Ui.configure do |config|
|
||||||
|
config.swagger_endpoint "/api/swagger", "Sanasto Wiki API"
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+30
-4
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user