Compare commits
92 Commits
5472a400d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d62b46b7df | |||
| 8d50c14a7b | |||
| aeb6989223 | |||
| ff5f387a87 | |||
| 37a65526e4 | |||
| aea001cdf6 | |||
| 35ad7da355 | |||
| a8f1ffd920 | |||
| 7db96cfab4 | |||
| bbd2cef168 | |||
| 1fa7bf10ec | |||
| 916c7af738 | |||
| 537f32ab8b | |||
| 5b9862c19c | |||
| 4ca711f5aa | |||
| b13ef30807 | |||
| 657c5ac47b | |||
| 502649620b | |||
| ee476ab81b | |||
| 0599def237 | |||
| b6da55723d | |||
| 9862f0c74b | |||
| 1b34d10dee | |||
| d28f888ee2 | |||
| 2b1a2c9296 | |||
| 1a8ea0afee | |||
| 2cadc8eca5 | |||
| c34b9654c8 | |||
| 341e626f6f | |||
| c22b529858 | |||
| 50050064c2 | |||
| 5aa69a108c | |||
| ef3c63ea67 | |||
| 6fb5ba5f3e | |||
| 6c16e5e232 | |||
| 2969a24cb0 | |||
| 4b5c09f63b | |||
| ca7399f490 | |||
| f27b43ef45 | |||
| 5b908fe37c | |||
| 2616cbaa71 | |||
| 70c0fed0c4 | |||
| 6c0f5167a4 | |||
| ac3b354480 | |||
| 71f2bb6b70 | |||
| ac83a599f3 | |||
| 67492cdd15 | |||
| 79fb1edfaf | |||
| 4645b45f5d | |||
| f78a082ad3 | |||
| b8674a126f | |||
| 67d2ef41b3 | |||
| 96b8553b1f | |||
| 194c126c90 | |||
| a33f3ff4de | |||
| 17a1af4e9f | |||
| baea71b312 | |||
| bc32387c21 | |||
| 55399d80fe | |||
| d7d90f0c91 | |||
| 7444a09046 | |||
| ec2201f9a8 | |||
| 939e2157ab | |||
| 5ec0e264ba | |||
| c4902d85d5 | |||
| e65d639ca6 | |||
| 1397ddce2f | |||
| 138fe67baa | |||
| 3a8517e637 | |||
| 6afde8a971 | |||
| 70005468c6 | |||
| 2f23938e81 | |||
| 378c3011ef | |||
| a421cd496d | |||
| 21f71f9d32 | |||
| 10fa821f19 | |||
| 8b0b1c6745 | |||
| 497768610d | |||
| 26b8064553 | |||
| 7023600cd1 | |||
| 12f9f33034 | |||
| 2144c22bd9 | |||
| a5d165c4b3 | |||
| c98caeea92 | |||
| f8bfb020bc | |||
| 14be4a32e6 | |||
| 7ce684ced9 | |||
| 5525cc814a | |||
| 2982f44acc | |||
| 9a2a3a6f33 | |||
| d47ebf22ab | |||
| 6b02eecb9b |
@@ -56,8 +56,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config google-chrome-stable
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -68,16 +69,12 @@ jobs:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run tests
|
||||
- name: Setup test database
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
# REDIS_URL: redis://localhost:6379/0
|
||||
run: bin/rails db:test:prepare test test:system
|
||||
run: bin/rails db:test:prepare
|
||||
|
||||
- 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
|
||||
- name: Run rspec
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
run: bundle exec rspec
|
||||
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@@ -1,12 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: bundler
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
@@ -48,9 +48,6 @@ RUN bundle exec bootsnap precompile app/ lib/
|
||||
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
|
||||
|
||||
|
||||
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
|
||||
@@ -58,6 +55,9 @@ FROM base
|
||||
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
|
||||
COPY --from=build /rails /rails
|
||||
|
||||
# TODO: find how not to depend on this hack to include the compiled SCSS.
|
||||
RUN cp app/assets/builds/application.css `ls public/assets/application-*.css`
|
||||
|
||||
# Run and own only the runtime files as a non-root user for security
|
||||
RUN groupadd --system --gid 1000 rails && \
|
||||
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
|
||||
|
||||
7
Gemfile
7
Gemfile
@@ -43,6 +43,9 @@ gem "thruster", require: false
|
||||
gem "slim"
|
||||
gem "dartsass-rails"
|
||||
gem "bootstrap", "~> 5.3.3"
|
||||
gem "friendly_id", "~> 5.5.0"
|
||||
gem "csv"
|
||||
gem "damerau-levenshtein"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
@@ -53,6 +56,10 @@ group :development, :test do
|
||||
|
||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
|
||||
gem "rspec-rails"
|
||||
gem "factory_bot_rails"
|
||||
gem "faker"
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
386
Gemfile.lock
386
Gemfile.lock
@@ -1,29 +1,29 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actioncable (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activestorage (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
actionview (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.0.4)
|
||||
actionview (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -31,35 +31,35 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activestorage (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
activerecord (8.0.4)
|
||||
activemodel (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.4)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -74,22 +74,21 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
annotaterb (4.14.0)
|
||||
ast (2.4.2)
|
||||
autoprefixer-rails (10.4.19.0)
|
||||
execjs (~> 2)
|
||||
base64 (0.2.0)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
bootstrap (5.3.3)
|
||||
autoprefixer-rails (>= 9.1.0)
|
||||
bootstrap (5.3.5)
|
||||
popper_js (>= 2.11.8, < 3)
|
||||
brakeman (7.0.0)
|
||||
brakeman (7.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -102,79 +101,98 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
connection_pool (2.5.4)
|
||||
crass (1.0.6)
|
||||
csv (3.3.5)
|
||||
damerau-levenshtein (1.3.3)
|
||||
dartsass-rails (0.5.1)
|
||||
railties (>= 6.0.0)
|
||||
sass-embedded (~> 1.63)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
dotenv (3.1.7)
|
||||
drb (2.2.1)
|
||||
ed25519 (1.3.0)
|
||||
diff-lcs (1.6.2)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
execjs (2.10.0)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
factory_bot (6.5.6)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
friendly_id (5.5.1)
|
||||
activerecord (>= 4.0.0)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.30.0)
|
||||
google-protobuf (4.33.0)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.30.0-aarch64-linux)
|
||||
google-protobuf (4.33.0-aarch64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.30.0-x86_64-linux)
|
||||
google-protobuf (4.33.0-aarch64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-x86_64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-x86_64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.1.0)
|
||||
importmap-rails (2.2.2)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.13.0)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
json (2.10.2)
|
||||
kamal (2.5.3)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.2)
|
||||
kamal (2.8.2)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
bcrypt_pbkdf (~> 1.0)
|
||||
concurrent-ruby (~> 1.2)
|
||||
dotenv (~> 3.1)
|
||||
ed25519 (~> 1.2)
|
||||
ed25519 (~> 1.4)
|
||||
net-ssh (~> 7.3)
|
||||
sshkit (>= 1.23.0, < 2.0)
|
||||
thor (~> 1.3)
|
||||
zeitwerk (>= 2.6.18, < 3.0)
|
||||
language_server-protocol (3.17.0.4)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.6.6)
|
||||
loofah (2.24.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
minitest (5.26.0)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.6)
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -189,88 +207,108 @@ GEM
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.3-aarch64-linux-gnu)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-aarch64-linux-musl)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm-linux-gnu)
|
||||
nokogiri (1.18.10-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm-linux-musl)
|
||||
nokogiri (1.18.10-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-gnu)
|
||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-musl)
|
||||
nokogiri (1.18.10-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.1)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
popper_js (2.11.8)
|
||||
pp (0.6.2)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
propshaft (1.1.0)
|
||||
prism (1.6.0)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.3)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
public_suffix (6.0.2)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.12)
|
||||
rack-session (2.1.0)
|
||||
rack (3.2.3)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.0.4)
|
||||
actioncable (= 8.0.4)
|
||||
actionmailbox (= 8.0.4)
|
||||
actionmailer (= 8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
actiontext (= 8.0.4)
|
||||
actionview (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activemodel (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activestorage (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
railties (= 8.0.4)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rdoc (6.12.0)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.1)
|
||||
rubocop (1.73.2)
|
||||
rexml (3.4.4)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.6)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.2)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.81.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -278,71 +316,72 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.1)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-performance (1.24.0)
|
||||
rubocop-ast (1.47.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.30.3)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.33.4)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (2.4.1)
|
||||
sass-embedded (1.85.1-aarch64-linux-gnu)
|
||||
google-protobuf (~> 4.29)
|
||||
sass-embedded (1.85.1-aarch64-linux-musl)
|
||||
google-protobuf (~> 4.29)
|
||||
sass-embedded (1.85.1-arm-linux-gnueabihf)
|
||||
google-protobuf (~> 4.29)
|
||||
sass-embedded (1.85.1-arm-linux-musleabihf)
|
||||
google-protobuf (~> 4.29)
|
||||
sass-embedded (1.85.1-x86_64-linux-gnu)
|
||||
google-protobuf (~> 4.29)
|
||||
sass-embedded (1.85.1-x86_64-linux-musl)
|
||||
google-protobuf (~> 4.29)
|
||||
rubyzip (3.2.1)
|
||||
sass-embedded (1.93.2-aarch64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.2-aarch64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.2-arm-linux-gnueabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.2-arm-linux-musleabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.2-x86_64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.2-x86_64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.29.1)
|
||||
selenium-webdriver (4.38.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
slim (5.2.1)
|
||||
temple (~> 0.10.0)
|
||||
tilt (>= 2.1.0)
|
||||
solid_cable (3.0.7)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.7)
|
||||
solid_cache (1.0.8)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.1.3)
|
||||
solid_queue (1.2.3)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11.0)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (~> 1.3.1)
|
||||
sqlite3 (2.6.0-aarch64-linux-gnu)
|
||||
sqlite3 (2.6.0-aarch64-linux-musl)
|
||||
sqlite3 (2.6.0-arm-linux-gnu)
|
||||
sqlite3 (2.6.0-arm-linux-musl)
|
||||
sqlite3 (2.6.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.6.0-x86_64-linux-musl)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||
sqlite3 (2.7.4-arm-linux-gnu)
|
||||
sqlite3 (2.7.4-arm-linux-musl)
|
||||
sqlite3 (2.7.4-x86_64-linux-gnu)
|
||||
sqlite3 (2.7.4-x86_64-linux-musl)
|
||||
sshkit (1.24.0)
|
||||
base64
|
||||
logger
|
||||
@@ -352,23 +391,24 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.5)
|
||||
temple (0.10.3)
|
||||
thor (1.3.2)
|
||||
thruster (0.1.12)
|
||||
thruster (0.1.12-aarch64-linux)
|
||||
thruster (0.1.12-x86_64-linux)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.13)
|
||||
stringio (3.1.7)
|
||||
temple (0.10.4)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
tilt (2.6.1)
|
||||
timeout (0.4.4)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.20)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
@@ -376,13 +416,13 @@ GEM
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.7)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -401,8 +441,13 @@ DEPENDENCIES
|
||||
bootstrap (~> 5.3.3)
|
||||
brakeman
|
||||
capybara
|
||||
csv
|
||||
damerau-levenshtein
|
||||
dartsass-rails
|
||||
debug
|
||||
factory_bot_rails
|
||||
faker
|
||||
friendly_id (~> 5.5.0)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
kamal
|
||||
@@ -410,6 +455,7 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
pundit (~> 2.5)
|
||||
rails (~> 8.0.2)
|
||||
rspec-rails
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
slim
|
||||
|
||||
41
README.md
41
README.md
@@ -3,3 +3,44 @@
|
||||
## Dependencies
|
||||
|
||||
Rails installation guide: https://guides.rubyonrails.org/install_ruby_on_rails.html.
|
||||
|
||||
## Put in production
|
||||
|
||||
### Create a master key
|
||||
|
||||
```
|
||||
bin/rails credentials:edit
|
||||
```
|
||||
|
||||
### Build docker image
|
||||
|
||||
```
|
||||
docker build -t puzzle_scoreboard .
|
||||
```
|
||||
|
||||
### Run docker container
|
||||
|
||||
```
|
||||
sudo docker run -d -p 3000:80 -e RAILS_MASTER_KEY=... -v puzzle-data:/rails/storage --name puzzle_scoreboard puzzle_scoreboard
|
||||
```
|
||||
|
||||
### Access command line
|
||||
|
||||
```
|
||||
sudo docker exec -it puzzle_scoreboard /bin/bash
|
||||
sudo docker exec -it puzzle_scoreboard /rails/bin/rails console
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
Attach to the docker container to see live logs:
|
||||
|
||||
```
|
||||
sudo docker container attach puzzle_scoreboard
|
||||
```
|
||||
|
||||
Or look at the logs saved on the host machine:
|
||||
|
||||
```
|
||||
sudo docker logs puzzle_scoreboard
|
||||
```
|
||||
@@ -2,9 +2,8 @@ class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
include Pundit::Authorization
|
||||
|
||||
before_action :set_title, :set_current_user
|
||||
# TODO: add later
|
||||
# after_action :verify_authorized
|
||||
before_action :set_title, :set_current_user, :set_lang
|
||||
after_action :verify_authorized
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
@@ -14,17 +13,32 @@ class ApplicationController < ActionController::Base
|
||||
private
|
||||
|
||||
def set_title
|
||||
@title = "Public scoreboard"
|
||||
t_action_name = action_name
|
||||
t_action_name = "new" if action_name == "create"
|
||||
t_action_name = "edit" if action_name == "update"
|
||||
@title = I18n.t("#{controller_name}.#{t_action_name}.title")
|
||||
end
|
||||
|
||||
def set_current_user
|
||||
@current_user = current_user
|
||||
end
|
||||
|
||||
def set_lang
|
||||
I18n.locale = @current_user.lang if @current_user
|
||||
end
|
||||
|
||||
def user_not_authorized(exception)
|
||||
policy_name = exception.policy.class.to_s.underscore
|
||||
|
||||
flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
|
||||
redirect_back_or_to(root_path)
|
||||
if current_user
|
||||
flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
|
||||
redirect_back_or_to(root_path)
|
||||
else
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
def not_found
|
||||
render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
37
app/controllers/categories_controller.rb
Normal file
37
app/controllers/categories_controller.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class CategoriesController < ApplicationController
|
||||
before_action :set_contest
|
||||
before_action :set_category, only: %i[ destroy]
|
||||
|
||||
def create
|
||||
authorize @contest
|
||||
|
||||
@category = Category.new(category_params)
|
||||
@category.contest_id = @contest.id
|
||||
if @category.save
|
||||
redirect_to edit_contest_path(@contest), notice: t("categories.new.notice")
|
||||
else
|
||||
redirect_to edit_contest_path(@contest), notice: t("categories.new.error")
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@category.destroy
|
||||
redirect_to edit_contest_path(@contest), notice: t("categories.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contest
|
||||
@contest = Contest.find(params[:contest_id])
|
||||
end
|
||||
|
||||
def set_category
|
||||
@category = Category.find(params[:id])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.expect(category: [ :name ])
|
||||
end
|
||||
end
|
||||
@@ -1,51 +1,87 @@
|
||||
class CompletionsController < ApplicationController
|
||||
include CompletionsConcern
|
||||
|
||||
before_action :set_contest
|
||||
before_action :set_contestant
|
||||
before_action :set_data, only: %i[ create edit new update ]
|
||||
before_action :set_completion, only: %i[ destroy edit update ]
|
||||
|
||||
def edit
|
||||
@title = "Edit completion"
|
||||
authorize @contest
|
||||
|
||||
if @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
authorize @contest
|
||||
|
||||
if @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
|
||||
@completion = Completion.new
|
||||
if params[:contestant_id]
|
||||
@completion.contestant_id = params[:contestant_id]
|
||||
end
|
||||
@title = "New completion"
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @contest
|
||||
|
||||
@completion = Completion.new(completion_params)
|
||||
@completion.contest_id = @contest.id
|
||||
if @completion.save
|
||||
redirect_to contest_path(@contest)
|
||||
extend_completions!(@completion.contestant)
|
||||
if @contestant && !params[:completion].key?(:message_id)
|
||||
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
|
||||
else
|
||||
redirect_to @contest, notice: t("completions.new.notice")
|
||||
end
|
||||
else
|
||||
logger = Logger.new(STDOUT)
|
||||
logger.info(@completion.errors)
|
||||
@title = "New completion"
|
||||
if params[:completion].key?(:message_id)
|
||||
@message = Message.find(params[:completion][:message_id])
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
elsif @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if params[:contestant_id]
|
||||
@completion.contestant_id = params[:contestant_id]
|
||||
end
|
||||
authorize @contest
|
||||
|
||||
@completion.contestant_id = params[:contestant_id] if params[:contestant_id]
|
||||
if @completion.update(completion_params)
|
||||
redirect_to @contest
|
||||
extend_completions!(@completion.contestant)
|
||||
if @contestant
|
||||
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.edit.notice")
|
||||
else
|
||||
redirect_to @contest, notice: t("completions.edit.notice")
|
||||
end
|
||||
else
|
||||
@title = "Edit completion"
|
||||
if @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@completion.destroy
|
||||
if params[:contestant_id]
|
||||
redirect_to contest_contestant_path(@contest, params[:contestant_id])
|
||||
redirect_to contest_contestant_path(@contest, params[:contestant_id]), notice: t("completions.destroy.notice")
|
||||
else
|
||||
redirect_to contest_path(@contest)
|
||||
redirect_to contest_path(@contest), notice: t("completions.destroy.notice")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,8 +91,16 @@ class CompletionsController < ApplicationController
|
||||
@contest = Contest.find(params[:contest_id])
|
||||
end
|
||||
|
||||
def set_contestant
|
||||
if params.key?(:contestant_id)
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
elsif params[:completion].key?(:contestant_id)
|
||||
@contestant = Contestant.find(params[:completion][:contestant_id])
|
||||
end
|
||||
end
|
||||
|
||||
def set_data
|
||||
@contestants = @contest.contestants
|
||||
@contestants = @contest.contestants.order(:name)
|
||||
@puzzles = @contest.puzzles
|
||||
end
|
||||
|
||||
@@ -65,6 +109,6 @@ class CompletionsController < ApplicationController
|
||||
end
|
||||
|
||||
def completion_params
|
||||
params.expect(completion: [ :time_seconds, :contestant_id, :puzzle_id ])
|
||||
params.expect(completion: [ :display_time_from_start, :remaining_pieces, :contestant_id, :message_id, :puzzle_id ])
|
||||
end
|
||||
end
|
||||
|
||||
32
app/controllers/concerns/completions_concern.rb
Normal file
32
app/controllers/concerns/completions_concern.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module CompletionsConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def pad(n)
|
||||
if n > 9
|
||||
return n.to_s
|
||||
end
|
||||
"0" + n.to_s
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
elsif m > 0
|
||||
return m.to_s + ":" + pad(s)
|
||||
end
|
||||
s.to_s
|
||||
end
|
||||
|
||||
def extend_completions!(contestant)
|
||||
current_time_from_start = 0
|
||||
contestant.completions.order(:time_seconds).each do |completion|
|
||||
completion.update(display_time_from_start: display_time(completion.time_seconds),
|
||||
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
|
||||
current_time_from_start = completion.time_seconds
|
||||
end
|
||||
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
|
||||
end
|
||||
end
|
||||
@@ -4,37 +4,129 @@ class ContestantsController < ApplicationController
|
||||
before_action :set_completions, only: %i[edit update ]
|
||||
|
||||
def edit
|
||||
@title = "Contestant"
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
end
|
||||
|
||||
def new
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@contestant = Contestant.new
|
||||
@title = "New contestant"
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @contest
|
||||
|
||||
@contestant = Contestant.new(contestant_params)
|
||||
@contestant.contest_id = @contest.id
|
||||
if @contestant.save
|
||||
redirect_to contest_path(@contest)
|
||||
update_contestant_categories
|
||||
redirect_to contest_path(@contest), notice: t("contestants.new.notice")
|
||||
else
|
||||
@title = "New contestant"
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contest
|
||||
|
||||
if @contestant.update(contestant_params)
|
||||
redirect_to @contest
|
||||
update_contestant_categories
|
||||
redirect_to @contest, notice: t("contestants.edit.notice")
|
||||
else
|
||||
@title = "Contestant"
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@contestant.destroy
|
||||
redirect_to contest_path(@contest)
|
||||
redirect_to contest_path(@contest), notice: t("contestants.destroy.notice")
|
||||
end
|
||||
|
||||
def import
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
|
||||
@csv_import = CsvImport.new
|
||||
end
|
||||
|
||||
def upload_csv
|
||||
authorize @contest
|
||||
|
||||
@csv_import = CsvImport.new(params.require(:csv_import).permit(:file, :separator))
|
||||
if @csv_import.save
|
||||
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :import, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def convert_csv
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@csv_import = CsvImport.find(params[:id])
|
||||
@content = JSON.parse(@csv_import.content)
|
||||
@form = Forms::CsvConversionForm.new
|
||||
end
|
||||
|
||||
def finalize_import
|
||||
authorize @contest
|
||||
|
||||
@csv_import = CsvImport.find(params[:id])
|
||||
@content = JSON.parse(@csv_import.content)
|
||||
all_params = params.require(:forms_csv_conversion_form)
|
||||
@form = Forms::CsvConversionForm.new(params.require(:forms_csv_conversion_form).permit(:email_column, :name_column))
|
||||
if @form.valid?
|
||||
@content.each_with_index do |row, i|
|
||||
if all_params["row_#{i}".to_sym] == "1"
|
||||
if @form.email_column == -1
|
||||
Contestant.create(name: row[@form.name_column], contest: @contest)
|
||||
else
|
||||
logger.info("Email")
|
||||
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
|
||||
end
|
||||
end
|
||||
end
|
||||
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :convert_csv, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def export
|
||||
authorize @contest
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
|
||||
respond_to do |format|
|
||||
format.csv do
|
||||
response.headers["Content-Type"] = "text/csv"
|
||||
response.headers["Content-Disposition"] = "attachment; filename=export.csv"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -47,35 +139,22 @@ class ContestantsController < ApplicationController
|
||||
@contestant = Contestant.find(params[:id])
|
||||
end
|
||||
|
||||
def pad(n)
|
||||
if n > 9
|
||||
return n.to_s
|
||||
end
|
||||
"0" + n.to_s
|
||||
end
|
||||
|
||||
def display_time(seconds)
|
||||
if seconds > 3600
|
||||
hours = seconds / 3600
|
||||
return hours.to_s + ":" + display_time(seconds % 3600)
|
||||
elsif seconds > 60
|
||||
minutes = seconds / 60
|
||||
return pad(minutes) + ":" + display_time(seconds % 60)
|
||||
end
|
||||
pad(seconds)
|
||||
end
|
||||
|
||||
def set_completions
|
||||
@completions = @contestant.completions.order(:time_seconds)
|
||||
current_time_from_start = 0
|
||||
@completions.each do |completion|
|
||||
completion.display_time_from_start = display_time(completion.time_seconds)
|
||||
completion.display_relative_time = display_time(completion.time_seconds - current_time_from_start)
|
||||
current_time_from_start += completion.time_seconds
|
||||
end
|
||||
end
|
||||
|
||||
def contestant_params
|
||||
params.expect(contestant: [ :email, :name ])
|
||||
end
|
||||
|
||||
def update_contestant_categories
|
||||
@contestant.categories.clear
|
||||
@contest.categories.each do |category|
|
||||
logger.info(params[:contestant]["category_#{category.id}".to_sym] == "1")
|
||||
if params[:contestant].key?("category_#{category.id}".to_sym) && params[:contestant]["category_#{category.id}".to_sym] == "1"
|
||||
@contestant.categories << category
|
||||
end
|
||||
end
|
||||
@contestant.save
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,54 +1,173 @@
|
||||
class ContestsController < ApplicationController
|
||||
before_action :set_contest, only: %i[ destroy edit show update ]
|
||||
before_action :offline_setup, only: %i[ offline_new offline_create offline_edit offline_update offline_completed ]
|
||||
skip_before_action :require_authentication, only: %i[ scoreboard offline_new offline_create offline_edit offline_update offline_completed ]
|
||||
|
||||
def index
|
||||
authorize :contest
|
||||
|
||||
@contests = current_user.contests
|
||||
@title = "Welcome #{current_user.username}!"
|
||||
@title = I18n.t("contests.index.title", username: current_user.username)
|
||||
end
|
||||
|
||||
def show
|
||||
@title = @contest.name
|
||||
@contestants = @contest.contestants.order(:name)
|
||||
authorize @contest
|
||||
|
||||
@title = I18n.t("contests.show.title", name: @contest.name)
|
||||
@action_name = t("helpers.buttons.edit")
|
||||
@action_path = edit_contest_path(@contest)
|
||||
@contestants = @contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
filter_contestants_per_category
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
@messages = @contest.messages.order(:time_seconds)
|
||||
set_badges
|
||||
end
|
||||
|
||||
def edit
|
||||
@title = "Edit contest settings"
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
end
|
||||
|
||||
def new
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new
|
||||
@title = "New jigsaw puzzle competition"
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new(contest_params)
|
||||
@contest.user_id = current_user.id
|
||||
if @contest.save
|
||||
redirect_to @contest
|
||||
redirect_to @contest, notice: t("contests.new.notice")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(contest_params)
|
||||
redirect_to @contest
|
||||
redirect_to @contest, notice: t("contests.edit.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@contest.destroy
|
||||
redirect_to contests_path, notice: t("contests.destroy.notice")
|
||||
end
|
||||
|
||||
def scoreboard
|
||||
@contest = Contest.find_by(slug: params[:id])
|
||||
unless @contest && @contest.public
|
||||
skip_authorization
|
||||
not_found and return
|
||||
end
|
||||
authorize @contest
|
||||
|
||||
I18n.locale = @contest.lang
|
||||
|
||||
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
|
||||
@contestants = @contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
filter_contestants_per_category
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
@action_name = t("helpers.buttons.refresh")
|
||||
if params.key?(:category)
|
||||
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
|
||||
else
|
||||
@action_path = "/public/#{@contest.friendly_id}"
|
||||
end
|
||||
render :scoreboard
|
||||
end
|
||||
|
||||
def offline_new
|
||||
authorize @contest
|
||||
@offline = Offline.new
|
||||
end
|
||||
|
||||
def offline_create
|
||||
authorize @contest
|
||||
@offline = Offline.new(offline_start_params)
|
||||
@offline.contest = @contest
|
||||
@offline.start_time = Time.now()
|
||||
if @offline.save
|
||||
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}"
|
||||
else
|
||||
render :offline_new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def offline_edit
|
||||
authorize @contest
|
||||
|
||||
@offline = Offline.find_by_token_for(:token, params[:token])
|
||||
if !@offline
|
||||
not_found and return
|
||||
end
|
||||
|
||||
if @offline.images.length > 1
|
||||
render :offline_already_submitted and return
|
||||
end
|
||||
end
|
||||
|
||||
def offline_update
|
||||
authorize @contest
|
||||
|
||||
@offline = Offline.find_by_token_for(:token, params[:token])
|
||||
if !@offline
|
||||
not_found and return
|
||||
end
|
||||
|
||||
@offline.completed = true
|
||||
@offline.images.attach(params[:offline][:end_image])
|
||||
if @offline.save
|
||||
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}/completed"
|
||||
else
|
||||
render :offline_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def offline_completed
|
||||
authorize @contest
|
||||
|
||||
@offline = Offline.find_by_token_for(:token, params[:token])
|
||||
if !@offline
|
||||
not_found and return
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def offline_setup
|
||||
@contest = Contest.find_by(slug: params[:id])
|
||||
I18n.locale = @contest.lang
|
||||
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
|
||||
end
|
||||
|
||||
def set_badges
|
||||
@badges = []
|
||||
@badges.push("team") if @contest.team
|
||||
@badges.push("registration") if @contest.allow_registration
|
||||
@badges.push(t("helpers.badges.team")) if @contest.team
|
||||
@badges.push(t("helpers.badges.registration")) if @contest.allow_registration
|
||||
end
|
||||
|
||||
def set_contest
|
||||
@@ -56,6 +175,24 @@ class ContestsController < ApplicationController
|
||||
end
|
||||
|
||||
def contest_params
|
||||
params.expect(contest: [ :name, :team, :allow_registration ])
|
||||
params.expect(contest: [ :lang, :name, :offline_form, :public, :team, :allow_registration ])
|
||||
end
|
||||
|
||||
def filter_contestants_per_category
|
||||
if params.key?(:category) && params[:category] != "-1"
|
||||
if params[:category] == "-2"
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
|
||||
else
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def offline_start_params
|
||||
params.expect(offline: [ :name, :images ])
|
||||
end
|
||||
|
||||
def offline_end_params
|
||||
params.expect(offline: [ :completed, :end_image ])
|
||||
end
|
||||
end
|
||||
|
||||
113
app/controllers/messages_controller.rb
Normal file
113
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,113 @@
|
||||
class MessagesController < ApplicationController
|
||||
include CompletionsConcern
|
||||
|
||||
skip_before_action :verify_authenticity_token, only: %i[ create connect cors_preflight_check ]
|
||||
skip_before_action :require_authentication, only: %i[ create connect cors_preflight_check ]
|
||||
|
||||
before_action :cors_set_access_control_headers, only: %i[ create connect cors_preflight_check ]
|
||||
before_action :set_contest, only: %i[ convert destroy ]
|
||||
before_action :set_data, only: %i[ convert ]
|
||||
|
||||
def self.local_prefixes
|
||||
super + [ "completions" ]
|
||||
end
|
||||
|
||||
def cors_set_access_control_headers
|
||||
response.set_header("Access-Control-Allow-Origin", "*")
|
||||
response.set_header("Access-Control-Allow-Credentials", "true")
|
||||
response.set_header("Access-Control-Allow-Methods", "POST")
|
||||
response.set_header("Access-Control-Allow-Headers", "*")
|
||||
response.set_header("Access-Control-Max-Age", "86400")
|
||||
end
|
||||
|
||||
def cors_preflight_check
|
||||
skip_authorization
|
||||
end
|
||||
|
||||
def connect
|
||||
skip_authorization
|
||||
|
||||
if !params.key?(:token)
|
||||
respond_to do |format|
|
||||
format.json { render json: { error: "no token provided" }, status: 400 }
|
||||
end
|
||||
else
|
||||
@contest = Contest.find_by_token_for(:token, params[:token])
|
||||
if @contest
|
||||
respond_to do |format|
|
||||
format.json { render json: { name: @contest.name }, status: 200 }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.json { render json: { error: "invalid token" }, status: 400 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
skip_authorization
|
||||
|
||||
begin
|
||||
@contest = Contest.find_by_token_for(:token, params[:token])
|
||||
@message = Message.new(text: params[:text], author: params[:author], time_seconds: params[:time_seconds],
|
||||
display_time: display_time(params[:time_seconds]), contest: @contest)
|
||||
if @contest && @message.save
|
||||
respond_to do |format|
|
||||
format.json { render json: {}, status: 200 }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.json { render json: { error: "invalid token" }, status: 400 }
|
||||
end
|
||||
end
|
||||
rescue
|
||||
respond_to do |format|
|
||||
format.json { render json: { error: "invalid token" }, status: 400 }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def convert
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
|
||||
@completion = Completion.new()
|
||||
@completion.display_time_from_start = @message.display_time
|
||||
|
||||
render "completions/new"
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@message = Message.find(params[:id])
|
||||
@message.destroy
|
||||
redirect_to contest_path(@contest), notice: t("messages.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contest
|
||||
@contest = Contest.find(params[:contest_id])
|
||||
end
|
||||
|
||||
def set_data
|
||||
@message = Message.find(params[:message_id])
|
||||
@puzzles = @contest.puzzles
|
||||
@contestants = @contest.contestants.order(:name)
|
||||
if @contestants.size > 0
|
||||
@closest_contestant = @contestants.first
|
||||
closest_distance = 10000
|
||||
@contestants.each do |contestant|
|
||||
distance = DamerauLevenshtein.distance(@message.author, contestant.name)
|
||||
if distance < closest_distance
|
||||
closest_distance = distance
|
||||
@closest_contestant = contestant
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,37 +3,51 @@ class PuzzlesController < ApplicationController
|
||||
before_action :set_puzzle, only: %i[ destroy edit update]
|
||||
|
||||
def edit
|
||||
@title = "Edit contest puzzle"
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
end
|
||||
|
||||
def new
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@puzzle = Puzzle.new
|
||||
@title = "New contest puzzle"
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @contest
|
||||
|
||||
@puzzle = Puzzle.new(puzzle_params)
|
||||
@puzzle.contest_id = @contest.id
|
||||
if @puzzle.save
|
||||
redirect_to contest_path(@contest)
|
||||
redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
|
||||
else
|
||||
@title = "New contest puzzle"
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contest
|
||||
|
||||
if @puzzle.update(puzzle_params)
|
||||
redirect_to @contest
|
||||
redirect_to @contest, notice: t("puzzles.edit.notice")
|
||||
else
|
||||
@title = "Edit contest puzzle"
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@puzzle.destroy
|
||||
redirect_to contest_path(@contest)
|
||||
redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -47,6 +61,6 @@ class PuzzlesController < ApplicationController
|
||||
end
|
||||
|
||||
def puzzle_params
|
||||
params.expect(puzzle: [ :brand, :name, :image ])
|
||||
params.expect(puzzle: [ :brand, :name, :image, :pieces ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class SessionsController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
|
||||
before_action :skip_authorization
|
||||
|
||||
def new
|
||||
end
|
||||
@@ -8,7 +9,7 @@ class SessionsController < ApplicationController
|
||||
def create
|
||||
if user = User.authenticate_by(params.permit(:email_address, :password))
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url
|
||||
redirect_to after_authentication_url, notice: t("sessions.new.notice")
|
||||
else
|
||||
redirect_to new_session_path, alert: "Try another email address or password."
|
||||
end
|
||||
|
||||
@@ -4,21 +4,18 @@ class UsersController < ApplicationController
|
||||
def index
|
||||
authorize :user
|
||||
|
||||
@title = "All users"
|
||||
@users = User.all
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @user
|
||||
|
||||
@title = "My settings"
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @user
|
||||
|
||||
if @user.update(user_params)
|
||||
redirect_to contests_path
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -33,7 +30,6 @@ class UsersController < ApplicationController
|
||||
def new
|
||||
authorize :user
|
||||
|
||||
@title = "New user"
|
||||
@user = User.new()
|
||||
end
|
||||
|
||||
@@ -42,9 +38,8 @@ class UsersController < ApplicationController
|
||||
|
||||
@user = User.new(user_params)
|
||||
if @user.save
|
||||
redirect_to users_path
|
||||
redirect_to users_path, notice: t("users.new.notice")
|
||||
else
|
||||
@title = "New user"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -60,6 +55,6 @@ class UsersController < ApplicationController
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.expect(user: [ :username, :email_address, :password ])
|
||||
params.expect(user: [ :username, :email_address, :lang, :password ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +1,20 @@
|
||||
module ContestsHelper
|
||||
def pad(n)
|
||||
if n > 9
|
||||
return n.to_s
|
||||
end
|
||||
"0" + n.to_s
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
elsif m > 0
|
||||
return m.to_s + ":" + pad(s)
|
||||
end
|
||||
"0:" + pad(s)
|
||||
end
|
||||
end
|
||||
|
||||
14
app/lib/forms.rb
Normal file
14
app/lib/forms.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Forms
|
||||
class CsvConversionForm
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
include ActiveModel::Validations::Callbacks
|
||||
include ActiveRecord::Transactions
|
||||
|
||||
attribute :name_column, :integer
|
||||
attribute :email_column, :integer
|
||||
|
||||
validates :name_column, presence: true
|
||||
validates_numericality_of :name_column, greater_than: -1
|
||||
end
|
||||
end
|
||||
3
app/lib/languages.rb
Normal file
3
app/lib/languages.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
module Languages
|
||||
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "Français" } ]
|
||||
end
|
||||
@@ -1,9 +1,8 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: puzzles
|
||||
# Table name: categories
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# brand :string
|
||||
# name :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -11,16 +10,15 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_puzzles_on_contest_id (contest_id)
|
||||
# index_categories_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
require "test_helper"
|
||||
class Category < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_and_belongs_to_many :contestants
|
||||
|
||||
class PuzzleTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
validates :name, presence: true
|
||||
end
|
||||
@@ -2,34 +2,67 @@
|
||||
#
|
||||
# Table name: completions
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer not null
|
||||
# puzzle_id :integer not null
|
||||
# id :integer not null, primary key
|
||||
# display_relative_time :string
|
||||
# display_time_from_start :string
|
||||
# remaining_pieces :integer
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer not null
|
||||
# message_id :integer
|
||||
# puzzle_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_completions_on_contest_id (contest_id)
|
||||
# index_completions_on_contestant_id (contestant_id)
|
||||
# index_completions_on_message_id (message_id)
|
||||
# index_completions_on_puzzle_id (puzzle_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
# message_id (message_id => messages.id)
|
||||
# puzzle_id (puzzle_id => puzzles.id)
|
||||
#
|
||||
class Completion < ApplicationRecord
|
||||
belongs_to :contest
|
||||
belongs_to :contestant
|
||||
belongs_to :puzzle
|
||||
belongs_to :message, optional: true
|
||||
|
||||
attr_accessor :display_time_from_start, :display_relative_time
|
||||
before_save :add_time_seconds
|
||||
before_save :nullify_display_time
|
||||
|
||||
validates :time_seconds, presence: true
|
||||
validates_numericality_of :time_seconds
|
||||
validates :puzzle_id, uniqueness: { score: :contestant }
|
||||
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { remaining_pieces == nil }
|
||||
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
|
||||
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
|
||||
validate :remaining_pieces_is_correct
|
||||
|
||||
def remaining_pieces_is_correct
|
||||
if self.remaining_pieces && self.remaining_pieces > self.puzzle.pieces
|
||||
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
|
||||
end
|
||||
end
|
||||
|
||||
def nullify_display_time
|
||||
if self.remaining_pieces
|
||||
self.display_time_from_start = nil
|
||||
self.display_relative_time = nil
|
||||
end
|
||||
end
|
||||
|
||||
def add_time_seconds
|
||||
arr = display_time_from_start.split(":")
|
||||
if arr.size == 3
|
||||
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
|
||||
elsif arr.size == 2
|
||||
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
|
||||
elsif arr.size == 1
|
||||
self.time_seconds = arr[0].to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# public :boolean default(FALSE)
|
||||
# slug :string
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -12,6 +16,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_contests_on_slug (slug) UNIQUE
|
||||
# index_contests_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
@@ -19,9 +24,20 @@
|
||||
# user_id (user_id => users.id)
|
||||
#
|
||||
class Contest < ApplicationRecord
|
||||
belongs_to :user
|
||||
extend FriendlyId
|
||||
|
||||
belongs_to :user
|
||||
has_many :categories
|
||||
has_many :completions, dependent: :destroy
|
||||
has_many :contestants, dependent: :destroy
|
||||
has_many :puzzles, dependent: :destroy
|
||||
has_many :messages, dependent: :destroy
|
||||
has_many :offlines, dependent: :destroy
|
||||
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
validates :name, presence: true
|
||||
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
||||
|
||||
generates_token_for :token
|
||||
end
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
#
|
||||
# Table name: contestants
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# email :string
|
||||
# name :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
# id :integer not null, primary key
|
||||
# display_time :string
|
||||
# email :string
|
||||
# name :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -19,8 +21,27 @@
|
||||
#
|
||||
class Contestant < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_many :completions, dependent: :destroy
|
||||
has_and_belongs_to_many :categories
|
||||
|
||||
has_many :completions
|
||||
before_validation :initialize_time_seconds_if_empty
|
||||
|
||||
validates :name, presence: true
|
||||
validates :time_seconds, presence: true
|
||||
|
||||
def form_name
|
||||
if email.present?
|
||||
"#{name} - #{email}"
|
||||
else
|
||||
name
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_time_seconds_if_empty
|
||||
if !self.time_seconds
|
||||
self.time_seconds = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
51
app/models/csv_import.rb
Normal file
51
app/models/csv_import.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: csv_imports
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# content :string not null
|
||||
# separator :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class CsvImport < ApplicationRecord
|
||||
enum :separator, { comma: ",", semicolon: ";" }, suffix: true, default: :comma
|
||||
|
||||
has_one_attached :file
|
||||
|
||||
validates :file, presence: true
|
||||
validate :acceptable_csv, on: :create
|
||||
|
||||
before_save :read_csv
|
||||
|
||||
def acceptable_csv
|
||||
return unless file.attached?
|
||||
|
||||
if file.blob.byte_size > 5 * 1024 * 1024
|
||||
errors.add(:file, "this csv file is too large, it must be under 5MB")
|
||||
return
|
||||
end
|
||||
|
||||
if file.content_type != "text/csv"
|
||||
errors.add(:file, :not_a_csv_file)
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
csv = CSV.read(attachment_changes["file"].attachable.path, col_sep: separator_for_database)
|
||||
|
||||
errors.add(:file, :empty) if csv.count < 1 || (csv.count == 1 && csv[0].count == 1 && csv[0][0] == "")
|
||||
rescue CSV::MalformedCSVError => e
|
||||
errors.add(:file, e.message)
|
||||
end
|
||||
end
|
||||
|
||||
def read_csv
|
||||
self.content = JSON.dump(CSV.read(attachment_changes["file"].attachable.path, col_sep: separator_for_database))
|
||||
end
|
||||
|
||||
def options_for_separator
|
||||
keys = self.class.separators.keys
|
||||
keys.map(&:humanize).zip(keys).to_h
|
||||
end
|
||||
end
|
||||
28
app/models/message.rb
Normal file
28
app/models/message.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: messages
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# author :string
|
||||
# display_time :string
|
||||
# text :string not null
|
||||
# time_seconds :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_messages_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
class Message < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_many :completions, dependent: :nullify
|
||||
|
||||
validates :author, presence: true
|
||||
validates :text, presence: true
|
||||
end
|
||||
46
app/models/offline.rb
Normal file
46
app/models/offline.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: offlines
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# completed :boolean
|
||||
# end_time :datetime
|
||||
# name :string not null
|
||||
# start_time :datetime not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
class Offline < ApplicationRecord
|
||||
belongs_to :contest
|
||||
|
||||
has_many_attached :images
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
validates :name, presence: true
|
||||
validates :start_time, presence: true
|
||||
|
||||
validate :end_image_is_present
|
||||
validate :start_image_is_present
|
||||
|
||||
def end_image_is_present
|
||||
if self.completed && self.images.length < 2
|
||||
errors.add(:end_image, I18n.t("activerecord.errors.models.offline.attributes.end_image.blank"))
|
||||
end
|
||||
end
|
||||
|
||||
def start_image_is_present
|
||||
if !self.images.attached?
|
||||
errors.add(:images, I18n.t("activerecord.errors.models.offline.attributes.start_image.blank"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@
|
||||
# id :integer not null, primary key
|
||||
# brand :string
|
||||
# name :string
|
||||
# pieces :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
@@ -20,9 +21,9 @@
|
||||
class Puzzle < ApplicationRecord
|
||||
belongs_to :contest
|
||||
|
||||
has_many :completions
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one_attached :image
|
||||
|
||||
validates :name, presence: true
|
||||
validates :brand, presence: true
|
||||
validates :pieces, presence: true
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
@@ -22,4 +23,6 @@ class User < ApplicationRecord
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
|
||||
validates :username, presence: true, uniqueness: true
|
||||
validates :email_address, presence: true, uniqueness: true
|
||||
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
||||
end
|
||||
|
||||
9
app/policies/completion_policy.rb
Normal file
9
app/policies/completion_policy.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class CompletionPolicy < ContestPolicy
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
end
|
||||
81
app/policies/contest_policy.rb
Normal file
81
app/policies/contest_policy.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
class ContestPolicy < ApplicationPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def new?
|
||||
true
|
||||
end
|
||||
|
||||
def create?
|
||||
true
|
||||
end
|
||||
|
||||
def convert?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def convert_csv?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def edit?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def finalize_import?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def import?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def export?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
|
||||
def offline?
|
||||
record.offline_form
|
||||
end
|
||||
|
||||
def offline_new?
|
||||
offline?
|
||||
end
|
||||
|
||||
def offline_create?
|
||||
offline?
|
||||
end
|
||||
|
||||
def offline_edit?
|
||||
offline?
|
||||
end
|
||||
|
||||
def offline_update?
|
||||
offline?
|
||||
end
|
||||
|
||||
def offline_completed?
|
||||
offline?
|
||||
end
|
||||
|
||||
def scoreboard?
|
||||
record.public
|
||||
end
|
||||
|
||||
def upload_csv?
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
end
|
||||
9
app/policies/contestant_policy.rb
Normal file
9
app/policies/contestant_policy.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class ContestantPolicy < ContestPolicy
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
end
|
||||
9
app/policies/puzzle_policy.rb
Normal file
9
app/policies/puzzle_policy.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class PuzzlePolicy < ContestPolicy
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class UserPolicy < ApplicationPolicy
|
||||
def index
|
||||
def index?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
= form_with model: completion, url: url, method: method do |form|
|
||||
- if @message
|
||||
= form.hidden_field :message_id, value: @message.id
|
||||
.row.mb-3
|
||||
.col
|
||||
h4 = t("messages.singular").capitalize
|
||||
.alert.alert-secondary
|
||||
b
|
||||
= @message.author
|
||||
br
|
||||
= @message.text
|
||||
.row
|
||||
.col
|
||||
h4 = t("completions.singular").capitalize
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :time_seconds, autocomplete: "off", class: "form-control"
|
||||
= form.label :time_seconds, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :contestant_id, @contestants.map { |contestant| [contestant.name, contestant.id] }, {}, class: "form-select"
|
||||
= form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select"
|
||||
= form.label :contestant_id
|
||||
- if @closest_contestant
|
||||
javascript:
|
||||
el = document.querySelector('select[name="completion[contestant_id]"]');
|
||||
el.value = "#{@closest_contestant.id}"
|
||||
- if @puzzles.size > 1
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
|
||||
= form.label :puzzle_id
|
||||
- elsif @puzzles.size == 1
|
||||
= form.hidden_field :puzzle_id, value: @puzzles.first.id
|
||||
- else
|
||||
= form.hidden_field :puzzle_id
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
|
||||
= form.label :puzzle_id
|
||||
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control"
|
||||
= form.label :display_time_from_start, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :remaining_pieces
|
||||
.form-text
|
||||
= t("activerecord.attributes.completion.remaining_pieces_description")
|
||||
.row
|
||||
.col
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, completion: @completion, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/completions/#{@completion.id}"
|
||||
= render "form", contest: @contest, completion: @completion, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/completions/#{@completion.id}"
|
||||
@@ -1 +1 @@
|
||||
= render "form", completion: @completion, submit_text: "Create", method: :post, url: "/contests/#{@contest.id}/completions"
|
||||
= render "form", completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/contests/#{@contest.id}/completions"
|
||||
@@ -13,11 +13,20 @@
|
||||
.form-floating
|
||||
= form.text_field :email, autocomplete: "off", class: "form-control"
|
||||
= form.label :email
|
||||
.form-text Optional. Fill this only if you intend to send emails through this app.
|
||||
.form-text
|
||||
= t("activerecord.attributes.contestant.email_description")
|
||||
- if @contest.categories
|
||||
.row.mt-4
|
||||
.col
|
||||
- @contest.categories.each do |category|
|
||||
.form-check.form-switch
|
||||
= form.check_box "category_#{category.id}".to_sym, class: "form-check-input", checked: @contestant.categories.where(id: category.id).any?
|
||||
= form.label category.name
|
||||
|
||||
.row.mt-4
|
||||
.col
|
||||
- if method == :patch
|
||||
= link_to "Delete", contest_contestant_path(contest, contestant), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
|
||||
= link_to t("helpers.buttons.delete"), contest_contestant_path(contest, contestant), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
|
||||
- if method == :patch
|
||||
@@ -27,27 +36,39 @@
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
- if @contest.puzzles.size > 1
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time_from_start")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_relative_time")
|
||||
- else
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time")
|
||||
th scope="col"
|
||||
| Time since start
|
||||
= t("activerecord.attributes.completion.remaining_pieces")
|
||||
th scope="col"
|
||||
| Relative time
|
||||
th scope="col"
|
||||
| Puzzle
|
||||
= t("activerecord.attributes.completion.puzzle")
|
||||
tbody
|
||||
- @completions.each do |completion|
|
||||
tr scope="row"
|
||||
td
|
||||
= completion.display_time_from_start
|
||||
= completion.display_time_from_start
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= completion.display_relative_time
|
||||
td
|
||||
= completion.display_relative_time
|
||||
= completion.remaining_pieces
|
||||
td
|
||||
| #{completion.puzzle.name} - #{completion.puzzle.brand}
|
||||
- if !completion.puzzle.brand.blank?
|
||||
| #{completion.puzzle.name} - #{completion.puzzle.brand}
|
||||
- else
|
||||
| #{completion.puzzle.name}
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant.id)
|
||||
| Edit
|
||||
= link_to "Delete", contest_completion_path(contest, completion, contestant_id: contestant.id),
|
||||
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant_id: contestant.id)
|
||||
= t("helpers.buttons.edit")
|
||||
= link_to t("helpers.buttons.delete"), contest_completion_path(contest, completion, contestant_id: contestant.id),
|
||||
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
|
||||
.row
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
|
||||
| Add completion
|
||||
= t("helpers.buttons.add")
|
||||
|
||||
40
app/views/contestants/convert_csv.html.slim
Normal file
40
app/views/contestants/convert_csv.html.slim
Normal file
@@ -0,0 +1,40 @@
|
||||
= form_with model: @form, url: "/contests/#{@contest.id}/import/#{@csv_import.id}" do |form|
|
||||
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :name_column, [[t("helpers.none"), -1]] + Array.new(@content[0].count) {|i| [t("helpers.field") + "_#{i}", i] }, {}, class: "form-select"
|
||||
= form.label :name_column
|
||||
= t("contestants.import.name_column")
|
||||
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :email_column, [[t("helpers.none"), -1]] + Array.new(@content[0].count) {|i| [t("helpers.field") + "_#{i}", i] }, {}, class: "form-select"
|
||||
= form.label :email_column
|
||||
= t("contestants.import.email_column")
|
||||
|
||||
.row.mb-3
|
||||
.col
|
||||
= form.submit t("helpers.buttons.confirm"), class: "btn btn-primary"
|
||||
|
||||
.row.g-3
|
||||
.col
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
- @content[0].each_with_index do |_, i|
|
||||
th scope="col"
|
||||
= t("helpers.field") + "_#{i}"
|
||||
th scope="col" style="white-space: nowrap"
|
||||
= t("contestants.import.import_column")
|
||||
tbody
|
||||
- @content.each_with_index do |row, i|
|
||||
tr scope="row"
|
||||
- row.each do |value|
|
||||
td
|
||||
= value
|
||||
td
|
||||
.form-check.form-switch
|
||||
= form.check_box "row_#{i}".to_sym, class: "form-check-input", checked: true
|
||||
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/contestants/#{@contestant.id}"
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/contestants/#{@contestant.id}"
|
||||
4
app/views/contestants/export.csv.slim
Normal file
4
app/views/contestants/export.csv.slim
Normal file
@@ -0,0 +1,4 @@
|
||||
= CSV.generate_line [t("helpers.rank"), t("activerecord.attributes.contestant.name"), t("activerecord.attributes.contestant.display_time"), t("activerecord.attributes.contestant.completions")]
|
||||
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
= CSV.generate_line([index + 1, contestant.name, contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time, contestant.completions.where(remaining_pieces: nil).length])
|
||||
19
app/views/contestants/import.html.slim
Normal file
19
app/views/contestants/import.html.slim
Normal file
@@ -0,0 +1,19 @@
|
||||
= form_with(model: @csv_import, url: contest_import_path(@contest), html: { novalidate: true }) do |form|
|
||||
.row.g-3
|
||||
.col
|
||||
.mb-3
|
||||
.form-floating
|
||||
= form.file_field :file, class: "form-control", accept: ".csv, text/csv"
|
||||
= form.label :file
|
||||
|
||||
.row.g-3
|
||||
.col
|
||||
.mb-3
|
||||
.form-floating
|
||||
= form.select :separator, @csv_import.options_for_separator, {}, class: "form-select"
|
||||
= form.label :separator
|
||||
|
||||
.row.g-3
|
||||
.col
|
||||
.mb-3
|
||||
= form.submit t("helpers.buttons.import"), class: "btn btn-primary"
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: "Add", method: :post, url: "/contests/#{@contest.id}/contestants"
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/contestants"
|
||||
19
app/views/contests/_category_selector.html.slim
Normal file
19
app/views/contests/_category_selector.html.slim
Normal file
@@ -0,0 +1,19 @@
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col
|
||||
select.mb-2 id="categories" style="padding: 5px"
|
||||
option value=-1
|
||||
| Tous.tes les participant.e.s
|
||||
- @contest.categories.each do |category|
|
||||
option value=category.id
|
||||
= category.name
|
||||
javascript:
|
||||
categorySelectEl = document.getElementById('categories');
|
||||
urlParams = new URLSearchParams(window.location.search);
|
||||
selectedCategory = urlParams.get('category');
|
||||
Array.from(categorySelectEl.children).forEach((option) => {
|
||||
if (option.value == selectedCategory) option.selected = true;
|
||||
});
|
||||
categorySelectEl.addEventListener('change', (e) => {
|
||||
window.location.replace(`/public/#{@contest.slug}?category=${e.target.value}`);
|
||||
})
|
||||
@@ -1,22 +1,70 @@
|
||||
h4.mt-5 = t("contests.form.general")
|
||||
= form_with model: contest do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :name, autocomplete: "off", class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
|
||||
= form.label :lang
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :public, class: "form-check-input"
|
||||
= form.label :public
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form, class: "form-check-input"
|
||||
= form.label :offline_form
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :team, class: "form-check-input"
|
||||
= form.label :team
|
||||
| Team contest
|
||||
.form-text For UI display purposes mainly
|
||||
.row.mb-3
|
||||
.form-text = t("activerecord.attributes.contest.team_description")
|
||||
.row.mb-3 style="display: none"
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :allow_registration, class: "form-check-input"
|
||||
= form.label :allow_registration
|
||||
.form-text Generates a shareable registration form for this contest
|
||||
.form-text = t("activerecord.attributes.contest.allow_registration_description")
|
||||
.row
|
||||
.col
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
|
||||
|
||||
h4.mt-5 = t("contests.form.categories")
|
||||
|
||||
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col-6
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("activerecord.attributes.category.name")
|
||||
th
|
||||
= t("activerecord.attributes.category.contestant_count")
|
||||
tbody
|
||||
- @contest.categories.each do |category|
|
||||
tr.align-middle scope="row"
|
||||
td
|
||||
= category.name
|
||||
td
|
||||
= category.contestants.size
|
||||
td
|
||||
= link_to t("helpers.buttons.delete"), contest_category_path(@contest, category), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"
|
||||
.row.mt-3
|
||||
.col-4
|
||||
.form-floating
|
||||
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
= t("activerecord.attributes.category.new")
|
||||
.row.mt-3
|
||||
.col
|
||||
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, submit_text: "Save"
|
||||
= render "form", contest: @contest, submit_text: t("helpers.buttons.save")
|
||||
@@ -1,10 +1,10 @@
|
||||
.row
|
||||
.col
|
||||
h4.mb-3
|
||||
| Manage your contests
|
||||
= t("contests.index.manage_contests")
|
||||
.float-end
|
||||
a.btn.btn-primary.mb-4 href=new_contest_path
|
||||
| Create a new contest
|
||||
= t("contests.index.new_contest")
|
||||
|
||||
.row.row-cols-1.row-cols-md-3.g-4
|
||||
- @contests.each do |contest|
|
||||
@@ -15,9 +15,14 @@
|
||||
.card-header
|
||||
= contest.name
|
||||
.card-body
|
||||
.card-text.mb-2
|
||||
= "#{contest.puzzles.length} #{t('puzzles.singular')}" if contest.puzzles.length <= 1
|
||||
= "#{contest.puzzles.length} #{t('puzzles.plural')}" if contest.puzzles.length > 1
|
||||
= " - #{contest.contestants.length} #{t('contestants.singular')}" if contest.contestants.length <= 1
|
||||
= " - #{contest.contestants.length} #{t('contestants.plural')}" if contest.contestants.length > 1
|
||||
.row
|
||||
- contest.puzzles.each do |puzzle|
|
||||
- if puzzle.image.attached?
|
||||
.col
|
||||
= image_tag puzzle.image, style: "max-height: 80px;"
|
||||
.col
|
||||
- contest.puzzles.each do |puzzle|
|
||||
- if puzzle.image.attached?
|
||||
= image_tag puzzle.image, style: "max-height: 50px;", class: "mb-2 me-2"
|
||||
a.stretched-link href=contest_path(contest)
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, submit_text: "Create"
|
||||
= render "form", contest: @contest, submit_text: t("helpers.buttons.create")
|
||||
2
app/views/contests/offline_already_submitted.html.slim
Normal file
2
app/views/contests/offline_already_submitted.html.slim
Normal file
@@ -0,0 +1,2 @@
|
||||
.mt-3
|
||||
= t("offlines.form.already_submitted")
|
||||
5
app/views/contests/offline_completed.html.slim
Normal file
5
app/views/contests/offline_completed.html.slim
Normal file
@@ -0,0 +1,5 @@
|
||||
h3 = t("offlines.form.completed_message")
|
||||
|
||||
- if @contest.public
|
||||
a.mt-3.btn.btn-success href="/public/#{@contest.slug}"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
40
app/views/contests/offline_edit.html.slim
Normal file
40
app/views/contests/offline_edit.html.slim
Normal file
@@ -0,0 +1,40 @@
|
||||
= form_with model: @offline, url: "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}" do |form|
|
||||
= form.hidden_field :completed
|
||||
h3 = t("offlines.form.start_message")
|
||||
h1 id="display-time" style="font-size: 80px;"
|
||||
javascript:
|
||||
const startTime = #{@offline.start_time.to_i};
|
||||
function updateTime() {
|
||||
const displayTimeEl = document.getElementById('display-time');
|
||||
const s = Math.floor((Date.now() - 1000 * startTime) / 1000);
|
||||
let ss = s % 60;
|
||||
let mm = Math.floor(s / 60) % 60;
|
||||
let hh = Math.floor(s / 3600);
|
||||
displayTimeEl.innerHTML = `${hh < 10 ? `0${hh}` : hh}:${mm < 10 ? `0${mm}` : mm}:${ss < 10 ? `0${ss}` : ss}`;
|
||||
setTimeout(updateTime, 1000);
|
||||
}
|
||||
setTimeout(updateTime, 5);
|
||||
.row.mt-5.mb-3
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("offlines.form.end_image_select")
|
||||
= form.file_field :end_image, accept: "image/*", class: "form-control"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 2 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
document.getElementById('image-error-message').style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setMaxUploadSize();
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.end"), class: "btn btn-primary"
|
||||
30
app/views/contests/offline_new.html.slim
Normal file
30
app/views/contests/offline_new.html.slim
Normal file
@@ -0,0 +1,30 @@
|
||||
= form_with model: @offline, url: "/public/#{@contest.friendly_id}/offline" do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :name, autocomplete: "off", class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("offlines.form.start_image_select")
|
||||
= form.file_field :images, accept: "image/*", class: "form-control"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 2 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
document.getElementById('image-error-message').style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setMaxUploadSize();
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.start"), class: "btn btn-primary"
|
||||
94
app/views/contests/scoreboard.html.slim
Normal file
94
app/views/contests/scoreboard.html.slim
Normal file
@@ -0,0 +1,94 @@
|
||||
css:
|
||||
@media (max-width: 800px) {
|
||||
a.btn { display: none; }
|
||||
.col-5 { display: none; }
|
||||
.col-6 { width: 100% !important; display: block !important; }
|
||||
.small-screen-image { display: block !important; }
|
||||
.container { margin-top: 2rem !important; }
|
||||
}
|
||||
|
||||
- if @contest.puzzles.size <= 1
|
||||
.row.small-screen-image style="display: none"
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
.d-flex.flex-column.justify-content-center.mb-5
|
||||
= image_tag(puzzle.image, style: "max-height: 200px; object-fit: contain") if puzzle.image.attached?
|
||||
.mt-2.fs-6 style="text-align: center"
|
||||
=> "#{puzzle.name} -"
|
||||
= "#{puzzle.brand} #{puzzle.pieces}p"
|
||||
|
||||
= render "category_selector"
|
||||
|
||||
.row
|
||||
.col-6.d-flex.flex-column style="height: calc(100vh - 180px)"
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(remaining_pieces: nil).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 64px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
.col-1
|
||||
.col-5
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
|
||||
.mt-3.fs-4 style="margin-left: 15px"
|
||||
= puzzle.name
|
||||
.fs-6 style="margin-left: 15px"
|
||||
b
|
||||
= "#{puzzle.brand} - #{puzzle.pieces}p"
|
||||
|
||||
- else
|
||||
.d-flex.flex-column style="height: calc(100vh - 180px)"
|
||||
.d-flex.flex-row.justify-content-center.mb-5
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3", style: "max-height: 220px") if puzzle.image.attached?
|
||||
|
||||
= render "category_selector"
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
= t("helpers.rank")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
@@ -1,63 +1,176 @@
|
||||
.row.mb-2
|
||||
.col
|
||||
css:
|
||||
.badges { margin-top: -18px; position: absolute; }
|
||||
.badges
|
||||
- @badges.each do |badge|
|
||||
span.badge.text-bg-info.me-2
|
||||
= badge
|
||||
.float-end
|
||||
a.btn.btn-primary href=edit_contest_path(@contest)
|
||||
| Edit contest
|
||||
- if @badges.size > 0 && false
|
||||
.row.mb-4
|
||||
.col
|
||||
.badges style="margin-top: -18px; position: absolute"
|
||||
- @badges.each do |badge|
|
||||
span.badge.text-bg-info.me-2
|
||||
= badge
|
||||
|
||||
.row.mb-4
|
||||
.col-6
|
||||
javascript:
|
||||
async function copyExtensionUrlToClipboard() {
|
||||
await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
|
||||
alert("#{t("contests.show.url_copied")}");
|
||||
}
|
||||
|
||||
.row.mb-5
|
||||
.col
|
||||
- if @contest.public
|
||||
a.btn.btn-success href="/public/#{@contest.slug}"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
- else
|
||||
a.btn.btn-success.disabled
|
||||
= t("contests.show.public_scoreboard_disabled")
|
||||
- if @contest.offline_form
|
||||
a.ms-3.btn.btn-success href="/public/#{@contest.slug}/offline"
|
||||
= t("contests.show.open_offline_form")
|
||||
- else
|
||||
a.ms-3.btn.btn-success.disabled
|
||||
= t("contests.show.offline_form_disabled")
|
||||
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
|
||||
css:
|
||||
button > svg {
|
||||
margin-right: 2px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
|
||||
</svg>
|
||||
=< t("contests.show.copy_extension_url")
|
||||
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col-6.d-flex.flex-column style="height: 100%"
|
||||
.row
|
||||
.col
|
||||
h4
|
||||
| Puzzles
|
||||
.row.row-cols-1.row-cols-md-3.g-4.mb-4
|
||||
- @puzzles.each do |puzzle|
|
||||
= t("contestants.plural").capitalize
|
||||
a.ms-3.btn.btn-sm.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px"
|
||||
| + #{t("helpers.buttons.add")}
|
||||
a.ms-2.btn.btn-sm.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.import")}
|
||||
a.ms-2.btn.btn-sm.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.export")}
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col
|
||||
css:
|
||||
.card:hover { background-color: lightblue; }
|
||||
.card.h-100
|
||||
.card-header
|
||||
= puzzle.name
|
||||
= image_tag puzzle.image if puzzle.image.attached?
|
||||
.card-body
|
||||
p.card-text
|
||||
= puzzle.brand
|
||||
a.stretched-link href=edit_contest_puzzle_path(@contest, puzzle)
|
||||
.row
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_puzzle_path(@contest)
|
||||
| Add puzzle
|
||||
.col-6
|
||||
select.mt-2.mb-2 id="categories" style="padding: 5px"
|
||||
option value=-1
|
||||
| Tous.tes les participant.e.s
|
||||
option value=-2
|
||||
| Participant.e.s sans catégorie
|
||||
- @contest.categories.each do |category|
|
||||
option value=category.id
|
||||
= category.name
|
||||
javascript:
|
||||
categorySelectEl = document.getElementById('categories');
|
||||
urlParams = new URLSearchParams(window.location.search);
|
||||
selectedCategory = urlParams.get('category');
|
||||
Array.from(categorySelectEl.children).forEach((option) => {
|
||||
if (option.value == selectedCategory) option.selected = true;
|
||||
});
|
||||
categorySelectEl.addEventListener('change', (e) => {
|
||||
window.location.replace(`#{contest_path(@contest)}?category=${e.target.value}`);
|
||||
})
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
|
||||
= t("helpers.buttons.open")
|
||||
.col-6.d-flex.flex-column style="height: 100%"
|
||||
.row
|
||||
.col
|
||||
h4
|
||||
| Contestants
|
||||
= t("puzzles.plural").capitalize
|
||||
a.ms-3.btn.btn-sm.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
|
||||
| + #{t("helpers.buttons.add")}
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Name
|
||||
th scope="col"
|
||||
| Completed puzzles
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.image")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.name")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.brand")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.pieces")
|
||||
tbody
|
||||
- @contestants.each do |contestant|
|
||||
tr scope="row"
|
||||
- @puzzles.each do |puzzle|
|
||||
tr.align-middle scope="row"
|
||||
td
|
||||
= contestant.name
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 48px;") if puzzle.image.attached?
|
||||
td
|
||||
= contestant.completions.length
|
||||
= puzzle.name
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
|
||||
| Open
|
||||
a.btn.btn-sm.btn-secondary.ms-2 href=new_contest_completion_path(@contest, contestant_id: contestant.id)
|
||||
| Add completion
|
||||
.row.mt-4
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_contestant_path(@contest)
|
||||
| Add contestant
|
||||
= puzzle.brand
|
||||
td
|
||||
= puzzle.pieces
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
|
||||
= t("helpers.buttons.edit")
|
||||
- if @messages
|
||||
.row.mt-5
|
||||
.col
|
||||
h4 = t("messages.plural").capitalize
|
||||
- if @puzzles.size == 0
|
||||
.row
|
||||
.col.alert.alert-danger
|
||||
= t("messages.warning")
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col" style="white-space: nowrap"
|
||||
= t("activerecord.attributes.message.processed")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.message.time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.message.author")
|
||||
th.w-25 scope="col"
|
||||
= t("activerecord.attributes.message.text")
|
||||
th.w-25 scope="col"
|
||||
tbody
|
||||
- @messages.each do |message|
|
||||
tr.align-middle scope="row"
|
||||
td style="text-align: center"
|
||||
- if message.completions.size > 0
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
|
||||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
|
||||
</svg>
|
||||
td
|
||||
= message.display_time
|
||||
td
|
||||
= message.author
|
||||
td
|
||||
= message.text
|
||||
td
|
||||
.d-inline-flex
|
||||
- if @puzzles.size > 0
|
||||
a.btn.btn-sm.btn-secondary href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
|
||||
= t("helpers.buttons.add_completion")
|
||||
- else
|
||||
a.btn.btn-sm.btn-secondary.disabled href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
|
||||
= t("helpers.buttons.add_completion")
|
||||
= link_to "x", contest_message_path(@contest, message), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"
|
||||
|
||||
@@ -8,14 +8,45 @@ html
|
||||
.float-end style="margin-top: -8px;"
|
||||
nav.navbar.bg-body-primary
|
||||
- if @current_user.admin
|
||||
a.navbar-brand href=users_path
|
||||
| Users
|
||||
a.navbar-brand href=contests_path
|
||||
| Home
|
||||
a.navbar-brand href=user_path(@current_user)
|
||||
| Settings
|
||||
= button_to "Log out", session_path, method: :delete
|
||||
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
|
||||
= t("nav.users")
|
||||
a.navbar-brand href=contests_path class="btn btn-light" style="margin-right: 0"
|
||||
= t("nav.home")
|
||||
a.navbar-brand href=user_path(@current_user) class="btn btn-light"
|
||||
= t("nav.settings")
|
||||
= button_to t("nav.log_out"), session_path, method: :delete, class: "btn btn-danger"
|
||||
|
||||
h1.mb-4 = @title
|
||||
css:
|
||||
.toast {
|
||||
opacity: 0;
|
||||
animation: fadeInAndOut 6s linear;
|
||||
}
|
||||
@keyframes fadeInAndOut {
|
||||
0%, 5%, 100% { opacity: 0 }
|
||||
7%, 85% { opacity: 1 }
|
||||
}
|
||||
javascript:
|
||||
function closeToast(event) {
|
||||
event.target.parentElement.parentElement.style.display = 'none';
|
||||
}
|
||||
|
||||
.toast-container.position-fixed.p-3 style="right: 30px; top: 85px"
|
||||
- flash.each do |type, msg|
|
||||
.toast role="alert" aria-live="assertive" aria-atomic="true" style="display: block"
|
||||
.toast-header
|
||||
strong.me-auto
|
||||
i.bi-bell-fill.fs-6.text-primary
|
||||
=< type.humanize
|
||||
small.text-body-secondary
|
||||
| Just now
|
||||
button.btn-close type="button" data-bs-dismiss="toast" aria-label="Close" onclick="closeToast(event)"
|
||||
.toast-body
|
||||
= msg
|
||||
|
||||
h1.mb-4
|
||||
= @title
|
||||
- if @action_path
|
||||
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
|
||||
= @action_name
|
||||
|
||||
= yield
|
||||
@@ -1,4 +1,10 @@
|
||||
= form_with model: puzzle, url: url, method: method do |form|
|
||||
.row.mb-3
|
||||
.col.alert.alert-warning
|
||||
= t("puzzles.form.fake_data_recommendation")
|
||||
.row.mb-3
|
||||
.col
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px") if puzzle.image.attached?
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
@@ -11,10 +17,32 @@
|
||||
= form.label :brand, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-text Select an image
|
||||
.form-floating
|
||||
= form.number_field :pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :pieces, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("puzzles.image_select")
|
||||
= form.file_field :image, accept: "image/*", class: "form-control"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 2 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
document.getElementById('image-error-message').style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setMaxUploadSize();
|
||||
.row.mt-4
|
||||
.col
|
||||
- if method == :patch
|
||||
= link_to "Delete", contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
|
||||
= link_to t("helpers.buttons.delete"), contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, puzzle: @puzzle, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/puzzles/#{@puzzle.id}"
|
||||
= render "form", contest: @contest, puzzle: @puzzle, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/puzzles/#{@puzzle.id}"
|
||||
@@ -1 +1 @@
|
||||
= render "form", contest: @contest, puzzle: @puzzle, submit_text: "Add", method: :post, url: "/contests/#{@contest.id}/puzzles"
|
||||
= render "form", contest: @contest, puzzle: @puzzle, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/puzzles"
|
||||
@@ -1,11 +0,0 @@
|
||||
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
|
||||
<%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
|
||||
|
||||
<%= form_with url: session_path do |form| %>
|
||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
|
||||
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %><br>
|
||||
<%= form.submit "Sign in" %>
|
||||
<% end %>
|
||||
<br>
|
||||
|
||||
<%= link_to "Forgot password?", new_password_path %>
|
||||
15
app/views/sessions/new.html.slim
Normal file
15
app/views/sessions/new.html.slim
Normal file
@@ -0,0 +1,15 @@
|
||||
= form_with url: session_path do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.email_field :email_address, autocomplete: "username", required: true, autofocus: true, class: "form-control"
|
||||
= form.label :email_address, class: "required"
|
||||
= t("activerecord.attributes.session.email_address")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "current-password", required: true, autofocus: true, class: "form-control", maxlength: 72
|
||||
= form.label :password, class: "required"
|
||||
= t("activerecord.attributes.session.password")
|
||||
|
||||
= form.submit t("helpers.buttons.sign_in")
|
||||
@@ -1,6 +1,7 @@
|
||||
= form_with model: user, method: method do |form|
|
||||
- if method == :patch
|
||||
h4 General settings
|
||||
h4 = t("users.edit.general_section")
|
||||
|
||||
.row.mb-3
|
||||
.col
|
||||
.input-group
|
||||
@@ -8,26 +9,34 @@
|
||||
.form-floating
|
||||
= form.text_field :username, autocomplete: "off", class: "form-control"
|
||||
= form.label :username, class: "required"
|
||||
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :email_address, autocomplete: "off", class: "form-control"
|
||||
= form.label :email_address, class: "required"
|
||||
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
|
||||
= form.label :lang
|
||||
|
||||
- if method == :post
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||
= form.label :password, class: "required"
|
||||
= form.submit "Save", class: "btn btn-primary"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
|
||||
- if method == :patch
|
||||
h4.mt-5 Change password
|
||||
h4.mt-5 = t("users.edit.password_section")
|
||||
|
||||
= form_with model: user, method: method do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||
= form.label :password, class: "required"
|
||||
| New password
|
||||
= form.submit "Save new password", class: "btn btn-primary"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
@@ -14,6 +14,7 @@ module PuzzleScoreboard
|
||||
# Please, add to the `ignore` list any other `lib` subdirectories that do
|
||||
# not contain `.rb` files, or that should not be reloaded or eager loaded.
|
||||
# Common ones are `templates`, `generators`, or `middleware`, for example.
|
||||
config.autoload_paths << Rails.root.join("lib")
|
||||
config.autoload_lib(ignore: %w[assets tasks])
|
||||
|
||||
# Configuration for the application, engines, and railties goes here.
|
||||
@@ -23,5 +24,8 @@ module PuzzleScoreboard
|
||||
#
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
config.i18n.default_locale = :en
|
||||
config.i18n.available_locales = [ :en, :fr ]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
ActionView::Base.field_error_proc = proc do |html_tag, instance|
|
||||
if html_tag.include? "<label"
|
||||
if (html_tag.include? "<label") || (html_tag.include? "<input accept=\"image")
|
||||
appended_html = ""
|
||||
if instance.error_message.is_a?(Array)
|
||||
appended_html = "<div class='error-message form-text'>#{instance.error_message.map(&:humanize).uniq.join(", ")}</div>"
|
||||
appended_html = "<div class='error-message form-text'>#{instance.error_message.map(&:capitalize).uniq.join(", ")}</div>"
|
||||
else
|
||||
appended_html = "<div class='error-message form-text'>#{instance.error_message.humanize}</div>"
|
||||
appended_html = "<div class='error-message form-text'>#{instance.error_message.capitalize}</div>"
|
||||
end
|
||||
html_tag + appended_html.html_safe
|
||||
else
|
||||
|
||||
107
config/initializers/friendly_id.rb
Normal file
107
config/initializers/friendly_id.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
# FriendlyId Global Configuration
|
||||
#
|
||||
# Use this to set up shared configuration options for your entire application.
|
||||
# Any of the configuration options shown here can also be applied to single
|
||||
# models by passing arguments to the `friendly_id` class method or defining
|
||||
# methods in your model.
|
||||
#
|
||||
# To learn more, check out the guide:
|
||||
#
|
||||
# http://norman.github.io/friendly_id/file.Guide.html
|
||||
|
||||
FriendlyId.defaults do |config|
|
||||
# ## Reserved Words
|
||||
#
|
||||
# Some words could conflict with Rails's routes when used as slugs, or are
|
||||
# undesirable to allow as slugs. Edit this list as needed for your app.
|
||||
config.use :reserved
|
||||
|
||||
config.reserved_words = %w[new edit index session login logout users admin
|
||||
stylesheets assets javascripts images]
|
||||
|
||||
# This adds an option to treat reserved words as conflicts rather than exceptions.
|
||||
# When there is no good candidate, a UUID will be appended, matching the existing
|
||||
# conflict behavior.
|
||||
|
||||
config.treat_reserved_as_conflict = true
|
||||
|
||||
# ## Friendly Finders
|
||||
#
|
||||
# Uncomment this to use friendly finders in all models. By default, if
|
||||
# you wish to find a record by its friendly id, you must do:
|
||||
#
|
||||
# MyModel.friendly.find('foo')
|
||||
#
|
||||
# If you uncomment this, you can do:
|
||||
#
|
||||
# MyModel.find('foo')
|
||||
#
|
||||
# This is significantly more convenient but may not be appropriate for
|
||||
# all applications, so you must explicitly opt-in to this behavior. You can
|
||||
# always also configure it on a per-model basis if you prefer.
|
||||
#
|
||||
# Something else to consider is that using the :finders addon boosts
|
||||
# performance because it will avoid Rails-internal code that makes runtime
|
||||
# calls to `Module.extend`.
|
||||
|
||||
config.use :finders
|
||||
|
||||
# ## Slugs
|
||||
#
|
||||
# Most applications will use the :slugged module everywhere. If you wish
|
||||
# to do so, uncomment the following line.
|
||||
|
||||
config.use :slugged
|
||||
|
||||
# By default, FriendlyId's :slugged addon expects the slug column to be named
|
||||
# 'slug', but you can change it if you wish.
|
||||
#
|
||||
# config.slug_column = 'slug'
|
||||
#
|
||||
# By default, slug has no size limit, but you can change it if you wish.
|
||||
#
|
||||
# config.slug_limit = 255
|
||||
#
|
||||
# When FriendlyId can not generate a unique ID from your base method, it appends
|
||||
# a UUID, separated by a single dash. You can configure the character used as the
|
||||
# separator. If you're upgrading from FriendlyId 4, you may wish to replace this
|
||||
# with two dashes.
|
||||
#
|
||||
# config.sequence_separator = '-'
|
||||
#
|
||||
# Note that you must use the :slugged addon **prior** to the line which
|
||||
# configures the sequence separator, or else FriendlyId will raise an undefined
|
||||
# method error.
|
||||
#
|
||||
# ## Tips and Tricks
|
||||
#
|
||||
# ### Controlling when slugs are generated
|
||||
#
|
||||
# As of FriendlyId 5.0, new slugs are generated only when the slug field is
|
||||
# nil, but if you're using a column as your base method can change this
|
||||
# behavior by overriding the `should_generate_new_friendly_id?` method that
|
||||
# FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave
|
||||
# more like 4.0.
|
||||
# Note: Use(include) Slugged module in the config if using the anonymous module.
|
||||
# If you have `friendly_id :name, use: slugged` in the model, Slugged module
|
||||
# is included after the anonymous module defined in the initializer, so it
|
||||
# overrides the `should_generate_new_friendly_id?` method from the anonymous module.
|
||||
#
|
||||
# config.use :slugged
|
||||
# config.use Module.new {
|
||||
# def should_generate_new_friendly_id?
|
||||
# slug.blank? || <your_column_name_here>_changed?
|
||||
# end
|
||||
# }
|
||||
#
|
||||
# FriendlyId uses Rails's `parameterize` method to generate slugs, but for
|
||||
# languages that don't use the Roman alphabet, that's not usually sufficient.
|
||||
# Here we use the Babosa library to transliterate Russian Cyrillic slugs to
|
||||
# ASCII. If you use this, don't forget to add "babosa" to your Gemfile.
|
||||
#
|
||||
# config.use Module.new {
|
||||
# def normalize_friendly_id(text)
|
||||
# text.to_slug.normalize! :transliterations => [:russian, :latin]
|
||||
# end
|
||||
# }
|
||||
end
|
||||
@@ -28,4 +28,256 @@
|
||||
# enabled: "ON"
|
||||
|
||||
en:
|
||||
hello: "Hello world"
|
||||
activemodel:
|
||||
errors:
|
||||
models:
|
||||
forms/csv_conversion_form:
|
||||
attributes:
|
||||
name_column:
|
||||
blank: "Participant names are required"
|
||||
greater_than: "Participant names are required"
|
||||
activerecord:
|
||||
attributes:
|
||||
category:
|
||||
contestant_count: Contestants count
|
||||
new: New category
|
||||
name: Category
|
||||
completion:
|
||||
contestant: Participant
|
||||
display_time: Time
|
||||
display_time_from_start: Time since start
|
||||
display_relative_time: Time for this puzzle
|
||||
puzzle: Puzzle
|
||||
remaining_pieces: Remaining pieces
|
||||
remaining_pieces_description: When this field is filled, the above time will not be taken into account
|
||||
contest:
|
||||
lang: Language for the public scoreboard
|
||||
name: Name
|
||||
offline_form: Enable the offline participation form
|
||||
offline_form_description: Offline participants will have to fill the form by providing an image taken of the undone puzzle, and validate their finish time with an upload of an image of the completed puzzle
|
||||
public: Enable the public scoreboard
|
||||
team: Team contest
|
||||
team_description: For UI display purposes mainly
|
||||
allow_registration: Allow registration
|
||||
allow_registration_description: Generates a shareable registration form for this contest
|
||||
contestant:
|
||||
completions: completions
|
||||
display_time: Time
|
||||
email: Email
|
||||
name: Name
|
||||
email_description: Optional. Used for sending emails through this app, or for identifying participants whose gmeet handle doesn't match their registered name.
|
||||
csv_import:
|
||||
file: File
|
||||
separator: Separator
|
||||
message:
|
||||
author: Author
|
||||
processed: Processed?
|
||||
text: Content
|
||||
time: Time
|
||||
offline:
|
||||
name: Your name
|
||||
puzzle:
|
||||
brand: Brand
|
||||
image: Image
|
||||
name: Name
|
||||
pieces: Number of pieces
|
||||
session:
|
||||
email_address: Email address
|
||||
password: Password
|
||||
user:
|
||||
username: Username
|
||||
email_address: Email address
|
||||
lang: Language
|
||||
password: New password
|
||||
errors:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
contestant_id:
|
||||
taken: "This contestant has already completed the puzzle"
|
||||
display_time_from_start:
|
||||
blank: Mandatory
|
||||
invalid: "Allowed formats: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
|
||||
puzzle_id:
|
||||
taken: "This contestant has already completed this puzzle"
|
||||
contest:
|
||||
attributes:
|
||||
name:
|
||||
blank: The contest name cannot be empty
|
||||
contestant:
|
||||
attributes:
|
||||
name:
|
||||
blank: The participant name cannot be empty
|
||||
csv_import:
|
||||
attributes:
|
||||
file:
|
||||
blank: "No file selected"
|
||||
empty: "This file is empty"
|
||||
not_a_csv_file: "it must be a CSV file"
|
||||
offline:
|
||||
attributes:
|
||||
end_image:
|
||||
blank: Please upload an image
|
||||
name:
|
||||
blank: Please enter a name for your participation
|
||||
start_image:
|
||||
blank: Please upload an image
|
||||
puzzle:
|
||||
attributes:
|
||||
name:
|
||||
blank: The puzzle name cannot be empty
|
||||
pieces:
|
||||
blank: It's mandatory to provide the number of pieces
|
||||
user:
|
||||
attributes:
|
||||
email_address:
|
||||
blank: Your email cannot be empty
|
||||
username:
|
||||
blank: Your username cannot be empty
|
||||
categories:
|
||||
destroy:
|
||||
notice: Category deleted
|
||||
new:
|
||||
error: The category name can't be empty
|
||||
notice: Category added
|
||||
completions:
|
||||
destroy:
|
||||
notice: Completion deleted
|
||||
edit:
|
||||
notice: Completion updated
|
||||
title: Edit completion
|
||||
new:
|
||||
notice: Completion added
|
||||
title: New completion
|
||||
singular: completion
|
||||
contests:
|
||||
destroy:
|
||||
notice: Contest deleted
|
||||
edit:
|
||||
notice: Contest updated
|
||||
title: Edit contest settings
|
||||
form:
|
||||
categories: Participant categories
|
||||
general: General parameters
|
||||
index:
|
||||
title: Welcome %{username}!
|
||||
manage_contests: Manage my contests
|
||||
new_contest: Create a new contest
|
||||
new:
|
||||
notice: Contest added
|
||||
title: New jigsaw puzzle contest
|
||||
scoreboard:
|
||||
refresh: Activate auto-refresh (every 5s)
|
||||
title: "%{name}"
|
||||
show:
|
||||
title: "%{name}"
|
||||
add_participant: Add participant
|
||||
add_puzzle: Add puzzle
|
||||
copy_extension_url: Copy the URL for connecting from the browser extension
|
||||
open_offline_form: Open offline form
|
||||
open_public_scoreboard: Open public scoreboard
|
||||
offline_form_disabled: The offline form is disabled
|
||||
public_scoreboard_disabled: The public scoreboard is disabled
|
||||
url_copied: URL copied to the clipboard
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Import participants
|
||||
destroy:
|
||||
notice: Participant deleted
|
||||
edit:
|
||||
notice: Participant updated
|
||||
title: Participant
|
||||
team_title: Teams
|
||||
finalize_import:
|
||||
title: Import participants
|
||||
import:
|
||||
email_column: Participant email
|
||||
import_column: Import?
|
||||
name_column: Participant name
|
||||
notice: Participants imported
|
||||
title: Import participants
|
||||
new:
|
||||
notice: Participant added
|
||||
title: New participant
|
||||
team_title: New team
|
||||
singular: participant
|
||||
plural: participants
|
||||
teams:
|
||||
singular: team
|
||||
plural: teams
|
||||
upload_csv:
|
||||
title: Import participants
|
||||
helpers:
|
||||
badges:
|
||||
registration: "registration"
|
||||
team: "team"
|
||||
buttons:
|
||||
add: "Add"
|
||||
add_completion: "Add completion"
|
||||
back: "⬅ Back to the contest"
|
||||
back_to_contestant: "⬅ Back to the participant"
|
||||
confirm: "Confirm"
|
||||
create: "Create"
|
||||
delete: "Delete"
|
||||
edit: "Edit"
|
||||
end: Click here to submit your completion
|
||||
export: Export
|
||||
import: CSV Import
|
||||
open: Open
|
||||
refresh: Refresh
|
||||
sign_in: Sign in
|
||||
save: Save
|
||||
start: Click here to start your participation
|
||||
field: Field
|
||||
none: No field selected
|
||||
rank: Rank
|
||||
messages:
|
||||
convert:
|
||||
title: New completion
|
||||
destroy:
|
||||
notice: Message deleted
|
||||
plural: "messages"
|
||||
singular: "message"
|
||||
warning: "You first need to add a puzzle before converting messages to completions."
|
||||
nav:
|
||||
users: Users
|
||||
home: My contests
|
||||
settings: Settings
|
||||
log_out: Log out
|
||||
offlines:
|
||||
form:
|
||||
already_submitted: You have already completed the puzzle
|
||||
completed_message: Thanks for your participation!
|
||||
end_image_select: Take a photo of your completed puzzle
|
||||
start_image_select: Take a photo of the puzzle with the provided code written on a paper before starting it
|
||||
start_message: Let's go!
|
||||
puzzles:
|
||||
destroy:
|
||||
notice: Puzzle deleted
|
||||
edit:
|
||||
notice: Puzzle updated
|
||||
title: Edit contest puzzle
|
||||
form:
|
||||
fake_data_recommendation: It is recommended to first enter a fake name and image, and to use the real ones only once the contest starts.
|
||||
file_too_big: File too big! Maximum allowed size is 2M
|
||||
image_select: Select an image
|
||||
new:
|
||||
notice: Puzzle added
|
||||
title: New contest puzzle
|
||||
singular: puzzle
|
||||
plural: puzzles
|
||||
sessions:
|
||||
new:
|
||||
notice: Login successful
|
||||
title: "Login to the Public Scoreboard app"
|
||||
users:
|
||||
edit:
|
||||
notice: Settings updated
|
||||
title: "My settings"
|
||||
general_section: "General settings"
|
||||
password_section: "Change password"
|
||||
index:
|
||||
title: "All users"
|
||||
new:
|
||||
notice: User created
|
||||
title: "New user"
|
||||
|
||||
254
config/locales/fr.yml
Normal file
254
config/locales/fr.yml
Normal file
@@ -0,0 +1,254 @@
|
||||
fr:
|
||||
activemodel:
|
||||
errors:
|
||||
models:
|
||||
forms/csv_conversion_form:
|
||||
attributes:
|
||||
name_column:
|
||||
blank: "Choisir une colonne pour les noms des participant.e.s est nécessaire"
|
||||
greater_than: "Choisir une colonne pour les noms des participant.e.s est nécessaire"
|
||||
activerecord:
|
||||
attributes:
|
||||
category:
|
||||
contestant_count: Nombre de participant.e.s
|
||||
new: Nouvelle catégorie
|
||||
name: Catégorie
|
||||
completion:
|
||||
contestant_id: Participant.e
|
||||
display_time: Temps
|
||||
display_time_from_start: Temps depuis le début
|
||||
display_relative_time: Temps pour ce puzzle
|
||||
puzzle: Puzzle
|
||||
remaining_pieces: Nombre de pièces restantes
|
||||
remaining_pieces_description: Si ce champ est rempli, le temps ci-dessus ne sera pas pris en compte
|
||||
contest:
|
||||
lang: Langue pour le classement public
|
||||
name: Nom
|
||||
offline_form: Activer le formulaire de participation hors-ligne
|
||||
offline_form_description: Les participant.e.s hors-ligne pourront participer en prenant une photo du puzzle non fait, puis valider leur temps avec une photo du puzzle une fois complété
|
||||
public: Activer le classement public
|
||||
team: Concours par équipes
|
||||
team_description: Principalement pour des raisons d'affichage
|
||||
allow_registration: Autoriser l'inscription via l'interface
|
||||
allow_registration_description: Génère un formulaire d'inscription pour ce concours
|
||||
contestant:
|
||||
completions: Complétions
|
||||
display_time: Temps
|
||||
email: Email
|
||||
name: Nom
|
||||
email_description: Optionnel. Utile pour envoyer des emails aux participant.e.s depuis cette app, ou pour reconnaître les pseudos gmeet quand ils ne correspondent pas au nom préalablement entré.
|
||||
csv_import:
|
||||
file: Fichier
|
||||
separator: Délimiteur
|
||||
message:
|
||||
author: Auteur.ice
|
||||
processed: Traité ?
|
||||
text: Contenu
|
||||
time: Temps
|
||||
offline:
|
||||
name: Ton nom ou pseudo
|
||||
puzzle:
|
||||
brand: Marque
|
||||
image: Image
|
||||
name: Nom
|
||||
pieces: Nombre de pièces
|
||||
session:
|
||||
email_address: Adresse email
|
||||
password: Mot de passe
|
||||
user:
|
||||
username: Nom d'utilisateur.ice
|
||||
email_address: Adresse email
|
||||
lang: Langue de l'interface
|
||||
password: Nouveau mot de passe
|
||||
errors:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
contestant_id:
|
||||
taken: "Ce.tte participant.e a déjà complété le puzzle"
|
||||
display_time_from_start:
|
||||
blank: Obligatoire
|
||||
invalid: "Formats autorisés: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
|
||||
puzzle_id:
|
||||
taken: "Ce.tte participant.e a déjà complété ce puzzle"
|
||||
contest:
|
||||
attributes:
|
||||
name:
|
||||
blank: Le nom du concours ne peut pas être vide
|
||||
contestant:
|
||||
attributes:
|
||||
name:
|
||||
blank: Le nom du ou de la participant.e ne peut pas être vide
|
||||
csv_import:
|
||||
attributes:
|
||||
file:
|
||||
blank: "Aucun fichier sélectionné"
|
||||
empty: "Ce fichier est vide"
|
||||
not_a_csv_file: "Le fichier doit être au format CSV"
|
||||
offline:
|
||||
attributes:
|
||||
end_image:
|
||||
blank: Tu dois inclure cette image pour pouvoir valider ton puzzle complété
|
||||
name:
|
||||
blank: Tu dois entrer un nom pour pouvoir participer
|
||||
start_image:
|
||||
blank: Tu dois inclure cette image pour pouvoir participer
|
||||
puzzle:
|
||||
attributes:
|
||||
name:
|
||||
blank: Le nom du puzzle est obligatoire
|
||||
pieces:
|
||||
blank: Il est obligatoire d'indiquer le nombre de pièces
|
||||
user:
|
||||
attributes:
|
||||
email_address:
|
||||
blank: L'email est obligatoire
|
||||
username:
|
||||
blank: Le nom d'utilisateur.ice est obligatoire
|
||||
categories:
|
||||
destroy:
|
||||
notice: Catégorie supprimée
|
||||
new:
|
||||
error: Le nom de la catégorie ne peut pas être vide
|
||||
notice: Catégorie ajoutée
|
||||
completions:
|
||||
destroy:
|
||||
notice: Complétion supprimée
|
||||
edit:
|
||||
notice: Complétion modifiée
|
||||
title: Modifier la complétion
|
||||
new:
|
||||
notice: Complétion ajoutée
|
||||
title: Ajout d'une complétion
|
||||
singular: complétion
|
||||
contests:
|
||||
destroy:
|
||||
notice: Concours supprimé
|
||||
edit:
|
||||
notice: Concours modifié
|
||||
title: Paramètres du concours
|
||||
form:
|
||||
categories: Catégories de participant.e.s
|
||||
general: Paramètres généraux
|
||||
index:
|
||||
title: Bienvenue %{username} !
|
||||
manage_contests: Mes concours de puzzle
|
||||
new_contest: Créer un nouveau concours
|
||||
new:
|
||||
notice: Concours ajouté
|
||||
title: Nouveau concours
|
||||
scoreboard:
|
||||
refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
|
||||
title: "%{name}"
|
||||
show:
|
||||
title: "%{name}"
|
||||
add_participant: Ajouter un.e participant.e
|
||||
add_puzzle: Ajouter un puzzle
|
||||
copy_extension_url: Copier l'URL pour la connexion depuis l'extension web
|
||||
open_offline_form: Ouvrir le formulaire hors-ligne
|
||||
open_public_scoreboard: Ouvrir le classement public
|
||||
offline_form_disabled: Le formulaire hors-ligne n'est pas activé
|
||||
public_scoreboard_disabled: Le classement public n'est pas activé
|
||||
url_copied: L’URL a été copiée dans le presse-papier
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Importer des participant.e.s
|
||||
destroy:
|
||||
notice: Participant.e supprimé.e
|
||||
edit:
|
||||
notice: Participant.e modifié.e
|
||||
title: Participant.e
|
||||
team_title: Équipe
|
||||
finalize_import:
|
||||
title: Importer des participant.e.s
|
||||
import:
|
||||
email_column: Email des participant.e.s
|
||||
import_column: Importer ?
|
||||
name_column: Noms des participant.e.s
|
||||
notice: Participant.e.s importé.e.s
|
||||
title: Importer des participant.e.s
|
||||
new:
|
||||
notice: Participant.e ajouté.e
|
||||
title: Nouveau.elle participant.e
|
||||
team_title: Nouvelle équipe
|
||||
singular: participant.e
|
||||
plural: participant.e.s
|
||||
teams:
|
||||
singular: équipe
|
||||
plural: équipes
|
||||
upload_csv:
|
||||
title: Importer des participant.e.s
|
||||
helpers:
|
||||
badges:
|
||||
registration: "auto-inscription"
|
||||
team: "équipes"
|
||||
buttons:
|
||||
add: "Ajouter"
|
||||
add_completion: "Convertir"
|
||||
back: "⬅ Revenir au concours"
|
||||
back_to_contestant: "⬅ Revenir au/à la participant.e"
|
||||
confirm: "Confirmer"
|
||||
create: "Créer"
|
||||
delete: "Supprimer"
|
||||
edit: "Modifier"
|
||||
end: Clique ici pour valider ta complétion du puzzle
|
||||
export: Exporter
|
||||
import: Importer un CSV
|
||||
open: Détails
|
||||
refresh: Rafraîchir
|
||||
sign_in: Se connecter
|
||||
save: Modifier
|
||||
start: Clique ici pour démarrer ta participation
|
||||
field: Champ
|
||||
none: Aucun champ sélectionné
|
||||
rank: Rang
|
||||
messages:
|
||||
convert:
|
||||
title: Ajout d'une complétion
|
||||
destroy:
|
||||
notice: Message supprimé
|
||||
plural: "messages"
|
||||
singular: "message"
|
||||
warning: "Au moins un puzzle doit être ajouté avant de pouvoir convertir des messages en complétions."
|
||||
nav:
|
||||
users: Utilisateur.ices
|
||||
home: Mes concours
|
||||
settings: Paramètres
|
||||
log_out: Déconnexion
|
||||
offlines:
|
||||
form:
|
||||
already_submitted: Tu as déjà complété ton puzzle
|
||||
completed_message: Merci pour ta participation !
|
||||
end_image_select: Prends une photo du puzzle une fois complété
|
||||
start_image_select: Prends une photo du puzzle avant de le commencer, avec le code donné par l'organisateur.ice écrit sur du papier
|
||||
start_message: C'est parti !
|
||||
puzzles:
|
||||
destroy:
|
||||
notice: Puzzle supprimé
|
||||
edit:
|
||||
notice: Puzzle modifié
|
||||
title: Modifier le puzzle
|
||||
form:
|
||||
fake_data_recommendation: Il est recommendé d'entrer de faux noms et images, et de mettre les vrais uniquement quand le concours démarre.
|
||||
file_too_big: La taille de l'image dépasse la taille maximum autorisée de 2M
|
||||
image_select: Choisis une image
|
||||
new:
|
||||
notice: Puzzle ajouté
|
||||
title: Nouveau puzzle
|
||||
singular: puzzle
|
||||
plural: puzzles
|
||||
sessions:
|
||||
new:
|
||||
notice: Connection réussie
|
||||
title: "Se connecter à l'app Public Scoreboard"
|
||||
users:
|
||||
edit:
|
||||
notice: Paramètres modifiés
|
||||
title: "Mes paramètres"
|
||||
general_section: "Paramètres globaux"
|
||||
password_section: "Modifier mon mot de passe"
|
||||
index:
|
||||
title: "Tous.tes les utilisateur.ices"
|
||||
new:
|
||||
notice: Utilisateur.ice ajouté.e
|
||||
title: "Nouveau.elle utilisateur.ice"
|
||||
@@ -9,11 +9,32 @@ Rails.application.routes.draw do
|
||||
root "contests#index"
|
||||
|
||||
resources :contests do
|
||||
resources :categories, only: [ :create, :destroy ]
|
||||
resources :completions
|
||||
resources :contestants
|
||||
resources :puzzles
|
||||
resources :messages, only: :destroy do
|
||||
get "convert", to: "messages#convert"
|
||||
end
|
||||
get "import", to: "contestants#import"
|
||||
post "import", to: "contestants#upload_csv"
|
||||
get "import/:id", to: "contestants#convert_csv"
|
||||
post "import/:id", to: "contestants#finalize_import"
|
||||
get "export", to: "contestants#export"
|
||||
end
|
||||
resources :passwords, param: :token
|
||||
resource :session
|
||||
resources :users
|
||||
|
||||
options "connect", to: "messages#cors_preflight_check"
|
||||
options "message", to: "messages#cors_preflight_check"
|
||||
post "connect", to: "messages#connect"
|
||||
post "message", to: "messages#create"
|
||||
|
||||
get "public/:id", to: "contests#scoreboard"
|
||||
get "public/:id/offline", to: "contests#offline_new"
|
||||
post "public/:id/offline", to: "contests#offline_create"
|
||||
get "public/:id/offline/:token", to: "contests#offline_edit"
|
||||
patch "public/:id/offline/:token", to: "contests#offline_update"
|
||||
get "public/:id/offline/:token/completed", to: "contests#offline_completed"
|
||||
end
|
||||
|
||||
5
db/migrate/20250322164205_add_slug_to_contest.rb
Normal file
5
db/migrate/20250322164205_add_slug_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddSlugToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :slug, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddDisplayTimesToCompletions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :completions, :display_time_from_start, :string
|
||||
add_column :completions, :display_relative_time, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddDisplayTimeToContestants < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contestants, :display_time, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250326162646_remove_slug_from_contests.rb
Normal file
5
db/migrate/20250326162646_remove_slug_from_contests.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class RemoveSlugFromContests < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_column :contests, :slug, :string
|
||||
end
|
||||
end
|
||||
6
db/migrate/20250326162828_add_slug_to_contests.rb
Normal file
6
db/migrate/20250326162828_add_slug_to_contests.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class AddSlugToContests < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :slug, :string
|
||||
add_index :contests, :slug, unique: true
|
||||
end
|
||||
end
|
||||
21
db/migrate/20250326162920_create_friendly_id_slugs.rb
Normal file
21
db/migrate/20250326162920_create_friendly_id_slugs.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
MIGRATION_CLASS =
|
||||
if ActiveRecord::VERSION::MAJOR >= 5
|
||||
ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
|
||||
else
|
||||
ActiveRecord::Migration
|
||||
end
|
||||
|
||||
class CreateFriendlyIdSlugs < MIGRATION_CLASS
|
||||
def change
|
||||
create_table :friendly_id_slugs do |t|
|
||||
t.string :slug, null: false
|
||||
t.integer :sluggable_id, null: false
|
||||
t.string :sluggable_type, limit: 50
|
||||
t.string :scope
|
||||
t.datetime :created_at
|
||||
end
|
||||
add_index :friendly_id_slugs, [ :sluggable_type, :sluggable_id ]
|
||||
add_index :friendly_id_slugs, [ :slug, :sluggable_type ], length: { slug: 140, sluggable_type: 50 }
|
||||
add_index :friendly_id_slugs, [ :slug, :sluggable_type, :scope ], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250327111835_add_lang_to_user.rb
Normal file
5
db/migrate/20250327111835_add_lang_to_user.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddLangToUser < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :lang, :string, default: 'en'
|
||||
end
|
||||
end
|
||||
11
db/migrate/20250511173749_create_messages.rb
Normal file
11
db/migrate/20250511173749_create_messages.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateMessages < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :messages do |t|
|
||||
t.integer :time_seconds, null: false
|
||||
t.belongs_to :contest, null: false, foreign_key: true
|
||||
t.string :text, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
12
db/migrate/20250515061619_add_author_to_message.rb
Normal file
12
db/migrate/20250515061619_add_author_to_message.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class AddAuthorToMessage < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :messages, :author, :string
|
||||
|
||||
Message.find_each do |message|
|
||||
message.author = "Unknown"
|
||||
message.save
|
||||
end
|
||||
|
||||
change_column_null :messages, :author, true
|
||||
end
|
||||
end
|
||||
12
db/migrate/20250515062154_add_display_time_to_message.rb
Normal file
12
db/migrate/20250515062154_add_display_time_to_message.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class AddDisplayTimeToMessage < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :messages, :display_time, :string
|
||||
|
||||
Message.find_each do |message|
|
||||
message.display_time = "12:30"
|
||||
message.save
|
||||
end
|
||||
|
||||
change_column_null :messages, :display_time, true
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250517083830_create_csv_imports.rb
Normal file
9
db/migrate/20250517083830_create_csv_imports.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class CreateCsvImports < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :csv_imports do |t|
|
||||
t.string :separator, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250517131707_add_content_to_csv_import.rb
Normal file
5
db/migrate/20250517131707_add_content_to_csv_import.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddContentToCsvImport < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :csv_imports, :content, :string, null: false
|
||||
end
|
||||
end
|
||||
15
db/migrate/20250618122655_add_time_seconds_to_contestant.rb
Normal file
15
db/migrate/20250618122655_add_time_seconds_to_contestant.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class AddTimeSecondsToContestant < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contestants, :time_seconds, :integer
|
||||
|
||||
Contestant.find_each do |contestant|
|
||||
contestant.time_seconds = 0
|
||||
contestant.completions.each do |completion|
|
||||
contestant.time_seconds += completion.time_seconds
|
||||
end
|
||||
contestant.save
|
||||
end
|
||||
|
||||
change_column_null :contestants, :time_seconds, true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddMessageRefToCompletion < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_reference :completions, :message, foreign_key: true
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250620051905_add_lang_to_contest.rb
Normal file
5
db/migrate/20250620051905_add_lang_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddLangToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :lang, :string, default: 'en'
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250625075513_add_public_to_contest.rb
Normal file
5
db/migrate/20250625075513_add_public_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddPublicToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :public, :boolean, default: false
|
||||
end
|
||||
end
|
||||
12
db/migrate/20250627070407_add_pieces_to_puzzle.rb
Normal file
12
db/migrate/20250627070407_add_pieces_to_puzzle.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class AddPiecesToPuzzle < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :puzzles, :pieces, :integer
|
||||
|
||||
Puzzle.find_each do |puzzle|
|
||||
puzzle.pieces = 500
|
||||
puzzle.save
|
||||
end
|
||||
|
||||
change_column_null :puzzles, :pieces, false
|
||||
end
|
||||
end
|
||||
15
db/migrate/20250714115208_create_categories.rb
Normal file
15
db/migrate/20250714115208_create_categories.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class CreateCategories < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :categories do |t|
|
||||
t.string :name
|
||||
t.belongs_to :contest, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_join_table :categories, :contestants do |t|
|
||||
t.index :category_id
|
||||
t.index :contestant_id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddRemainingPiecesToCompletion < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :completions, :remaining_pieces, :integer
|
||||
end
|
||||
end
|
||||
10
db/migrate/20251029155116_create_offlines.rb
Normal file
10
db/migrate/20251029155116_create_offlines.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateOfflines < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :offlines do |t|
|
||||
t.timestamps
|
||||
t.belongs_to :contest, null: false, foreign_key: true
|
||||
t.datetime :start_time, null: false
|
||||
t.datetime :end_time
|
||||
end
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251030092221_add_offline_form_to_contest.rb
Normal file
5
db/migrate/20251030092221_add_offline_form_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddOfflineFormToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :offline_form, :boolean, default: false
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251030094151_add_name_to_offline.rb
Normal file
5
db/migrate/20251030094151_add_name_to_offline.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddNameToOffline < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :offlines, :name, :string, null: false
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251031095604_add_completed_to_offline.rb
Normal file
5
db/migrate/20251031095604_add_completed_to_offline.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCompletedToOffline < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :offlines, :completed, :boolean
|
||||
end
|
||||
end
|
||||
75
db/schema.rb
generated
75
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_22_071308) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_10_31_095604) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -39,6 +39,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_071308) do
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "categories", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "contest_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["contest_id"], name: "index_categories_on_contest_id"
|
||||
end
|
||||
|
||||
create_table "categories_contestants", id: false, force: :cascade do |t|
|
||||
t.integer "category_id", null: false
|
||||
t.integer "contestant_id", null: false
|
||||
t.index ["category_id"], name: "index_categories_contestants_on_category_id"
|
||||
t.index ["contestant_id"], name: "index_categories_contestants_on_contestant_id"
|
||||
end
|
||||
|
||||
create_table "completions", force: :cascade do |t|
|
||||
t.integer "time_seconds"
|
||||
t.integer "contestant_id", null: false
|
||||
@@ -46,8 +61,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_071308) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contest_id", null: false
|
||||
t.string "display_time_from_start"
|
||||
t.string "display_relative_time"
|
||||
t.integer "message_id"
|
||||
t.integer "remaining_pieces"
|
||||
t.index ["contest_id"], name: "index_completions_on_contest_id"
|
||||
t.index ["contestant_id"], name: "index_completions_on_contestant_id"
|
||||
t.index ["message_id"], name: "index_completions_on_message_id"
|
||||
t.index ["puzzle_id"], name: "index_completions_on_puzzle_id"
|
||||
end
|
||||
|
||||
@@ -57,6 +77,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_071308) do
|
||||
t.integer "contest_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "display_time"
|
||||
t.integer "time_seconds"
|
||||
t.index ["contest_id"], name: "index_contestants_on_contest_id"
|
||||
end
|
||||
|
||||
@@ -67,15 +89,61 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_071308) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "team", default: false
|
||||
t.boolean "allow_registration", default: false
|
||||
t.string "slug"
|
||||
t.string "lang", default: "en"
|
||||
t.boolean "public", default: false
|
||||
t.boolean "offline_form", default: false
|
||||
t.index ["slug"], name: "index_contests_on_slug", unique: true
|
||||
t.index ["user_id"], name: "index_contests_on_user_id"
|
||||
end
|
||||
|
||||
create_table "csv_imports", force: :cascade do |t|
|
||||
t.string "separator", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "content", null: false
|
||||
end
|
||||
|
||||
create_table "friendly_id_slugs", force: :cascade do |t|
|
||||
t.string "slug", null: false
|
||||
t.integer "sluggable_id", null: false
|
||||
t.string "sluggable_type", limit: 50
|
||||
t.string "scope"
|
||||
t.datetime "created_at"
|
||||
t.index ["slug", "sluggable_type", "scope"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope", unique: true
|
||||
t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type"
|
||||
t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id"
|
||||
end
|
||||
|
||||
create_table "messages", force: :cascade do |t|
|
||||
t.integer "time_seconds", null: false
|
||||
t.integer "contest_id", null: false
|
||||
t.string "text", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "author"
|
||||
t.string "display_time"
|
||||
t.index ["contest_id"], name: "index_messages_on_contest_id"
|
||||
end
|
||||
|
||||
create_table "offlines", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contest_id", null: false
|
||||
t.datetime "start_time", null: false
|
||||
t.datetime "end_time"
|
||||
t.string "name", null: false
|
||||
t.boolean "completed"
|
||||
t.index ["contest_id"], name: "index_offlines_on_contest_id"
|
||||
end
|
||||
|
||||
create_table "puzzles", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contest_id", null: false
|
||||
t.string "brand"
|
||||
t.integer "pieces", null: false
|
||||
t.index ["contest_id"], name: "index_puzzles_on_contest_id"
|
||||
end
|
||||
|
||||
@@ -95,16 +163,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_071308) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "username"
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.string "lang", default: "en"
|
||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "categories", "contests"
|
||||
add_foreign_key "completions", "contestants"
|
||||
add_foreign_key "completions", "contests"
|
||||
add_foreign_key "completions", "messages"
|
||||
add_foreign_key "completions", "puzzles"
|
||||
add_foreign_key "contestants", "contests"
|
||||
add_foreign_key "contests", "users"
|
||||
add_foreign_key "messages", "contests"
|
||||
add_foreign_key "offlines", "contests"
|
||||
add_foreign_key "puzzles", "contests"
|
||||
add_foreign_key "sessions", "users"
|
||||
end
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: puzzles
|
||||
# Table name: categories
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# brand :string
|
||||
# name :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -13,14 +10,14 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_puzzles_on_contest_id (contest_id)
|
||||
# index_categories_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
one:
|
||||
name: MyString
|
||||
|
||||
two:
|
||||
name: MyString
|
||||
FactoryBot.define do
|
||||
factory :category do
|
||||
name { "MyString" }
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,11 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# public :boolean default(FALSE)
|
||||
# slug :string
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -12,16 +16,15 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_contests_on_slug (slug) UNIQUE
|
||||
# index_contests_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# user_id (user_id => users.id)
|
||||
#
|
||||
require "test_helper"
|
||||
|
||||
class ContestTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
FactoryBot.define do
|
||||
factory :contest do
|
||||
name { Faker::Company.unique.name }
|
||||
end
|
||||
end
|
||||
15
spec/factories/csv_imports.rb
Normal file
15
spec/factories/csv_imports.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: csv_imports
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# content :string not null
|
||||
# separator :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :csv_import do
|
||||
separator { 1 }
|
||||
end
|
||||
end
|
||||
28
spec/factories/messages.rb
Normal file
28
spec/factories/messages.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: messages
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# author :string
|
||||
# display_time :string
|
||||
# text :string not null
|
||||
# time_seconds :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_messages_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :message do
|
||||
time_seconds { 1 }
|
||||
contest { nil }
|
||||
text { "MyString" }
|
||||
end
|
||||
end
|
||||
26
spec/factories/offlines.rb
Normal file
26
spec/factories/offlines.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: offlines
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# completed :boolean
|
||||
# end_time :datetime
|
||||
# name :string not null
|
||||
# start_time :datetime not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :offline do
|
||||
name { "test" }
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,3 @@
|
||||
<% password_digest = BCrypt::Password.create("password") %>
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: users
|
||||
@@ -7,6 +5,7 @@
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
@@ -16,10 +15,14 @@
|
||||
#
|
||||
# index_users_on_email_address (email_address) UNIQUE
|
||||
#
|
||||
one:
|
||||
email_address: one@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
FactoryBot.define do
|
||||
factory :user do
|
||||
username { Faker::Internet.unique.username }
|
||||
email_address { Faker::Internet.unique.email }
|
||||
password { Faker::Internet.unique.password(min_length: 12, max_length: 18) }
|
||||
end
|
||||
|
||||
two:
|
||||
email_address: two@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
trait :admin do
|
||||
admin { true }
|
||||
end
|
||||
end
|
||||
78
spec/features/contest_spec.rb
Normal file
78
spec/features/contest_spec.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.feature "Contests", type: :feature do
|
||||
let!(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
login(user)
|
||||
end
|
||||
|
||||
context "index" do
|
||||
let!(:first_contest) { create(:contest, user: user) }
|
||||
let!(:second_contest) { create(:contest, user: user) }
|
||||
|
||||
it "should list existing contests and offer to open them" do
|
||||
visit contests_path
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.index.title", username: user.username))
|
||||
expect(page).to have_content(first_contest.name)
|
||||
expect(page).to have_content(second_contest.name)
|
||||
|
||||
find(".stretched-link[href=\"/contests/#{first_contest.friendly_id}\"]").click
|
||||
|
||||
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}")
|
||||
end
|
||||
|
||||
it "should offer to create a new contest" do
|
||||
visit contests_path
|
||||
|
||||
click_link I18n.t("contests.index.new_contest")
|
||||
|
||||
expect(page).to have_current_path("/contests/new")
|
||||
end
|
||||
end
|
||||
|
||||
context "new" do
|
||||
it "should prevent creating contests without a name" do
|
||||
visit new_contest_path
|
||||
|
||||
click_button I18n.t("helpers.buttons.create")
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
|
||||
end
|
||||
|
||||
it "should allow creating new contests with valid parameters" do
|
||||
visit new_contest_path
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
|
||||
|
||||
click_button I18n.t("helpers.buttons.create")
|
||||
|
||||
expect(page).to have_current_path(contest_path(Contest.find_by(name: "Contest name")))
|
||||
end
|
||||
end
|
||||
|
||||
context "edit" do
|
||||
let!(:contest) { create(:contest, user: user) }
|
||||
|
||||
it "should prevent editing contests without a name" do
|
||||
visit edit_contest_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.contest.name"), with: ""
|
||||
|
||||
click_button I18n.t("helpers.buttons.save")
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
|
||||
end
|
||||
|
||||
it "should allow editing contests with valid parameters" do
|
||||
visit edit_contest_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
|
||||
|
||||
click_button I18n.t("helpers.buttons.save")
|
||||
|
||||
expect(page).to have_current_path(contest_path(contest))
|
||||
end
|
||||
end
|
||||
end
|
||||
34
spec/features/login_spec.rb
Normal file
34
spec/features/login_spec.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.feature "Login", type: :feature do
|
||||
context "visiting the login page" do
|
||||
let!(:user) { create(:user) }
|
||||
|
||||
it "should log in the user with the correct credentials" do
|
||||
visit '/'
|
||||
fill_in "Email address", with: user.email_address
|
||||
fill_in "Password", with: user.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).not_to have_content(I18n.t("sessions.new.title"))
|
||||
end
|
||||
|
||||
it "should fail to log in the user with an incorrect email address" do
|
||||
visit '/'
|
||||
fill_in "Email address", with: Faker::Internet.unique.email
|
||||
fill_in "Password", with: user.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).to have_content(I18n.t("sessions.new.title"))
|
||||
end
|
||||
|
||||
it "should fail to log in the user with an incorrect password" do
|
||||
visit '/'
|
||||
fill_in "Email address", with: user.email_address
|
||||
fill_in "Password", with: Faker::Internet.unique.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).to have_content(I18n.t("sessions.new.title"))
|
||||
end
|
||||
end
|
||||
end
|
||||
63
spec/features/user_spec.rb
Normal file
63
spec/features/user_spec.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.feature "Users", type: :feature do
|
||||
context "when the user is a regular user" do
|
||||
let!(:user) { create(:user) }
|
||||
let!(:contest) { create(:contest, user: user) }
|
||||
|
||||
before do
|
||||
login(user)
|
||||
end
|
||||
|
||||
it "should not see a link to all users" do
|
||||
visit root_path
|
||||
|
||||
expect(page).not_to have_content(I18n.t("nav.users"))
|
||||
end
|
||||
|
||||
it "should not be able to see the user list" do
|
||||
visit users_path
|
||||
|
||||
expect(page).not_to have_content(I18n.t("users.index.title"))
|
||||
end
|
||||
|
||||
it "should be able to create a new contest" do
|
||||
visit root_path
|
||||
|
||||
click_link "Create a new contest"
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.new.title"))
|
||||
end
|
||||
|
||||
it "should be able to open an existing contest" do
|
||||
visit root_path
|
||||
|
||||
expect(page).to have_content(contest.name)
|
||||
find("div.card", text: contest.name).find("a").click
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.show.title", name: contest.name))
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user is an admin" do
|
||||
let!(:admin) { create(:user, :admin) }
|
||||
let!(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
login(admin)
|
||||
end
|
||||
|
||||
it "should see a link to all users" do
|
||||
visit root_path
|
||||
|
||||
expect(page).to have_content(I18n.t("nav.users"))
|
||||
end
|
||||
|
||||
it "should be able to see the user list" do
|
||||
visit users_path
|
||||
|
||||
expect(page).to have_content(I18n.t("users.index.title"))
|
||||
expect(page).to have_content(user.username)
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user