Compare commits
55 Commits
a79b27020a
...
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 |
Vendored
+49
-33
@@ -83,42 +83,58 @@ jobs:
|
||||
with:
|
||||
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
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
||||
# REDIS_URL: redis://localhost:6379/0
|
||||
run: bin/rails db:test:prepare test
|
||||
run: bin/rails test
|
||||
|
||||
system-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# services:
|
||||
# redis:
|
||||
# image: valkey/valkey:8
|
||||
# ports:
|
||||
# - 6379:6379
|
||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run System Tests
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
||||
# REDIS_URL: redis://localhost:6379/0
|
||||
run: bin/rails db:test:prepare 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
|
||||
# system-test:
|
||||
# runs-on: ubuntu-latest
|
||||
#
|
||||
# # services:
|
||||
# # redis:
|
||||
# # image: valkey/valkey:8
|
||||
# # ports:
|
||||
# # - 6379:6379
|
||||
# # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: Set up Ruby
|
||||
# uses: ruby/setup-ruby@v1
|
||||
# with:
|
||||
# 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 System Tests
|
||||
# env:
|
||||
# RAILS_ENV: test
|
||||
# # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
||||
# # REDIS_URL: redis://localhost:6379/0
|
||||
# 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
|
||||
.claude
|
||||
.env*
|
||||
.ruby-version
|
||||
.tool-versions
|
||||
.yarn-integrity
|
||||
.DS_Store
|
||||
@@ -24,3 +23,8 @@
|
||||
/db/*.sqlite3-*
|
||||
/db/schema.rb
|
||||
!.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"
|
||||
gem "rails", "~> 8.1.2"
|
||||
# Excel import for seeds
|
||||
gem "roo", "~> 2.10"
|
||||
gem "roo", "~> 3.0"
|
||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||
gem "propshaft"
|
||||
# Use sqlite3 as the database for Active Record
|
||||
@@ -18,6 +18,8 @@ gem "turbo-rails"
|
||||
gem "stimulus-rails"
|
||||
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
|
||||
gem "jbuilder"
|
||||
gem "grape"
|
||||
gem "rswag-ui"
|
||||
gem "caxlsx"
|
||||
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]
|
||||
gem "image_processing", "~> 1.2"
|
||||
|
||||
# Pagination [https://github.com/ddnexus/pagy]
|
||||
gem "pagy", "~> 8.0"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
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/]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
|
||||
# Parallel test runner [https://github.com/grosser/parallel_tests]
|
||||
gem "parallel_tests", require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||
gem "web-console"
|
||||
gem "mailcatcher"
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "benchmark", require: false
|
||||
gem "simplecov", require: false
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
end
|
||||
|
||||
+111
-92
@@ -81,11 +81,12 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bcrypt_pbkdf (1.1.2)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
brakeman (8.0.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -111,29 +112,52 @@ GEM
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
daemons (1.4.1)
|
||||
csv (3.3.5)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.1)
|
||||
dotenv (3.2.0)
|
||||
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)
|
||||
erb (6.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
eventmachine (1.2.7)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
grape (3.1.1)
|
||||
activesupport (>= 7.1)
|
||||
dry-configurable
|
||||
dry-types (>= 1.1)
|
||||
mustermann-grape (~> 1.1.0)
|
||||
rack (>= 2)
|
||||
zeitwerk
|
||||
htmlentities (4.4.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@@ -145,14 +169,15 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.18.0)
|
||||
json (2.18.1)
|
||||
kamal (2.10.1)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -176,16 +201,6 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
mailcatcher (0.2.4)
|
||||
eventmachine
|
||||
haml
|
||||
i18n
|
||||
json
|
||||
mail
|
||||
sinatra
|
||||
skinny (>= 0.1.2)
|
||||
sqlite3-ruby
|
||||
thin
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
@@ -196,7 +211,9 @@ GEM
|
||||
msgpack (1.8.0)
|
||||
mustermann (3.0.4)
|
||||
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
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -211,17 +228,20 @@ GEM
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
ostruct (0.6.3)
|
||||
pagy (8.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
parallel_tests (5.6.0)
|
||||
parallel
|
||||
parser (3.3.10.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.8.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -230,15 +250,11 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
puma (7.1.0)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack (3.2.5)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -278,7 +294,7 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -286,10 +302,16 @@ GEM
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
roo (2.10.1)
|
||||
roo (3.0.0)
|
||||
base64 (~> 0.2)
|
||||
csv (~> 3)
|
||||
logger (~> 1)
|
||||
nokogiri (~> 1)
|
||||
rubyzip (>= 1.3.0, < 3.0.0)
|
||||
rubocop (1.82.1)
|
||||
rubyzip (>= 3.0.0, < 4.0.0)
|
||||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.84.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -297,7 +319,7 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.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)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
@@ -322,24 +344,20 @@ GEM
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.40.0)
|
||||
selenium-webdriver (4.41.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sinatra (4.2.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-protection (= 4.2.1)
|
||||
rack-session (>= 2.0.0, < 3)
|
||||
tilt (~> 2.0)
|
||||
skinny (0.2.2)
|
||||
eventmachine (~> 1.0)
|
||||
thin
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
@@ -349,7 +367,7 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.3.1)
|
||||
solid_queue (1.3.2)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -357,8 +375,6 @@ GEM
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
||||
sqlite3-ruby (1.3.3)
|
||||
sqlite3 (>= 1.3.3)
|
||||
sshkit (1.25.0)
|
||||
base64
|
||||
logger
|
||||
@@ -369,18 +385,11 @@ GEM
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.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)
|
||||
thruster (0.1.17-x86_64-linux)
|
||||
tilt (2.7.0)
|
||||
thruster (0.1.18-x86_64-linux)
|
||||
timeout (0.6.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -402,13 +411,14 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt (~> 3.1.7)
|
||||
benchmark
|
||||
bootsnap
|
||||
brakeman
|
||||
bundler-audit
|
||||
@@ -416,17 +426,21 @@ DEPENDENCIES
|
||||
caxlsx
|
||||
caxlsx_rails
|
||||
debug
|
||||
grape
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
kamal
|
||||
mailcatcher
|
||||
pagy (~> 8.0)
|
||||
parallel_tests
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.2)
|
||||
roo (~> 2.10)
|
||||
roo (~> 3.0)
|
||||
rswag-ui
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
simplecov
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
@@ -455,10 +469,11 @@ CHECKSUMS
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
|
||||
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
||||
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
|
||||
bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4
|
||||
brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce
|
||||
bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214
|
||||
brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a
|
||||
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
|
||||
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
|
||||
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
|
||||
@@ -467,35 +482,39 @@ CHECKSUMS
|
||||
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
|
||||
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
||||
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
|
||||
daemons (1.4.1) sha256=8fc76d76faec669feb5e455d72f35bd4c46dc6735e28c420afb822fac1fa9a1d
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
|
||||
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
|
||||
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
|
||||
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
|
||||
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
|
||||
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
|
||||
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
|
||||
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
|
||||
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
|
||||
ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
|
||||
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
|
||||
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
|
||||
haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a
|
||||
grape (3.1.1) sha256=774f16782d917a90e69de0499dfaab571e5ad967569ac066a2b0b918af12de69
|
||||
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
|
||||
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
|
||||
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
|
||||
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
|
||||
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
|
||||
json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
|
||||
json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
|
||||
kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340
|
||||
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
||||
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
|
||||
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
|
||||
mailcatcher (0.2.4) sha256=ba1d6f23d32f69929dce332d0aa7aeabddadd15507de474754e593807111dda9
|
||||
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
|
||||
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
|
||||
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
|
||||
@@ -503,7 +522,8 @@ CHECKSUMS
|
||||
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
|
||||
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
|
||||
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-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
|
||||
net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d
|
||||
@@ -511,21 +531,22 @@ CHECKSUMS
|
||||
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
|
||||
net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0
|
||||
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
|
||||
pagy (8.6.3) sha256=537b2ee3119f237dd6c4a0d0a35c67a77b9d91ebb9d4f85e31407c2686774fb2
|
||||
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
|
||||
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
||||
prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
|
||||
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
|
||||
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
|
||||
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
||||
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
|
||||
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
||||
rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
|
||||
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
|
||||
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
|
||||
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
||||
@@ -535,12 +556,13 @@ CHECKSUMS
|
||||
railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
|
||||
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
||||
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
|
||||
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e
|
||||
rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273
|
||||
roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1
|
||||
rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212
|
||||
rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
|
||||
rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
|
||||
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
||||
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
|
||||
@@ -548,27 +570,24 @@ CHECKSUMS
|
||||
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
||||
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
|
||||
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
|
||||
selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b
|
||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
||||
skinny (0.2.2) sha256=f40caceccfe3e1d9826f60195a090f43ea7c1130c36b3170887db69a9fb52102
|
||||
selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22
|
||||
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
|
||||
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
|
||||
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
|
||||
solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
|
||||
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-ruby (1.3.3) sha256=140b6742875dd5afc3f30ab95720fe60d38e154ae1f4d0728e250778a04094e7
|
||||
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
|
||||
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
|
||||
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
||||
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
|
||||
thin (2.0.1) sha256=5bbde5648377f5c3864b5da7cd89a23b5c2d8d8bb9435719f6db49644bcdade9
|
||||
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
||||
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
|
||||
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
|
||||
thruster (0.1.18-x86_64-linux) sha256=0ec1ff5f12289c1ac10cf8e28ce6b5266f4e73416b34a664b79d037c7d955c40
|
||||
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
|
||||
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
|
||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||
@@ -579,7 +598,7 @@ CHECKSUMS
|
||||
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
|
||||
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
|
||||
xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
|
||||
zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
|
||||
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.4
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
"Sanasto Wiki" is a web-based dictionary application for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors.
|
||||
"Sanasto Wiki" is a web-based glossary for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors.
|
||||
|
||||
---
|
||||
|
||||
@@ -188,15 +188,25 @@ See 'public/Kristillisyyden sanasto ver 23.5.2013.xlsx'
|
||||
|
||||
---
|
||||
|
||||
## API (Optional Future)
|
||||
## API (Public Sync)
|
||||
|
||||
REST API for potential mobile app or integration:
|
||||
Public JSON endpoint for syncing entries:
|
||||
```
|
||||
GET /api/entries
|
||||
GET /api/entries/:id
|
||||
GET /api/entries/search?q=:query&lang=:code
|
||||
POST /api/entries (authenticated)
|
||||
PATCH /api/entries/:id (authenticated)
|
||||
GET /api/entries?since=2026-01-01T12:00:00Z
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Sanasto Wiki TODO List
|
||||
|
||||
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
|
||||
|
||||
---
|
||||
|
||||
## High Priority
|
||||
|
||||
### Bugs
|
||||
|
||||
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
|
||||
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
|
||||
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
|
||||
|
||||
### Improvements
|
||||
|
||||
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### New Features
|
||||
|
||||
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
|
||||
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
|
||||
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
|
||||
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
|
||||
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
|
||||
|
||||
### Discussion
|
||||
|
||||
- [x] **Add comments to entries**: Users can now add comments to entries.
|
||||
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
|
||||
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
|
||||
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
|
||||
|
||||
### Refactoring
|
||||
|
||||
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
|
||||
|
||||
---
|
||||
|
||||
## Low Priority
|
||||
|
||||
### New Features
|
||||
|
||||
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
|
||||
|
||||
### Improvements
|
||||
|
||||
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
|
||||
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
|
||||
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
|
||||
@pending_suggestions_count = SuggestedMeaning.pending.count
|
||||
@accepted_suggestions_count = SuggestedMeaning.accepted.count
|
||||
@rejected_suggestions_count = SuggestedMeaning.rejected.count
|
||||
@requested_entries_count = Entry.requested.count
|
||||
|
||||
@comment_count = Comment.count
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
|
||||
def create
|
||||
@invitation = User.new(invitation_params)
|
||||
@invitation.invitation_token = SecureRandom.urlsafe_base64(32)
|
||||
@invitation.invitation_sent_at = Time.current
|
||||
@invitation.invited_by = current_user
|
||||
@invitation.invite_by(current_user)
|
||||
@invitation.password = SecureRandom.urlsafe_base64(16)
|
||||
|
||||
if @invitation.save
|
||||
@@ -29,6 +27,21 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
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
|
||||
@invitation = User.find(params[:id])
|
||||
|
||||
@@ -44,6 +57,12 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@users = User.order(created_at: :desc)
|
||||
@users = @users.where(role: params[:role]) if params[:role].present?
|
||||
@users = @users.where("email LIKE ?", "%#{params[:q]}%") if params[:q].present?
|
||||
.by_role(params[:role])
|
||||
.search_email(params[:q])
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
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)
|
||||
redirect_to admin_users_path, notice: "User updated successfully."
|
||||
else
|
||||
@@ -40,6 +46,13 @@ class Admin::UsersController < Admin::BaseController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -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,7 +1,14 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include BotBlocker
|
||||
include Pagy::Backend
|
||||
|
||||
# Changes to the importmap will invalidate the etag for HTML responses
|
||||
stale_when_importmap_changes
|
||||
|
||||
SESSION_TIMEOUT = 3.days
|
||||
|
||||
before_action :check_session_timeout
|
||||
|
||||
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
|
||||
:contributor_or_above?, :setup_completed?
|
||||
|
||||
@@ -12,7 +19,40 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def logged_in?
|
||||
|
||||
@@ -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
|
||||
@@ -2,35 +2,33 @@ class EntriesController < ApplicationController
|
||||
before_action :set_entry, only: [ :show, :edit, :update ]
|
||||
|
||||
def index
|
||||
@language_code = params[:language].presence
|
||||
@language_code = validate_language_code(params[:language].presence)
|
||||
@category = params[:category].presence
|
||||
@query = params[:q].to_s.strip
|
||||
@starts_with = params[:starts_with].presence
|
||||
@page = [ params[:page].to_i, 1 ].max
|
||||
@per_page = 25
|
||||
|
||||
entries_scope = Entry.all
|
||||
entries_scope = Entry.active_entries
|
||||
entries_scope = entries_scope.with_category(@category)
|
||||
entries_scope = entries_scope.search(@query, language_code: @language_code)
|
||||
entries_scope = entries_scope.starts_with(@starts_with, language_code: @language_code) if @starts_with.present?
|
||||
entries_scope = entries_scope.alphabetical_for(@language_code) if @query.blank? && @starts_with.blank? && @language_code.present?
|
||||
entries_scope = entries_scope.order(created_at: :desc) if entries_scope.order_values.empty?
|
||||
|
||||
@total_entries = entries_scope.count
|
||||
@total_pages = (@total_entries.to_f / @per_page).ceil
|
||||
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
|
||||
@pagy, @entries = pagy(entries_scope, items: 25)
|
||||
@total_entries = @pagy.count
|
||||
|
||||
@entry_count = Entry.count
|
||||
@verified_count = Entry.where(verified: true).count
|
||||
@entry_count = Entry.active_entries.count
|
||||
@requested_count = Entry.requested.count
|
||||
@verified_count = Entry.active_entries.where(verified: true).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, "" ])
|
||||
end.count
|
||||
@missing_entries_count = @entry_count - @complete_entries_count
|
||||
@language_completion = supported_languages.index_with do |language|
|
||||
next 0 if @entry_count.zero?
|
||||
|
||||
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
|
||||
(Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
|
||||
end
|
||||
|
||||
if @language_code.present?
|
||||
@@ -61,7 +59,7 @@ class EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def download
|
||||
@entries = Entry.order(:id)
|
||||
@entries = Entry.active_entries.order(:id)
|
||||
respond_to do |format|
|
||||
format.xlsx do
|
||||
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
|
||||
@@ -77,6 +75,12 @@ class EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(:category)
|
||||
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
|
||||
end
|
||||
|
||||
def validate_language_code(code)
|
||||
return nil if code.blank?
|
||||
|
||||
SupportedLanguage.valid_codes.include?(code) ? code : nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
|
||||
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
|
||||
|
||||
@@ -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
|
||||
include RateLimiter
|
||||
|
||||
def new
|
||||
# Redirect to admin if already logged in
|
||||
if logged_in?
|
||||
@@ -17,7 +19,23 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Reset rate limit on successful login
|
||||
reset_rate_limit
|
||||
|
||||
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}!"
|
||||
else
|
||||
flash.now[:alert] = "Invalid email or password."
|
||||
@@ -26,7 +44,9 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
session[:user_id] = nil
|
||||
redirect_to root_path, notice: "You have been logged out."
|
||||
current_user&.forget_me if cookies.signed[:remember_token]
|
||||
reset_session
|
||||
cookies.delete(:remember_token)
|
||||
redirect_to root_path, notice: "You have been logged out.", status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
def language_name(code)
|
||||
supported_languages.find { |l| l.code == code }&.name
|
||||
end
|
||||
|
||||
@@ -28,4 +28,15 @@ module EntriesHelper
|
||||
def format_entry_category(entry)
|
||||
entry.category.to_s.tr("_", " ").capitalize
|
||||
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
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "button"]
|
||||
static targets = ["modal", "languageSelect", "form"]
|
||||
|
||||
connect() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
this.buttonTarget.classList.remove("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() {
|
||||
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
|
||||
default from: "from@example.com"
|
||||
default from: Rails.application.credentials.dig(:mail, :from)
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite(user)
|
||||
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: "You've been invited to join Sanasto Wiki"
|
||||
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
|
||||
+13
-1
@@ -1,15 +1,21 @@
|
||||
class Entry < ApplicationRecord
|
||||
belongs_to :created_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 :comments, as: :commentable, dependent: :destroy
|
||||
|
||||
enum :category, %i[word phrase proper_name title reference other]
|
||||
enum :status, %i[requested approved active], default: :active
|
||||
|
||||
validates :category, presence: true
|
||||
validate :at_least_one_translation
|
||||
|
||||
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)
|
||||
return all if query.blank?
|
||||
@@ -35,7 +41,7 @@ class Entry < ApplicationRecord
|
||||
return none unless valid_lang?(language_code)
|
||||
|
||||
where.not(language_code => [ nil, "" ])
|
||||
.order(Arel.sql("#{language_code} ASC"))
|
||||
.order(arel_table[language_code].asc)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
|
||||
def self.valid_lang?(code)
|
||||
SupportedLanguage.valid_codes.include?(code.to_s)
|
||||
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
|
||||
|
||||
+48
-1
@@ -6,6 +6,7 @@ class User < ApplicationRecord
|
||||
|
||||
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 :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify
|
||||
has_many :submitted_suggested_meanings,
|
||||
class_name: "SuggestedMeaning",
|
||||
foreign_key: :submitted_by_id,
|
||||
@@ -18,11 +19,18 @@ class User < ApplicationRecord
|
||||
|
||||
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? }
|
||||
|
||||
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?
|
||||
@@ -33,10 +41,49 @@ class User < ApplicationRecord
|
||||
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
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<%= link_to admin_users_path do %>
|
||||
<dl>
|
||||
<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>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,10 +46,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<%= link_to root_path do %>
|
||||
<dl>
|
||||
<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>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,10 +73,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<%= link_to admin_requests_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,10 +100,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<%= link_to admin_invitations_path do %>
|
||||
<dl>
|
||||
<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>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
|
||||
</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" %>
|
||||
</td>
|
||||
</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>
|
||||
@@ -2,12 +2,16 @@
|
||||
<%= render "entries/comment", comment: @comment %>
|
||||
<% end %>
|
||||
|
||||
<% if @comment.language_code.present? %>
|
||||
<%= turbo_stream.replace "comment-details-#{@comment.language_code}" do %>
|
||||
<%= render "entries/language_comment_details", entry: @commentable, language_code: @comment.language_code %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.replace "comment_tabs" do %>
|
||||
<%= 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 %>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<%= form_with(model: [entry, Comment.new(commentable: entry)],
|
||||
data: { turbo_stream: true },
|
||||
data: { turbo_stream: true, comments_target: "form" },
|
||||
html: { class: "space-y-4" }) do |form| %>
|
||||
<%= form.hidden_field :language_code, value: (local_assigns[:language_code].presence || nil) %>
|
||||
<div>
|
||||
<%= form.label :body, "Comment", class: "sr-only" %>
|
||||
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
|
||||
<%= form.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 class="flex justify-end">
|
||||
<div>
|
||||
<%= form.label :body, "Comment", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-lg shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" data-action="click->comments#closeWithButton" class="px-4 py-2 text-sm font-medium text-slate-700 hover:text-slate-900 transition">
|
||||
Cancel
|
||||
</button>
|
||||
<%= form.submit "Submit", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</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>
|
||||
@@ -1,8 +1,8 @@
|
||||
<% if current_user %>
|
||||
<div class="mt-8" data-controller="comments">
|
||||
<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 id="add_comment_button" data-action="click->comments#open" data-comments-target="button" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
|
||||
<button data-action="click->comments#open" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
|
||||
Add Comment
|
||||
</button>
|
||||
</div>
|
||||
@@ -10,15 +10,7 @@
|
||||
<%= render "entries/comment_tabs", entry: entry %>
|
||||
|
||||
<div id="comment_form_modal" data-comments-target="modal" data-action="click->comments#close" class="hidden fixed inset-0 bg-slate-900 bg-opacity-50 z-40 flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-lg font-bold">Add a Comment</h4>
|
||||
<button id="close_comment_form" data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= render "entries/comment_form", entry: entry %>
|
||||
</div>
|
||||
<%= render "entries/comment_modal_content", entry: entry %>
|
||||
</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 %>
|
||||
@@ -1,8 +0,0 @@
|
||||
<details class="text-xs">
|
||||
<summary class="inline-flex items-center gap-1 text-indigo-600 font-semibold cursor-pointer">
|
||||
Add comment
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<%= render "entries/comment_form", entry: entry, language_code: language_code %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -42,6 +42,9 @@
|
||||
<tr>
|
||||
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
|
||||
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>
|
||||
</tr>
|
||||
<% else %>
|
||||
@@ -83,17 +86,19 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<% previous_page = @page > 1 ? @page - 1 : nil %>
|
||||
<% next_page = @page < @total_pages ? @page + 1 : nil %>
|
||||
<% pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
|
||||
<%= link_to "Previous", previous_page ? entries_path(pagination_params.merge(page: previous_page)) : "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{previous_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
|
||||
data: { turbo_stream: true } %>
|
||||
<%= link_to "Next", next_page ? entries_path(pagination_params.merge(page: next_page)) : "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{next_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
|
||||
data: { turbo_stream: true } %>
|
||||
<%
|
||||
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
|
||||
next_url = @pagy.next ? entries_path(pagination_params.merge(page: @pagy.next)) : nil
|
||||
%>
|
||||
<%= link_to "Previous", prev_url || "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{'opacity-50 pointer-events-none' unless prev_url}",
|
||||
data: (prev_url ? { 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>
|
||||
|
||||
@@ -1,70 +1,55 @@
|
||||
<% content_for :title, "Edit Entry" %>
|
||||
|
||||
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200">
|
||||
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
|
||||
<%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-sm font-semibold text-indigo-700 px-3 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<%= render "shared/header", show_request_button: false, show_browse_button: true %>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= link_to "← Back to entry", entry_path(@entry), class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
<%= link_to "Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
</div>
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Edit Category</span>
|
||||
<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-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<span class="text-xs font-bold">Verified</span>
|
||||
<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-xs font-semibold text-amber-600">Unverified</span>
|
||||
<span class="text-sm font-semibold text-amber-600 px-3 py-1 rounded-full bg-amber-50">Unverified</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<%= form_with model: @entry, class: "space-y-4" do |form| %>
|
||||
<div>
|
||||
<%= form.label :category, "Category", class: "block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2" %>
|
||||
<%= form.select :category,
|
||||
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] },
|
||||
{},
|
||||
class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %>
|
||||
<p class="text-gray-600">Update the translations and details for this entry.</p>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit "Save Category", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
|
||||
<%= 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="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
|
||||
<% supported_languages.each do |language| %>
|
||||
<% translation = entry_translation_for(@entry, language.code) %>
|
||||
<% next if translation.blank? %>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-2">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="space-y-6">
|
||||
<%= render 'entries/form_fields', f: f, category_prompt: false %>
|
||||
</div>
|
||||
|
||||
<% if @entry.notes.present? %>
|
||||
<div class="mt-6 pt-5 border-t border-slate-100">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4>
|
||||
<p class="text-sm text-slate-600 italic"><%= @entry.notes %></p>
|
||||
<div 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>
|
||||
</main>
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
<% content_for :title, "Sanasto Wiki" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-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/header" %>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<section id="search">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</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>
|
||||
<%= link_to "← Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
</div>
|
||||
@@ -39,14 +39,16 @@
|
||||
<% next if translation.blank? %>
|
||||
<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>
|
||||
<% 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>
|
||||
<% if current_user %>
|
||||
<div id="comment-details-<%= language.code %>">
|
||||
<%= render "entries/language_comment_details", entry: @entry, language_code: language.code %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,32 @@
|
||||
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>
|
||||
@@ -90,11 +116,53 @@
|
||||
<div class="content">
|
||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||
|
||||
<% if @approved_entry %>
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
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>
|
||||
|
||||
@@ -4,9 +4,28 @@ 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
|
||||
--------------------
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %>
|
||||
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
|
||||
<div>
|
||||
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password,
|
||||
|
||||
@@ -16,31 +16,109 @@
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= 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-light text-slate-400">Admin</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-3">
|
||||
<% end %>
|
||||
|
||||
<!-- 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 "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" %>
|
||||
<% 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" %>
|
||||
<%= 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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<% if flash.any? %>
|
||||
<div class="max-w-7xl mx-auto px-4 mt-4">
|
||||
<% 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-4 py-3 rounded-lg mb-4" role="alert">
|
||||
<span class="block sm:inline"><%= message %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<script>
|
||||
function setupAdminMobileMenu() {
|
||||
const menuButton = document.getElementById('admin-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('admin-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', setupAdminMobileMenu);
|
||||
document.addEventListener('turbo:load', setupAdminMobileMenu);
|
||||
</script>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<!-- Main content -->
|
||||
<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!) %>
|
||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.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 %>
|
||||
<%= 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",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"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>
|
||||
</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">
|
||||
@@ -20,13 +22,8 @@
|
||||
<p class="text-sm text-slate-600">Enter your credentials to continue</p>
|
||||
</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>
|
||||
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.email_field :email,
|
||||
@@ -38,7 +35,10 @@
|
||||
</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,
|
||||
autocomplete: "current-password",
|
||||
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" %>
|
||||
</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">
|
||||
<%= 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" %>
|
||||
|
||||
@@ -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 "rails/all"
|
||||
require_relative "../lib/middleware/sanasto_cors"
|
||||
|
||||
# Require the gems listed in Gemfile, including any gems
|
||||
# you've limited to :test, :development, or :production.
|
||||
@@ -24,5 +25,7 @@ module SanastoWiki
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
config.active_record.schema_format = :sql
|
||||
|
||||
config.middleware.insert_before 0, Middleware::SanastoCors
|
||||
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
|
||||
|
||||
development:
|
||||
primary:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
queue:
|
||||
<<: *default
|
||||
database: storage/development_queue.sqlite3
|
||||
migrations_paths: db/queue_migrate
|
||||
|
||||
# Warning: The database defined as "test" will be erased and
|
||||
# re-generated from your development database when you run "rake".
|
||||
# Do not set this db to the same as development or production.
|
||||
test:
|
||||
<<: *default
|
||||
database: storage/test.sqlite3
|
||||
database: storage/test<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3
|
||||
|
||||
# Store production database in the storage/ directory, which by default
|
||||
# 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).
|
||||
config.active_storage.service = :local
|
||||
|
||||
# Don't care if the mailer can't send.
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
# Raise delivery errors to see what's happening with email
|
||||
config.action_mailer.raise_delivery_errors = true
|
||||
|
||||
# Make template changes take effect immediately.
|
||||
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.smtp_settings = {
|
||||
address: "localhost",
|
||||
|
||||
@@ -50,23 +50,24 @@ Rails.application.configure do
|
||||
# config.cache_store = :mem_cache_store
|
||||
|
||||
# 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.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# 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.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
|
||||
# password: Rails.application.credentials.dig(:smtp, :password),
|
||||
# address: "smtp.example.com",
|
||||
# port: 587,
|
||||
# authentication: :plain
|
||||
# }
|
||||
config.action_mailer.smtp_settings = {
|
||||
user_name: Rails.application.credentials.dig(:mail, :username),
|
||||
password: Rails.application.credentials.dig(:mail, :password),
|
||||
address: Rails.application.credentials.dig(:mail, :server),
|
||||
port: 587,
|
||||
authentication: :plain
|
||||
}
|
||||
|
||||
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
|
||||
# the I18n.default_locale when a translation cannot be found).
|
||||
|
||||
@@ -20,7 +20,7 @@ Rails.application.configure do
|
||||
|
||||
# Show full error reports.
|
||||
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.
|
||||
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
|
||||
+23
-1
@@ -1,6 +1,10 @@
|
||||
Rails.application.routes.draw do
|
||||
# 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.
|
||||
# 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
|
||||
@@ -21,16 +25,34 @@ Rails.application.routes.draw do
|
||||
post "login", to: "sessions#create"
|
||||
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
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
get "dashboard", to: "dashboard#index"
|
||||
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
|
||||
|
||||
resources :entries do
|
||||
|
||||
@@ -18,6 +18,5 @@ class CreateSuggestedMeanings < ActiveRecord::Migration[8.1]
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class AddStatusToEntries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :entries, :status, :integer, default: 2, null: false
|
||||
add_index :entries, :status
|
||||
|
||||
# Set all existing entries to status: 2 (active)
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute "UPDATE entries SET status = 2"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :entries, :requested_by_id, :integer
|
||||
add_foreign_key :entries, :users, column: :requested_by_id
|
||||
add_index :entries, :requested_by_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddPasswordResetToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :reset_password_token, :string
|
||||
add_column :users, :reset_password_sent_at, :datetime
|
||||
add_index :users, :reset_password_token, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddRememberTokenToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :remember_token, :string
|
||||
add_column :users, :remember_created_at, :datetime
|
||||
add_index :users, :remember_token, unique: true
|
||||
end
|
||||
end
|
||||
@@ -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');
|
||||
|
||||
+22
-31
@@ -1,15 +1,5 @@
|
||||
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 "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
|
||||
FOREIGN KEY ("created_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_6f84c41258"
|
||||
FOREIGN KEY ("updated_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
|
||||
FOREIGN KEY ("user_id")
|
||||
REFERENCES "users" ("id")
|
||||
@@ -18,7 +8,7 @@ CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application
|
||||
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
|
||||
CREATE 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 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")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
@@ -59,28 +49,29 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
CREATE TRIGGER entries_fts_after_insert
|
||||
AFTER INSERT ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
|
||||
END;
|
||||
CREATE TRIGGER entries_fts_after_update
|
||||
AFTER UPDATE ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
|
||||
END;
|
||||
CREATE TRIGGER entries_fts_after_delete
|
||||
AFTER DELETE ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
|
||||
FOREIGN KEY ("updated_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_367d1ab731"
|
||||
FOREIGN KEY ("created_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_4d36fd8a36"
|
||||
FOREIGN KEY ("requested_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/;
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20260130080931'),
|
||||
('20260130080745'),
|
||||
('20260129204706'),
|
||||
('20260129204705'),
|
||||
('20260123130957'),
|
||||
('20260123125325'),
|
||||
('20260122131000'),
|
||||
|
||||
@@ -194,3 +194,40 @@ for i in range(10):
|
||||
```
|
||||
```
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Deployment (Kamal)
|
||||
|
||||
We deploy with Kamal. You do NOT need to manually set up web servers or Docker
|
||||
on the VM. `kamal setup` provisions everything.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Local machine
|
||||
- Ruby + Bundler
|
||||
- Docker (for building images)
|
||||
- SSH key access to the VM
|
||||
- Registry credentials (see `config/deploy.yml`)
|
||||
|
||||
### Remote VM
|
||||
- Bare VM with SSH access
|
||||
- Open ports: 22, 80, 443
|
||||
- A domain name pointing at the VM (for SSL)
|
||||
|
||||
## Configure
|
||||
|
||||
Update `config/deploy.yml`:
|
||||
|
||||
```yaml
|
||||
service: sanasto-wiki
|
||||
image: your-registry/sanasto-wiki
|
||||
|
||||
servers:
|
||||
web:
|
||||
- your-server-ip
|
||||
|
||||
proxy:
|
||||
ssl: true
|
||||
host: sanasto.example.com
|
||||
|
||||
registry:
|
||||
server: ghcr.io
|
||||
username: your-github-username
|
||||
|
||||
ssh:
|
||||
user: deploy
|
||||
```
|
||||
|
||||
Set the registry password:
|
||||
|
||||
```bash
|
||||
export KAMAL_REGISTRY_PASSWORD="your-token"
|
||||
```
|
||||
|
||||
Make sure `config/master.key` is present locally.
|
||||
|
||||
## First deploy
|
||||
|
||||
```bash
|
||||
bundle exec kamal setup
|
||||
```
|
||||
|
||||
## Regular deploys
|
||||
|
||||
```bash
|
||||
bundle exec kamal deploy
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
bundle exec kamal app logs --follow
|
||||
bundle exec kamal app details
|
||||
bundle exec kamal app exec --interactive --reuse "bin/rails console"
|
||||
bundle exec kamal rollback
|
||||
```
|
||||
|
||||
## Initial app setup
|
||||
|
||||
Visit `https://your-domain/setup` once after the first deploy to create the
|
||||
admin account.
|
||||
+57
-31
@@ -2,14 +2,15 @@
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- [ ] **Authentication system**
|
||||
- [x] **Authentication system**
|
||||
- [x] Sessions controller and views (login/logout)
|
||||
- [x] Email/password authentication with session management
|
||||
- [x] Login redirects (admin vs regular users)
|
||||
- [x] Logout functionality
|
||||
- [ ] Password reset flow
|
||||
- [ ] Rate limiting on login attempts
|
||||
- [ ] Session management (remember me, session timeout)
|
||||
- [x] Password reset flow (email-based, 1 hour expiry)
|
||||
- [x] Rate limiting on login attempts (5 attempts, 15 minute lockout)
|
||||
- [x] Session management (remember me for 2 weeks, 30 minute timeout)
|
||||
- [x] Sign in status in the site header
|
||||
- [x] **Invitation system**
|
||||
- [x] Invitations controller (create, list, cancel)
|
||||
- [x] Invitation token generation
|
||||
@@ -22,6 +23,24 @@
|
||||
- [ ] Contributor permissions enforcement (for entry editing)
|
||||
- [ ] Reviewer permissions enforcement (for review queue)
|
||||
|
||||
## Security & Vulnerabilities
|
||||
|
||||
- [x] **Fixed user-controlled method execution** (HIGH)
|
||||
- Added language code validation in EntriesController
|
||||
- Prevents arbitrary method execution via `public_send()`
|
||||
- [x] **Fixed SQL injection in Entry model** (MEDIUM)
|
||||
- Replaced string interpolation with Arel safe column references
|
||||
- Changed `Arel.sql("#{language_code} ASC")` to `arel_table[language_code].asc`
|
||||
- [x] **Fixed mass assignment vulnerabilities** (MEDIUM)
|
||||
- Added role validation in admin invitations and user management
|
||||
- Only allows valid enum role values
|
||||
- Prevents users from modifying their own role
|
||||
- [x] **Fixed SQL LIKE injection** (MEDIUM)
|
||||
- Added `sanitize_sql_like()` for email search in UsersController
|
||||
- Prevents wildcard injection attacks
|
||||
|
||||
**Status:** All Brakeman security warnings resolved ✓
|
||||
|
||||
## Core Features
|
||||
|
||||
### Search & Browse
|
||||
@@ -31,11 +50,11 @@
|
||||
- [x] **Live search** (AJAX updates without page reload)
|
||||
|
||||
### Entry Management
|
||||
- [ ] **Create entry form** with all language fields
|
||||
- [ ] **Edit entry form** (contributors only)
|
||||
- [x] **Create entry form** with all language fields
|
||||
- [x] **Edit entry form** (contributors only)
|
||||
- [ ] **Delete entries** (admin only, with confirmation)
|
||||
- [ ] **Bulk actions** (select multiple, bulk edit/delete)
|
||||
- [ ] **Entry validation** (at least one language required, category required)
|
||||
- [x] **Entry validation** (at least one language required, category required)
|
||||
|
||||
### Suggested Meanings
|
||||
- [ ] **Submit alternative translation** form
|
||||
@@ -45,7 +64,7 @@
|
||||
- [ ] **Notifications** when suggestions are reviewed
|
||||
|
||||
### Comments & Discussion
|
||||
- [ ] **Add comment** form on entry view
|
||||
- [x] **Add comment** form on entry view
|
||||
- [ ] **Edit/delete own comments**
|
||||
- [ ] **Comment threading** (optional: replies to comments)
|
||||
- [ ] **Comment notifications** for entry contributors
|
||||
@@ -90,22 +109,38 @@
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] **Controller tests** for all actions
|
||||
- [ ] **System tests** for critical user flows
|
||||
- [ ] Public browsing and search
|
||||
- [ ] Contributor creates/edits entry
|
||||
- [ ] Reviewer workflow
|
||||
- [ ] Admin user management
|
||||
- [ ] **Integration tests** for authentication flows
|
||||
- [ ] **Performance tests** for search queries
|
||||
- [x] **Controller tests** for all actions
|
||||
- [x] EntriesController (index, show, edit, update, download, filters, search)
|
||||
- [x] PasswordResetsController (new, create, edit, update, token validation)
|
||||
- [x] Existing tests: Sessions, Invitations, Setup, Admin controllers, Comments, Requests
|
||||
- [x] **System tests** for critical user flows
|
||||
- [x] Public browsing and search
|
||||
- [x] Contributor creates/edits entry
|
||||
- [ ] Reviewer workflow (pending feature implementation)
|
||||
- [x] Admin user management
|
||||
- [x] **Integration tests** for authentication flows
|
||||
- [x] Sign in/sign out flows
|
||||
- [x] Remember me functionality
|
||||
- [x] Session timeout
|
||||
- [x] Rate limiting
|
||||
- [x] Password reset flow
|
||||
- [x] Invitation acceptance flow
|
||||
- [x] **Performance tests** for search queries
|
||||
- [x] Full text search benchmarks
|
||||
- [x] Language-specific search
|
||||
- [x] Alphabetical browsing
|
||||
- [x] Category filtering
|
||||
- [x] Combined filters
|
||||
- [x] XLSX download performance
|
||||
|
||||
## Deployment
|
||||
|
||||
- [ ] **Kamal configuration**
|
||||
- [ ] **Production environment** setup
|
||||
- [ ] **SSL certificate** configuration
|
||||
- [x] **Kamal configuration** (see docs/DEPLOYMENT.md)
|
||||
- [x] **Production environment** setup (automated via Kamal)
|
||||
- [x] **SSL certificate** configuration (Let's Encrypt via Kamal proxy)
|
||||
- [x] **Backup automation** (documented: manual and cron strategies)
|
||||
- [ ] **Monitoring** (error tracking, performance monitoring)
|
||||
- [ ] **Backup automation** (Litestream to S3 or similar)
|
||||
- [ ] **Litestream setup** (optional: SQLite replication to S3)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
@@ -118,24 +153,15 @@
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
## Completed (Not Tracked Above)
|
||||
|
||||
- [x] **Invitation system** (complete flow with email, acceptance, and expiry validation)
|
||||
- [x] **Invitation acceptance flow** (users can accept invitations and set passwords)
|
||||
- [x] **Invitation mailer** (HTML and text email templates with styled design)
|
||||
- [x] **Token expiry validation** (14-day expiration for invitation links)
|
||||
- [x] **Controller tests** (40 tests with 160+ assertions for authentication)
|
||||
- [x] **Authentication system** (login/logout with session management)
|
||||
- [x] **Admin layout design** updated to match entries page style
|
||||
- [x] **Dynamic navigation** (Admin button for logged-in admins, Sign In for guests)
|
||||
- [x] **Authorization middleware** (Admin::BaseController with role checks)
|
||||
- [x] **Invitation token generation** (secure token creation for new users)
|
||||
- [x] **Controller tests** (40 tests with 160+ assertions for authentication)
|
||||
- [x] **Search input loses focus on filter change**
|
||||
- [x] **Mismatched enum syntax** in models
|
||||
- [x] **Replace hardcoded LANGUAGE_COLUMNS** with dynamic query
|
||||
- [x] **Improve fixture quality** (resolved foreign key violations)
|
||||
- [x] **XLSX download** button for entries
|
||||
- [x] **FTS5 integration** (migration added)
|
||||
- [x] **Database schema** implementation (all models and migrations)
|
||||
- [x] **Supported languages** table with seed data
|
||||
- [x] **Filters do not update with new search results**
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook that runs rubocop on staged Ruby files
|
||||
|
||||
echo "Running rubocop on staged files..."
|
||||
|
||||
# Get list of staged Ruby files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.rb$|\.rake$')
|
||||
|
||||
# If no Ruby files are staged, exit successfully
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
echo "No Ruby files staged, skipping rubocop."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run rubocop on staged files
|
||||
echo "$STAGED_FILES" | xargs bundle exec rubocop --force-exclusion
|
||||
|
||||
RUBOCOP_EXIT=$?
|
||||
|
||||
# If rubocop failed, prevent commit
|
||||
if [ $RUBOCOP_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Rubocop found issues. Please fix them before committing."
|
||||
echo " You can run 'bundle exec rubocop -A' to auto-fix some issues."
|
||||
echo " To skip this hook, use 'git commit --no-verify'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Rubocop passed!"
|
||||
exit 0
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Pre-push hook that runs security scans (brakeman + bundler-audit)
|
||||
|
||||
echo "Running security scans..."
|
||||
echo ""
|
||||
|
||||
# Run brakeman
|
||||
echo "🔍 Running brakeman..."
|
||||
bundle exec brakeman --no-pager --quiet
|
||||
|
||||
BRAKEMAN_EXIT=$?
|
||||
|
||||
if [ $BRAKEMAN_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Brakeman found security issues."
|
||||
echo " Run 'bundle exec brakeman' for detailed output."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Run bundler-audit
|
||||
echo "🔍 Running bundler-audit..."
|
||||
bundle exec bundler-audit check --update
|
||||
|
||||
BUNDLER_AUDIT_EXIT=$?
|
||||
|
||||
if [ $BUNDLER_AUDIT_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Bundler-audit found vulnerable dependencies."
|
||||
echo " Run 'bundle exec bundler-audit check' for detailed output."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# If either scan failed, prevent push
|
||||
if [ $BRAKEMAN_EXIT -ne 0 ] || [ $BUNDLER_AUDIT_EXIT -ne 0 ]; then
|
||||
echo "❌ Security scans failed. Please fix the issues before pushing."
|
||||
echo " To skip this hook, use 'git push --no-verify'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ All security scans passed!"
|
||||
exit 0
|
||||
@@ -0,0 +1,86 @@
|
||||
module Middleware
|
||||
class SanastoCors
|
||||
ALLOWED_APP_ID = ENV.fetch("SANASTO_APP_ID", "app.sanasto").freeze
|
||||
APP_ID_HEADER = "HTTP_X_SANASTO_APP"
|
||||
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if allow_cors_for?(env)
|
||||
if env["REQUEST_METHOD"] == "OPTIONS"
|
||||
return preflight_response(env["HTTP_ORIGIN"], allowed_request_headers(env))
|
||||
end
|
||||
end
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
if allow_cors_for?(env)
|
||||
apply_cors_headers(headers, env["HTTP_ORIGIN"])
|
||||
end
|
||||
[ status, headers, body ]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def allow_cors_for?(env)
|
||||
origin = env["HTTP_ORIGIN"].to_s
|
||||
return false if origin.empty?
|
||||
|
||||
if env["REQUEST_METHOD"] == "OPTIONS"
|
||||
return preflight_includes_app_id_header?(env)
|
||||
end
|
||||
|
||||
app_id = env[APP_ID_HEADER].to_s
|
||||
return false if app_id.empty?
|
||||
|
||||
app_id == ALLOWED_APP_ID
|
||||
end
|
||||
|
||||
def preflight_includes_app_id_header?(env)
|
||||
access_control_headers = env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
|
||||
return false if access_control_headers.empty?
|
||||
|
||||
access_control_headers
|
||||
.split(",")
|
||||
.map { |header_name| header_name.strip.downcase }
|
||||
.include?("x-sanasto-app")
|
||||
end
|
||||
|
||||
def allowed_request_headers(env)
|
||||
access_control_headers = env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
|
||||
return default_allowed_headers if access_control_headers.empty?
|
||||
|
||||
sanitized = access_control_headers
|
||||
.split(",")
|
||||
.map { |header_name| header_name.strip }
|
||||
.reject(&:empty?)
|
||||
.join(", ")
|
||||
|
||||
sanitized.empty? ? default_allowed_headers : sanitized
|
||||
end
|
||||
|
||||
def preflight_response(origin, allowed_headers)
|
||||
headers = {}
|
||||
apply_cors_headers(headers, origin, allowed_headers)
|
||||
headers["Access-Control-Max-Age"] = "86400"
|
||||
headers["Vary"] = [
|
||||
headers["Vary"],
|
||||
"Access-Control-Request-Headers",
|
||||
"Access-Control-Request-Method"
|
||||
].compact.join(", ")
|
||||
[ 204, headers, [] ]
|
||||
end
|
||||
|
||||
def apply_cors_headers(headers, origin, allowed_headers = default_allowed_headers)
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
headers["Access-Control-Allow-Headers"] = allowed_headers
|
||||
headers["Vary"] = [ headers["Vary"], "Origin, X-Sanasto-App" ].compact.join(", ")
|
||||
end
|
||||
|
||||
def default_allowed_headers
|
||||
"Origin, Content-Type, Accept, Authorization, X-Sanasto-App"
|
||||
end
|
||||
end
|
||||
end
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user