Compare commits

...

61 Commits

Author SHA1 Message Date
sto
7db96cfab4 CSV export: correct display time + remaining pieces
Some checks failed
CI / scan_ruby (push) Failing after 14s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 37s
2025-10-28 15:22:45 +01:00
sto
bbd2cef168 Support remaining pieces in completions and scoreboards
Some checks failed
CI / scan_ruby (push) Failing after 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-10-28 15:13:29 +01:00
sto
1fa7bf10ec Add contestant export feature
Some checks failed
CI / scan_ruby (push) Failing after 1m5s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-10-28 10:19:19 +01:00
sto
916c7af738 Fix refresh
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 28s
2025-07-16 10:53:33 +02:00
sto
537f32ab8b Don't change browser history on category filter change
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 31s
2025-07-16 10:43:08 +02:00
sto
5b9862c19c Fix completion deps for puzzle deletion
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 28s
2025-07-16 10:39:59 +02:00
sto
4ca711f5aa Add category selectors on public scoreboards
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 30s
2025-07-16 10:38:21 +02:00
sto
b13ef30807 Permit to modify contestants categories
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-07-16 10:22:47 +02:00
sto
657c5ac47b Internal scoreboard: add category filter
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 34s
2025-07-15 18:23:26 +02:00
sto
502649620b Add contestant categories to contests
All checks were successful
CI / scan_ruby (push) Successful in 1m4s
CI / scan_js (push) Successful in 3m20s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 2m7s
2025-07-14 14:54:55 +02:00
sto
ee476ab81b Improve dashboard display
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-06-27 13:15:53 +02:00
sto
0599def237 Add number of pieces to puzzles
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 34s
2025-06-27 09:23:25 +02:00
sto
b6da55723d Ensure puzzle uniqueness per contestant validation error is shown
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 33s
2025-06-27 08:59:27 +02:00
sto
9862f0c74b Relative times: pad to minutes format
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 39s
2025-06-26 10:56:09 +02:00
sto
1b34d10dee Improve public scoreboard UI + make it responsive
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 43s
2025-06-26 10:53:21 +02:00
sto
d28f888ee2 Add refresh button for the scoreboard
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 35s
2025-06-25 17:36:33 +02:00
sto
2b1a2c9296 Add "public" setting to contests
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 35s
2025-06-25 10:07:27 +02:00
sto
1a8ea0afee Suggest closest contestant name when converting a message to completion
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 39s
2025-06-25 08:54:02 +02:00
sto
2cadc8eca5 Add completion: order contestants by name + add email if present
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-06-25 08:31:42 +02:00
sto
c34b9654c8 Client side puzzle image size validation
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 40s
2025-06-25 08:00:49 +02:00
sto
341e626f6f Delete test directory
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 28s
2025-06-22 07:56:22 +02:00
sto
c22b529858 Add contest feature specs
Some checks failed
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Has been cancelled
2025-06-22 07:55:23 +02:00
sto
50050064c2 Bundle update
All checks were successful
CI / scan_ruby (push) Successful in 35s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 33s
2025-06-22 06:59:51 +02:00
sto
5aa69a108c Allow all origins for sending messages
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
2025-06-21 18:20:27 +02:00
sto
ef3c63ea67 Add /connect route
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 26s
2025-06-21 18:00:06 +02:00
sto
6fb5ba5f3e Flexify scoreboard
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 27s
2025-06-21 11:11:18 +02:00
sto
6c16e5e232 Improve notice height
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 30s
2025-06-21 11:04:08 +02:00
sto
2969a24cb0 Fix display_time
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
2025-06-21 10:54:20 +02:00
sto
4b5c09f63b Deactivate badges
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 29s
2025-06-21 10:37:11 +02:00
sto
ca7399f490 Flexify the contest dashboard
Some checks failed
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Has been cancelled
2025-06-21 10:36:19 +02:00
sto
f27b43ef45 Improve top buttons
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 27s
2025-06-21 10:07:42 +02:00
sto
5b908fe37c Add notices
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 39s
2025-06-21 09:59:18 +02:00
sto
2616cbaa71 Add message when the URL is copied to the clipboard
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
2025-06-21 07:23:02 +02:00
sto
70c0fed0c4 Show current puzzle image in puzzle edit form
All checks were successful
CI / scan_ruby (push) Successful in 14s
CI / scan_js (push) Successful in 10s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 27s
2025-06-21 07:08:14 +02:00
sto
6c0f5167a4 Add puzzle images to the scoreboard
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 31s
2025-06-21 07:05:12 +02:00
sto
ac3b354480 Contest language & top buttons
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 33s
2025-06-20 08:07:39 +02:00
sto
71f2bb6b70 Fix completion conversion in case of errors
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 30s
2025-06-19 17:28:55 +02:00
sto
ac83a599f3 Make email mandatory + sign in translations
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
2025-06-19 16:54:47 +02:00
sto
67492cdd15 Update completion -> back to contestant
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 30s
2025-06-19 11:30:21 +02:00
sto
79fb1edfaf Multiples traductions
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 32s
2025-06-19 11:20:33 +02:00
sto
4645b45f5d Fix CSV import & contestant deletion
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 35s
2025-06-19 10:33:36 +02:00
sto
f78a082ad3 Add warning for messages
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 33s
2025-06-18 19:23:44 +02:00
sto
b8674a126f Back buttons
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
2025-06-18 19:09:55 +02:00
sto
67d2ef41b3 Add indicator for processed messages
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
2025-06-18 18:42:04 +02:00
sto
96b8553b1f Add puzzle fake data recommendation
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-06-18 15:00:02 +02:00
sto
194c126c90 Correctly order participants
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 31s
2025-06-18 14:49:16 +02:00
sto
a33f3ff4de Display more participant info on contest dashboard
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 28s
2025-06-18 07:46:45 +02:00
sto
17a1af4e9f Prevent the user from converting messages and warn them, if there are no puzzles
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 33s
2025-06-18 07:40:05 +02:00
sto
baea71b312 Autofill puzzle and don't show it when there's only one puzzle
All checks were successful
CI / scan_ruby (push) Successful in 14s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 30s
2025-06-18 07:15:39 +02:00
sto
bc32387c21 Allow message deletion
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-06-18 07:05:39 +02:00
sto
55399d80fe Add CORS to /message
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-06-13 19:33:56 +02:00
sto
d7d90f0c91 Add extension URL display
All checks were successful
CI / scan_ruby (push) Successful in 1m3s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 38s
2025-06-13 18:30:47 +02:00
sto
7444a09046 Translations for the contest dashboard page
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 28s
2025-05-18 09:52:58 +02:00
sto
ec2201f9a8 Implement CSV import and conversion to contestants
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 26s
2025-05-17 17:40:03 +02:00
sto
939e2157ab Start CSV importer feature
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 32s
2025-05-17 12:03:10 +02:00
sto
5ec0e264ba Upgrade gems
All checks were successful
CI / scan_ruby (push) Successful in 41s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 58s
2025-05-15 08:58:22 +02:00
sto
c4902d85d5 Messages to completions conversion
Some checks failed
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 31s
2025-05-15 08:57:25 +02:00
sto
e65d639ca6 Improve add buttons
Some checks failed
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 31s
2025-05-14 18:09:36 +02:00
sto
1397ddce2f Implement message delete method
Some checks failed
CI / scan_ruby (push) Failing after 13s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 41s
2025-05-14 16:37:28 +02:00
sto
138fe67baa Improve show contest buttons
Some checks failed
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 10s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 29s
2025-05-14 15:17:42 +02:00
sto
3a8517e637 Show messages on contest management view
Some checks failed
CI / scan_ruby (push) Failing after 11m23s
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-05-11 21:22:19 +02:00
88 changed files with 1854 additions and 696 deletions

View File

@@ -44,6 +44,8 @@ gem "slim"
gem "dartsass-rails" gem "dartsass-rails"
gem "bootstrap", "~> 5.3.3" gem "bootstrap", "~> 5.3.3"
gem "friendly_id", "~> 5.5.0" gem "friendly_id", "~> 5.5.0"
gem "csv"
gem "damerau-levenshtein"
group :development, :test do group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem

View File

@@ -74,22 +74,21 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0) annotaterb (4.16.0)
ast (2.4.2) activerecord (>= 6.0.0)
autoprefixer-rails (10.4.19.0) activesupport (>= 6.0.0)
execjs (~> 2) ast (2.4.3)
base64 (0.2.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
benchmark (0.4.0) benchmark (0.4.1)
bigdecimal (3.1.9) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.4) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
bootstrap (5.3.3) bootstrap (5.3.5)
autoprefixer-rails (>= 9.1.0)
popper_js (>= 2.11.8, < 3) popper_js (>= 2.11.8, < 3)
brakeman (7.0.0) brakeman (7.0.2)
racc racc
builder (3.3.0) builder (3.3.0)
capybara (3.40.0) capybara (3.40.0)
@@ -102,28 +101,30 @@ GEM
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.0) connection_pool (2.5.3)
crass (1.0.6) crass (1.0.6)
csv (3.3.5)
damerau-levenshtein (1.3.3)
dartsass-rails (0.5.1) dartsass-rails (0.5.1)
railties (>= 6.0.0) railties (>= 6.0.0)
sass-embedded (~> 1.63) sass-embedded (~> 1.63)
date (3.4.1) date (3.4.1)
debug (1.10.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
diff-lcs (1.6.1) diff-lcs (1.6.2)
dotenv (3.1.7) dotenv (3.1.8)
drb (2.2.1) drb (2.2.3)
ed25519 (1.3.0) ed25519 (1.4.0)
erb (5.0.1)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
execjs (2.10.0) factory_bot (6.5.4)
factory_bot (6.5.1)
activesupport (>= 6.1.0) activesupport (>= 6.1.0)
factory_bot_rails (6.4.4) factory_bot_rails (6.5.0)
factory_bot (~> 6.5) factory_bot (~> 6.5)
railties (>= 5.0.0) railties (>= 6.1.0)
faker (3.5.1) faker (3.5.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
friendly_id (5.5.1) friendly_id (5.5.1)
@@ -133,13 +134,19 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.30.0) google-protobuf (4.31.1)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.30.0-aarch64-linux) google-protobuf (4.31.1-aarch64-linux-gnu)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.30.0-x86_64-linux) google-protobuf (4.31.1-aarch64-linux-musl)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-linux-gnu)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-linux-musl)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
i18n (1.14.7) i18n (1.14.7)
@@ -149,29 +156,29 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.0) io-console (0.8.0)
irb (1.15.1) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.13.0) jbuilder (2.13.0)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
json (2.10.2) json (2.12.2)
kamal (2.5.3) kamal (2.7.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2) concurrent-ruby (~> 1.2)
dotenv (~> 3.1) dotenv (~> 3.1)
ed25519 (~> 1.2) ed25519 (~> 1.4)
net-ssh (~> 7.3) net-ssh (~> 7.3)
sshkit (>= 1.23.0, < 2.0) sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3) thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0) 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) lint_roller (1.1.0)
logger (1.6.6) logger (1.7.0)
loofah (2.24.0) loofah (2.24.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -180,11 +187,11 @@ GEM
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
matrix (0.4.2) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.4) minitest (5.25.5)
msgpack (1.8.0) msgpack (1.8.0)
net-imap (0.5.6) net-imap (0.5.9)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -199,44 +206,45 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.3-aarch64-linux-gnu) nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-aarch64-linux-musl) nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-arm-linux-gnu) nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-arm-linux-musl) nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu) nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-musl) nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
ostruct (0.6.1) ostruct (0.6.2)
parallel (1.26.3) parallel (1.27.0)
parser (3.3.7.1) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
popper_js (2.11.8) popper_js (2.11.8)
pp (0.6.2) pp (0.6.2)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.4.0)
propshaft (1.1.0) propshaft (1.1.0)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
railties (>= 7.0.0) railties (>= 7.0.0)
psych (5.2.3) psych (5.2.6)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.2)
puma (6.6.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.5.0) pundit (2.5.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.12) rack (3.1.16)
rack-session (2.1.0) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
@@ -257,7 +265,7 @@ GEM
activesupport (= 8.0.2) activesupport (= 8.0.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.2)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
@@ -273,31 +281,32 @@ GEM
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.3.0)
rdoc (6.12.0) rdoc (6.14.1)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.0) reline (0.6.1)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.1) rexml (3.4.1)
rspec-core (3.13.3) rspec-core (3.13.4)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.3) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.2) rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.1.1) rspec-rails (8.0.1)
actionpack (>= 7.0) actionpack (>= 7.2)
activesupport (>= 7.0) activesupport (>= 7.2)
railties (>= 7.0) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (~> 3.13)
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.2) rspec-support (3.13.4)
rubocop (1.73.2) rubocop (1.77.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -305,41 +314,42 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.45.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1) rubocop-ast (1.45.1)
parser (>= 3.3.1.0) parser (>= 3.3.7.2)
rubocop-performance (1.24.0) prism (~> 1.4)
rubocop-performance (1.25.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3) rubocop-rails (2.32.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.72.1, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0) rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72) rubocop (>= 1.72)
rubocop-performance (>= 1.24) rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30) rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rubyzip (2.4.1) rubyzip (2.4.1)
sass-embedded (1.85.1-aarch64-linux-gnu) sass-embedded (1.89.2-aarch64-linux-gnu)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-aarch64-linux-musl) sass-embedded (1.89.2-aarch64-linux-musl)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-arm-linux-gnueabihf) sass-embedded (1.89.2-arm-linux-gnueabihf)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-arm-linux-musleabihf) sass-embedded (1.89.2-arm-linux-musleabihf)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-x86_64-linux-gnu) sass-embedded (1.89.2-x86_64-linux-gnu)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-x86_64-linux-musl) sass-embedded (1.89.2-x86_64-linux-musl)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.29.1) selenium-webdriver (4.33.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@@ -348,7 +358,7 @@ GEM
slim (5.2.1) slim (5.2.1)
temple (~> 0.10.0) temple (~> 0.10.0)
tilt (>= 2.1.0) tilt (>= 2.1.0)
solid_cable (3.0.7) solid_cable (3.0.10)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
@@ -357,19 +367,19 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_queue (1.1.3) solid_queue (1.1.5)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0) fugit (~> 1.11.0)
railties (>= 7.1) railties (>= 7.1)
thor (~> 1.3.1) thor (~> 1.3.1)
sqlite3 (2.6.0-aarch64-linux-gnu) sqlite3 (2.7.0-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl) sqlite3 (2.7.0-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu) sqlite3 (2.7.0-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl) sqlite3 (2.7.0-arm-linux-musl)
sqlite3 (2.6.0-x86_64-linux-gnu) sqlite3 (2.7.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl) sqlite3 (2.7.0-x86_64-linux-musl)
sshkit (1.24.0) sshkit (1.24.0)
base64 base64
logger logger
@@ -379,15 +389,15 @@ GEM
ostruct ostruct
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.5) stringio (3.1.7)
temple (0.10.3) temple (0.10.3)
thor (1.3.2) thor (1.3.2)
thruster (0.1.12) thruster (0.1.14)
thruster (0.1.12-aarch64-linux) thruster (0.1.14-aarch64-linux)
thruster (0.1.12-x86_64-linux) thruster (0.1.14-x86_64-linux)
tilt (2.6.0) tilt (2.6.0)
timeout (0.4.3) timeout (0.4.3)
turbo-rails (2.0.13) turbo-rails (2.0.16)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -403,13 +413,13 @@ GEM
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.7.7) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.2) zeitwerk (2.7.3)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -428,6 +438,8 @@ DEPENDENCIES
bootstrap (~> 5.3.3) bootstrap (~> 5.3.3)
brakeman brakeman
capybara capybara
csv
damerau-levenshtein
dartsass-rails dartsass-rails
debug debug
factory_bot_rails factory_bot_rails

View File

@@ -0,0 +1,37 @@
class CategoriesController < ApplicationController
before_action :set_contest
before_action :set_category, only: %i[ destroy]
def create
authorize @contest
@category = Category.new(category_params)
@category.contest_id = @contest.id
if @category.save
redirect_to edit_contest_path(@contest), notice: t("categories.new.notice")
else
redirect_to edit_contest_path(@contest), notice: t("categories.new.error")
end
end
def destroy
authorize @contest
@category.destroy
redirect_to edit_contest_path(@contest), notice: t("categories.destroy.notice")
end
private
def set_contest
@contest = Contest.find(params[:contest_id])
end
def set_category
@category = Category.find(params[:id])
end
def category_params
params.expect(category: [ :name ])
end
end

View File

@@ -2,16 +2,27 @@ class CompletionsController < ApplicationController
include CompletionsConcern include CompletionsConcern
before_action :set_contest before_action :set_contest
before_action :set_contestant
before_action :set_data, only: %i[ create edit new update ] before_action :set_data, only: %i[ create edit new update ]
before_action :set_completion, only: %i[ destroy edit update ] before_action :set_completion, only: %i[ destroy edit update ]
def edit def edit
authorize @contest authorize @contest
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
end end
def new def new
authorize @contest authorize @contest
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
@completion = Completion.new @completion = Completion.new
if params[:contestant_id] if params[:contestant_id]
@completion.contestant_id = params[:contestant_id] @completion.contestant_id = params[:contestant_id]
@@ -25,10 +36,20 @@ class CompletionsController < ApplicationController
@completion.contest_id = @contest.id @completion.contest_id = @contest.id
if @completion.save if @completion.save
extend_completions!(@completion.contestant) 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 else
logger = Logger.new(STDOUT) redirect_to @contest, notice: t("completions.new.notice")
logger.info(@completion.errors) end
else
if params[:completion].key?(:message_id)
@message = Message.find(params[:completion][:message_id])
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
elsif @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
@@ -36,13 +57,19 @@ class CompletionsController < ApplicationController
def update def update
authorize @contest authorize @contest
if params[:contestant_id] @completion.contestant_id = params[:contestant_id] if params[:contestant_id]
@completion.contestant_id = params[:contestant_id]
end
if @completion.update(completion_params) if @completion.update(completion_params)
extend_completions!(@completion.contestant) extend_completions!(@completion.contestant)
redirect_to @contest if @contestant
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.edit.notice")
else else
redirect_to @contest, notice: t("completions.edit.notice")
end
else
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -52,9 +79,9 @@ class CompletionsController < ApplicationController
@completion.destroy @completion.destroy
if params[:contestant_id] 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 else
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("completions.destroy.notice")
end end
end end
@@ -64,8 +91,16 @@ class CompletionsController < ApplicationController
@contest = Contest.find(params[:contest_id]) @contest = Contest.find(params[:contest_id])
end 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 def set_data
@contestants = @contest.contestants @contestants = @contest.contestants.order(:name)
@puzzles = @contest.puzzles @puzzles = @contest.puzzles
end end
@@ -74,6 +109,6 @@ class CompletionsController < ApplicationController
end end
def completion_params def completion_params
params.expect(completion: [ :time_seconds, :contestant_id, :puzzle_id ]) params.expect(completion: [ :display_time_from_start, :remaining_pieces, :contestant_id, :message_id, :puzzle_id ])
end end
end end

View File

@@ -8,15 +8,16 @@ module CompletionsConcern
"0" + n.to_s "0" + n.to_s
end end
def display_time(seconds) def display_time(time)
if seconds > 3600 h = time / 3600
hours = seconds / 3600 m = (time % 3600) / 60
return hours.to_s + ":" + display_time(seconds % 3600) s = (time % 3600) % 60
elsif seconds > 60 if h > 0
minutes = seconds / 60 return h.to_s + ":" + pad(m) + ":" + pad(s)
return pad(minutes) + ":" + display_time(seconds % 60) elsif m > 0
return m.to_s + ":" + pad(s)
end end
pad(seconds) s.to_s
end end
def extend_completions!(contestant) def extend_completions!(contestant)
@@ -26,6 +27,6 @@ module CompletionsConcern
display_relative_time: display_time(completion.time_seconds - current_time_from_start)) display_relative_time: display_time(completion.time_seconds - current_time_from_start))
current_time_from_start = completion.time_seconds current_time_from_start = completion.time_seconds
end end
contestant.update(display_time: display_time(current_time_from_start)) contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
end end
end end

View File

@@ -5,11 +5,16 @@ class ContestantsController < ApplicationController
def edit def edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@contestant = Contestant.new @contestant = Contestant.new
end end
@@ -19,8 +24,11 @@ class ContestantsController < ApplicationController
@contestant = Contestant.new(contestant_params) @contestant = Contestant.new(contestant_params)
@contestant.contest_id = @contest.id @contestant.contest_id = @contest.id
if @contestant.save if @contestant.save
redirect_to contest_path(@contest) update_contestant_categories
redirect_to contest_path(@contest), notice: t("contestants.new.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
@@ -29,8 +37,11 @@ class ContestantsController < ApplicationController
authorize @contest authorize @contest
if @contestant.update(contestant_params) if @contestant.update(contestant_params)
redirect_to @contest update_contestant_categories
redirect_to @contest, notice: t("contestants.edit.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -39,7 +50,83 @@ class ContestantsController < ApplicationController
authorize @contest authorize @contest
@contestant.destroy @contestant.destroy
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("contestants.destroy.notice")
end
def import
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.new
end
def upload_csv
authorize @contest
@csv_import = CsvImport.new(params.require(:csv_import).permit(:file, :separator))
if @csv_import.save
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :import, status: :unprocessable_entity
end
end
def convert_csv
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
@form = Forms::CsvConversionForm.new
end
def finalize_import
authorize @contest
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
all_params = params.require(:forms_csv_conversion_form)
@form = Forms::CsvConversionForm.new(params.require(:forms_csv_conversion_form).permit(:email_column, :name_column))
if @form.valid?
@content.each_with_index do |row, i|
if all_params["row_#{i}".to_sym] == "1"
if @form.email_column == -1
Contestant.create(name: row[@form.name_column], contest: @contest)
else
logger.info("Email")
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
end
end
end
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :convert_csv, status: :unprocessable_entity
end
end
def export
authorize @contest
@contestants = @contest.contestants.sort_by { |contestant| [
-contestant.completions.where(remaining_pieces: nil).size,
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
contestant.time_seconds
] }
respond_to do |format|
format.csv do
response.headers["Content-Type"] = "text/csv"
response.headers["Content-Disposition"] = "attachment; filename=export.csv"
end
end
end end
private private
@@ -59,4 +146,15 @@ class ContestantsController < ApplicationController
def contestant_params def contestant_params
params.expect(contestant: [ :email, :name ]) params.expect(contestant: [ :email, :name ])
end end
def update_contestant_categories
@contestant.categories.clear
@contest.categories.each do |category|
logger.info(params[:contestant]["category_#{category.id}".to_sym] == "1")
if params[:contestant].key?("category_#{category.id}".to_sym) && params[:contestant]["category_#{category.id}".to_sym] == "1"
@contestant.categories << category
end
end
@contestant.save
end
end end

View File

@@ -13,13 +13,25 @@ class ContestsController < ApplicationController
authorize @contest authorize @contest
@title = I18n.t("contests.show.title", name: @contest.name) @title = I18n.t("contests.show.title", name: @contest.name)
@contestants = @contest.contestants.order(:name) @action_name = t("helpers.buttons.edit")
@action_path = edit_contest_path(@contest)
@contestants = @contest.contestants.sort_by { |contestant| [
-contestant.completions.where(remaining_pieces: nil).size,
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
contestant.time_seconds
] }
filter_contestants_per_category
@puzzles = @contest.puzzles.order(:id) @puzzles = @contest.puzzles.order(:id)
@messages = @contest.messages.order(:time_seconds)
set_badges set_badges
end end
def edit def edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
@@ -34,7 +46,7 @@ class ContestsController < ApplicationController
@contest = Contest.new(contest_params) @contest = Contest.new(contest_params)
@contest.user_id = current_user.id @contest.user_id = current_user.id
if @contest.save if @contest.save
redirect_to @contest redirect_to @contest, notice: t("contests.new.notice")
else else
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
@@ -44,27 +56,46 @@ class ContestsController < ApplicationController
authorize @contest authorize @contest
if @contest.update(contest_params) if @contest.update(contest_params)
redirect_to @contest redirect_to @contest, notice: t("contests.edit.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
def destroy def destroy
authorize @contest authorize @contest
@contest.destroy
redirect_to contests_path, notice: t("contests.destroy.notice")
end end
def scoreboard def scoreboard
@contest = Contest.find_by(slug: params[:id]) @contest = Contest.find_by(slug: params[:id])
unless @contest unless @contest && @contest.public
skip_authorization skip_authorization
not_found and return not_found and return
end end
authorize @contest authorize @contest
I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name) @title = I18n.t("contests.scoreboard.title", name: @contest.name)
@contestants = @contest.contestants.order(:name) @contestants = @contest.contestants.sort_by { |contestant| [
-contestant.completions.where(remaining_pieces: nil).size,
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
contestant.time_seconds
] }
filter_contestants_per_category
@puzzles = @contest.puzzles.order(:id) @puzzles = @contest.puzzles.order(:id)
@action_name = t("helpers.buttons.refresh")
if params.key?(:category)
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
else
@action_path = "/public/#{@contest.friendly_id}"
end
render :scoreboard render :scoreboard
end end
@@ -72,8 +103,8 @@ class ContestsController < ApplicationController
def set_badges def set_badges
@badges = [] @badges = []
@badges.push("team") if @contest.team @badges.push(t("helpers.badges.team")) if @contest.team
@badges.push("registration") if @contest.allow_registration @badges.push(t("helpers.badges.registration")) if @contest.allow_registration
end end
def set_contest def set_contest
@@ -81,6 +112,16 @@ class ContestsController < ApplicationController
end end
def contest_params def contest_params
params.expect(contest: [ :name, :team, :allow_registration ]) params.expect(contest: [ :lang, :name, :public, :team, :allow_registration ])
end
def filter_contestants_per_category
if params.key?(:category) && params[:category] != "-1"
if params[:category] == "-2"
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
else
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
end
end
end end
end end

View File

@@ -1,25 +1,113 @@
class MessagesController < ApplicationController class MessagesController < ApplicationController
allow_unauthenticated_access include CompletionsConcern
skip_before_action :verify_authenticity_token
skip_before_action :verify_authenticity_token, only: %i[ create connect cors_preflight_check ]
skip_before_action :require_authentication, only: %i[ create connect cors_preflight_check ]
before_action :cors_set_access_control_headers, only: %i[ create connect cors_preflight_check ]
before_action :set_contest, only: %i[ convert destroy ]
before_action :set_data, only: %i[ convert ]
def self.local_prefixes
super + [ "completions" ]
end
def cors_set_access_control_headers
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Credentials", "true")
response.set_header("Access-Control-Allow-Methods", "POST")
response.set_header("Access-Control-Allow-Headers", "*")
response.set_header("Access-Control-Max-Age", "86400")
end
def cors_preflight_check
skip_authorization
end
def connect
skip_authorization
if !params.key?(:token)
respond_to do |format|
format.json { render json: { error: "no token provided" }, status: 400 }
end
else
@contest = Contest.find_by_token_for(:token, params[:token])
if @contest
respond_to do |format|
format.json { render json: { name: @contest.name }, status: 200 }
end
else
respond_to do |format|
format.json { render json: { error: "invalid token" }, status: 400 }
end
end
end
end
def create def create
skip_authorization skip_authorization
@message_params = message_params begin
@contest = Contest.find_by_token_for(:token, params[:token]) @contest = Contest.find_by_token_for(:token, params[:token])
@message = Message.new(text: params[:text], time_seconds: params[:time_seconds], contest: @contest) @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 if @contest && @message.save
respond_to do |format| respond_to do |format|
format.json { render json: {}, status: 200 } format.json { render json: {}, status: 200 }
end end
else else
respond_to do |format| respond_to do |format|
format.json { render json: { error: "invalid contest token" }, status: 400 } 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 end
end end
def message_params def convert
params.expect(message: [ :text, :time_seconds, :token ]) authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@completion = Completion.new()
@completion.display_time_from_start = @message.display_time
render "completions/new"
end
def destroy
authorize @contest
@message = Message.find(params[:id])
@message.destroy
redirect_to contest_path(@contest), notice: t("messages.destroy.notice")
end
private
def set_contest
@contest = Contest.find(params[:contest_id])
end
def set_data
@message = Message.find(params[:message_id])
@puzzles = @contest.puzzles
@contestants = @contest.contestants.order(:name)
if @contestants.size > 0
@closest_contestant = @contestants.first
closest_distance = 10000
@contestants.each do |contestant|
distance = DamerauLevenshtein.distance(@message.author, contestant.name)
if distance < closest_distance
closest_distance = distance
@closest_contestant = contestant
end
end
end
end end
end end

View File

@@ -4,11 +4,16 @@ class PuzzlesController < ApplicationController
def edit def edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@puzzle = Puzzle.new @puzzle = Puzzle.new
end end
@@ -18,8 +23,10 @@ class PuzzlesController < ApplicationController
@puzzle = Puzzle.new(puzzle_params) @puzzle = Puzzle.new(puzzle_params)
@puzzle.contest_id = @contest.id @puzzle.contest_id = @contest.id
if @puzzle.save if @puzzle.save
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
@@ -28,8 +35,10 @@ class PuzzlesController < ApplicationController
authorize @contest authorize @contest
if @puzzle.update(puzzle_params) if @puzzle.update(puzzle_params)
redirect_to @contest redirect_to @contest, notice: t("puzzles.edit.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -38,7 +47,7 @@ class PuzzlesController < ApplicationController
authorize @contest authorize @contest
@puzzle.destroy @puzzle.destroy
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice")
end end
private private
@@ -52,6 +61,6 @@ class PuzzlesController < ApplicationController
end end
def puzzle_params def puzzle_params
params.expect(puzzle: [ :brand, :name, :image ]) params.expect(puzzle: [ :brand, :name, :image, :pieces ])
end end
end end

View File

@@ -9,7 +9,7 @@ class SessionsController < ApplicationController
def create def create
if user = User.authenticate_by(params.permit(:email_address, :password)) if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url redirect_to after_authentication_url, notice: t("sessions.new.notice")
else else
redirect_to new_session_path, alert: "Try another email address or password." redirect_to new_session_path, alert: "Try another email address or password."
end end

View File

@@ -15,7 +15,7 @@ class UsersController < ApplicationController
authorize @user authorize @user
if @user.update(user_params) if @user.update(user_params)
redirect_to contests_path redirect_to contests_path, notice: t("users.edit.notice")
else else
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
@@ -38,7 +38,7 @@ class UsersController < ApplicationController
@user = User.new(user_params) @user = User.new(user_params)
if @user.save if @user.save
redirect_to users_path redirect_to users_path, notice: t("users.new.notice")
else else
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end

View File

@@ -1,2 +1,20 @@
module ContestsHelper module ContestsHelper
def pad(n)
if n > 9
return n.to_s
end
"0" + n.to_s
end
def display_time(time)
h = time / 3600
m = (time % 3600) / 60
s = (time % 3600) % 60
if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s)
elsif m > 0
return m.to_s + ":" + pad(s)
end
"0:" + pad(s)
end
end end

14
app/lib/forms.rb Normal file
View 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

View File

@@ -1,3 +1,3 @@
module Languages module Languages
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "French" } ] AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "Français" } ]
end end

View File

@@ -1,9 +1,8 @@
# == Schema Information # == Schema Information
# #
# Table name: puzzles # Table name: categories
# #
# id :integer not null, primary key # id :integer not null, primary key
# brand :string
# name :string # name :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
@@ -11,16 +10,15 @@
# #
# Indexes # Indexes
# #
# index_puzzles_on_contest_id (contest_id) # index_categories_on_contest_id (contest_id)
# #
# Foreign Keys # Foreign Keys
# #
# contest_id (contest_id => contests.id) # 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 validates :name, presence: true
# test "the truth" do
# assert true
# end
end end

View File

@@ -5,31 +5,64 @@
# id :integer not null, primary key # id :integer not null, primary key
# display_relative_time :string # display_relative_time :string
# display_time_from_start :string # display_time_from_start :string
# remaining_pieces :integer
# time_seconds :integer # time_seconds :integer
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# contest_id :integer not null # contest_id :integer not null
# contestant_id :integer not null # contestant_id :integer not null
# message_id :integer
# puzzle_id :integer not null # puzzle_id :integer not null
# #
# Indexes # Indexes
# #
# index_completions_on_contest_id (contest_id) # index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id) # index_completions_on_contestant_id (contestant_id)
# index_completions_on_message_id (message_id)
# index_completions_on_puzzle_id (puzzle_id) # index_completions_on_puzzle_id (puzzle_id)
# #
# Foreign Keys # Foreign Keys
# #
# contest_id (contest_id => contests.id) # contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id) # contestant_id (contestant_id => contestants.id)
# message_id (message_id => messages.id)
# puzzle_id (puzzle_id => puzzles.id) # puzzle_id (puzzle_id => puzzles.id)
# #
class Completion < ApplicationRecord class Completion < ApplicationRecord
belongs_to :contest belongs_to :contest
belongs_to :contestant belongs_to :contestant
belongs_to :puzzle belongs_to :puzzle
belongs_to :message, optional: true
validates :time_seconds, presence: true before_save :add_time_seconds
validates_numericality_of :time_seconds before_save :nullify_display_time
validates :puzzle_id, uniqueness: { scope: :contestant }
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { remaining_pieces == nil }
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
validate :remaining_pieces_is_correct
def remaining_pieces_is_correct
if self.remaining_pieces && self.remaining_pieces > self.puzzle.pieces
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
end
end
def nullify_display_time
if self.remaining_pieces
self.display_time_from_start = nil
self.display_relative_time = nil
end
end
def add_time_seconds
arr = display_time_from_start.split(":")
if arr.size == 3
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
elsif arr.size == 2
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
elsif arr.size == 1
self.time_seconds = arr[0].to_i
end
end
end end

View File

@@ -4,7 +4,9 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_registration :boolean default(FALSE) # allow_registration :boolean default(FALSE)
# lang :string default("en")
# name :string # name :string
# public :boolean default(FALSE)
# slug :string # slug :string
# team :boolean default(FALSE) # team :boolean default(FALSE)
# created_at :datetime not null # created_at :datetime not null
@@ -24,14 +26,16 @@ class Contest < ApplicationRecord
extend FriendlyId extend FriendlyId
belongs_to :user belongs_to :user
has_many :categories
has_many :completions, dependent: :destroy has_many :completions, dependent: :destroy
has_many :contestants, dependent: :destroy has_many :contestants, dependent: :destroy
has_many :puzzles, dependent: :destroy has_many :puzzles, dependent: :destroy
has_many :messages has_many :messages, dependent: :destroy
friendly_id :name, use: :slugged friendly_id :name, use: :slugged
validates :name, presence: true validates :name, presence: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
generates_token_for :token generates_token_for :token
end end

View File

@@ -6,6 +6,7 @@
# display_time :string # display_time :string
# email :string # email :string
# name :string # name :string
# time_seconds :integer
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# contest_id :integer not null # contest_id :integer not null
@@ -20,7 +21,27 @@
# #
class Contestant < ApplicationRecord class Contestant < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions has_many :completions, dependent: :destroy
has_and_belongs_to_many :categories
before_validation :initialize_time_seconds_if_empty
validates :name, presence: true validates :name, presence: true
validates :time_seconds, presence: true
def form_name
if email.present?
"#{name} - #{email}"
else
name
end
end
private
def initialize_time_seconds_if_empty
if !self.time_seconds
self.time_seconds = 0
end
end
end end

51
app/models/csv_import.rb Normal file
View 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

View File

@@ -3,6 +3,8 @@
# Table name: messages # Table name: messages
# #
# id :integer not null, primary key # id :integer not null, primary key
# author :string
# display_time :string
# text :string not null # text :string not null
# time_seconds :integer not null # time_seconds :integer not null
# created_at :datetime not null # created_at :datetime not null
@@ -19,6 +21,8 @@
# #
class Message < ApplicationRecord class Message < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions, dependent: :nullify
validates :author, presence: true
validates :text, presence: true validates :text, presence: true
end end

View File

@@ -5,6 +5,7 @@
# id :integer not null, primary key # id :integer not null, primary key
# brand :string # brand :string
# name :string # name :string
# pieces :integer not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# contest_id :integer not null # contest_id :integer not null
@@ -20,9 +21,9 @@
class Puzzle < ApplicationRecord class Puzzle < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions has_many :completions, dependent: :destroy
has_one_attached :image has_one_attached :image
validates :name, presence: true validates :name, presence: true
validates :brand, presence: true validates :pieces, presence: true
end end

View File

@@ -23,5 +23,6 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(e) { e.strip.downcase } normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :username, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true
validates :email_address, presence: true, uniqueness: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } } validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
end end

View File

@@ -15,10 +15,22 @@ class ContestPolicy < ApplicationPolicy
true true
end end
def convert?
record.user.id == user.id || user.admin?
end
def convert_csv?
record.user.id == user.id || user.admin?
end
def edit? def edit?
record.user.id == user.id || user.admin? record.user.id == user.id || user.admin?
end end
def finalize_import?
record.user.id == user.id || user.admin?
end
def update? def update?
record.user.id == user.id || user.admin? record.user.id == user.id || user.admin?
end end
@@ -27,7 +39,19 @@ class ContestPolicy < ApplicationPolicy
record.user.id == user.id || user.admin? record.user.id == user.id || user.admin?
end end
def import?
record.user.id == user.id || user.admin?
end
def export?
record.user.id == user.id || user.admin?
end
def scoreboard? def scoreboard?
true true
end end
def upload_csv?
record.user.id == user.id || user.admin?
end
end end

View File

@@ -1,19 +1,48 @@
= form_with model: completion, url: url, method: method do |form| = form_with model: completion, url: url, method: method do |form|
- if @message
= form.hidden_field :message_id, value: @message.id
.row.mb-3
.col
h4 = t("messages.singular").capitalize
.alert.alert-secondary
b
= @message.author
br
= @message.text
.row
.col
h4 = t("completions.singular").capitalize
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.text_field :time_seconds, autocomplete: "off", class: "form-control" = form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select"
= 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 = 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 .row.mb-3
.col .col
.form-floating .form-floating
= form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select" = form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
= form.label :puzzle_id = form.label :puzzle_id
- elsif @puzzles.size == 1
= form.hidden_field :puzzle_id, value: @puzzles.first.id
- else
= form.hidden_field :puzzle_id
.row.mb-3
.col
.form-floating
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control"
= form.label :display_time_from_start, class: "required"
.row.mb-3
.col
.form-floating
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control"
= form.label :remaining_pieces
.form-text
= t("activerecord.attributes.completion.remaining_pieces_description")
.row .row
.col .col
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"

View File

@@ -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}"

View File

@@ -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"

View File

@@ -13,11 +13,20 @@
.form-floating .form-floating
= form.text_field :email, autocomplete: "off", class: "form-control" = form.text_field :email, autocomplete: "off", class: "form-control"
= form.label :email = form.label :email
.form-text Optional. Fill this only if you intend to send emails through this app. .form-text
= t("activerecord.attributes.contestant.email_description")
- if @contest.categories
.row.mt-4
.col
- @contest.categories.each do |category|
.form-check.form-switch
= form.check_box "category_#{category.id}".to_sym, class: "form-check-input", checked: @contestant.categories.where(id: category.id).any?
= form.label category.name
.row.mt-4 .row.mt-4
.col .col
- if method == :patch - 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" = form.submit submit_text, class: "btn btn-primary"
- if method == :patch - if method == :patch
@@ -27,27 +36,39 @@
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
- if @contest.puzzles.size > 1
th scope="col" th scope="col"
| Time since start = t("activerecord.attributes.completion.display_time_from_start")
th scope="col" th scope="col"
| Relative time = t("activerecord.attributes.completion.display_relative_time")
- else
th scope="col" th scope="col"
| Puzzle = t("activerecord.attributes.completion.display_time")
th scope="col"
= t("activerecord.attributes.completion.remaining_pieces")
th scope="col"
= t("activerecord.attributes.completion.puzzle")
tbody tbody
- @completions.each do |completion| - @completions.each do |completion|
tr scope="row" tr scope="row"
td td
= completion.display_time_from_start = completion.display_time_from_start
- if @contest.puzzles.size > 1
td td
= completion.display_relative_time = completion.display_relative_time
td td
| #{completion.puzzle.name} - #{completion.puzzle.brand} = completion.remaining_pieces
td td
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant.id) - if !completion.puzzle.brand.blank?
| Edit | #{completion.puzzle.name} - #{completion.puzzle.brand}
= link_to "Delete", contest_completion_path(contest, completion, contestant_id: contestant.id), - 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" data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
.row .row
.col .col
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id) a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
| Add completion = t("helpers.buttons.add")

View 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

View File

@@ -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}"

View 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])

View 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"

View File

@@ -1 +1 @@
= render "form", contest: @contest, contestant: @contestant, submit_text: "Add", method: :post, url: "/contests/#{@contest.id}/contestants" = render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/contestants"

View File

@@ -0,0 +1,19 @@
- if @contest.categories.size > 0
.row
.col
select.mb-2 id="categories" style="padding: 5px"
option value=-1
| Tous.tes les participant.e.s
- @contest.categories.each do |category|
option value=category.id
= category.name
javascript:
categorySelectEl = document.getElementById('categories');
urlParams = new URLSearchParams(window.location.search);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
window.location.replace(`/public/#{@contest.slug}?category=${e.target.value}`);
})

View File

@@ -1,16 +1,27 @@
h4.mt-5 = t("contests.form.general")
= form_with model: contest do |form| = form_with model: contest do |form|
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.text_field :name, autocomplete: "off", class: "form-control" = form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required" = form.label :name, class: "required"
.row.mb-3
.col
.form-floating
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang
.row.mb-3
.col
.form-check.form-switch
= form.check_box :public, class: "form-check-input"
= form.label :public
.row.mb-3 .row.mb-3
.col .col
.form-check.form-switch .form-check.form-switch
= form.check_box :team, class: "form-check-input" = form.check_box :team, class: "form-check-input"
= form.label :team = form.label :team
.form-text = t("activerecord.attributes.contest.team_description") .form-text = t("activerecord.attributes.contest.team_description")
.row.mb-3 .row.mb-3 style="display: none"
.col .col
.form-check.form-switch .form-check.form-switch
= form.check_box :allow_registration, class: "form-check-input" = form.check_box :allow_registration, class: "form-check-input"
@@ -19,3 +30,36 @@
.row .row
.col .col
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"
h4.mt-5 = t("contests.form.categories")
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
- if @contest.categories.size > 0
.row
.col-6
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.category.name")
th
= t("activerecord.attributes.category.contestant_count")
tbody
- @contest.categories.each do |category|
tr.align-middle scope="row"
td
= category.name
td
= category.contestants.size
td
= link_to t("helpers.buttons.delete"), contest_category_path(@contest, category), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"
.row.mt-3
.col-4
.form-floating
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
= form.label :name, class: "required"
= t("activerecord.attributes.category.new")
.row.mt-3
.col
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"

View File

@@ -1,14 +1,38 @@
css:
@media (max-width: 800px) {
a.btn { display: none; }
.col-5 { display: none; }
.col-6 { width: 100% !important; display: block !important; }
.small-screen-image { display: block !important; }
.container { margin-top: 2rem !important; }
}
- if @contest.puzzles.size <= 1
.row.small-screen-image style="display: none"
- @contest.puzzles.each do |puzzle|
.d-flex.flex-column.justify-content-center.mb-5
= image_tag(puzzle.image, style: "max-height: 200px; object-fit: contain") if puzzle.image.attached?
.mt-2.fs-6 style="text-align: center"
=> "#{puzzle.name} -"
= "#{puzzle.brand} #{puzzle.pieces}p"
= render "category_selector"
.row
.col-6.d-flex.flex-column style="height: calc(100vh - 180px)"
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
th scope="col" th
| Rank = t("helpers.rank")
th scope="col" th
| Name = t("activerecord.attributes.contestant.name")
th scope="col" - if @contest.puzzles.size > 1
| Completed puzzles th
th scope="col" = t("activerecord.attributes.contestant.completions")
| Total time th style="width: 170px"
= t("activerecord.attributes.contestant.display_time")
tbody tbody
- @contestants.each_with_index do |contestant, index| - @contestants.each_with_index do |contestant, index|
tr scope="row" tr scope="row"
@@ -16,7 +40,55 @@ table.table.table-striped.table-hover
= index + 1 = index + 1
td td
= contestant.name = contestant.name
- if @contest.puzzles.size > 1
td td
= contestant.completions.length = contestant.completions.where(remaining_pieces: nil).length
td style="position: relative"
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(remaining_pieces: nil).size > 0
.relative-time style="position:absolute; margin: 1px 0 0 64px; font-size: 14px; color: grey"
|> +
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
.col-1
.col-5
- @contest.puzzles.each do |puzzle|
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
.mt-3.fs-4 style="margin-left: 15px"
= puzzle.name
.fs-6 style="margin-left: 15px"
b
= "#{puzzle.brand} - #{puzzle.pieces}p"
- else
.d-flex.flex-column style="height: calc(100vh - 180px)"
.d-flex.flex-row.justify-content-center.mb-5
- @contest.puzzles.each do |puzzle|
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3", style: "max-height: 220px") if puzzle.image.attached?
= render "category_selector"
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th scope="col"
= t("helpers.rank")
th scope="col"
= t("activerecord.attributes.contestant.name")
- if @contest.puzzles.size > 1
th scope="col"
= t("activerecord.attributes.contestant.completions")
th scope="col"
= t("activerecord.attributes.contestant.display_time")
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td td
= contestant.display_time = index + 1
td
= contestant.name
- if @contest.puzzles.size > 1
td
= contestant.completions.where(remaining_pieces: nil).length
td
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time

View File

@@ -1,75 +1,170 @@
- if @badges.size > 0 && false
.row.mb-4 .row.mb-4
.col .col
css: .badges style="margin-top: -18px; position: absolute"
.badges { margin-top: -18px; position: absolute; }
.badges
- @badges.each do |badge| - @badges.each do |badge|
span.badge.text-bg-info.me-2 span.badge.text-bg-info.me-2
= badge = badge
.row.mb-4 javascript:
.col async function copyExtensionUrlToClipboard() {
.float-end await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
a.btn.btn-primary href=edit_contest_path(@contest) alert("#{t("contests.show.url_copied")}");
| Edit contest }
p
= t("contests.show.public_scoreboard")
= link_to root_url + "public/#{@contest.slug}", root_url + "public/#{@contest.slug}"
.row.mb-4 .row.mb-5
.col-6 .col
- if @contest.public
a.btn.btn-success href="/public/#{@contest.slug}"
= t("contests.show.open_public_scoreboard")
- else
a.btn.btn-success.disabled
= t("contests.show.public_scoreboard_disabled")
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
css:
button > svg {
margin-right: 2px;
margin-top: -3px;
}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
</svg>
=< t("contests.show.copy_extension_url")
.row.mb-4 style="height: calc(100vh - 280px)"
.col-6.d-flex.flex-column style="height: 100%"
.row .row
.col .col
h4 = t("puzzles.plural").capitalize h4
= t("contestants.plural").capitalize
a.ms-3.btn.btn-sm.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
a.ms-2.btn.btn-sm.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
| #{t("helpers.buttons.import")}
a.ms-2.btn.btn-sm.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
| #{t("helpers.buttons.export")}
- if @contest.categories.size > 0
.row
.col
select.mt-2.mb-2 id="categories" style="padding: 5px"
option value=-1
| Tous.tes les participant.e.s
option value=-2
| Participant.e.s sans catégorie
- @contest.categories.each do |category|
option value=category.id
= category.name
javascript:
categorySelectEl = document.getElementById('categories');
urlParams = new URLSearchParams(window.location.search);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
window.location.replace(`#{contest_path(@contest)}?category=${e.target.value}`);
})
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
th scope="col" th
| Image = t("helpers.rank")
th scope="col" th
| Title = t("activerecord.attributes.contestant.name")
th scope="col" th
| Brand = t("activerecord.attributes.contestant.completions")
th
= t("activerecord.attributes.contestant.display_time")
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= index + 1
td
= contestant.name
td
= contestant.completions.where(remaining_pieces: nil).length
td
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
= t("helpers.buttons.open")
.col-6.d-flex.flex-column style="height: 100%"
.row
.col
h4
= t("puzzles.plural").capitalize
a.ms-3.btn.btn-sm.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.puzzle.image")
th
= t("activerecord.attributes.puzzle.name")
th
= t("activerecord.attributes.puzzle.brand")
th
= t("activerecord.attributes.puzzle.pieces")
tbody tbody
- @puzzles.each do |puzzle| - @puzzles.each do |puzzle|
tr.align-middle scope="row" tr.align-middle scope="row"
td td
= image_tag(puzzle.image, class: "img-fluid", style: "max-width: 140px;") if puzzle.image.attached? = image_tag(puzzle.image, class: "img-fluid", style: "max-height: 48px;") if puzzle.image.attached?
td td
= puzzle.name = puzzle.name
td td
= puzzle.brand = puzzle.brand
td
= puzzle.pieces
td td
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle) a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
| Edit = t("helpers.buttons.edit")
.row - if @messages
.row.mt-5
.col .col
a.btn.btn-primary href=new_contest_puzzle_path(@contest) h4 = t("messages.plural").capitalize
= t("contests.show.add_puzzle") - if @puzzles.size == 0
.col-6
.row .row
.col .col.alert.alert-danger
h4 = t("contestants.plural").capitalize = t("messages.warning")
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
th scope="col" style="white-space: nowrap"
= t("activerecord.attributes.message.processed")
th scope="col" th scope="col"
| Name = t("activerecord.attributes.message.time")
th scope="col" th scope="col"
| Completed puzzles = t("activerecord.attributes.message.author")
th.w-25 scope="col"
= t("activerecord.attributes.message.text")
th.w-25 scope="col"
tbody tbody
- @contestants.each do |contestant| - @messages.each do |message|
tr scope="row" 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 td
= contestant.name = message.display_time
td td
= contestant.completions.length = message.author
td td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant) = message.text
| Open td
a.btn.btn-sm.btn-secondary.ms-2 href=new_contest_completion_path(@contest, contestant_id: contestant.id) .d-inline-flex
| Add completion - if @puzzles.size > 0
.row.mt-4 a.btn.btn-sm.btn-secondary href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
.col = t("helpers.buttons.add_completion")
a.btn.btn-primary href=new_contest_contestant_path(@contest) - else
= t("contests.show.add_participant") 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"

View File

@@ -8,14 +8,45 @@ html
.float-end style="margin-top: -8px;" .float-end style="margin-top: -8px;"
nav.navbar.bg-body-primary nav.navbar.bg-body-primary
- if @current_user.admin - 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") = 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") = 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") = 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
= @title
- if @action_path
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
= @action_name
= yield = yield

View File

@@ -1,4 +1,10 @@
= form_with model: puzzle, url: url, method: method do |form| = form_with model: puzzle, url: url, method: method do |form|
.row.mb-3
.col.alert.alert-warning
= t("puzzles.form.fake_data_recommendation")
.row.mb-3
.col
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px") if puzzle.image.attached?
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
@@ -11,10 +17,32 @@
= form.label :brand, class: "required" = form.label :brand, class: "required"
.row.mb-3 .row.mb-3
.col .col
.form-text Select an image .form-floating
= form.number_field :pieces, autocomplete: "off", class: "form-control"
= form.label :pieces, class: "required"
.row.mb-3
.col
.form-text.mb-1
= t("puzzles.image_select")
= form.file_field :image, accept: "image/*", class: "form-control" = form.file_field :image, accept: "image/*", class: "form-control"
.form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big")
javascript:
function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]');
el.onchange = function() {
if(this.files[0].size > 2 * 1024 * 1024) {
document.getElementById('image-error-message').style.display = 'block';
this.value = "";
} else {
document.getElementById('image-error-message').style.display = 'none';
}
};
}
setMaxUploadSize();
.row.mt-4 .row.mt-4
.col .col
- if method == :patch - 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" = form.submit submit_text, class: "btn btn-primary"

View File

@@ -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}"

View File

@@ -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"

View File

@@ -4,10 +4,12 @@
.form-floating .form-floating
= form.email_field :email_address, autocomplete: "username", required: true, autofocus: true, class: "form-control" = form.email_field :email_address, autocomplete: "username", required: true, autofocus: true, class: "form-control"
= form.label :email_address, class: "required" = form.label :email_address, class: "required"
= t("activerecord.attributes.session.email_address")
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.password_field :password, autocomplete: "current-password", required: true, autofocus: true, class: "form-control", maxlength: 72 = form.password_field :password, autocomplete: "current-password", required: true, autofocus: true, class: "form-control", maxlength: 72
= form.label :password, class: "required" = form.label :password, class: "required"
= t("activerecord.attributes.session.password")
= form.submit "Sign in" = form.submit t("helpers.buttons.sign_in")

View File

@@ -28,77 +28,233 @@
# enabled: "ON" # enabled: "ON"
en: en:
activemodel:
errors:
models:
forms/csv_conversion_form:
attributes:
name_column:
blank: "Participant names are required"
greater_than: "Participant names are required"
activerecord: activerecord:
attributes: attributes:
category:
contestant_count: Contestants count
new: New category
name: Category
completion:
contestant: Participant
display_time: Time
display_time_from_start: Time since start
display_relative_time: Time for this puzzle
puzzle: Puzzle
remaining_pieces: Remaining pieces
remaining_pieces_description: When this field is filled, the above time will not be taken into account
contest: contest:
name: "Name" lang: Language for the public scoreboard
team: "Team contest" name: Name
team_description: "For UI display purposes mainly" public: Enable the public scoreboard
allow_registration: "Allow registration" team: Team contest
allow_registration_description: "Generates a shareable registration form for this contest" team_description: For UI display purposes mainly
allow_registration: Allow registration
allow_registration_description: Generates a shareable registration form for this contest
contestant:
completions: completions
display_time: Time
email: Email
name: Name
email_description: Optional. Used for sending emails through this app, or for identifying participants whose gmeet handle doesn't match their registered name.
csv_import:
file: File
separator: Separator
message:
author: Author
processed: Processed?
text: Content
time: Time
puzzle:
brand: Brand
image: Image
name: Name
pieces: Number of pieces
session:
email_address: Email address
password: Password
user: user:
username: "Username" username: Username
email_address: "Email address" email_address: Email address
lang: "Language" lang: Language
password: "New password" password: New password
errors:
models:
completion:
attributes:
contestant_id:
taken: "This contestant has already completed the puzzle"
display_time_from_start:
blank: Mandatory
invalid: "Allowed formats: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
puzzle_id:
taken: "This contestant has already completed this puzzle"
contest:
attributes:
name:
blank: The contest name cannot be empty
contestant:
attributes:
name:
blank: The participant name cannot be empty
csv_import:
attributes:
file:
blank: "No file selected"
empty: "This file is empty"
not_a_csv_file: "it must be a CSV file"
puzzle:
attributes:
name:
blank: The puzzle name cannot be empty
pieces:
blank: It's mandatory to provide the number of pieces
user:
attributes:
email_address:
blank: Your email cannot be empty
username:
blank: Your username cannot be empty
categories:
destroy:
notice: Category deleted
new:
error: The category name can't be empty
notice: Category added
completions: completions:
destroy:
notice: Completion deleted
edit: edit:
title: "Edit completion" notice: Completion updated
title: Edit completion
new: new:
title: "New completion" notice: Completion added
title: New completion
singular: completion
contests: contests:
destroy:
notice: Contest deleted
edit: edit:
title: "Edit contest settings" notice: Contest updated
title: Edit contest settings
form:
categories: Participant categories
general: General parameters
index: index:
title: "Welcome %{username}!" title: Welcome %{username}!
manage_contests: "Manage my contests" manage_contests: Manage my contests
new_contest: "Create a new contest" new_contest: Create a new contest
new: new:
title: "New jigsaw puzzle contest" notice: Contest added
title: New jigsaw puzzle contest
scoreboard: scoreboard:
refresh: Activate auto-refresh (every 5s)
title: "%{name}" title: "%{name}"
show: show:
title: "%{name}" title: "%{name}"
add_participant: "Add contestant" add_participant: Add participant
add_puzzle: "Add puzzle" add_puzzle: Add puzzle
public_scoreboard: "Public scoreboard: " copy_extension_url: Copy the URL for connecting from the browser extension
open_public_scoreboard: Open public scoreboard
public_scoreboard_disabled: The public scoreboard is disabled
url_copied: URL copied to the clipboard
contestants: contestants:
convert_csv:
title: Import participants
destroy:
notice: Participant deleted
edit: edit:
title: "Participant" notice: Participant updated
team_title: "Teams" title: Participant
team_title: Teams
finalize_import:
title: Import participants
import:
email_column: Participant email
import_column: Import?
name_column: Participant name
notice: Participants imported
title: Import participants
new: new:
title: "New participant" notice: Participant added
team_title: "New team" title: New participant
singular: "participant" team_title: New team
plural: "participants" singular: participant
plural: participants
teams: teams:
singular: "team" singular: team
plural: "teams" plural: teams
upload_csv:
title: Import participants
helpers: helpers:
badges:
registration: "registration"
team: "team"
buttons: buttons:
add: "Add"
add_completion: "Add completion"
back: "⬅ Back to the contest"
back_to_contestant: "⬅ Back to the participant"
confirm: "Confirm"
create: "Create" create: "Create"
save: "Save" delete: "Delete"
edit: "Edit"
export: Export
import: CSV Import
open: Open
refresh: Refresh
sign_in: Sign in
save: Save
field: Field
none: No field selected
rank: Rank
messages:
convert:
title: New completion
destroy:
notice: Message deleted
plural: "messages"
singular: "message"
warning: "You first need to add a puzzle before converting messages to completions."
nav: nav:
users: "Users" users: Users
home: "Home" home: My contests
settings: "Settings" settings: Settings
log_out: "Log out" log_out: Log out
puzzles: puzzles:
destroy:
notice: Puzzle deleted
edit: 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 2M
image_select: Select an image
new: new:
title: "New contest puzzle" notice: Puzzle added
singular: "puzzle" title: New contest puzzle
plural: "puzzles" singular: puzzle
plural: puzzles
sessions: sessions:
new: new:
notice: Login successful
title: "Login to the Public Scoreboard app" title: "Login to the Public Scoreboard app"
users: users:
edit: edit:
notice: Settings updated
title: "My settings" title: "My settings"
general_section: "General settings" general_section: "General settings"
password_section: "Change password" password_section: "Change password"
index: index:
title: "All users" title: "All users"
new: new:
notice: User created
title: "New user" title: "New user"

View File

@@ -1,75 +1,231 @@
fr: 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: activerecord:
attributes: attributes:
category:
contestant_count: Nombre de participant.e.s
new: Nouvelle catégorie
name: Catégorie
completion:
contestant_id: Participant.e
display_time: Temps
display_time_from_start: Temps depuis le début
display_relative_time: Temps pour ce puzzle
puzzle: Puzzle
remaining_pieces: Nombre de pièces restantes
remaining_pieces_description: Si ce champ est rempli, le temps ci-dessus ne sera pas pris en compte
contest: contest:
name: "Nom" lang: Langue pour le classement public
team: "Concours par équipes" name: Nom
team_description: "Principalement pour des raisons d'affichage" public: Activer le classement public
allow_registration: "Autoriser l'inscription via l'interface" team: Concours par équipes
allow_registration_description: "Génère un formulaire d'inscription pour ce concours" team_description: Principalement pour des raisons d'affichage
allow_registration: Autoriser l'inscription via l'interface
allow_registration_description: Génère un formulaire d'inscription pour ce concours
contestant:
completions: Complétions
display_time: Temps
email: Email
name: Nom
email_description: Optionnel. Utile pour envoyer des emails aux participant.e.s depuis cette app, ou pour reconnaître les pseudos gmeet quand ils ne correspondent pas au nom préalablement entré.
csv_import:
file: Fichier
separator: Délimiteur
message:
author: Auteur.ice
processed: Traité ?
text: Contenu
time: Temps
puzzle:
brand: Marque
image: Image
name: Nom
pieces: Nombre de pièces
session:
email_address: Adresse email
password: Mot de passe
user: user:
username: "Nom d'utilisateur.ice" username: Nom d'utilisateur.ice
email_address: "Adresse email" email_address: Adresse email
lang: "Langue de l'interface" lang: Langue de l'interface
password: "Nouveau mot de passe" password: Nouveau mot de passe
errors:
models:
completion:
attributes:
contestant_id:
taken: "Ce.tte participant.e a déjà complété le puzzle"
display_time_from_start:
blank: Obligatoire
invalid: "Formats autorisés: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
puzzle_id:
taken: "Ce.tte participant.e a déjà complété ce puzzle"
contest:
attributes:
name:
blank: Le nom du concours ne peut pas être vide
contestant:
attributes:
name:
blank: Le nom du ou de la participant.e ne peut pas être vide
csv_import:
attributes:
file:
blank: "Aucun fichier sélectionné"
empty: "Ce fichier est vide"
not_a_csv_file: "Le fichier doit être au format CSV"
puzzle:
attributes:
name:
blank: Le nom du puzzle est obligatoire
pieces:
blank: Il est obligatoire d'indiquer le nombre de pièces
user:
attributes:
email_address:
blank: L'email est obligatoire
username:
blank: Le nom d'utilisateur.ice est obligatoire
categories:
destroy:
notice: Catégorie supprimée
new:
error: Le nom de la catégorie ne peut pas être vide
notice: Catégorie ajoutée
completions: completions:
destroy:
notice: Complétion supprimée
edit: edit:
title: "Modifier la complétion" notice: Complétion modifiée
title: Modifier la complétion
new: new:
title: "Nouvelle complétion" notice: Complétion ajoutée
title: Ajout d'une complétion
singular: complétion
contests: contests:
destroy:
notice: Concours supprimé
edit: edit:
title: "Paramètres du concours" notice: Concours modifié
title: Paramètres du concours
form:
categories: Catégories de participant.e.s
general: Paramètres généraux
index: index:
title: "Bienvenue %{username} !" title: Bienvenue %{username} !
manage_contests: "Mes concours de puzzle" manage_contests: Mes concours de puzzle
new_contest: "Créer un nouveau concours" new_contest: Créer un nouveau concours
new: new:
title: "Nouveau concours" notice: Concours ajouté
title: Nouveau concours
scoreboard: scoreboard:
refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
title: "%{name}" title: "%{name}"
show: show:
title: "%{name}" title: "%{name}"
add_participant: "Ajouter un.e participant.e" add_participant: Ajouter un.e participant.e
add_puzzle: "Ajouter un puzzle" add_puzzle: Ajouter un puzzle
public_scoreboard: "Classement public : " copy_extension_url: Copier l'URL pour la connexion depuis l'extension web
open_public_scoreboard: Ouvrir le classement public
public_scoreboard_disabled: Le classement public n'est pas activé
url_copied: LURL a été copiée dans le presse-papier
contestants: contestants:
convert_csv:
title: Importer des participant.e.s
destroy:
notice: Participant.e supprimé.e
edit: edit:
title: "Participant.e" notice: Participant.e modifié.e
team_title: "Équipe" title: Participant.e
team_title: Équipe
finalize_import:
title: Importer des participant.e.s
import:
email_column: Email des participant.e.s
import_column: Importer ?
name_column: Noms des participant.e.s
notice: Participant.e.s importé.e.s
title: Importer des participant.e.s
new: new:
title: "Nouveau.elle participant.e" notice: Participant.e ajouté.e
team_title: "Nouvelle équipe" title: Nouveau.elle participant.e
singular: "participant.e" team_title: Nouvelle équipe
plural: "participant.e.s" singular: participant.e
plural: participant.e.s
teams: teams:
singular: "équipe" singular: équipe
plural: "équipes" plural: équipes
upload_csv:
title: Importer des participant.e.s
helpers: helpers:
badges:
registration: "auto-inscription"
team: "équipes"
buttons: buttons:
add: "Ajouter"
add_completion: "Convertir"
back: "⬅ Revenir au concours"
back_to_contestant: "⬅ Revenir au/à la participant.e"
confirm: "Confirmer"
create: "Créer" create: "Créer"
save: "Modifier" delete: "Supprimer"
edit: "Modifier"
export: Exporter
import: Importer un CSV
open: Détails
refresh: Rafraîchir
sign_in: Se connecter
save: Modifier
field: Champ
none: Aucun champ sélectionné
rank: Rang
messages:
convert:
title: Ajout d'une complétion
destroy:
notice: Message supprimé
plural: "messages"
singular: "message"
warning: "Au moins un puzzle doit être ajouté avant de pouvoir convertir des messages en complétions."
nav: nav:
users: "Utilisateur.ices" users: Utilisateur.ices
home: "Accueil" home: Mes concours
settings: "Paramètres" settings: Paramètres
log_out: "Déconnexion" log_out: Déconnexion
puzzles: puzzles:
destroy:
notice: Puzzle supprimé
edit: 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 2M
image_select: Choisis une image
new: new:
title: "Nouveau puzzle" notice: Puzzle ajouté
singular: "puzzle" title: Nouveau puzzle
plural: "puzzles" singular: puzzle
plural: puzzles
sessions: sessions:
new: new:
notice: Connection réussie
title: "Se connecter à l'app Public Scoreboard" title: "Se connecter à l'app Public Scoreboard"
users: users:
edit: edit:
notice: Paramètres modifiés
title: "Mes paramètres" title: "Mes paramètres"
general_section: "Paramètres globaux" general_section: "Paramètres globaux"
password_section: "Modifier mon mot de passe" password_section: "Modifier mon mot de passe"
index: index:
title: "Tous.tes les utilisateur.ices" title: "Tous.tes les utilisateur.ices"
new: new:
notice: Utilisateur.ice ajouté.e
title: "Nouveau.elle utilisateur.ice" title: "Nouveau.elle utilisateur.ice"

View File

@@ -9,14 +9,26 @@ Rails.application.routes.draw do
root "contests#index" root "contests#index"
resources :contests do resources :contests do
resources :categories, only: [ :create, :destroy ]
resources :completions resources :completions
resources :contestants resources :contestants
resources :puzzles resources :puzzles
resources :messages, only: :destroy do
get "convert", to: "messages#convert"
end
get "import", to: "contestants#import"
post "import", to: "contestants#upload_csv"
get "import/:id", to: "contestants#convert_csv"
post "import/:id", to: "contestants#finalize_import"
get "export", to: "contestants#export"
end end
resources :passwords, param: :token resources :passwords, param: :token
resource :session resource :session
resources :users resources :users
options "connect", to: "messages#cors_preflight_check"
options "message", to: "messages#cors_preflight_check"
post "connect", to: "messages#connect"
post "message", to: "messages#create" post "message", to: "messages#create"
get "public/:id", to: "contests#scoreboard" get "public/:id", to: "contests#scoreboard"

View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
class AddContentToCsvImport < ActiveRecord::Migration[8.0]
def change
add_column :csv_imports, :content, :string, null: false
end
end

View 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

View File

@@ -0,0 +1,5 @@
class AddMessageRefToCompletion < ActiveRecord::Migration[8.0]
def change
add_reference :completions, :message, foreign_key: true
end
end

View File

@@ -0,0 +1,5 @@
class AddLangToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :lang, :string, default: 'en'
end
end

View File

@@ -0,0 +1,5 @@
class AddPublicToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :public, :boolean, default: false
end
end

View 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

View 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

View File

@@ -0,0 +1,5 @@
class AddRemainingPiecesToCompletion < ActiveRecord::Migration[8.0]
def change
add_column :completions, :remaining_pieces, :integer
end
end

35
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do ActiveRecord::Schema[8.0].define(version: 2025_10_28_131431) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -39,6 +39,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end end
create_table "categories", force: :cascade do |t|
t.string "name"
t.integer "contest_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["contest_id"], name: "index_categories_on_contest_id"
end
create_table "categories_contestants", id: false, force: :cascade do |t|
t.integer "category_id", null: false
t.integer "contestant_id", null: false
t.index ["category_id"], name: "index_categories_contestants_on_category_id"
t.index ["contestant_id"], name: "index_categories_contestants_on_contestant_id"
end
create_table "completions", force: :cascade do |t| create_table "completions", force: :cascade do |t|
t.integer "time_seconds" t.integer "time_seconds"
t.integer "contestant_id", null: false t.integer "contestant_id", null: false
@@ -48,8 +63,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
t.integer "contest_id", null: false t.integer "contest_id", null: false
t.string "display_time_from_start" t.string "display_time_from_start"
t.string "display_relative_time" t.string "display_relative_time"
t.integer "message_id"
t.integer "remaining_pieces"
t.index ["contest_id"], name: "index_completions_on_contest_id" t.index ["contest_id"], name: "index_completions_on_contest_id"
t.index ["contestant_id"], name: "index_completions_on_contestant_id" t.index ["contestant_id"], name: "index_completions_on_contestant_id"
t.index ["message_id"], name: "index_completions_on_message_id"
t.index ["puzzle_id"], name: "index_completions_on_puzzle_id" t.index ["puzzle_id"], name: "index_completions_on_puzzle_id"
end end
@@ -60,6 +78,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "display_time" t.string "display_time"
t.integer "time_seconds"
t.index ["contest_id"], name: "index_contestants_on_contest_id" t.index ["contest_id"], name: "index_contestants_on_contest_id"
end end
@@ -71,10 +90,19 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
t.boolean "team", default: false t.boolean "team", default: false
t.boolean "allow_registration", default: false t.boolean "allow_registration", default: false
t.string "slug" t.string "slug"
t.string "lang", default: "en"
t.boolean "public", default: false
t.index ["slug"], name: "index_contests_on_slug", unique: true t.index ["slug"], name: "index_contests_on_slug", unique: true
t.index ["user_id"], name: "index_contests_on_user_id" t.index ["user_id"], name: "index_contests_on_user_id"
end end
create_table "csv_imports", force: :cascade do |t|
t.string "separator", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "content", null: false
end
create_table "friendly_id_slugs", force: :cascade do |t| create_table "friendly_id_slugs", force: :cascade do |t|
t.string "slug", null: false t.string "slug", null: false
t.integer "sluggable_id", null: false t.integer "sluggable_id", null: false
@@ -92,6 +120,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
t.string "text", null: false t.string "text", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "author"
t.string "display_time"
t.index ["contest_id"], name: "index_messages_on_contest_id" t.index ["contest_id"], name: "index_messages_on_contest_id"
end end
@@ -101,6 +131,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "contest_id", null: false t.integer "contest_id", null: false
t.string "brand" t.string "brand"
t.integer "pieces", null: false
t.index ["contest_id"], name: "index_puzzles_on_contest_id" t.index ["contest_id"], name: "index_puzzles_on_contest_id"
end end
@@ -126,8 +157,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_11_173749) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "categories", "contests"
add_foreign_key "completions", "contestants" add_foreign_key "completions", "contestants"
add_foreign_key "completions", "contests" add_foreign_key "completions", "contests"
add_foreign_key "completions", "messages"
add_foreign_key "completions", "puzzles" add_foreign_key "completions", "puzzles"
add_foreign_key "contestants", "contests" add_foreign_key "contestants", "contests"
add_foreign_key "contests", "users" add_foreign_key "contests", "users"

View File

@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: categories
#
# id :integer not null, primary key
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_categories_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
FactoryBot.define do
factory :category do
name { "MyString" }
end
end

View File

@@ -4,7 +4,9 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_registration :boolean default(FALSE) # allow_registration :boolean default(FALSE)
# lang :string default("en")
# name :string # name :string
# public :boolean default(FALSE)
# slug :string # slug :string
# team :boolean default(FALSE) # team :boolean default(FALSE)
# created_at :datetime not null # created_at :datetime not null

View File

@@ -0,0 +1,15 @@
# == Schema Information
#
# Table name: csv_imports
#
# id :integer not null, primary key
# content :string not null
# separator :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
FactoryBot.define do
factory :csv_import do
separator { 1 }
end
end

View File

@@ -3,6 +3,8 @@
# Table name: messages # Table name: messages
# #
# id :integer not null, primary key # id :integer not null, primary key
# author :string
# display_time :string
# text :string not null # text :string not null
# time_seconds :integer not null # time_seconds :integer not null
# created_at :datetime not null # created_at :datetime not null

View File

@@ -1,17 +1,78 @@
require 'rails_helper' require 'rails_helper'
RSpec.feature "Contests", type: :feature do RSpec.feature "Contests", type: :feature do
context "visiting the home page" do
let!(:user) { create(:user) } let!(:user) { create(:user) }
before do before do
login(user) login(user)
end end
it "should display the username" do context "index" do
visit root_path let!(:first_contest) { create(:contest, user: user) }
let!(:second_contest) { create(:contest, user: user) }
expect(page).to have_content(user.username) it "should list existing contests and offer to open them" do
visit contests_path
expect(page).to have_content(I18n.t("contests.index.title", username: user.username))
expect(page).to have_content(first_contest.name)
expect(page).to have_content(second_contest.name)
find(".stretched-link[href=\"/contests/#{first_contest.friendly_id}\"]").click
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}")
end
it "should offer to create a new contest" do
visit contests_path
click_link I18n.t("contests.index.new_contest")
expect(page).to have_current_path("/contests/new")
end
end
context "new" do
it "should prevent creating contests without a name" do
visit new_contest_path
click_button I18n.t("helpers.buttons.create")
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
end
it "should allow creating new contests with valid parameters" do
visit new_contest_path
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
click_button I18n.t("helpers.buttons.create")
expect(page).to have_current_path(contest_path(Contest.find_by(name: "Contest name")))
end
end
context "edit" do
let!(:contest) { create(:contest, user: user) }
it "should prevent editing contests without a name" do
visit edit_contest_path(contest)
fill_in I18n.t("activerecord.attributes.contest.name"), with: ""
click_button I18n.t("helpers.buttons.save")
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
end
it "should allow editing contests with valid parameters" do
visit edit_contest_path(contest)
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
click_button I18n.t("helpers.buttons.save")
expect(page).to have_current_path(contest_path(contest))
end end
end end
end end

View File

@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: categories
#
# id :integer not null, primary key
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_categories_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
require 'rails_helper'
RSpec.describe Category, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@@ -0,0 +1,15 @@
# == Schema Information
#
# Table name: csv_imports
#
# id :integer not null, primary key
# content :string not null
# separator :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
require 'rails_helper'
RSpec.describe CsvImport, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@@ -3,6 +3,8 @@
# Table name: messages # Table name: messages
# #
# id :integer not null, primary key # id :integer not null, primary key
# author :string
# display_time :string
# text :string not null # text :string not null
# time_seconds :integer not null # time_seconds :integer not null
# created_at :datetime not null # created_at :datetime not null

View File

@@ -1,5 +0,0 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
end

View File

View File

@@ -1,7 +0,0 @@
require "test_helper"
class CompletionsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -1,7 +0,0 @@
require "test_helper"
class ContestantsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -1,4 +0,0 @@
require "test_helper"
class ContestsControllerTest < ActionDispatch::IntegrationTest
end

View File

@@ -1,7 +0,0 @@
require "test_helper"
class PuzzlesControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -1,4 +0,0 @@
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
end

View File

@@ -1,39 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: completions
#
# id :integer not null, primary key
# display_relative_time :string
# display_time_from_start :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# puzzle_id :integer not null
#
# Indexes
#
# index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id)
# index_completions_on_puzzle_id (puzzle_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
# puzzle_id (puzzle_id => puzzles.id)
#
completion_one:
time_seconds: 1
contestant: team_one
puzzle: puzzle_one
contest: team_contest
completion_two:
time_seconds: 2
contestant: solo_one
puzzle: puzzle_two
contest: solo_contest

View File

@@ -1,33 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# 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
#
# Indexes
#
# index_contestants_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
team_one:
name: Team one
contest: team_contest
team_two:
name: Team two
contest: team_contest
solo_one:
name: Solo one
contest: solo_contest

View File

@@ -1,36 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: contests
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# name :string
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_contests_on_slug (slug) UNIQUE
# index_contests_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
team_contest:
name: Team contest
user: user
slug: team-contest
team: true
solo_contest:
name: Solo contest
user: user
slug: solo-contest
team: false

View File

View File

@@ -1,30 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: puzzles
#
# id :integer not null, primary key
# brand :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_puzzles_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
puzzle_one:
name: Puzzle one
brand: Brand one
contest: team_contest
puzzle_two:
name: Puzzle two
brand: Brand two
contest: solo_contest

View File

@@ -1,31 +0,0 @@
<% admin_password_digest = BCrypt::Password.create("admin") %>
<% user_password_digest = BCrypt::Password.create("user") %>
# == Schema Information
#
# 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
#
# Indexes
#
# index_users_on_email_address (email_address) UNIQUE
#
admin_user:
email_address: admin@admin.org
password_digest: <%= admin_password_digest %>
username: admin
admin: true
user:
email_address: user@user.org
password_digest: <%= user_password_digest %>
username: user
admin: false

View File

View File

View File

View File

@@ -1,7 +0,0 @@
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
class PasswordsMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
def reset
PasswordsMailer.reset(User.take)
end
end

View File

View File

@@ -1,33 +0,0 @@
# == Schema Information
#
# Table name: completions
#
# id :integer not null, primary key
# display_relative_time :string
# display_time_from_start :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# puzzle_id :integer not null
#
# Indexes
#
# index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id)
# index_completions_on_puzzle_id (puzzle_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
# puzzle_id (puzzle_id => puzzles.id)
#
require "test_helper"
class CompletionTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,29 +0,0 @@
# == Schema Information
#
# Table name: contests
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# name :string
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_contests_on_slug (slug) UNIQUE
# index_contests_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
require "test_helper"
class ContestTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,27 +0,0 @@
# == Schema Information
#
# 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
#
# Indexes
#
# index_contestants_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
require "test_helper"
class ContestantTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,24 +0,0 @@
# == Schema Information
#
# 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
#
# Indexes
#
# index_users_on_email_address (email_address) UNIQUE
#
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "the truth" do
assert true
end
end

View File

View File

@@ -1,15 +0,0 @@
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
end
end