Compare commits
135 Commits
2f23938e81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec2eaf5535 | |||
| 5a49f14e04 | |||
| ab3409ccaa | |||
| 0760c9fe46 | |||
| 279e7eaf3f | |||
| 683d99ab12 | |||
| 76553d4cbc | |||
| 31fe8789ce | |||
| 360157e0c8 | |||
| 5345e419df | |||
| 2c87a5b63c | |||
| 8cea403dc9 | |||
| cce090587a | |||
| ee250b96ad | |||
| 1fc05bea63 | |||
| 7bd1dce1ea | |||
| e2c50515b1 | |||
| 51e55f0828 | |||
| a2a8a9fcef | |||
| d08370f5f8 | |||
| cd41d83429 | |||
| 3a6ee2ea98 | |||
| d01775471e | |||
| 66d968fca8 | |||
| 7a80c434af | |||
| 768af7c3e9 | |||
| c0f2358a36 | |||
| 7a64fa181a | |||
| 024b254808 | |||
| 6ec4a89907 | |||
| d59090cded | |||
| ae3c7c73e1 | |||
| db6f732e63 | |||
| fdd47c231a | |||
| b2429b71f4 | |||
| 710953919c | |||
| 94e725d20a | |||
| b43a801e3c | |||
| c4f0f603f6 | |||
| 709719b801 | |||
| 3e071f9281 | |||
| b87800f6bd | |||
| bf127bb932 | |||
| 3dd153d587 | |||
| 63a88ea113 | |||
| ecb36e19ed | |||
| bc96b16bcb | |||
| 0f725e2eef | |||
| e67ee92838 | |||
| b88460ae71 | |||
| f91145637f | |||
| cdf87e48f2 | |||
| 97ea17b7c2 | |||
| 0f31265f7b | |||
| 86dd0b7b9e | |||
| f4136ea58a | |||
| 6549124c08 | |||
| d1df551a0c | |||
| 69a82f4d3f | |||
| de568d225c | |||
| a1736ff076 | |||
| ae5082fff6 | |||
| cd032e3456 | |||
| 5348574ea4 | |||
| 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 |
17
Dockerfile
17
Dockerfile
@@ -16,7 +16,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 chromium && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Set production environment
|
||||
@@ -51,19 +51,18 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
|
||||
# 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
|
||||
USER 1000:1000
|
||||
|
||||
# Copy built artifacts: gems, application
|
||||
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
|
||||
COPY --from=build /rails /rails
|
||||
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
|
||||
COPY --chown=rails:rails --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 && \
|
||||
chown -R rails:rails db log storage tmp
|
||||
USER 1000:1000
|
||||
|
||||
# Entrypoint prepares the database.
|
||||
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
|
||||
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -44,6 +44,9 @@ gem "slim"
|
||||
gem "dartsass-rails"
|
||||
gem "bootstrap", "~> 5.3.3"
|
||||
gem "friendly_id", "~> 5.5.0"
|
||||
gem "csv"
|
||||
gem "damerau-levenshtein"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
@@ -71,6 +74,7 @@ group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
gem "so_many_devices"
|
||||
end
|
||||
|
||||
gem "pundit", "~> 2.5"
|
||||
|
||||
409
Gemfile.lock
409
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
|
||||
@@ -72,24 +72,23 @@ GEM
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
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)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bcrypt_pbkdf (1.1.2)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
bootsnap (1.21.1)
|
||||
msgpack (~> 1.2)
|
||||
bootstrap (5.3.3)
|
||||
autoprefixer-rails (>= 9.1.0)
|
||||
bootstrap (5.3.8)
|
||||
popper_js (>= 2.11.8, < 3)
|
||||
brakeman (7.0.0)
|
||||
brakeman (7.1.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -101,90 +100,101 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
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.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
diff-lcs (1.6.1)
|
||||
dotenv (3.1.7)
|
||||
drb (2.2.1)
|
||||
ed25519 (1.3.0)
|
||||
diff-lcs (1.6.2)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (6.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
execjs (2.10.0)
|
||||
factory_bot (6.5.1)
|
||||
factory_bot (6.5.6)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.4.4)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 5.0.0)
|
||||
faker (3.5.1)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.5.3)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
friendly_id (5.5.1)
|
||||
activerecord (>= 4.0.0)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
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.4)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.30.0-aarch64-linux)
|
||||
google-protobuf (4.33.4-aarch64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.30.0-x86_64-linux)
|
||||
google-protobuf (4.33.4-aarch64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
i18n (1.14.7)
|
||||
google-protobuf (4.33.4-x86_64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.4-x86_64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.1.0)
|
||||
importmap-rails (2.2.3)
|
||||
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.2)
|
||||
irb (1.16.0)
|
||||
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.18.0)
|
||||
kamal (2.10.1)
|
||||
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.25.0)
|
||||
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 (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.6)
|
||||
net-imap (0.6.2)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -198,106 +208,113 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.3-aarch64-linux-gnu)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-aarch64-linux-musl)
|
||||
nokogiri (1.19.0-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm-linux-gnu)
|
||||
nokogiri (1.19.0-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm-linux-musl)
|
||||
nokogiri (1.19.0-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-musl)
|
||||
nokogiri (1.19.0-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.1)
|
||||
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.8.0)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.3)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
public_suffix (7.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.4)
|
||||
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)
|
||||
rackup (2.3.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 (7.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.1)
|
||||
rspec-core (3.13.3)
|
||||
rexml (3.4.4)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.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.2)
|
||||
rubocop (1.73.2)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -305,72 +322,75 @@ 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.48.0, < 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.49.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
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.34.3)
|
||||
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.2)
|
||||
sass-embedded (1.97.2-aarch64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.97.2-aarch64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.97.2-arm-linux-gnueabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.97.2-arm-linux-musleabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.97.2-x86_64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.97.2-x86_64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.29.1)
|
||||
selenium-webdriver (4.39.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)
|
||||
so_many_devices (1.0.0)
|
||||
capybara (>= 3.0)
|
||||
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.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.1.3)
|
||||
solid_queue (1.3.1)
|
||||
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)
|
||||
sshkit (1.24.0)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.9.0-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.0-aarch64-linux-musl)
|
||||
sqlite3 (2.9.0-arm-linux-gnu)
|
||||
sqlite3 (2.9.0-arm-linux-musl)
|
||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.0-x86_64-linux-musl)
|
||||
sshkit (1.25.0)
|
||||
base64
|
||||
logger
|
||||
net-scp (>= 1.1.2)
|
||||
@@ -379,23 +399,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.2.0)
|
||||
temple (0.10.4)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.17)
|
||||
thruster (0.1.17-aarch64-linux)
|
||||
thruster (0.1.17-x86_64-linux)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
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.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
@@ -403,13 +424,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.4)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -428,6 +449,8 @@ DEPENDENCIES
|
||||
bootstrap (~> 5.3.3)
|
||||
brakeman
|
||||
capybara
|
||||
csv
|
||||
damerau-levenshtein
|
||||
dartsass-rails
|
||||
debug
|
||||
factory_bot_rails
|
||||
@@ -440,10 +463,12 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
pundit (~> 2.5)
|
||||
rails (~> 8.0.2)
|
||||
rqrcode (~> 3.0)
|
||||
rspec-rails
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
slim
|
||||
so_many_devices
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
|
||||
@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
include Pundit::Authorization
|
||||
|
||||
before_action :set_title, :set_current_user, :set_lang
|
||||
before_action :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.
|
||||
@@ -12,13 +12,6 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def set_title
|
||||
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
|
||||
@@ -30,8 +23,12 @@ class ApplicationController < ActionController::Base
|
||||
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
|
||||
|
||||
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 "/contests/#{@contest.id}/settings/categories", notice: t("categories.new.notice")
|
||||
else
|
||||
redirect_to "/contests/#{@contest.id}/settings/categories", notice: t("categories.new.error")
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@category.destroy
|
||||
redirect_to "/contests/#{@contest.id}/settings/categories", 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
|
||||
@@ -2,6 +2,7 @@ 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 ]
|
||||
|
||||
@@ -13,6 +14,7 @@ class CompletionsController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@completion = Completion.new
|
||||
@completion.completed = true
|
||||
if params[:contestant_id]
|
||||
@completion.contestant_id = params[:contestant_id]
|
||||
end
|
||||
@@ -22,13 +24,18 @@ class CompletionsController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@completion = Completion.new(completion_params)
|
||||
@completion.contest_id = @contest.id
|
||||
@completion.contest = @contest
|
||||
if @completion.save
|
||||
extend_completions!(@completion.contestant)
|
||||
redirect_to contest_path(@contest)
|
||||
if @contestant && !params[:completion].key?(:message_id)
|
||||
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
|
||||
else
|
||||
redirect_to contest_messages_path(@contest), notice: t("completions.new.notice")
|
||||
end
|
||||
else
|
||||
logger = Logger.new(STDOUT)
|
||||
logger.info(@completion.errors)
|
||||
if params[:completion].key?(:message_id)
|
||||
@message = Message.find(params[:completion][:message_id])
|
||||
end
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -36,12 +43,14 @@ class CompletionsController < ApplicationController
|
||||
def update
|
||||
authorize @contest
|
||||
|
||||
if params[:contestant_id]
|
||||
@completion.contestant_id = params[:contestant_id]
|
||||
end
|
||||
@completion.contestant_id = params[:contestant_id] if params[:contestant_id]
|
||||
if @completion.update(completion_params)
|
||||
extend_completions!(@completion.contestant)
|
||||
redirect_to @contest
|
||||
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
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -52,9 +61,9 @@ class CompletionsController < ApplicationController
|
||||
|
||||
@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
|
||||
|
||||
@@ -64,8 +73,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
|
||||
|
||||
@@ -74,6 +91,6 @@ class CompletionsController < ApplicationController
|
||||
end
|
||||
|
||||
def completion_params
|
||||
params.expect(completion: [ :time_seconds, :contestant_id, :puzzle_id ])
|
||||
params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :contestant_id, :message_id, :puzzle_id ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@ module Authentication
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
session.delete(:return_to_after_authenticating) || "/contests"
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
|
||||
@@ -8,24 +8,39 @@ module CompletionsConcern
|
||||
"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)
|
||||
def display_time(time)
|
||||
if time == nil
|
||||
return ""
|
||||
end
|
||||
pad(seconds)
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
end
|
||||
m.to_s + ":" + pad(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
|
||||
completions = contestant.completions
|
||||
puzzles = contestant.contest.puzzles
|
||||
if puzzles.length > 1
|
||||
current_time_from_start = 0
|
||||
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)
|
||||
elsif puzzles.length == 1 && completions.length >= 1
|
||||
if completions[0].remaining_pieces != nil
|
||||
contestant.update(
|
||||
display_time: "#{display_time(completions[0].time_seconds)} - #{puzzles[0].pieces - completions[0].remaining_pieces}p",
|
||||
time_seconds: completions[0].projected_time
|
||||
)
|
||||
else
|
||||
contestant.update(display_time: display_time(completions[0].time_seconds), time_seconds: completions[0].time_seconds)
|
||||
end
|
||||
end
|
||||
contestant.update(display_time: display_time(current_time_from_start))
|
||||
end
|
||||
end
|
||||
|
||||
16
app/controllers/concerns/contestants_concern.rb
Normal file
16
app/controllers/concerns/contestants_concern.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module ContestantsConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ranked_contestants(contest)
|
||||
if contest.ranking_mode == "actual"
|
||||
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
|
||||
] }
|
||||
elsif contest.ranking_mode == "theorical"
|
||||
contest.contestants.sort_by { |contestant| contestant.completions.map { |completion| completion.projected_time }.sum }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,18 @@
|
||||
class ContestantsController < ApplicationController
|
||||
before_action :set_contest
|
||||
include CompletionsConcern
|
||||
include ContestantsConcern
|
||||
|
||||
before_action :set_contest, only: %i[ index edit new create update destroy import upload_csv convert_csv finalize_import export generate_qrcodes generate_qrcodes_pdf ]
|
||||
before_action :set_contestant, only: %i[ destroy edit update]
|
||||
before_action :set_completions, only: %i[edit update ]
|
||||
skip_before_action :require_authentication, only: %i[ get_public_completion post_public_completion public_completion_updated ]
|
||||
|
||||
def index
|
||||
authorize @contest
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
filter_contestants_per_category
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @contest
|
||||
@@ -19,7 +30,8 @@ class ContestantsController < ApplicationController
|
||||
@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
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -29,7 +41,8 @@ class ContestantsController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
if @contestant.update(contestant_params)
|
||||
redirect_to @contest
|
||||
update_contestant_categories
|
||||
redirect_to contest_contestants_path(@contest), notice: t("contestants.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -39,7 +52,149 @@ class ContestantsController < ApplicationController
|
||||
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
|
||||
render :import, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def convert_csv
|
||||
authorize @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
|
||||
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
|
||||
render :convert_csv, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def export
|
||||
authorize @contest
|
||||
|
||||
@contestants = ranked_contestants(@contest)
|
||||
|
||||
respond_to do |format|
|
||||
format.csv do
|
||||
response.headers["Content-Type"] = "text/csv"
|
||||
response.headers["Content-Disposition"] = "attachment; filename=#{@contest.friendly_id}_results.csv"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_qrcodes
|
||||
authorize @contest
|
||||
|
||||
generate_contestants_qrcodes(@contest)
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
end
|
||||
|
||||
def generate_qrcodes_pdf
|
||||
authorize @contest
|
||||
|
||||
generate_contestants_qrcodes(@contest)
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
@nonav = true
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render layout: "blank" }
|
||||
end
|
||||
end
|
||||
|
||||
def get_public_completion
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
@contest = @contestant.contest
|
||||
I18n.locale = @contest.lang
|
||||
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
|
||||
@completion = Completion.new
|
||||
@completion.completed = true
|
||||
@public = true
|
||||
|
||||
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:contestant_id]}" }
|
||||
end
|
||||
|
||||
def post_public_completion
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
@contest = @contestant.contest
|
||||
I18n.locale = @contest.lang
|
||||
|
||||
@completion = Completion.new(completion_params)
|
||||
@completion.contest = @contest
|
||||
@completion.contestant = @contestant
|
||||
if !@completion.code.present?
|
||||
to_modify = true
|
||||
@completion.code = "incorrect-xZy"
|
||||
end
|
||||
if @completion.save
|
||||
extend_completions!(@completion.contestant)
|
||||
redirect_to "/public/p/#{params[:contestant_id]}/updated"
|
||||
else
|
||||
@puzzles = @contest.puzzles
|
||||
@public = true
|
||||
if to_modify
|
||||
@completion.code = nil
|
||||
end
|
||||
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:contestant_id]}" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def public_completion_updated
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
I18n.locale = @contestant.contest.lang
|
||||
end
|
||||
|
||||
private
|
||||
@@ -59,4 +214,36 @@ class ContestantsController < ApplicationController
|
||||
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
|
||||
|
||||
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 completion_params
|
||||
params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :puzzle_id, :code ])
|
||||
end
|
||||
|
||||
def generate_contestants_qrcodes(contest)
|
||||
contest.contestants.where(qrcode: nil).each do |contestant|
|
||||
contestant.generate_qrcode
|
||||
contestant.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
class ContestsController < ApplicationController
|
||||
before_action :set_contest, only: %i[ destroy edit show update ]
|
||||
skip_before_action :require_authentication, only: %i[ scoreboard ]
|
||||
include CompletionsConcern
|
||||
include ContestantsConcern
|
||||
|
||||
before_action :set_contest, only: %i[ destroy show ]
|
||||
before_action :set_settings_contest, only: %i[ stopwatch stopwatch_continue stopwatch_pause stopwatch_reset stopwatch_start settings_general_edit settings_general_update settings_public_edit settings_public_update settings_onsite_edit settings_onsite_update settings_online_edit settings_online_update settings_categories_edit ]
|
||||
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
|
||||
@@ -12,75 +17,297 @@ class ContestsController < ApplicationController
|
||||
def show
|
||||
authorize @contest
|
||||
|
||||
@title = I18n.t("contests.show.title", name: @contest.name)
|
||||
@contestants = @contest.contestants.order(:name)
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
set_badges
|
||||
redirect_to contest_contestants_path(@contest)
|
||||
end
|
||||
|
||||
def edit
|
||||
def settings_general_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_public_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_onsite_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_online_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_categories_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_general_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_general_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_general_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_public_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_public_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/public", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_public_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_onsite_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_onsite_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/onsite", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_onsite_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_online_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_online_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/online", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_online_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def stopwatch
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def stopwatch_continue
|
||||
authorize @contest
|
||||
|
||||
pause_duration = Time.now() - @contest.pause_time
|
||||
@contest.start_time = @contest.start_time + pause_duration
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_pause
|
||||
authorize @contest
|
||||
authorize @contest
|
||||
|
||||
@contest.pause_time = Time.now()
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_reset
|
||||
authorize @contest
|
||||
|
||||
@contest.start_time = nil
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_start
|
||||
authorize @contest
|
||||
|
||||
@contest.start_time = Time.now()
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def new
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new
|
||||
@title = I18n.t("contests.new.title")
|
||||
@nonav = true
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new(contest_params)
|
||||
@contest = Contest.new(new_contest_params)
|
||||
@contest.lang = @current_user.lang
|
||||
@contest.ranking_mode = "actual"
|
||||
@contest.user_id = current_user.id
|
||||
if @contest.save
|
||||
redirect_to @contest
|
||||
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice")
|
||||
else
|
||||
@title = I18n.t("contests.new.title")
|
||||
@nonav = true
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(contest_params)
|
||||
redirect_to @contest
|
||||
else
|
||||
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
|
||||
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.order(:name)
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
@contestants = ranked_contestants(@contest)
|
||||
if params.key?(:category)
|
||||
@category = params[:category]
|
||||
filter_contestants_per_category
|
||||
end
|
||||
if params.key?(:hide_offline) && params[:hide_offline] == "true"
|
||||
@contestants = @contestants.select { |contestant| !contestant.offline.present? }
|
||||
@hide_offline = true
|
||||
end
|
||||
if params.key?(:autorefresh)
|
||||
@autorefresh = true
|
||||
end
|
||||
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
|
||||
@action_path = "/public/#{@contest.friendly_id}"
|
||||
@space = " "
|
||||
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 offline_form_edit_path(@contest, @offline)
|
||||
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.submitted
|
||||
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.submitted = true
|
||||
@offline.completed = params[:offline][:completed]
|
||||
@offline.end_time = Time.now()
|
||||
@offline.images.attach(params[:offline][:end_image])
|
||||
if @offline.completed
|
||||
@offline.missing_pieces = params[:offline][:missing_pieces]
|
||||
else
|
||||
@offline.remaining_pieces = params[:offline][:remaining_pieces]
|
||||
end
|
||||
if @offline.save
|
||||
if @contest.puzzles.length > 0
|
||||
dp = display_time(@offline.end_time.to_i - @offline.start_time.to_i)
|
||||
contestant = Contestant.create(contest: @contest, name: @offline.name, offline: @offline)
|
||||
Completion.create(contest: @contest,
|
||||
contestant: contestant,
|
||||
offline: @offline,
|
||||
puzzle: @contest.puzzles[0],
|
||||
completed: @offline.completed,
|
||||
display_time_from_start: dp,
|
||||
missing_pieces: @offline.missing_pieces,
|
||||
remaining_pieces: @offline.remaining_pieces)
|
||||
extend_completions!(contestant)
|
||||
end
|
||||
redirect_to offline_form_completed_path(@contest, @offline)
|
||||
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 set_badges
|
||||
@badges = []
|
||||
@badges.push("team") if @contest.team
|
||||
@badges.push("registration") if @contest.allow_registration
|
||||
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_contest
|
||||
@contest = Contest.find(params[:id])
|
||||
end
|
||||
|
||||
def contest_params
|
||||
params.expect(contest: [ :name, :team, :allow_registration ])
|
||||
def set_settings_contest
|
||||
@contest = Contest.find(params[:contest_id])
|
||||
end
|
||||
|
||||
def new_contest_params
|
||||
params.expect(contest: [ :name, :duration ])
|
||||
end
|
||||
|
||||
def settings_general_params
|
||||
params.expect(contest: [ :lang, :name, :duration, :team, :allow_registration ])
|
||||
end
|
||||
|
||||
def settings_public_params
|
||||
params.expect(contest: [ :public, :ranking_mode, :show_stopwatch ])
|
||||
end
|
||||
|
||||
def settings_onsite_params
|
||||
params.expect(contest: [ :code ])
|
||||
end
|
||||
|
||||
def settings_online_params
|
||||
params.expect(contest: [ :offline_form ])
|
||||
end
|
||||
|
||||
def filter_contestants_per_category
|
||||
if @category != "-1"
|
||||
if @category == "-2"
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
|
||||
else
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.where(id: @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, :remaining_pieces, :missing_pieces ])
|
||||
end
|
||||
end
|
||||
|
||||
119
app/controllers/messages_controller.rb
Normal file
119
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
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 index ]
|
||||
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 index
|
||||
authorize @contest
|
||||
|
||||
@title = @contest.name
|
||||
@messages = @contest.messages.order(:time_seconds)
|
||||
@puzzles = @contest.puzzles
|
||||
end
|
||||
|
||||
def convert
|
||||
authorize @contest
|
||||
|
||||
@completion = Completion.new()
|
||||
@completion.display_time_from_start = @message.display_time
|
||||
@completion.completed = true
|
||||
|
||||
render "completions/new"
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@message = Message.find(params[:id])
|
||||
@message.destroy
|
||||
redirect_to contest_messages_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
|
||||
@@ -2,6 +2,13 @@ class PuzzlesController < ApplicationController
|
||||
before_action :set_contest
|
||||
before_action :set_puzzle, only: %i[ destroy edit update]
|
||||
|
||||
def index
|
||||
authorize @contest
|
||||
|
||||
@title = @contest.name
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @contest
|
||||
end
|
||||
@@ -18,7 +25,7 @@ class PuzzlesController < ApplicationController
|
||||
@puzzle = Puzzle.new(puzzle_params)
|
||||
@puzzle.contest_id = @contest.id
|
||||
if @puzzle.save
|
||||
redirect_to contest_path(@contest)
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.new.notice")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -28,7 +35,7 @@ class PuzzlesController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
if @puzzle.update(puzzle_params)
|
||||
redirect_to @contest
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -38,7 +45,7 @@ class PuzzlesController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@puzzle.destroy
|
||||
redirect_to contest_path(@contest)
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -52,6 +59,6 @@ class PuzzlesController < ApplicationController
|
||||
end
|
||||
|
||||
def puzzle_params
|
||||
params.expect(puzzle: [ :brand, :name, :image ])
|
||||
params.expect(puzzle: [ :brand, :name, :image, :pieces, :hidden ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,13 +4,15 @@ class SessionsController < ApplicationController
|
||||
before_action :skip_authorization
|
||||
|
||||
def new
|
||||
@title = "Puzzle scoreboard"
|
||||
end
|
||||
|
||||
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
|
||||
@title = "Puzzle scoreboard"
|
||||
redirect_to new_session_path, alert: "Try another email address or password."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
class UsersController < ApplicationController
|
||||
include CompletionsConcern
|
||||
|
||||
before_action :set_user, only: %i[ destroy edit show update ]
|
||||
|
||||
def index
|
||||
authorize :user
|
||||
|
||||
@title = t("users.index.title")
|
||||
@users = User.all
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @user
|
||||
|
||||
@title = t("users.edit.title")
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @user
|
||||
|
||||
if @user.update(user_params)
|
||||
redirect_to contests_path
|
||||
@user.password_change_attempt = false
|
||||
if @user.update(user_general_params)
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def change_password
|
||||
@user = User.find(params[:user_id])
|
||||
authorize @user
|
||||
|
||||
@user.password_change_attempt = true
|
||||
if @user.update(user_password_params)
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -38,7 +56,7 @@ 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
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -48,6 +66,35 @@ class UsersController < ApplicationController
|
||||
authorize @user
|
||||
end
|
||||
|
||||
def update_contestants
|
||||
authorize :user
|
||||
|
||||
total = 0
|
||||
updated = 0
|
||||
Contestant.all.each do |contestant|
|
||||
if contestant.completions.length > 0
|
||||
total += 1
|
||||
contestant.completions.each do |completion|
|
||||
completion.save
|
||||
end
|
||||
if extend_completions!(contestant)
|
||||
updated += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to users_path, notice: "Updated contestants: #{updated}/#{total}"
|
||||
end
|
||||
|
||||
def regenerate_qrcodes
|
||||
authorize :user
|
||||
|
||||
Contestant.all.each do |contestant|
|
||||
contestant.generate_qrcode
|
||||
contestant.save
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@@ -57,4 +104,12 @@ class UsersController < ApplicationController
|
||||
def user_params
|
||||
params.expect(user: [ :username, :email_address, :lang, :password ])
|
||||
end
|
||||
|
||||
def user_general_params
|
||||
params.expect(user: [ :username, :email_address, :lang ])
|
||||
end
|
||||
|
||||
def user_password_params
|
||||
params.expect(user: [ :password ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +1,21 @@
|
||||
module ContestsHelper
|
||||
def pad(n)
|
||||
if n > 9
|
||||
return n.to_s
|
||||
end
|
||||
"0" + n.to_s
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
if time == nil
|
||||
return ""
|
||||
end
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
end
|
||||
m.to_s + ":" + pad(s)
|
||||
end
|
||||
end
|
||||
|
||||
5
app/helpers/style_helper.rb
Normal file
5
app/helpers/style_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module StyleHelper
|
||||
def active_page(path)
|
||||
request.path.starts_with?(path) ? "active" : ""
|
||||
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
|
||||
@@ -1,3 +1,3 @@
|
||||
module Languages
|
||||
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "French" } ]
|
||||
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "Français" } ]
|
||||
end
|
||||
|
||||
3
app/lib/ranking.rb
Normal file
3
app/lib/ranking.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
module Ranking
|
||||
AVAILABLE_RANKING_MODES = [ { id: "actual", name: I18n.t("lib.ranking.actual") }, { id: "theorical", name: I18n.t("lib.ranking.theorical") } ]
|
||||
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
|
||||
@@ -3,33 +3,101 @@
|
||||
# Table name: completions
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# code :string
|
||||
# completed :boolean
|
||||
# display_relative_time :string
|
||||
# display_time_from_start :string
|
||||
# missing_pieces :integer
|
||||
# projected_time :integer
|
||||
# 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
|
||||
include ContestsHelper
|
||||
|
||||
belongs_to :contest
|
||||
belongs_to :contestant
|
||||
belongs_to :puzzle
|
||||
belongs_to :message, optional: true
|
||||
|
||||
validates :time_seconds, presence: true
|
||||
validates_numericality_of :time_seconds
|
||||
validates :puzzle_id, uniqueness: { scope: :contestant }
|
||||
has_one :offline, dependent: :destroy
|
||||
|
||||
before_save :clean_pieces
|
||||
before_save :compute_projected_time
|
||||
|
||||
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: -> { completed || offline.present? }
|
||||
validates :remaining_pieces, presence: true, if: -> { !completed }
|
||||
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
|
||||
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
|
||||
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
|
||||
validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? }
|
||||
validate :contest_code_is_correct, if: -> { code.present? }
|
||||
|
||||
def remaining_pieces_is_correct
|
||||
if self.remaining_pieces > self.puzzle.pieces
|
||||
errors.add(:remaining_pieces, I18n.t("activerecord.errors.models.completion.attributes.remaining_pieces.too_large"))
|
||||
end
|
||||
end
|
||||
|
||||
def add_time_seconds
|
||||
if display_time_from_start.present?
|
||||
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
|
||||
else
|
||||
self.time_seconds = self.contest.duration_seconds
|
||||
self.display_time_from_start = display_time(self.time_seconds)
|
||||
end
|
||||
end
|
||||
|
||||
def clean_pieces
|
||||
if self.completed
|
||||
self.remaining_pieces = nil
|
||||
else
|
||||
self.missing_pieces = nil
|
||||
end
|
||||
end
|
||||
|
||||
def compute_projected_time
|
||||
add_time_seconds
|
||||
if self.completed
|
||||
self.projected_time = self.time_seconds
|
||||
else
|
||||
assembled_time = self.time_seconds
|
||||
assembled_pieces = self.puzzle.pieces - self.remaining_pieces
|
||||
pieces_per_second = assembled_pieces.to_f / assembled_time.to_f
|
||||
self.projected_time = assembled_time + Integer(self.remaining_pieces.to_f / pieces_per_second)
|
||||
end
|
||||
end
|
||||
|
||||
def contest_code_is_correct
|
||||
if self.code != self.contest.code
|
||||
errors.add(:code, I18n.t("activerecord.errors.models.completion.attributes.code.mismatch"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,18 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# code :string
|
||||
# duration :string
|
||||
# duration_seconds :integer
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# pause_time :datetime
|
||||
# public :boolean default(FALSE)
|
||||
# ranking_mode :string
|
||||
# show_stopwatch :boolean
|
||||
# slug :string
|
||||
# start_time :datetime
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -24,11 +34,28 @@ class Contest < ApplicationRecord
|
||||
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
|
||||
|
||||
before_save :add_duration_seconds, if: -> { duration.present? }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
||||
validates :ranking_mode, inclusion: { in: Ranking::AVAILABLE_RANKING_MODES.map { |lang| lang[:id] } }
|
||||
validates :duration, presence: true, format: { with: /\A(\d\d:\d\d|\d:\d\d)\z/ }
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
def add_duration_seconds
|
||||
arr = self.duration.split(":")
|
||||
if arr.size == 2
|
||||
self.duration_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
#
|
||||
# Table name: contestants
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# display_time :string
|
||||
# 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
|
||||
# projected_time :string
|
||||
# qrcode :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -20,7 +23,41 @@
|
||||
#
|
||||
class Contestant < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_many :completions
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one :offline, dependent: :destroy
|
||||
has_and_belongs_to_many :categories
|
||||
|
||||
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
|
||||
|
||||
def generate_qrcode
|
||||
host = Rails.application.config.action_controller.default_url_options[:host]
|
||||
qrcode = RQRCode::QRCode.new("https://#{host}/public/p/#{self.id}")
|
||||
self.qrcode = qrcode.as_svg(
|
||||
color: "000",
|
||||
shape_rendering: "crispEdges",
|
||||
module_size: 3,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
viewbox: true
|
||||
)
|
||||
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
|
||||
@@ -3,6 +3,8 @@
|
||||
# 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
|
||||
@@ -19,6 +21,8 @@
|
||||
#
|
||||
class Message < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_many :completions, dependent: :nullify
|
||||
|
||||
validates :author, presence: true
|
||||
validates :text, presence: true
|
||||
end
|
||||
|
||||
85
app/models/offline.rb
Normal file
85
app/models/offline.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: offlines
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# completed :boolean
|
||||
# end_time :datetime
|
||||
# missing_pieces :integer
|
||||
# name :string not null
|
||||
# remaining_pieces :integer
|
||||
# start_time :datetime not null
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# completion_id :integer
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_completion_id (completion_id)
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# completion_id (completion_id => completions.id)
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
class Offline < ApplicationRecord
|
||||
belongs_to :contest
|
||||
belongs_to :contestant, optional: true
|
||||
belongs_to :completion, optional: true
|
||||
|
||||
has_many_attached :images, dependent: :destroy
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
before_save :clean_pieces
|
||||
|
||||
validates :name, presence: true
|
||||
validates :remaining_pieces, presence: true, if: -> { submitted && !completed }
|
||||
validates :start_time, presence: true
|
||||
|
||||
validate :end_image_is_present
|
||||
validate :start_image_is_present
|
||||
|
||||
validates :missing_pieces, numericality: { only_integer: true }, if: -> { missing_pieces.present? }
|
||||
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
|
||||
validate :missing_pieces_is_correct, if: -> { missing_pieces.present? }
|
||||
validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? }
|
||||
|
||||
def missing_pieces_is_correct
|
||||
if self.contest.puzzles.length > 0 && self.missing_pieces > self.contest.puzzles[0].pieces
|
||||
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
|
||||
end
|
||||
end
|
||||
|
||||
def remaining_pieces_is_correct
|
||||
if self.contest.puzzles.length > 0 && self.remaining_pieces > self.contest.puzzles[0].pieces
|
||||
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
|
||||
end
|
||||
end
|
||||
|
||||
def end_image_is_present
|
||||
if self.submitted && 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
|
||||
|
||||
def clean_pieces
|
||||
if self.completed
|
||||
self.remaining_pieces = nil
|
||||
else
|
||||
self.missing_pieces = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,9 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# brand :string
|
||||
# hidden :boolean
|
||||
# name :string
|
||||
# pieces :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
@@ -20,9 +22,9 @@
|
||||
class Puzzle < ApplicationRecord
|
||||
belongs_to :contest
|
||||
|
||||
has_many :completions
|
||||
has_one_attached :image
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one_attached :image, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :brand, presence: true
|
||||
validates :pieces, presence: true
|
||||
end
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# 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
|
||||
# updated_at :datetime not null
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -23,5 +24,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
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
class CompletionPolicy < ContestPolicy
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,33 +1,153 @@
|
||||
class ContestPolicy < ApplicationPolicy
|
||||
def owner_or_admin
|
||||
if record == :contest
|
||||
true
|
||||
else
|
||||
record.user.id == user.id || user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
def index?
|
||||
true
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def show?
|
||||
record.user.id == user.id || user.admin?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def new?
|
||||
true
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def create?
|
||||
true
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def convert?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def convert_csv?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def edit?
|
||||
record.user.id == user.id || user.admin?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def generate_qrcodes?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def settings_general_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_general_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_public_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_public_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_onsite_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_onsite_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_online_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_online_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_categories_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_continue?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_pause?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_reset?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_start?
|
||||
edit?
|
||||
end
|
||||
|
||||
def finalize_import?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def update?
|
||||
record.user.id == user.id || user.admin?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def destroy?
|
||||
record.user.id == user.id || user.admin?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def import?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def export?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def generate_qrcodes_pdf?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def upload_csv?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def offline?
|
||||
record.offline_form && record.puzzles.length < 2
|
||||
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?
|
||||
true
|
||||
record.public
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
class ContestantPolicy < ContestPolicy
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
class PuzzlePolicy < ContestPolicy
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,10 +20,22 @@ class UserPolicy < ApplicationPolicy
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin? || user.id == record.id
|
||||
edit?
|
||||
end
|
||||
|
||||
def change_password?
|
||||
edit?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def update_contestants?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def regenerate_qrcodes?
|
||||
user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
18
app/views/application/_contest_nav.html.slim
Normal file
18
app/views/application/_contest_nav.html.slim
Normal file
@@ -0,0 +1,18 @@
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_contestants_path(@contest)) href=contest_contestants_path(@contest)
|
||||
= t("contestants.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest)
|
||||
= t("puzzles.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/stopwatch") href="/contests/#{@contest.id}/stopwatch"
|
||||
= t("contests.nav.stopwatch").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest)
|
||||
= t("messages.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.nav.settings")
|
||||
18
app/views/application/_params_nav.html.slim
Normal file
18
app/views/application/_params_nav.html.slim
Normal file
@@ -0,0 +1,18 @@
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.nav.general")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/public") href="/contests/#{@contest.id}/settings/public"
|
||||
= t("contests.nav.public")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/onsite") href="/contests/#{@contest.id}/settings/onsite"
|
||||
= t("contests.nav.onsite")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/online") href="/contests/#{@contest.id}/settings/online"
|
||||
= t("contests.nav.online")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
|
||||
= t("contests.nav.categories")
|
||||
@@ -1,19 +1,103 @@
|
||||
= form_with model: completion, url: url, method: method do |form|
|
||||
.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.label :contestant_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
|
||||
.row
|
||||
.col
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
- if @public && @puzzles.length == @contestant.completions.length
|
||||
h4
|
||||
= t("completions.form.validate_name", name: @contestant.name)
|
||||
.mt-3.alert.alert-warning
|
||||
= t("completions.form.all_finished", name: @contestant.name)
|
||||
- else
|
||||
= 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.mb-2
|
||||
.col
|
||||
h4
|
||||
- if @public
|
||||
= t("completions.form.validate_name", name: @contestant.name)
|
||||
- else
|
||||
= t("completions.singular").capitalize
|
||||
- if @contestants.present?
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= 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-check.form-switch
|
||||
= form.check_box :completed, class: "form-check-input"
|
||||
= form.label :completed
|
||||
javascript:
|
||||
completedEl = document.getElementById('completion_completed');
|
||||
completedEl.addEventListener('change', (e) => {
|
||||
const timeEl = document.getElementById('time');
|
||||
const missingPiecesEl = document.getElementById('missing_pieces');
|
||||
const remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (e.target.checked) {
|
||||
timeEl.value = '#{@completion.display_time_from_start}';
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
timeEl.value = '#{display_time(@contest.duration_seconds)}';
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time"
|
||||
= form.label :display_time_from_start, class: "required"
|
||||
.form-text
|
||||
= t("activerecord.attributes.completion.display_time_from_start_description")
|
||||
.row.mb-3 id="missing_pieces"
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :missing_pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :missing_pieces
|
||||
.row.mb-3 id="remaining_pieces" style="display: none;"
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :remaining_pieces
|
||||
javascript:
|
||||
completedEl = document.getElementById('completion_completed');
|
||||
missingPiecesEl = document.getElementById('missing_pieces');
|
||||
remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (completedEl.checked) {
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
- if @public
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :code, autocomplete: "off", class: "form-control"
|
||||
= form.label :code
|
||||
= t("completions.form.code")
|
||||
.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"
|
||||
@@ -1,7 +1,3 @@
|
||||
.row
|
||||
.col
|
||||
h3 Informations
|
||||
|
||||
= form_with model: contestant, url: url, method: method do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
@@ -13,41 +9,109 @@
|
||||
.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 && method == :patch
|
||||
.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
|
||||
.row.mt-5
|
||||
.col
|
||||
h3 Completions
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Time since start
|
||||
th scope="col"
|
||||
| Relative time
|
||||
th scope="col"
|
||||
| Puzzle
|
||||
tbody
|
||||
- @completions.each do |completion|
|
||||
tr scope="row"
|
||||
td
|
||||
= completion.display_time_from_start
|
||||
td
|
||||
= completion.display_relative_time
|
||||
td
|
||||
| #{completion.puzzle.name} - #{completion.puzzle.brand}
|
||||
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),
|
||||
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
|
||||
.row
|
||||
- if @contest.puzzles.length == 0
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contestants.edit.no_puzzles_note")
|
||||
- else
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
= t("contestants.edit.completions_note")
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.completed")
|
||||
- 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"
|
||||
= t("activerecord.attributes.completion.projected_time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.missing_pieces")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.remaining_pieces")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.puzzle")
|
||||
tbody
|
||||
- @completions.each do |completion|
|
||||
tr scope="row"
|
||||
td
|
||||
- if completion.completed
|
||||
<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
|
||||
= display_time(completion.time_seconds)
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= completion.display_relative_time
|
||||
- else
|
||||
td
|
||||
= display_time(completion.projected_time)
|
||||
td
|
||||
= completion.missing_pieces
|
||||
td
|
||||
= completion.remaining_pieces
|
||||
td
|
||||
- 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: 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)
|
||||
= t("helpers.buttons.add")
|
||||
|
||||
- if contestant.offline.present?
|
||||
.row.mt-5.mb-2
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
|
||||
| Add completion
|
||||
h3 = t("contestants.edit.offline_participation")
|
||||
.row.mb-5
|
||||
.col-6
|
||||
h4 = t("contestants.edit.start_image")
|
||||
- if contestant.offline.images.length > 0
|
||||
.mt-3.mb-1
|
||||
= contestant.offline.start_time.to_fs(:rfc822)
|
||||
= image_tag(contestant.offline.images[0], class: "img-fluid", style: "max-height: 400px")
|
||||
.col-6
|
||||
h4 = t("contestants.edit.end_image")
|
||||
- if contestant.offline.images.length > 1
|
||||
.mt-3.mb-1
|
||||
= contestant.offline.end_time.to_fs(:rfc822)
|
||||
= image_tag(contestant.offline.images[1], class: "img-fluid", style: "max-height: 400px")
|
||||
- else
|
||||
= t("contestants.edit.not_finished")
|
||||
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])
|
||||
27
app/views/contestants/generate_qrcodes.html.slim
Normal file
27
app/views/contestants/generate_qrcodes.html.slim
Normal file
@@ -0,0 +1,27 @@
|
||||
- if @contest.code.present?
|
||||
.mb-4 style="height: calc(100vh - 280px)"
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
= t("contestants.generate_qrcodes.note")
|
||||
|
||||
a.mt-3.mb-3.btn.btn-primary href="#{contest_generate_qrcodes_pdf_path(@contest)}" target="_blank" style="margin-top: -3px"
|
||||
= t("helpers.buttons.open_raw")
|
||||
|
||||
.col.d-flex.flex-column style="height: calc(100% - 200px)"
|
||||
.d-flex.flex-column style="overflow-y: auto;"
|
||||
- for row in 0..((@contestants.length - 1) / 4)
|
||||
.mt-4.d-flex.flex-row
|
||||
- for col in 0..3
|
||||
- if row * 4 + col < @contestants.length
|
||||
.d-flex.flex-column.ms-5 style="align-items: center"
|
||||
- if @contestants[row * 4 + col].qrcode.present?
|
||||
.mt-1 style="width: 128px; height: 120px;"
|
||||
= @contestants[row * 4 + col].qrcode.html_safe
|
||||
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
|
||||
= @contestants[row * 4 + col].name
|
||||
- else
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contestants.generate_qrcodes.no_code_note")
|
||||
13
app/views/contestants/generate_qrcodes_pdf.html.slim
Normal file
13
app/views/contestants/generate_qrcodes_pdf.html.slim
Normal file
@@ -0,0 +1,13 @@
|
||||
h1.text-center.mt-4.mb-4 = @contest.name
|
||||
|
||||
.d-flex.flex-column.align-items-center
|
||||
- for row in 0..((@contestants.length - 1) / 4)
|
||||
.mt-4.d-flex.flex-row
|
||||
- for col in 0..3
|
||||
- if row * 4 + col < @contestants.length
|
||||
.d-flex.flex-column.ms-4.me-4 style="align-items: center"
|
||||
- if @contestants[row * 4 + col].qrcode.present?
|
||||
.mt-1 style="width: 128px; height: 120px;"
|
||||
= @contestants[row * 4 + col].qrcode.html_safe
|
||||
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
|
||||
= @contestants[row * 4 + col].name
|
||||
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"
|
||||
64
app/views/contestants/index.html.slim
Normal file
64
app/views/contestants/index.html.slim
Normal file
@@ -0,0 +1,64 @@
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col.d-flex.flex-column style="height: 100%"
|
||||
.row.mb-4
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px"
|
||||
| + #{t("helpers.buttons.add")}
|
||||
a.ms-2.btn.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.import")}
|
||||
a.ms-2.btn.btn.btn-primary href=contest_generate_qrcodes_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.generate_qrcodes")}
|
||||
a.ms-2.btn.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.export")}
|
||||
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col
|
||||
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_contestants_path(@contest)}?category=${e.target.value}`);
|
||||
})
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.offline")
|
||||
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
|
||||
= contestant.name
|
||||
td
|
||||
- if contestant.offline.present?
|
||||
<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
|
||||
= 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.details")
|
||||
@@ -1 +1,2 @@
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: "Add", method: :post, url: "/contests/#{@contest.id}/contestants"
|
||||
h5.mb-3 = t("contestants.new.title")
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/contestants"
|
||||
@@ -0,0 +1,2 @@
|
||||
h4
|
||||
= "Puzzle validé pour #{@contestant.name} !"
|
||||
@@ -1,21 +0,0 @@
|
||||
= 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-check.form-switch
|
||||
= form.check_box :team, class: "form-check-input"
|
||||
= form.label :team
|
||||
.form-text = t("activerecord.attributes.contest.team_description")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :allow_registration, class: "form-check-input"
|
||||
= form.label :allow_registration
|
||||
.form-text = t("activerecord.attributes.contest.allow_registration_description")
|
||||
.row
|
||||
.col
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
50
app/views/contests/_scoreboard_desktop_marathon.html.slim
Normal file
50
app/views/contests/_scoreboard_desktop_marathon.html.slim
Normal file
@@ -0,0 +1,50 @@
|
||||
.d-flex.flex-column style="height: calc(100vh - 180px)"
|
||||
|
||||
= render "selectors"
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
.table-responsive-md
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
- if @contest.show_stopwatch
|
||||
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400;"
|
||||
= render "stopwatch_js"
|
||||
th
|
||||
th
|
||||
th
|
||||
- @puzzles.each do |puzzle|
|
||||
th scope="col"
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 64px;") if puzzle.image.attached?
|
||||
tr
|
||||
th scope="col"
|
||||
= t("helpers.rank")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
- @puzzles.each do |puzzle|
|
||||
th scope="col"
|
||||
= puzzle.name
|
||||
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
|
||||
- @puzzles.each do |puzzle|
|
||||
td
|
||||
- contestant.completions.each do |completion|
|
||||
- if completion.puzzle == puzzle
|
||||
- if completion.completed
|
||||
= completion.display_relative_time
|
||||
- elsif completion.remaining_pieces.present?
|
||||
= "#{puzzle.pieces - completion.remaining_pieces}p"
|
||||
43
app/views/contests/_scoreboard_desktop_single.html.slim
Normal file
43
app/views/contests/_scoreboard_desktop_single.html.slim
Normal file
@@ -0,0 +1,43 @@
|
||||
.row
|
||||
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 310px)"
|
||||
.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
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
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(completed: true).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.display_time
|
||||
.col-1
|
||||
.col-5
|
||||
- @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"
|
||||
46
app/views/contests/_scoreboard_mobile_single.html.slim
Normal file
46
app/views/contests/_scoreboard_mobile_single.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
css:
|
||||
.container { margin-top: 2rem !important; }
|
||||
|
||||
.row
|
||||
- if @puzzles.size > 0
|
||||
.d-flex.flex-column.justify-content-center.mb-2
|
||||
= image_tag(@puzzles[0].image, style: "max-height: 200px; object-fit: contain") if @puzzles[0].image.attached?
|
||||
.mt-2.fs-6 style="text-align: center"
|
||||
=> "#{@puzzles[0].name} -"
|
||||
= "#{@puzzles[0].brand} #{@puzzles[0].pieces}p"
|
||||
|
||||
.row
|
||||
.mt-3.d-flex.flex-column
|
||||
.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
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
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(completed: true).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.display_time
|
||||
.col-1
|
||||
33
app/views/contests/_selectors.html.slim
Normal file
33
app/views/contests/_selectors.html.slim
Normal file
@@ -0,0 +1,33 @@
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col
|
||||
select.mb-2 id="categories" style="padding: 5px"
|
||||
option value=-1
|
||||
= t("contests.scoreboard.all_categories")
|
||||
- @contest.categories.each do |category|
|
||||
- if @category == category.id.to_s
|
||||
option value=category.id selected=true
|
||||
= category.name
|
||||
- else
|
||||
option value=category.id
|
||||
= category.name
|
||||
javascript:
|
||||
document.getElementById('categories').addEventListener('change', (e) => {
|
||||
addParam('category', e.target.value);
|
||||
})
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
.row
|
||||
.col
|
||||
- if @hide_offline
|
||||
input type="checkbox" id="offline" style="padding: 5px;" checked=true
|
||||
- else
|
||||
input type="checkbox" id="offline" style="padding: 5px;"
|
||||
label for="offline"
|
||||
.ms-2
|
||||
= t("contests.scoreboard.hide_offline")
|
||||
javascript:
|
||||
document.getElementById('offline').addEventListener('change', (e) => {
|
||||
console.log('changed');
|
||||
if (e.target.checked) addParam('hide_offline', e.target.checked);
|
||||
else removeParam('hide_offline');
|
||||
})
|
||||
22
app/views/contests/_stopwatch_js.html.slim
Normal file
22
app/views/contests/_stopwatch_js.html.slim
Normal file
@@ -0,0 +1,22 @@
|
||||
javascript:
|
||||
startTime = #{@contest.start_time.present? ? @contest.start_time.to_i : "null"};
|
||||
pauseTime = #{@contest.pause_time.present? ? @contest.pause_time.to_i : "null"};
|
||||
function updateTime() {
|
||||
const displayTimeEl = document.getElementById('display-time');
|
||||
if (displayTimeEl) {
|
||||
if (startTime) {
|
||||
let s = Math.floor((Date.now() - 1000 * startTime) / 1000);
|
||||
if (pauseTime) s = Math.floor(pauseTime - startTime);
|
||||
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);
|
||||
} else {
|
||||
displayTimeEl.innerHTML = '00:00:00';
|
||||
}
|
||||
} else {
|
||||
setTimeout(updateTime, 20);
|
||||
}
|
||||
}
|
||||
setTimeout(updateTime, 1);
|
||||
@@ -1 +0,0 @@
|
||||
= render "form", contest: @contest, submit_text: t("helpers.buttons.save")
|
||||
@@ -1 +1,15 @@
|
||||
= render "form", contest: @contest, submit_text: t("helpers.buttons.create")
|
||||
= 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.text_field :duration, autocomplete: "off", class: "form-control"
|
||||
= form.label :duration, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.duration_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.create"), class: "btn btn-primary"
|
||||
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")
|
||||
86
app/views/contests/offline_edit.html.slim
Normal file
86
app/views/contests/offline_edit.html.slim
Normal file
@@ -0,0 +1,86 @@
|
||||
= form_with model: @offline, url: "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}" do |form|
|
||||
h3 = t("offlines.form.start_message", name: @offline.name)
|
||||
h1 id="display-time" style="font-size: 80px;"
|
||||
javascript:
|
||||
startTime = #{@offline.start_time.to_i};
|
||||
function updateTime() {
|
||||
const displayTimeEl = document.getElementById('display-time');
|
||||
if (displayTimeEl) {
|
||||
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);
|
||||
} else {
|
||||
setTimeout(updateTime, 20);
|
||||
}
|
||||
}
|
||||
setTimeout(updateTime, 1);
|
||||
.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", capture: "user"
|
||||
.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 > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
document.getElementById('image-error-message').style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setMaxUploadSize();
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :completed, class: "form-check-input"
|
||||
= form.label :completed
|
||||
javascript:
|
||||
completedEl = document.getElementById('offline_completed');
|
||||
completedEl.addEventListener('change', (e) => {
|
||||
missingPiecesEl = document.getElementById('missing_pieces');
|
||||
remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (e.target.checked) {
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.row.mb-3 id="missing_pieces" style="display: none;"
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :missing_pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :missing_pieces
|
||||
.form-text
|
||||
= t("offlines.form.missing_pieces")
|
||||
.row.mb-3 id="remaining_pieces"
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :remaining_pieces
|
||||
.form-text
|
||||
= t("offlines.form.remaining_pieces")
|
||||
javascript:
|
||||
completedEl = document.getElementById('offline_completed');
|
||||
missingPiecesEl = document.getElementById('missing_pieces');
|
||||
remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (completedEl.checked) {
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
.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", capture: "user"
|
||||
.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 > 9 * 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"
|
||||
@@ -1,22 +1,25 @@
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Rank
|
||||
th scope="col"
|
||||
| Name
|
||||
th scope="col"
|
||||
| Completed puzzles
|
||||
th scope="col"
|
||||
| Total time
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
td
|
||||
= contestant.completions.length
|
||||
td
|
||||
= contestant.display_time
|
||||
css:
|
||||
@media (max-width: 800px) {
|
||||
.mobile-single { display: block !important; }
|
||||
.desktop-single { display: none; }
|
||||
#scoreboard-switches { display: none; }
|
||||
.stopwatch { font-size: 50px !important; }
|
||||
}
|
||||
|
||||
- if @contest.puzzles.size < 2
|
||||
= render "selectors"
|
||||
|
||||
turbo-frame id="scoreboard"
|
||||
- if @contest.show_stopwatch
|
||||
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400; margin-bottom: 0; text-align: center;"
|
||||
= render "stopwatch_js"
|
||||
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
|
||||
.mobile-single style="display: none;"
|
||||
= render "scoreboard_mobile_single"
|
||||
.desktop-single
|
||||
= render "scoreboard_desktop_single"
|
||||
|
||||
- else
|
||||
turbo-frame id="scoreboard"
|
||||
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
|
||||
= render "scoreboard_desktop_marathon"
|
||||
36
app/views/contests/settings_categories_edit.html.slim
Normal file
36
app/views/contests/settings_categories_edit.html.slim
Normal file
@@ -0,0 +1,36 @@
|
||||
= render "params_nav"
|
||||
|
||||
.row
|
||||
.col
|
||||
.alert.alert-primary role="alert"
|
||||
= t("contests.nav.categories_description")
|
||||
|
||||
= 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-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"
|
||||
34
app/views/contests/settings_general_edit.html.slim
Normal file
34
app/views/contests/settings_general_edit.html.slim
Normal file
@@ -0,0 +1,34 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
|
||||
.row.mt-2.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.text_field :duration, autocomplete: "off", class: "form-control"
|
||||
= form.label :duration, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.duration_description")
|
||||
.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 style="display: none"
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :team, class: "form-check-input"
|
||||
= form.label :team
|
||||
.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 = t("activerecord.attributes.contest.allow_registration_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
46
app/views/contests/settings_online_edit.html.slim
Normal file
46
app/views/contests/settings_online_edit.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
= render "params_nav"
|
||||
|
||||
javascript:
|
||||
async function copyExtensionUrlToClipboard() {
|
||||
await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
|
||||
alert("#{t("contests.show.url_copied")}");
|
||||
}
|
||||
|
||||
.row.mb-4.mt-2
|
||||
.col
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
a.btn.btn-success href="/public/#{@contest.slug}/offline"
|
||||
= t("contests.show.open_offline_form")
|
||||
- else
|
||||
a.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")
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/online" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
- if @contest.puzzles.length <= 1
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form, class: "form-check-input"
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
- else
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form_fake, class: "form-check-input", disabled: true
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
12
app/views/contests/settings_onsite_edit.html.slim
Normal file
12
app/views/contests/settings_onsite_edit.html.slim
Normal file
@@ -0,0 +1,12 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/onsite" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :code, autocomplete: "off", class: "form-control"
|
||||
= form.label :code, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.code_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
22
app/views/contests/settings_public_edit.html.slim
Normal file
22
app/views/contests/settings_public_edit.html.slim
Normal file
@@ -0,0 +1,22 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/public" do |form|
|
||||
.row.mt-2
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :public, class: "form-check-input"
|
||||
= form.label :public
|
||||
.row.mt-2
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :show_stopwatch, class: "form-check-input"
|
||||
= form.label :show_stopwatch
|
||||
.row.mt-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select"
|
||||
= form.label :ranking_mode
|
||||
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
@@ -1,67 +0,0 @@
|
||||
.row.mb-4
|
||||
.col
|
||||
css:
|
||||
.badges { margin-top: -18px; position: absolute; }
|
||||
.badges
|
||||
- @badges.each do |badge|
|
||||
span.badge.text-bg-info.me-2
|
||||
= badge
|
||||
|
||||
.row.mb-4
|
||||
.col
|
||||
.float-end
|
||||
a.btn.btn-primary href=edit_contest_path(@contest)
|
||||
| Edit contest
|
||||
p
|
||||
= t("contests.show.public_scoreboard")
|
||||
= link_to root_url + "public/#{@contest.slug}", root_url + "public/#{@contest.slug}"
|
||||
|
||||
.row.mb-4
|
||||
.col-6
|
||||
.row
|
||||
.col
|
||||
h4 = t("puzzles.plural").capitalize
|
||||
.row.row-cols-1.row-cols-md-3.g-4.mb-4
|
||||
- @puzzles.each do |puzzle|
|
||||
.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)
|
||||
= t("contests.show.add_puzzle")
|
||||
.col-6
|
||||
.row
|
||||
.col
|
||||
h4 = t("contestants.plural").capitalize
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Name
|
||||
th scope="col"
|
||||
| Completed puzzles
|
||||
tbody
|
||||
- @contestants.each do |contestant|
|
||||
tr scope="row"
|
||||
td
|
||||
= contestant.name
|
||||
td
|
||||
= contestant.completions.length
|
||||
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)
|
||||
= t("contests.show.add_participant")
|
||||
16
app/views/contests/stopwatch.html.slim
Normal file
16
app/views/contests/stopwatch.html.slim
Normal file
@@ -0,0 +1,16 @@
|
||||
.row
|
||||
.col
|
||||
.alert.alert-primary
|
||||
= t("contests.stopwatch.info")
|
||||
h1.mt-3 id="display-time" style="font-size: 80px;"
|
||||
= render "stopwatch_js"
|
||||
.row.mt-3
|
||||
.col.d-flex
|
||||
- if !@contest.start_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_start"), "/contests/#{@contest.id}/stopwatch_start", method: :post, class: "btn btn-primary"
|
||||
- if @contest.pause_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_continue"), "/contests/#{@contest.id}/stopwatch_continue", method: :post, class: "btn btn-primary"
|
||||
- if @contest.start_time.present? && !@contest.pause_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_pause"), "/contests/#{@contest.id}/stopwatch_pause", method: :post, class: "btn btn-primary"
|
||||
- if @contest.start_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_reset"), "/contests/#{@contest.id}/stopwatch_reset", method: :post, class: "ms-3 btn btn-warning"
|
||||
@@ -5,17 +5,104 @@ html
|
||||
body
|
||||
.container.mt-5
|
||||
- if @current_user
|
||||
.float-end style="margin-top: -8px;"
|
||||
.float-end style="margin-top: -5px;"
|
||||
nav.navbar.bg-body-primary
|
||||
- if @current_user.admin
|
||||
a.navbar-brand href=users_path
|
||||
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
|
||||
= t("nav.users")
|
||||
a.navbar-brand href=contests_path
|
||||
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)
|
||||
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
|
||||
= 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
|
||||
- if @contest && @contest.id.present?
|
||||
- if active_page("/public") == "active" && @action_path
|
||||
= @contest.name
|
||||
.float-end style="margin-top: -5px;" id="scoreboard-switches"
|
||||
.d-inline-flex.align-items-center
|
||||
.ms-4.form-check.form-switch style="font-size: 16px; font-weight: 300;"
|
||||
input.form-check-input type="checkbox" id="refresh-checkbox"
|
||||
label.ms-1 style="font-size: 16px; font-weight: 300;"
|
||||
= t("contests.scoreboard.auto_refresh")
|
||||
.js data-turbo="false"
|
||||
javascript:
|
||||
function refresh() {
|
||||
if (document.getElementById('refresh-checkbox').checked) {
|
||||
addParam('autorefresh', 1);
|
||||
setTimeout(refresh, 30000);
|
||||
}
|
||||
}
|
||||
function addParam(key, value) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete(key);
|
||||
urlParams.append(key, value);
|
||||
const refreshBtn = document.getElementById('refresh-button')
|
||||
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
|
||||
refreshBtn.click();
|
||||
}
|
||||
function removeParam(key) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete(key);
|
||||
const refreshBtn = document.getElementById('refresh-button')
|
||||
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
|
||||
refreshBtn.click();
|
||||
}
|
||||
function autoRefresh() {
|
||||
if (document.getElementById('refresh-checkbox').checked) setTimeout(refresh, 30000);
|
||||
document.getElementById('refresh-checkbox').addEventListener('change', (e) => {
|
||||
if (e.target.checked) refresh();
|
||||
else removeParam('autorefresh');
|
||||
});
|
||||
}
|
||||
async function startAutoRefresh(count) {
|
||||
if (count == 0) return;
|
||||
if (document.getElementById('refresh-button') && document.getElementById('refresh-checkbox')) autoRefresh();
|
||||
else setTimeout(() => startAutoRefresh(count - 1), 10);
|
||||
}
|
||||
startAutoRefresh(200);
|
||||
- elsif active_page("/contests") == "active"
|
||||
= @contest.name
|
||||
- if @contest.public
|
||||
a.ms-4.btn.btn-success href="/public/#{@contest.slug}" style="margin-top: -6px;"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
- else
|
||||
a.ms-4.btn.btn-success.disabled style="margin-top: -6px;"
|
||||
= t("contests.show.public_scoreboard_disabled")
|
||||
- else
|
||||
= @contest.name
|
||||
- else
|
||||
= @title
|
||||
|
||||
- if @contest && active_page("/contests") == "active" && !@nonav
|
||||
= render "contest_nav"
|
||||
|
||||
= yield
|
||||
6
app/views/layouts/blank.html.slim
Normal file
6
app/views/layouts/blank.html.slim
Normal file
@@ -0,0 +1,6 @@
|
||||
doctype html
|
||||
html
|
||||
= render "layouts/header"
|
||||
|
||||
body
|
||||
= yield
|
||||
52
app/views/messages/index.html.slim
Normal file
52
app/views/messages/index.html.slim
Normal file
@@ -0,0 +1,52 @@
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col.d-flex.flex-column style="height: 100%"
|
||||
|
||||
.row.mb-4
|
||||
.col
|
||||
.alert.alert-primary
|
||||
= t("messages.index.info")
|
||||
- if @messages.length == 0
|
||||
.alert.alert-warning
|
||||
= t("messages.index.no_messages")
|
||||
- else
|
||||
- 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"
|
||||
@@ -1,4 +1,11 @@
|
||||
= form_with model: puzzle, url: url, method: method do |form|
|
||||
.row.mb-3
|
||||
.col.alert.alert-warning
|
||||
= t("puzzles.form.fake_data_recommendation")
|
||||
- if puzzle.id && puzzle.image.attached?
|
||||
.row.mb-3
|
||||
.col
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
@@ -11,10 +18,39 @@
|
||||
= 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-check.form-switch
|
||||
= form.check_box :hidden, class: "form-check-input"
|
||||
= form.label :hidden
|
||||
.form-text
|
||||
= t("activerecord.attributes.puzzle.hidden_description")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("puzzles.image_select")
|
||||
= form.file_field :image, accept: "image/*", class: "form-control"
|
||||
.row.mt-4
|
||||
.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 > 9 * 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.mb-5
|
||||
.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}"
|
||||
42
app/views/puzzles/index.html.slim
Normal file
42
app/views/puzzles/index.html.slim
Normal file
@@ -0,0 +1,42 @@
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col.d-flex.flex-column style="height: 100%"
|
||||
|
||||
.row.mb-4
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
|
||||
| + #{t("helpers.buttons.add")}
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.image")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.name")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.brand")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.pieces")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.hidden")
|
||||
tbody
|
||||
- @puzzles.each do |puzzle|
|
||||
tr.align-middle scope="row"
|
||||
td
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 128px;") if puzzle.image.attached?
|
||||
td
|
||||
= puzzle.name
|
||||
td
|
||||
= puzzle.brand
|
||||
td
|
||||
= puzzle.pieces
|
||||
td
|
||||
- if puzzle.hidden?
|
||||
<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
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
|
||||
= t("helpers.buttons.edit")
|
||||
@@ -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"
|
||||
@@ -4,10 +4,12 @@
|
||||
.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 "Sign in"
|
||||
= form.submit t("helpers.buttons.sign_in")
|
||||
@@ -30,13 +30,13 @@
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
|
||||
- if method == :patch
|
||||
h4.mt-5 = t("users.edit.password_section")
|
||||
- if method == :patch
|
||||
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"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
= form_with model: user, url: user_password_path(user) do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save_password"), class: "btn btn-primary"
|
||||
@@ -1,27 +1,39 @@
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Id
|
||||
th scope="col"
|
||||
| Name
|
||||
th scope="col"
|
||||
| Admin?
|
||||
th scope="col"
|
||||
| # contests
|
||||
tbody
|
||||
- @users.each do |user|
|
||||
tr scope="row"
|
||||
td
|
||||
= user.id
|
||||
td
|
||||
= user.username
|
||||
td
|
||||
= user.admin ? "Yes" : "No"
|
||||
td
|
||||
= user.contests.length
|
||||
|
||||
.row
|
||||
.col
|
||||
.d-flex.flex-row.justify-content-start.align-items-center
|
||||
a.btn.btn-primary href=new_user_path
|
||||
| New user
|
||||
| New user
|
||||
= button_to "Update contestants", "/update_contestants", method: :post, class: "ms-3 btn btn-success"
|
||||
= button_to "Regenerate QR codes", "/regenerate_qrcodes", method: :post, class: "ms-3 btn btn-success"
|
||||
|
||||
- @users.each do |user|
|
||||
- if user.admin
|
||||
h3.mt-5 = "#{user.username} (admin)"
|
||||
- else
|
||||
h3.mt-5 = user.username
|
||||
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| ID
|
||||
th scope="col"
|
||||
| Friendly ID
|
||||
th scope="col"
|
||||
| # Puzzles
|
||||
th scope="col"
|
||||
| # Participants
|
||||
tbody
|
||||
- user.contests.each do |contest|
|
||||
tr scope="row"
|
||||
td
|
||||
= contest.id
|
||||
td
|
||||
= contest.friendly_id
|
||||
td
|
||||
= contest.puzzles.length
|
||||
td
|
||||
= contest.contestants.length
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=contest_path(contest)
|
||||
= t("helpers.buttons.open")
|
||||
|
||||
|
||||
73
config/brakeman.ignore
Normal file
73
config/brakeman.ignore
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"ignored_warnings": [
|
||||
{
|
||||
"warning_type": "Cross-Site Scripting",
|
||||
"warning_code": 2,
|
||||
"fingerprint": "00462a5825f8e46fe0b5167b1c822296cb5d8443117790a04966ba059a260f2b",
|
||||
"check_name": "CrossSiteScripting",
|
||||
"message": "Unescaped model attribute",
|
||||
"file": "app/views/contestants/generate_qrcodes.html.slim",
|
||||
"line": 20,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
|
||||
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "ContestantsController",
|
||||
"method": "generate_qrcodes",
|
||||
"line": 126,
|
||||
"file": "app/controllers/contestants_controller.rb",
|
||||
"rendered": {
|
||||
"name": "contestants/generate_qrcodes",
|
||||
"file": "app/views/contestants/generate_qrcodes.html.slim"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "contestants/generate_qrcodes"
|
||||
},
|
||||
"user_input": "Contest.find(params[:contest_id]).contestants",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
79
|
||||
],
|
||||
"note": "SVG HTML code is generated by the app"
|
||||
},
|
||||
{
|
||||
"warning_type": "Cross-Site Scripting",
|
||||
"warning_code": 2,
|
||||
"fingerprint": "d17a497a9b261007930226914a64e99d6f6237c99cc1c33c88745e1341ac4fb7",
|
||||
"check_name": "CrossSiteScripting",
|
||||
"message": "Unescaped model attribute",
|
||||
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim",
|
||||
"line": 11,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
|
||||
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "ContestantsController",
|
||||
"method": "generate_qrcodes_pdf",
|
||||
"line": 135,
|
||||
"file": "app/controllers/contestants_controller.rb",
|
||||
"rendered": {
|
||||
"name": "contestants/generate_qrcodes_pdf",
|
||||
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "contestants/generate_qrcodes_pdf"
|
||||
},
|
||||
"user_input": "Contest.find(params[:contest_id]).contestants",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
79
|
||||
],
|
||||
"note": "SVG HTML code is generated by the app"
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.1.1"
|
||||
}
|
||||
@@ -40,6 +40,8 @@ Rails.application.configure do
|
||||
# Set localhost to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
||||
|
||||
config.action_controller.default_url_options = { host: "192.168.122.105", port: 3000 }
|
||||
|
||||
# Print deprecation notices to the Rails logger.
|
||||
config.active_support.deprecation = :log
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ Rails.application.configure do
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
|
||||
config.action_controller.default_url_options = { host: "puzzle-scoreboard.org" }
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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(&:capitalize).uniq.join(", ")}</div>"
|
||||
|
||||
@@ -28,77 +28,337 @@
|
||||
# enabled: "ON"
|
||||
|
||||
en:
|
||||
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:
|
||||
completed: Puzzle completed
|
||||
contestant: Participant
|
||||
display_time: Time
|
||||
display_time_from_start: Time since start
|
||||
display_time_from_start_description: Format mm:ss or h:mm:ss
|
||||
display_relative_time: Time for this puzzle
|
||||
puzzle: Puzzle
|
||||
missing_pieces: Missing pieces
|
||||
projected_time: Projected time
|
||||
remaining_pieces: Remaining pieces (not completed puzzle)
|
||||
contest:
|
||||
name: "Name"
|
||||
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"
|
||||
code: Code for onsite judges
|
||||
code_description: Optional. Used for organizers on onsite contests, when printing QR codes on tables
|
||||
duration: Duration
|
||||
duration_description: Format h:mm or hh:mm
|
||||
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 puzzle before starting solving it, and validate their finish time with an upload of an image of the completed puzzle
|
||||
offline_form_warning: Only for single-puzzle contests
|
||||
public: Enable the public scoreboard
|
||||
ranking_mode: Ranking mode (public scoreboard & CSV exports)
|
||||
show_stopwatch: Display the stopwatch
|
||||
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
|
||||
offline: Offline?
|
||||
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:
|
||||
completed: Puzzle completed
|
||||
name: Your name
|
||||
missing_pieces: Missing pieces
|
||||
remaining_pieces: Remaining pieces
|
||||
puzzle:
|
||||
brand: Brand
|
||||
hidden: hidden
|
||||
hidden_description: When hidden, a puzzle doesn't appear in the public scoreboard, nor public forms
|
||||
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"
|
||||
username: Username
|
||||
email_address: Email address
|
||||
lang: Language
|
||||
password: New password
|
||||
errors:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
code:
|
||||
mismatch: "Wrong code"
|
||||
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"
|
||||
remaining_pieces:
|
||||
blank: This is required
|
||||
not_an_integer: This is not an integer
|
||||
not_a_number: This is not an integer
|
||||
too_large: "Cannot be greater than the number of pieces for this puzzle"
|
||||
contest:
|
||||
attributes:
|
||||
duration:
|
||||
blank: Must be filled
|
||||
invalid: Invalid duration
|
||||
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
|
||||
missing_pieces:
|
||||
not_an_integer: This is not an integer
|
||||
not_a_number: This is not an integer
|
||||
name:
|
||||
blank: Please enter a name for your participation
|
||||
remaining_pieces:
|
||||
blank: You need to provide the number of remaining pieces to assemble
|
||||
not_an_integer: This is not an integer
|
||||
not_a_number: This is not an integer
|
||||
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
|
||||
taken: This username is already taken
|
||||
password:
|
||||
blank: Your password 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:
|
||||
title: "Edit completion"
|
||||
notice: Completion updated
|
||||
title: Edit completion
|
||||
form:
|
||||
all_finished: "All puzzles were already completed by %{name}"
|
||||
code: Judges code
|
||||
validate_name: "Validate a puzzle for %{name}"
|
||||
new:
|
||||
title: "New completion"
|
||||
notice: Completion added
|
||||
title: New completion
|
||||
singular: completion
|
||||
contests:
|
||||
destroy:
|
||||
notice: Contest deleted
|
||||
edit:
|
||||
title: "Edit contest settings"
|
||||
notice: Contest updated
|
||||
title: Edit contest settings
|
||||
form:
|
||||
offline_single_puzzle_warning: This is not available for contests with more than one puzzle
|
||||
index:
|
||||
title: "Welcome %{username}!"
|
||||
manage_contests: "Manage my contests"
|
||||
new_contest: "Create a new contest"
|
||||
title: Welcome %{username}!
|
||||
manage_contests: Manage my contests
|
||||
new_contest: Create a new contest
|
||||
nav:
|
||||
categories: Participant categories
|
||||
categories_description: Once you add categories, you will be able to assign them to participants on their profiles, and a filter for categories will be available on the public scoreboard
|
||||
general: General
|
||||
online: Online contests
|
||||
onsite: Onsite contests
|
||||
public: Public scoreboard
|
||||
settings: Settings
|
||||
stopwatch: Stopwatch
|
||||
new:
|
||||
title: "New jigsaw puzzle contest"
|
||||
notice: Contest added
|
||||
title: New jigsaw puzzle contest
|
||||
scoreboard:
|
||||
all_categories: All categories
|
||||
auto_refresh: Auto refresh (30s)
|
||||
hide_offline: Hide offline participants
|
||||
refresh: Activate auto-refresh (every 5s)
|
||||
title: "%{name}"
|
||||
show:
|
||||
title: "%{name}"
|
||||
add_participant: "Add contestant"
|
||||
add_puzzle: "Add puzzle"
|
||||
public_scoreboard: "Public scoreboard: "
|
||||
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
|
||||
stopwatch:
|
||||
info: This stopwatch is used for display in the public scoreboard, when allowed in the settings
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Import participants
|
||||
destroy:
|
||||
notice: Participant deleted
|
||||
edit:
|
||||
title: "Participant"
|
||||
team_title: "Teams"
|
||||
completions_note: The time doesn't automatically account penalties for missing pieces. The ability to specify time penalties will be added later on, stay tuned!
|
||||
end_image: End image
|
||||
notice: Participant updated
|
||||
not_finished: Not yet finished
|
||||
no_puzzles_note: No puzzles were added yet
|
||||
offline_participation: Offline participation
|
||||
start_image: Start image
|
||||
title: Participant
|
||||
team_title: Teams
|
||||
finalize_import:
|
||||
title: Import participants
|
||||
generate_qrcodes:
|
||||
note: These QR codes allow for judges to fill in results without the need of the organizer's account, for example by printing them and placing them on participant tables
|
||||
no_code_note: Those can't be used until a code for judges has been set up in the general settings
|
||||
import:
|
||||
email_column: Participant email
|
||||
import_column: Import?
|
||||
name_column: Participant name
|
||||
notice: Participants imported
|
||||
title: Import participants
|
||||
new:
|
||||
title: "New participant"
|
||||
team_title: "New team"
|
||||
singular: "participant"
|
||||
plural: "participants"
|
||||
notice: Participant added
|
||||
title: New participant
|
||||
team_title: New team
|
||||
singular: participant
|
||||
plural: participants
|
||||
teams:
|
||||
singular: "team"
|
||||
plural: "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"
|
||||
save: "Save"
|
||||
details: Open
|
||||
delete: "Delete"
|
||||
edit: "Edit"
|
||||
end: Click here to submit your completion
|
||||
export: Export
|
||||
generate_qrcodes: Generate QR codes
|
||||
import: CSV Import
|
||||
open: Open
|
||||
open_raw: Open in a raw page
|
||||
refresh: Refresh
|
||||
settings: Settings
|
||||
sign_in: Sign in
|
||||
save: Save
|
||||
save_password: Save password
|
||||
start: Click here to start your participation
|
||||
stopwatch_continue: Continue
|
||||
stopwatch_pause: Pause
|
||||
stopwatch_reset: Reset
|
||||
stopwatch_start: Start
|
||||
update: Save modifications
|
||||
field: Field
|
||||
none: No field selected
|
||||
rank: Rank
|
||||
lib:
|
||||
ranking:
|
||||
actual: First by number of pieces assembled, then by time (recommended if unsure)
|
||||
theorical: By time only (projected time calculated with the ppm count)
|
||||
messages:
|
||||
index:
|
||||
info: This section is only used for contests that rely on the connection with Google Meet
|
||||
no_messages: No messages received yet
|
||||
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: "Home"
|
||||
settings: "Settings"
|
||||
log_out: "Log out"
|
||||
users: Users
|
||||
home: My contests
|
||||
settings: My account
|
||||
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, or on the state it is if you decide to give up
|
||||
missing_pieces: Indicate the number of missing pieces, if any
|
||||
remaining_pieces: Indicate the number of remaining pieces to assemble
|
||||
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 %{name}!
|
||||
puzzles:
|
||||
destroy:
|
||||
notice: Puzzle deleted
|
||||
edit:
|
||||
title: "Edit contest puzzle"
|
||||
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 8M
|
||||
image_select: Select an image
|
||||
new:
|
||||
title: "New contest puzzle"
|
||||
singular: "puzzle"
|
||||
plural: "puzzles"
|
||||
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"
|
||||
password_section: "Password"
|
||||
index:
|
||||
title: "All users"
|
||||
new:
|
||||
notice: User created
|
||||
title: "New user"
|
||||
|
||||
@@ -1,75 +1,335 @@
|
||||
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:
|
||||
completed: Puzzle terminé
|
||||
contestant_id: Participant.e
|
||||
display_time: Temps
|
||||
display_time_from_start: Temps depuis le début
|
||||
display_time_from_start_description: Format mm:ss ou h:mm:ss
|
||||
display_relative_time: Temps pour ce puzzle
|
||||
puzzle: Puzzle
|
||||
missing_pieces: Pièces manquantes
|
||||
projected_time: Temps projeté
|
||||
remaining_pieces: Pièces restantes (puzzle non fini)
|
||||
contest:
|
||||
name: "Nom"
|
||||
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"
|
||||
code: Code pour les organisateur.ice.s
|
||||
code_description: Optionnel. Utilisé pour les organisateur.ices dans les concours en présentiel lorsque sont imprimés des QR codes à placer sur les tables
|
||||
duration: Durée
|
||||
duration_description: Format h:mm ou hh:mm
|
||||
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 avant de le commencer, puis valider leur temps avec une photo du puzzle une fois complété
|
||||
offline_form_warning: Activable uniquement pour les concours avec un seul puzzle
|
||||
public: Activer le classement public
|
||||
ranking_mode: Mode de classement (classement public & exports CSV)
|
||||
show_stopwatch: Afficher le chronomètre
|
||||
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
|
||||
offline: Hors-ligne ?
|
||||
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:
|
||||
completed: Puzzle terminé
|
||||
name: Ton nom ou pseudo
|
||||
missing_pieces: Pièces manquantes
|
||||
remaining_pieces: Pièces restantes
|
||||
puzzle:
|
||||
brand: Marque
|
||||
hidden: Non découvert
|
||||
hidden_description: Un puzzle non découvert n'apparaît pas dans le classement public, ni dans les formulaires publics
|
||||
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"
|
||||
username: Nom d'utilisateur.ice
|
||||
email_address: Adresse email
|
||||
lang: Langue de l'interface
|
||||
password: Nouveau mot de passe
|
||||
errors:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
code:
|
||||
mismatch: "Code non valide"
|
||||
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"
|
||||
remaining_pieces:
|
||||
blank: Ce champ est obligatoire
|
||||
not_an_integer: Ce n'est pas un nombre entier
|
||||
not_a_number: Ce n'est pas un nombre entier
|
||||
too_large: "Ne peut pas être plus grand que le nombre de pièces du puzzle"
|
||||
contest:
|
||||
attributes:
|
||||
duration:
|
||||
blank: Obligatoire
|
||||
invalid: Durée invalide
|
||||
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 ta participation
|
||||
missing_pieces:
|
||||
not_an_integer: Ce n'est pas un entier
|
||||
not_a_number: Ce n'est pas un entier
|
||||
name:
|
||||
blank: Tu dois entrer un nom pour pouvoir participer
|
||||
remaining_pieces:
|
||||
blank: Tu dois renseigner le nombre de pièces restantes à assembler
|
||||
not_an_integer: Ce n'est pas un entier
|
||||
not_a_number: Ce n'est pas un entier
|
||||
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
|
||||
taken: Ce nom d'utilisateur.ice est déjà utilisé
|
||||
password:
|
||||
blank: Le mot de passe ne peut pas être vide
|
||||
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:
|
||||
title: "Modifier la complétion"
|
||||
notice: Complétion modifiée
|
||||
title: Modifier la complétion
|
||||
form:
|
||||
all_finished: Tous les puzzles ont déjà été complétés par %{name}
|
||||
code: Code organisateur.ice
|
||||
validate_name: "Valider un puzzle pour %{name}"
|
||||
new:
|
||||
title: "Nouvelle complétion"
|
||||
notice: Complétion ajoutée
|
||||
title: Ajout d'une complétion
|
||||
singular: complétion
|
||||
contests:
|
||||
destroy:
|
||||
notice: Concours supprimé
|
||||
edit:
|
||||
title: "Paramètres du concours"
|
||||
notice: Concours modifié
|
||||
title: Paramètres du concours
|
||||
form:
|
||||
offline_single_puzzle_warning: Ce n'est pas activable pour les concours avec plusieurs puzzles
|
||||
index:
|
||||
title: "Bienvenue %{username} !"
|
||||
manage_contests: "Mes concours de puzzle"
|
||||
new_contest: "Créer un nouveau concours"
|
||||
title: Bienvenue %{username} !
|
||||
manage_contests: Mes concours de puzzle
|
||||
new_contest: Créer un nouveau concours
|
||||
nav:
|
||||
categories: Catégories de participant.e.s
|
||||
categories_description: Après avoir ajouté des catégories, elles pourront être attributées aux participant.e.s sur leurs profils, et un filtre sera disponible sur le classement public
|
||||
general: Général
|
||||
online: Concours en ligne
|
||||
onsite: Concours en présentiel
|
||||
public: Classement public
|
||||
settings: Paramètres
|
||||
stopwatch: Chronomètre
|
||||
new:
|
||||
title: "Nouveau concours"
|
||||
notice: Concours ajouté
|
||||
title: Nouveau concours
|
||||
scoreboard:
|
||||
all_categories: Toutes les catégories
|
||||
auto_refresh: Auto-rafraichissement (30s)
|
||||
hide_offline: Cacher les participant.e.s hors-ligne
|
||||
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"
|
||||
public_scoreboard: "Classement public : "
|
||||
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
|
||||
stopwatch:
|
||||
info: Ce chronomètre est utilisé pour être affiché dans le classement public, quand autorisé dans les paramètres
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Importer des participant.e.s
|
||||
destroy:
|
||||
notice: Participant.e supprimé.e
|
||||
edit:
|
||||
title: "Participant.e"
|
||||
team_title: "Équipe"
|
||||
completions_note: Le temps n'inclut actuellement pas de pénalité pour les pièces manquantes. La possibilité de spécifier des pénalités en temps sera ajouté plus tard à l'interface !
|
||||
end_image: Image de fin
|
||||
notice: Participant.e modifié.e
|
||||
not_finished: Non terminé
|
||||
no_puzzles_note: Aucun puzzle n'a été défini encore pour ce concours
|
||||
offline_participation: Participation hors-ligne
|
||||
start_image: Image de début
|
||||
title: Participant.e
|
||||
team_title: Équipe
|
||||
finalize_import:
|
||||
title: Importer des participant.e.s
|
||||
generate_qrcodes:
|
||||
note: Ces QR codes permettent, quand imprimés et placés sur les tables des participant.e.s, aux organisateur.ice.s de valider les temps de complétion des puzzles
|
||||
no_code_note: Les codes ne seront générés qu'une fois un code pour les organisateur.ice.s défini dans les paramètres généraux
|
||||
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:
|
||||
title: "Nouveau.elle participant.e"
|
||||
team_title: "Nouvelle équipe"
|
||||
singular: "participant.e"
|
||||
plural: "participant.e.s"
|
||||
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"
|
||||
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"
|
||||
save: "Modifier"
|
||||
delete: "Supprimer"
|
||||
details: Détails
|
||||
edit: "Modifier"
|
||||
end: Clique ici pour valider ta complétion du puzzle
|
||||
export: Exporter
|
||||
generate_qrcodes: Générer des QR codes
|
||||
import: Importer un CSV
|
||||
open: Ouvrir
|
||||
open_raw: Ouvrir dans un format imprimable
|
||||
refresh: Rafraîchir
|
||||
settings: Paramètres
|
||||
sign_in: Se connecter
|
||||
save: Modifier
|
||||
save_password: Modifier le mot de passe
|
||||
start: Clique ici pour démarrer ta participation
|
||||
stopwatch_continue: Reprendre
|
||||
stopwatch_pause: Pause
|
||||
stopwatch_reset: Ré-initialiser
|
||||
stopwatch_start: Démarrer
|
||||
update: Enregistrer les modifications
|
||||
field: Champ
|
||||
none: Aucun champ sélectionné
|
||||
rank: Rang
|
||||
lib:
|
||||
ranking:
|
||||
actual: Par nombre de pièces assemblées, puis par temps (recommandé)
|
||||
theorical: Par temps uniquement (temps projeté calculé à partir de la vitesse d'assemblage)
|
||||
messages:
|
||||
index:
|
||||
info: Cette section n'est pertinente que pour les concours en ligne qui utilisent la connexion depuis Google Meet
|
||||
no_messages: Pas de messages reçus pour le moment
|
||||
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: "Accueil"
|
||||
settings: "Paramètres"
|
||||
log_out: "Déconnexion"
|
||||
users: Utilisateur.ices
|
||||
home: Mes concours
|
||||
settings: Mon compte
|
||||
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é, ou de l'état actuel si tu choisis de t'arrêter là
|
||||
missing_pieces: Indique le nombre de pièces manquantes s'il y en a
|
||||
remaining_pieces: Indique ici le nombre de pièces restantes à assembler
|
||||
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 %{name} !
|
||||
puzzles:
|
||||
destroy:
|
||||
notice: Puzzle supprimé
|
||||
edit:
|
||||
title: "Modifier le puzzle"
|
||||
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 8M
|
||||
image_select: Choisis une image
|
||||
new:
|
||||
title: "Nouveau puzzle"
|
||||
singular: "puzzle"
|
||||
plural: "puzzles"
|
||||
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"
|
||||
password_section: "Mot de passe"
|
||||
index:
|
||||
title: "Tous.tes les utilisateur.ices"
|
||||
new:
|
||||
notice: Utilisateur.ice ajouté.e
|
||||
title: "Nouveau.elle utilisateur.ice"
|
||||
|
||||
@@ -9,13 +9,76 @@ Rails.application.routes.draw do
|
||||
root "contests#index"
|
||||
|
||||
resources :contests do
|
||||
get "settings/general", to: "contests#settings_general_edit"
|
||||
patch "settings/general", to: "contests#settings_general_update"
|
||||
get "settings/public", to: "contests#settings_public_edit"
|
||||
patch "settings/public", to: "contests#settings_public_update"
|
||||
get "settings/onsite", to: "contests#settings_onsite_edit"
|
||||
patch "settings/onsite", to: "contests#settings_onsite_update"
|
||||
get "settings/online", to: "contests#settings_online_edit"
|
||||
patch "settings/online", to: "contests#settings_online_update"
|
||||
get "settings/categories", to: "contests#settings_categories_edit"
|
||||
get "stopwatch", to: "contests#stopwatch"
|
||||
post "stopwatch_continue", to: "contests#stopwatch_continue"
|
||||
post "stopwatch_pause", to: "contests#stopwatch_pause"
|
||||
post "stopwatch_reset", to: "contests#stopwatch_reset"
|
||||
post "stopwatch_start", to: "contests#stopwatch_start"
|
||||
resources :categories, only: [ :create, :destroy ]
|
||||
resources :completions
|
||||
resources :contestants
|
||||
resources :puzzles
|
||||
resources :messages, only: [ :destroy, :index ] 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"
|
||||
get "generate_qrcodes", to: "contestants#generate_qrcodes"
|
||||
get "generate_qrcodes_pdf", to: "contestants#generate_qrcodes_pdf"
|
||||
end
|
||||
resources :passwords, param: :token
|
||||
resource :session
|
||||
resources :users
|
||||
resources :users do
|
||||
patch "password", to: "users#change_password"
|
||||
end
|
||||
|
||||
options "connect", to: "messages#cors_preflight_check"
|
||||
options "message", to: "messages#cors_preflight_check"
|
||||
post "connect", to: "messages#connect"
|
||||
post "message", to: "messages#create"
|
||||
|
||||
post "regenerate_qrcodes", to: "users#regenerate_qrcodes"
|
||||
post "update_contestants", to: "users#update_contestants"
|
||||
|
||||
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"
|
||||
get "public/p/:contestant_id", to: "contestants#get_public_completion"
|
||||
post "public/p/:contestant_id", to: "contestants#post_public_completion"
|
||||
get "public/p/:contestant_id/updated", to: "contestants#public_completion_updated"
|
||||
|
||||
direct :direct_test do
|
||||
"https://lol.com"
|
||||
end
|
||||
|
||||
direct :public_scoreboard do |contest|
|
||||
"/public/#{contest.friendly_id}/public"
|
||||
end
|
||||
|
||||
direct :offline_form do |contest|
|
||||
"/public/#{contest.friendly_id}/offline"
|
||||
end
|
||||
|
||||
direct :offline_form_edit do |contest, offline|
|
||||
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}"
|
||||
end
|
||||
|
||||
direct :offline_form_completed do |contest, offline|
|
||||
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}/completed"
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user