Compare commits

...

135 Commits

Author SHA1 Message Date
sto
ec2eaf5535 Fix public contestant form URLs creations (token -> contestant_id)
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 39s
2026-01-21 11:30:41 +01:00
sto
5a49f14e04 Merge 658c989d8b
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 38s
2026-01-17 09:49:02 +01:00
sto
ab3409ccaa Fix onsite settings view
All checks were successful
CI / scan_ruby (push) Successful in 1m9s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 49s
2026-01-17 09:23:22 +01:00
sto
0760c9fe46 Bundle update 2026-01-17 09:17:18 +01:00
sto
279e7eaf3f Comment export spec until resolving the ChromeDriver Gitea runner issue
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 36s
2025-12-12 11:05:07 +01:00
sto
683d99ab12 System test for CSV export
Some checks failed
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 1m28s
#5
2025-12-12 10:31:03 +01:00
sto
76553d4cbc Fix CSV export
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 40s
2025-12-11 17:27:34 +01:00
sto
31fe8789ce Up the image file limit to 8M
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 40s
2025-12-11 09:18:15 +01:00
sto
360157e0c8 Permit password creation
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 38s
2025-12-10 17:50:07 +01:00
sto
5345e419df Fix password change
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 40s
2025-12-10 17:43:00 +01:00
sto
2c87a5b63c Add url helpers & specs for offline participation forms
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 38s
#5
2025-12-10 15:12:19 +01:00
sto
8cea403dc9 Fix account page forms & add account actions rspec
All checks were successful
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
#5
2025-12-10 10:44:03 +01:00
sto
cce090587a Public scoreboard stopwatch feature
All checks were successful
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
#10
2025-12-09 10:10:54 +01:00
sto
ee250b96ad Auto refresh feature on public scoreboards
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 38s
#12
#13
2025-12-08 17:11:47 +01:00
sto
1fc05bea63 Split scoreboard UI in partials
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
#13
2025-12-08 09:49:24 +01:00
sto
7bd1dce1ea Add extra nav for settings & clean header buttons
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-12-05 10:53:52 +01:00
sto
e2c50515b1 Redirect to /contests after authentication
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 35s
#25
2025-12-04 17:31:06 +01:00
sto
51e55f0828 Add hidden setting to puzzles
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
#19
2025-12-04 11:35:41 +01:00
sto
a2a8a9fcef Make public contestant forms activated only when organizers code are set
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
#20
2025-12-04 10:50:10 +01:00
sto
d08370f5f8 Use contestant IDs instead of tokens for QR codes
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 37s
#6
2025-12-04 10:30:44 +01:00
sto
cd41d83429 Fix rspec as is
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 36s
#18
2025-12-03 15:42:45 +01:00
sto
3a6ee2ea98 Add QR codes inline HTML in Brakeman ignore list
Some checks failed
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 39s
#18
2025-12-03 15:29:34 +01:00
sto
d01775471e Display time per puzzle for marathon scoreboards
Some checks failed
CI / scan_ruby (push) Failing after 21s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 36s
2025-11-27 16:26:37 +01:00
sto
66d968fca8 Add admin action to checkout contests
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 39s
2025-11-26 10:44:30 +01:00
sto
7a80c434af Fix number of QR codes shown
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 1m54s
2025-11-25 09:58:14 +01:00
sto
768af7c3e9 Remove PDF generation and make HTML one printable
Some checks failed
CI / scan_ruby (push) Failing after 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 39s
2025-11-24 12:04:08 +01:00
sto
c0f2358a36 Install chromium for prod + extend process timeout
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 40s
2025-11-24 11:41:51 +01:00
sto
7a64fa181a Add PDF generation for QR codes
Some checks failed
CI / scan_ruby (push) Failing after 24s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 39s
2025-11-24 10:58:26 +01:00
sto
024b254808 Fix QR codes generation on 'finalize_import' action
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 36s
2025-11-24 10:07:48 +01:00
sto
6ec4a89907 Add title on new sessions
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 35s
2025-11-24 09:57:30 +01:00
sto
d59090cded Improve admin UI
Some checks failed
CI / scan_ruby (push) Failing after 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 32s
2025-11-22 09:06:10 +01:00
sto
ae3c7c73e1 Add admin action to regenerate QR codes
Some checks failed
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Failing after 10s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 39s
2025-11-21 10:53:23 +01:00
sto
db6f732e63 Fix prod name
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 37s
2025-11-21 10:47:17 +01:00
sto
fdd47c231a Reset the default_utl_options
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 41s
2025-11-21 10:43:40 +01:00
sto
b2429b71f4 Rollback prod config change
Some checks failed
CI / scan_ruby (push) Failing after 37s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 41s
2025-11-21 10:39:16 +01:00
sto
710953919c Allow offline participants to take photos from their devices
Some checks failed
CI / scan_ruby (push) Failing after 23s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 43s
2025-11-21 10:08:51 +01:00
sto
94e725d20a Add judges codes
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 45s
2025-11-20 16:59:30 +01:00
sto
b43a801e3c Generate QR codes when not present and asked for
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 42s
2025-11-20 15:50:50 +01:00
sto
c4f0f603f6 Remove useless team contest setting
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 43s
2025-11-20 15:44:27 +01:00
sto
709719b801 QR codes generation
Some checks failed
CI / scan_ruby (push) Failing after 31s
CI / scan_js (push) Successful in 16s
CI / lint (push) Successful in 16s
CI / test (push) Failing after 45s
2025-11-20 12:01:30 +01:00
sto
3e071f9281 Add public form to add completions
Some checks failed
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 16s
CI / test (push) Failing after 43s
2025-11-20 10:24:26 +01:00
sto
b87800f6bd Correct category selector URL
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) Failing after 26s
2025-11-18 12:02:14 +01:00
sto
bf127bb932 Completions: notice when no puzzles defined
Some checks failed
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 39s
2025-11-18 11:57:48 +01:00
sto
3dd153d587 Require contest durations, prefill end times for unfinished puzzles & allow to modify them
Some checks failed
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 44s
2025-11-18 11:46:02 +01:00
sto
63a88ea113 Add method to update contestants through admin UI
Some checks failed
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 35s
2025-11-18 11:15:23 +01:00
sto
ecb36e19ed Reorder general settings
Some checks failed
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 35s
2025-11-18 09:38:15 +01:00
sto
bc96b16bcb Improve redirections after puzzles/messages controllers
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) Failing after 34s
2025-11-18 09:33:42 +01:00
sto
0f725e2eef Fix contest creation
Some checks failed
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 35s
2025-11-18 09:30:40 +01:00
sto
e67ee92838 Add contest duration & complete ranking mode implementation
Some checks failed
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 37s
2025-11-18 09:18:18 +01:00
sto
b88460ae71 Implement projected time
Some checks failed
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 35s
2025-11-14 12:13:09 +01:00
sto
f91145637f Add ranking mode
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 35s
2025-11-14 10:19:08 +01:00
sto
cdf87e48f2 Merge settings & core indexes into a single nav 2025-11-13 18:21:31 +01:00
sto
97ea17b7c2 Turn contest dashboard into tabs for easier navigation
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 50s
2025-11-13 12:06:12 +01:00
sto
0f31265f7b Warning in offline settings when more than one puzzle
Some checks failed
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 38s
2025-11-12 15:55:47 +01:00
sto
86dd0b7b9e Revamp contest settings into tabs
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 37s
2025-11-12 11:33:44 +01:00
sto
f4136ea58a Implement missing & remaining pieces propagation + cleaner forms
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 34s
2025-11-10 12:26:48 +01:00
sto
6549124c08 Use contestant name in title of their page
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 43s
2025-11-08 09:59:08 +01:00
sto
d1df551a0c Offline participation form: ask for missing/remaining pieces
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 30s
2025-11-08 09:55:56 +01:00
sto
69a82f4d3f Up the image size limit to 5MB
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 36s
2025-11-08 09:18:43 +01:00
sto
de568d225c Update gems
All checks were successful
CI / scan_ruby (push) Successful in 28s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
2025-11-07 09:32:11 +01:00
sto
a1736ff076 Restrict offline participation to single-puzzle
Some checks failed
CI / scan_ruby (push) Failing after 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Has been cancelled
contests
2025-11-07 09:30:04 +01:00
sto
ae5082fff6 Contestant view: show offline participation images
Some checks failed
CI / scan_ruby (push) Failing after 16s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 37s
2025-11-06 11:05:09 +01:00
sto
cd032e3456 Show offline participants on public scoreboard + filter
Some checks failed
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 42s
2025-11-06 10:40:47 +01:00
sto
5348574ea4 Show offline contestants in dashboard
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 17s
CI / test (push) Failing after 53s
2025-11-05 17:18:47 +01:00
sto
d62b46b7df Offline participation: already submitted page
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 40s
2025-11-03 11:35:00 +01:00
sto
8d50c14a7b Offline participation completed page
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 42s
2025-11-03 11:21:34 +01:00
sto
aeb6989223 Display elapsed time for offline participations
All checks were successful
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 39s
2025-11-03 11:03:46 +01:00
sto
ff5f387a87 Offline participation: implement completed puzzle methods
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-31 11:55:28 +01:00
sto
37a65526e4 Implement offline POST method for starting an offline participation
Some checks failed
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 15s
CI / test (push) Successful in 42s
2025-10-30 11:37:20 +01:00
sto
aea001cdf6 Add contest param for offline form participation
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 15s
CI / lint (push) Failing after 13s
CI / test (push) Successful in 38s
2025-10-30 10:30:32 +01:00
sto
35ad7da355 Add offline model and "new" form/controller
Some checks failed
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Failing after 14s
CI / test (push) Failing after 15m10s
2025-10-29 17:20:38 +01:00
sto
a8f1ffd920 Update gems
All checks were successful
CI / scan_ruby (push) Successful in 1m3s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 1m10s
2025-10-29 16:48:13 +01:00
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
sto
6afde8a971 Turn puzzles into table
Some checks failed
CI / scan_ruby (push) Failing after 13s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 28s
2025-05-11 21:09:57 +02:00
sto
70005468c6 Add route and controller for incoming messages 2025-05-11 21:09:45 +02:00
160 changed files with 4126 additions and 1024 deletions

View File

@@ -16,7 +16,7 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 chromium && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
@@ -51,19 +51,18 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# TODO: find how not to depend on this hack to include the compiled SCSS.
RUN cp app/assets/builds/application.css `ls public/assets/application-*.css`
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

View File

@@ -44,6 +44,9 @@ gem "slim"
gem "dartsass-rails"
gem "bootstrap", "~> 5.3.3"
gem "friendly_id", "~> 5.5.0"
gem "csv"
gem "damerau-levenshtein"
gem "rqrcode", "~> 3.0"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
@@ -71,6 +74,7 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
gem "so_many_devices"
end
gem "pundit", "~> 2.5"

View File

@@ -1,29 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
actioncable (8.0.4)
actionpack (= 8.0.4)
activesupport (= 8.0.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actionmailbox (8.0.4)
actionpack (= 8.0.4)
activejob (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
mail (>= 2.8.0)
actionmailer (8.0.2)
actionpack (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activesupport (= 8.0.2)
actionmailer (8.0.4)
actionpack (= 8.0.4)
actionview (= 8.0.4)
activejob (= 8.0.4)
activesupport (= 8.0.4)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2)
actionview (= 8.0.2)
activesupport (= 8.0.2)
actionpack (8.0.4)
actionview (= 8.0.4)
activesupport (= 8.0.4)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2)
actionpack (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actiontext (8.0.4)
actionpack (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2)
activesupport (= 8.0.2)
actionview (8.0.4)
activesupport (= 8.0.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2)
activesupport (= 8.0.2)
activejob (8.0.4)
activesupport (= 8.0.4)
globalid (>= 0.3.6)
activemodel (8.0.2)
activesupport (= 8.0.2)
activerecord (8.0.2)
activemodel (= 8.0.2)
activesupport (= 8.0.2)
activemodel (8.0.4)
activesupport (= 8.0.4)
activerecord (8.0.4)
activemodel (= 8.0.4)
activesupport (= 8.0.4)
timeout (>= 0.4.0)
activestorage (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activesupport (= 8.0.2)
activestorage (8.0.4)
actionpack (= 8.0.4)
activejob (= 8.0.4)
activerecord (= 8.0.4)
activesupport (= 8.0.4)
marcel (~> 1.0)
activesupport (8.0.2)
activesupport (8.0.4)
base64
benchmark (>= 0.3)
bigdecimal
@@ -72,24 +72,23 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
ast (2.4.2)
autoprefixer-rails (10.4.19.0)
execjs (~> 2)
base64 (0.2.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.0)
bigdecimal (3.1.9)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.18.4)
bootsnap (1.21.1)
msgpack (~> 1.2)
bootstrap (5.3.3)
autoprefixer-rails (>= 9.1.0)
bootstrap (5.3.8)
popper_js (>= 2.11.8, < 3)
brakeman (7.0.0)
brakeman (7.1.2)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -101,90 +100,101 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.5)
damerau-levenshtein (1.3.3)
dartsass-rails (0.5.1)
railties (>= 6.0.0)
sass-embedded (~> 1.63)
date (3.4.1)
debug (1.10.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
diff-lcs (1.6.1)
dotenv (3.1.7)
drb (2.2.1)
ed25519 (1.3.0)
diff-lcs (1.6.2)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.2.11)
et-orbi (1.4.0)
tzinfo
execjs (2.10.0)
factory_bot (6.5.1)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.4.4)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 5.0.0)
faker (3.5.1)
railties (>= 6.1.0)
faker (3.5.3)
i18n (>= 1.8.11, < 2)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
google-protobuf (4.30.0)
google-protobuf (4.33.4)
bigdecimal
rake (>= 13)
google-protobuf (4.30.0-aarch64-linux)
google-protobuf (4.33.4-aarch64-linux-gnu)
bigdecimal
rake (>= 13)
google-protobuf (4.30.0-x86_64-linux)
google-protobuf (4.33.4-aarch64-linux-musl)
bigdecimal
rake (>= 13)
i18n (1.14.7)
google-protobuf (4.33.4-x86_64-linux-gnu)
bigdecimal
rake (>= 13)
google-protobuf (4.33.4-x86_64-linux-musl)
bigdecimal
rake (>= 13)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.1.0)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.0)
irb (1.15.1)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.10.2)
kamal (2.5.3)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.18.0)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2)
dotenv (~> 3.1)
ed25519 (~> 1.2)
ed25519 (~> 1.4)
net-ssh (~> 7.3)
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.4)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.6.6)
loofah (2.24.0)
logger (1.7.0)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
marcel (1.1.0)
matrix (0.4.3)
mini_mime (1.1.5)
minitest (5.25.4)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
net-imap (0.5.6)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -198,106 +208,113 @@ GEM
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nokogiri (1.18.3-aarch64-linux-gnu)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-aarch64-linux-musl)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.3-arm-linux-gnu)
nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-arm-linux-musl)
nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-musl)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
parallel (1.26.3)
parser (3.3.7.1)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
popper_js (2.11.8)
pp (0.6.2)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
propshaft (1.1.0)
prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.3)
psych (5.3.1)
date
stringio
public_suffix (6.0.1)
puma (6.6.0)
public_suffix (7.0.2)
puma (7.1.0)
nio4r (~> 2.0)
pundit (2.5.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.12)
rack-session (2.1.0)
rack (3.2.4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.2)
actioncable (= 8.0.2)
actionmailbox (= 8.0.2)
actionmailer (= 8.0.2)
actionpack (= 8.0.2)
actiontext (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activemodel (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
rails (8.0.4)
actioncable (= 8.0.4)
actionmailbox (= 8.0.4)
actionmailer (= 8.0.4)
actionpack (= 8.0.4)
actiontext (= 8.0.4)
actionview (= 8.0.4)
activejob (= 8.0.4)
activemodel (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
bundler (>= 1.15.0)
railties (= 8.0.2)
rails-dom-testing (2.2.0)
railties (= 8.0.4)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
railties (8.0.4)
actionpack (= 8.0.4)
activesupport (= 8.0.4)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rdoc (6.12.0)
rake (13.3.1)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
regexp_parser (2.10.0)
reline (0.6.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.1)
rspec-core (3.13.3)
rexml (3.4.4)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.73.2)
rspec-support (3.13.6)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -305,72 +322,75 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-performance (1.24.0)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
rubyzip (2.4.1)
sass-embedded (1.85.1-aarch64-linux-gnu)
google-protobuf (~> 4.29)
sass-embedded (1.85.1-aarch64-linux-musl)
google-protobuf (~> 4.29)
sass-embedded (1.85.1-arm-linux-gnueabihf)
google-protobuf (~> 4.29)
sass-embedded (1.85.1-arm-linux-musleabihf)
google-protobuf (~> 4.29)
sass-embedded (1.85.1-x86_64-linux-gnu)
google-protobuf (~> 4.29)
sass-embedded (1.85.1-x86_64-linux-musl)
google-protobuf (~> 4.29)
rubyzip (3.2.2)
sass-embedded (1.97.2-aarch64-linux-gnu)
google-protobuf (~> 4.31)
sass-embedded (1.97.2-aarch64-linux-musl)
google-protobuf (~> 4.31)
sass-embedded (1.97.2-arm-linux-gnueabihf)
google-protobuf (~> 4.31)
sass-embedded (1.97.2-arm-linux-musleabihf)
google-protobuf (~> 4.31)
sass-embedded (1.97.2-x86_64-linux-gnu)
google-protobuf (~> 4.31)
sass-embedded (1.97.2-x86_64-linux-musl)
google-protobuf (~> 4.31)
securerandom (0.4.1)
selenium-webdriver (4.29.1)
selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
slim (5.2.1)
temple (~> 0.10.0)
tilt (>= 2.1.0)
solid_cable (3.0.7)
so_many_devices (1.0.0)
capybara (>= 3.0)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.7)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.1.3)
solid_queue (1.3.1)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0)
fugit (~> 1.11)
railties (>= 7.1)
thor (~> 1.3.1)
sqlite3 (2.6.0-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl)
sqlite3 (2.6.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl)
sshkit (1.24.0)
thor (>= 1.3.1)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -379,23 +399,24 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.5)
temple (0.10.3)
thor (1.3.2)
thruster (0.1.12)
thruster (0.1.12-aarch64-linux)
thruster (0.1.12-x86_64-linux)
tilt (2.6.0)
timeout (0.4.3)
turbo-rails (2.0.13)
stringio (3.2.0)
temple (0.10.4)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-x86_64-linux)
tilt (2.7.0)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)
@@ -403,13 +424,13 @@ GEM
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket (1.2.11)
websocket-driver (0.7.7)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.2)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
@@ -428,6 +449,8 @@ DEPENDENCIES
bootstrap (~> 5.3.3)
brakeman
capybara
csv
damerau-levenshtein
dartsass-rails
debug
factory_bot_rails
@@ -440,10 +463,12 @@ DEPENDENCIES
puma (>= 5.0)
pundit (~> 2.5)
rails (~> 8.0.2)
rqrcode (~> 3.0)
rspec-rails
rubocop-rails-omakase
selenium-webdriver
slim
so_many_devices
solid_cable
solid_cache
solid_queue

View File

@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
include Authentication
include Pundit::Authorization
before_action :set_title, :set_current_user, :set_lang
before_action :set_current_user, :set_lang
after_action :verify_authorized
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
@@ -12,13 +12,6 @@ class ApplicationController < ActionController::Base
private
def set_title
t_action_name = action_name
t_action_name = "new" if action_name == "create"
t_action_name = "edit" if action_name == "update"
@title = I18n.t("#{controller_name}.#{t_action_name}.title")
end
def set_current_user
@current_user = current_user
end
@@ -30,8 +23,12 @@ class ApplicationController < ActionController::Base
def user_not_authorized(exception)
policy_name = exception.policy.class.to_s.underscore
flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
redirect_back_or_to(root_path)
if current_user
flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
redirect_back_or_to(root_path)
else
not_found
end
end
def not_found

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 "/contests/#{@contest.id}/settings/categories", notice: t("categories.new.notice")
else
redirect_to "/contests/#{@contest.id}/settings/categories", notice: t("categories.new.error")
end
end
def destroy
authorize @contest
@category.destroy
redirect_to "/contests/#{@contest.id}/settings/categories", notice: t("categories.destroy.notice")
end
private
def set_contest
@contest = Contest.find(params[:contest_id])
end
def set_category
@category = Category.find(params[:id])
end
def category_params
params.expect(category: [ :name ])
end
end

View File

@@ -2,6 +2,7 @@ class CompletionsController < ApplicationController
include CompletionsConcern
before_action :set_contest
before_action :set_contestant
before_action :set_data, only: %i[ create edit new update ]
before_action :set_completion, only: %i[ destroy edit update ]
@@ -13,6 +14,7 @@ class CompletionsController < ApplicationController
authorize @contest
@completion = Completion.new
@completion.completed = true
if params[:contestant_id]
@completion.contestant_id = params[:contestant_id]
end
@@ -22,13 +24,18 @@ class CompletionsController < ApplicationController
authorize @contest
@completion = Completion.new(completion_params)
@completion.contest_id = @contest.id
@completion.contest = @contest
if @completion.save
extend_completions!(@completion.contestant)
redirect_to contest_path(@contest)
if @contestant && !params[:completion].key?(:message_id)
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
else
redirect_to contest_messages_path(@contest), notice: t("completions.new.notice")
end
else
logger = Logger.new(STDOUT)
logger.info(@completion.errors)
if params[:completion].key?(:message_id)
@message = Message.find(params[:completion][:message_id])
end
render :new, status: :unprocessable_entity
end
end
@@ -36,12 +43,14 @@ class CompletionsController < ApplicationController
def update
authorize @contest
if params[:contestant_id]
@completion.contestant_id = params[:contestant_id]
end
@completion.contestant_id = params[:contestant_id] if params[:contestant_id]
if @completion.update(completion_params)
extend_completions!(@completion.contestant)
redirect_to @contest
if @contestant
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.edit.notice")
else
redirect_to @contest, notice: t("completions.edit.notice")
end
else
render :edit, status: :unprocessable_entity
end
@@ -52,9 +61,9 @@ class CompletionsController < ApplicationController
@completion.destroy
if params[:contestant_id]
redirect_to contest_contestant_path(@contest, params[:contestant_id])
redirect_to contest_contestant_path(@contest, params[:contestant_id]), notice: t("completions.destroy.notice")
else
redirect_to contest_path(@contest)
redirect_to contest_path(@contest), notice: t("completions.destroy.notice")
end
end
@@ -64,8 +73,16 @@ class CompletionsController < ApplicationController
@contest = Contest.find(params[:contest_id])
end
def set_contestant
if params.key?(:contestant_id)
@contestant = Contestant.find(params[:contestant_id])
elsif params[:completion].key?(:contestant_id)
@contestant = Contestant.find(params[:completion][:contestant_id])
end
end
def set_data
@contestants = @contest.contestants
@contestants = @contest.contestants.order(:name)
@puzzles = @contest.puzzles
end
@@ -74,6 +91,6 @@ class CompletionsController < ApplicationController
end
def completion_params
params.expect(completion: [ :time_seconds, :contestant_id, :puzzle_id ])
params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :contestant_id, :message_id, :puzzle_id ])
end
end

View File

@@ -35,7 +35,7 @@ module Authentication
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
session.delete(:return_to_after_authenticating) || "/contests"
end
def start_new_session_for(user)

View File

@@ -8,24 +8,39 @@ module CompletionsConcern
"0" + n.to_s
end
def display_time(seconds)
if seconds > 3600
hours = seconds / 3600
return hours.to_s + ":" + display_time(seconds % 3600)
elsif seconds > 60
minutes = seconds / 60
return pad(minutes) + ":" + display_time(seconds % 60)
def display_time(time)
if time == nil
return ""
end
pad(seconds)
h = time / 3600
m = (time % 3600) / 60
s = (time % 3600) % 60
if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s)
end
m.to_s + ":" + pad(s)
end
def extend_completions!(contestant)
current_time_from_start = 0
contestant.completions.order(:time_seconds).each do |completion|
completion.update(display_time_from_start: display_time(completion.time_seconds),
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
current_time_from_start = completion.time_seconds
completions = contestant.completions
puzzles = contestant.contest.puzzles
if puzzles.length > 1
current_time_from_start = 0
completions.order(:time_seconds).each do |completion|
completion.update(display_time_from_start: display_time(completion.time_seconds),
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
current_time_from_start = completion.time_seconds
end
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
elsif puzzles.length == 1 && completions.length >= 1
if completions[0].remaining_pieces != nil
contestant.update(
display_time: "#{display_time(completions[0].time_seconds)} - #{puzzles[0].pieces - completions[0].remaining_pieces}p",
time_seconds: completions[0].projected_time
)
else
contestant.update(display_time: display_time(completions[0].time_seconds), time_seconds: completions[0].time_seconds)
end
end
contestant.update(display_time: display_time(current_time_from_start))
end
end

View File

@@ -0,0 +1,16 @@
module ContestantsConcern
extend ActiveSupport::Concern
def ranked_contestants(contest)
if contest.ranking_mode == "actual"
contest.contestants.sort_by { |contestant| [
-contestant.completions.where(remaining_pieces: nil).size,
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
contestant.time_seconds
] }
elsif contest.ranking_mode == "theorical"
contest.contestants.sort_by { |contestant| contestant.completions.map { |completion| completion.projected_time }.sum }
end
end
end

View File

@@ -1,7 +1,18 @@
class ContestantsController < ApplicationController
before_action :set_contest
include CompletionsConcern
include ContestantsConcern
before_action :set_contest, only: %i[ index edit new create update destroy import upload_csv convert_csv finalize_import export generate_qrcodes generate_qrcodes_pdf ]
before_action :set_contestant, only: %i[ destroy edit update]
before_action :set_completions, only: %i[edit update ]
skip_before_action :require_authentication, only: %i[ get_public_completion post_public_completion public_completion_updated ]
def index
authorize @contest
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
filter_contestants_per_category
end
def edit
authorize @contest
@@ -19,7 +30,8 @@ class ContestantsController < ApplicationController
@contestant = Contestant.new(contestant_params)
@contestant.contest_id = @contest.id
if @contestant.save
redirect_to contest_path(@contest)
update_contestant_categories
redirect_to contest_path(@contest), notice: t("contestants.new.notice")
else
render :new, status: :unprocessable_entity
end
@@ -29,7 +41,8 @@ class ContestantsController < ApplicationController
authorize @contest
if @contestant.update(contestant_params)
redirect_to @contest
update_contestant_categories
redirect_to contest_contestants_path(@contest), notice: t("contestants.edit.notice")
else
render :edit, status: :unprocessable_entity
end
@@ -39,7 +52,149 @@ class ContestantsController < ApplicationController
authorize @contest
@contestant.destroy
redirect_to contest_path(@contest)
redirect_to contest_path(@contest), notice: t("contestants.destroy.notice")
end
def import
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.new
end
def upload_csv
authorize @contest
@csv_import = CsvImport.new(params.require(:csv_import).permit(:file, :separator))
if @csv_import.save
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
else
render :import, status: :unprocessable_entity
end
end
def convert_csv
authorize @contest
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
@form = Forms::CsvConversionForm.new
end
def finalize_import
authorize @contest
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
all_params = params.require(:forms_csv_conversion_form)
@form = Forms::CsvConversionForm.new(params.require(:forms_csv_conversion_form).permit(:email_column, :name_column))
if @form.valid?
@content.each_with_index do |row, i|
if all_params["row_#{i}".to_sym] == "1"
if @form.email_column == -1
Contestant.create(name: row[@form.name_column], contest: @contest)
else
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
end
end
end
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
else
render :convert_csv, status: :unprocessable_entity
end
end
def export
authorize @contest
@contestants = ranked_contestants(@contest)
respond_to do |format|
format.csv do
response.headers["Content-Type"] = "text/csv"
response.headers["Content-Disposition"] = "attachment; filename=#{@contest.friendly_id}_results.csv"
end
end
end
def generate_qrcodes
authorize @contest
generate_contestants_qrcodes(@contest)
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
end
def generate_qrcodes_pdf
authorize @contest
generate_contestants_qrcodes(@contest)
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
@nonav = true
respond_to do |format|
format.html { render layout: "blank" }
end
end
def get_public_completion
skip_authorization
@contestant = Contestant.find(params[:contestant_id])
if !@contestant || !@contestant.contest.code.present?
not_found and return
end
@contest = @contestant.contest
I18n.locale = @contest.lang
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
@completion = Completion.new
@completion.completed = true
@public = true
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:contestant_id]}" }
end
def post_public_completion
skip_authorization
@contestant = Contestant.find(params[:contestant_id])
if !@contestant || !@contestant.contest.code.present?
not_found and return
end
@contest = @contestant.contest
I18n.locale = @contest.lang
@completion = Completion.new(completion_params)
@completion.contest = @contest
@completion.contestant = @contestant
if !@completion.code.present?
to_modify = true
@completion.code = "incorrect-xZy"
end
if @completion.save
extend_completions!(@completion.contestant)
redirect_to "/public/p/#{params[:contestant_id]}/updated"
else
@puzzles = @contest.puzzles
@public = true
if to_modify
@completion.code = nil
end
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:contestant_id]}" }, status: :unprocessable_entity
end
end
def public_completion_updated
skip_authorization
@contestant = Contestant.find(params[:contestant_id])
if !@contestant || !@contestant.contest.code.present?
not_found and return
end
I18n.locale = @contestant.contest.lang
end
private
@@ -59,4 +214,36 @@ class ContestantsController < ApplicationController
def contestant_params
params.expect(contestant: [ :email, :name ])
end
def update_contestant_categories
@contestant.categories.clear
@contest.categories.each do |category|
logger.info(params[:contestant]["category_#{category.id}".to_sym] == "1")
if params[:contestant].key?("category_#{category.id}".to_sym) && params[:contestant]["category_#{category.id}".to_sym] == "1"
@contestant.categories << category
end
end
@contestant.save
end
def filter_contestants_per_category
if params.key?(:category) && params[:category] != "-1"
if params[:category] == "-2"
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
else
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
end
end
end
def completion_params
params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :puzzle_id, :code ])
end
def generate_contestants_qrcodes(contest)
contest.contestants.where(qrcode: nil).each do |contestant|
contestant.generate_qrcode
contestant.save
end
end
end

View File

@@ -1,6 +1,11 @@
class ContestsController < ApplicationController
before_action :set_contest, only: %i[ destroy edit show update ]
skip_before_action :require_authentication, only: %i[ scoreboard ]
include CompletionsConcern
include ContestantsConcern
before_action :set_contest, only: %i[ destroy show ]
before_action :set_settings_contest, only: %i[ stopwatch stopwatch_continue stopwatch_pause stopwatch_reset stopwatch_start settings_general_edit settings_general_update settings_public_edit settings_public_update settings_onsite_edit settings_onsite_update settings_online_edit settings_online_update settings_categories_edit ]
before_action :offline_setup, only: %i[ offline_new offline_create offline_edit offline_update offline_completed ]
skip_before_action :require_authentication, only: %i[ scoreboard offline_new offline_create offline_edit offline_update offline_completed ]
def index
authorize :contest
@@ -12,75 +17,297 @@ class ContestsController < ApplicationController
def show
authorize @contest
@title = I18n.t("contests.show.title", name: @contest.name)
@contestants = @contest.contestants.order(:name)
@puzzles = @contest.puzzles.order(:id)
set_badges
redirect_to contest_contestants_path(@contest)
end
def edit
def settings_general_edit
authorize @contest
end
def settings_public_edit
authorize @contest
end
def settings_onsite_edit
authorize @contest
end
def settings_online_edit
authorize @contest
end
def settings_categories_edit
authorize @contest
end
def settings_general_update
authorize @contest
if @contest.update(settings_general_params)
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.edit.notice")
else
render :settings_general_edit, status: :unprocessable_entity
end
end
def settings_public_update
authorize @contest
if @contest.update(settings_public_params)
redirect_to "/contests/#{@contest.id}/settings/public", notice: t("contests.edit.notice")
else
render :settings_public_edit, status: :unprocessable_entity
end
end
def settings_onsite_update
authorize @contest
if @contest.update(settings_onsite_params)
redirect_to "/contests/#{@contest.id}/settings/onsite", notice: t("contests.edit.notice")
else
render :settings_onsite_edit, status: :unprocessable_entity
end
end
def settings_online_update
authorize @contest
if @contest.update(settings_online_params)
redirect_to "/contests/#{@contest.id}/settings/online", notice: t("contests.edit.notice")
else
render :settings_online_edit, status: :unprocessable_entity
end
end
def stopwatch
authorize @contest
end
def stopwatch_continue
authorize @contest
pause_duration = Time.now() - @contest.pause_time
@contest.start_time = @contest.start_time + pause_duration
@contest.pause_time = nil
@contest.save
redirect_to "/contests/#{@contest.id}/stopwatch"
end
def stopwatch_pause
authorize @contest
authorize @contest
@contest.pause_time = Time.now()
@contest.save
redirect_to "/contests/#{@contest.id}/stopwatch"
end
def stopwatch_reset
authorize @contest
@contest.start_time = nil
@contest.pause_time = nil
@contest.save
redirect_to "/contests/#{@contest.id}/stopwatch"
end
def stopwatch_start
authorize @contest
@contest.start_time = Time.now()
@contest.pause_time = nil
@contest.save
redirect_to "/contests/#{@contest.id}/stopwatch"
end
def new
authorize :contest
@contest = Contest.new
@title = I18n.t("contests.new.title")
@nonav = true
end
def create
authorize :contest
@contest = Contest.new(contest_params)
@contest = Contest.new(new_contest_params)
@contest.lang = @current_user.lang
@contest.ranking_mode = "actual"
@contest.user_id = current_user.id
if @contest.save
redirect_to @contest
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice")
else
@title = I18n.t("contests.new.title")
@nonav = true
render :new, status: :unprocessable_entity
end
end
def update
authorize @contest
if @contest.update(contest_params)
redirect_to @contest
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @contest
@contest.destroy
redirect_to contests_path, notice: t("contests.destroy.notice")
end
def scoreboard
@contest = Contest.find_by(slug: params[:id])
unless @contest
unless @contest && @contest.public
skip_authorization
not_found and return
end
authorize @contest
I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
@contestants = @contest.contestants.order(:name)
@puzzles = @contest.puzzles.order(:id)
@contestants = ranked_contestants(@contest)
if params.key?(:category)
@category = params[:category]
filter_contestants_per_category
end
if params.key?(:hide_offline) && params[:hide_offline] == "true"
@contestants = @contestants.select { |contestant| !contestant.offline.present? }
@hide_offline = true
end
if params.key?(:autorefresh)
@autorefresh = true
end
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
@action_path = "/public/#{@contest.friendly_id}"
@space = " "
render :scoreboard
end
def offline_new
authorize @contest
@offline = Offline.new
end
def offline_create
authorize @contest
@offline = Offline.new(offline_start_params)
@offline.contest = @contest
@offline.start_time = Time.now()
if @offline.save
redirect_to offline_form_edit_path(@contest, @offline)
else
render :offline_new, status: :unprocessable_entity
end
end
def offline_edit
authorize @contest
@offline = Offline.find_by_token_for(:token, params[:token])
if !@offline
not_found and return
end
if @offline.submitted
render :offline_already_submitted and return
end
end
def offline_update
authorize @contest
@offline = Offline.find_by_token_for(:token, params[:token])
if !@offline
not_found and return
end
@offline.submitted = true
@offline.completed = params[:offline][:completed]
@offline.end_time = Time.now()
@offline.images.attach(params[:offline][:end_image])
if @offline.completed
@offline.missing_pieces = params[:offline][:missing_pieces]
else
@offline.remaining_pieces = params[:offline][:remaining_pieces]
end
if @offline.save
if @contest.puzzles.length > 0
dp = display_time(@offline.end_time.to_i - @offline.start_time.to_i)
contestant = Contestant.create(contest: @contest, name: @offline.name, offline: @offline)
Completion.create(contest: @contest,
contestant: contestant,
offline: @offline,
puzzle: @contest.puzzles[0],
completed: @offline.completed,
display_time_from_start: dp,
missing_pieces: @offline.missing_pieces,
remaining_pieces: @offline.remaining_pieces)
extend_completions!(contestant)
end
redirect_to offline_form_completed_path(@contest, @offline)
else
render :offline_edit, status: :unprocessable_entity
end
end
def offline_completed
authorize @contest
@offline = Offline.find_by_token_for(:token, params[:token])
if !@offline
not_found and return
end
end
private
def set_badges
@badges = []
@badges.push("team") if @contest.team
@badges.push("registration") if @contest.allow_registration
def offline_setup
@contest = Contest.find_by(slug: params[:id])
I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
end
def set_contest
@contest = Contest.find(params[:id])
end
def contest_params
params.expect(contest: [ :name, :team, :allow_registration ])
def set_settings_contest
@contest = Contest.find(params[:contest_id])
end
def new_contest_params
params.expect(contest: [ :name, :duration ])
end
def settings_general_params
params.expect(contest: [ :lang, :name, :duration, :team, :allow_registration ])
end
def settings_public_params
params.expect(contest: [ :public, :ranking_mode, :show_stopwatch ])
end
def settings_onsite_params
params.expect(contest: [ :code ])
end
def settings_online_params
params.expect(contest: [ :offline_form ])
end
def filter_contestants_per_category
if @category != "-1"
if @category == "-2"
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
else
@contestants = @contestants.select { |contestant| contestant.categories.where(id: @category).any? }
end
end
end
def offline_start_params
params.expect(offline: [ :name, :images ])
end
def offline_end_params
params.expect(offline: [ :completed, :end_image, :remaining_pieces, :missing_pieces ])
end
end

View File

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

View File

@@ -2,6 +2,13 @@ class PuzzlesController < ApplicationController
before_action :set_contest
before_action :set_puzzle, only: %i[ destroy edit update]
def index
authorize @contest
@title = @contest.name
@puzzles = @contest.puzzles.order(:id)
end
def edit
authorize @contest
end
@@ -18,7 +25,7 @@ class PuzzlesController < ApplicationController
@puzzle = Puzzle.new(puzzle_params)
@puzzle.contest_id = @contest.id
if @puzzle.save
redirect_to contest_path(@contest)
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.new.notice")
else
render :new, status: :unprocessable_entity
end
@@ -28,7 +35,7 @@ class PuzzlesController < ApplicationController
authorize @contest
if @puzzle.update(puzzle_params)
redirect_to @contest
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.edit.notice")
else
render :edit, status: :unprocessable_entity
end
@@ -38,7 +45,7 @@ class PuzzlesController < ApplicationController
authorize @contest
@puzzle.destroy
redirect_to contest_path(@contest)
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.destroy.notice")
end
private
@@ -52,6 +59,6 @@ class PuzzlesController < ApplicationController
end
def puzzle_params
params.expect(puzzle: [ :brand, :name, :image ])
params.expect(puzzle: [ :brand, :name, :image, :pieces, :hidden ])
end
end

View File

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

View File

@@ -1,21 +1,39 @@
class UsersController < ApplicationController
include CompletionsConcern
before_action :set_user, only: %i[ destroy edit show update ]
def index
authorize :user
@title = t("users.index.title")
@users = User.all
end
def edit
authorize @user
@title = t("users.edit.title")
end
def update
authorize @user
if @user.update(user_params)
redirect_to contests_path
@user.password_change_attempt = false
if @user.update(user_general_params)
redirect_to contests_path, notice: t("users.edit.notice")
else
render :edit, status: :unprocessable_entity
end
end
def change_password
@user = User.find(params[:user_id])
authorize @user
@user.password_change_attempt = true
if @user.update(user_password_params)
redirect_to contests_path, notice: t("users.edit.notice")
else
render :edit, status: :unprocessable_entity
end
@@ -38,7 +56,7 @@ class UsersController < ApplicationController
@user = User.new(user_params)
if @user.save
redirect_to users_path
redirect_to users_path, notice: t("users.new.notice")
else
render :new, status: :unprocessable_entity
end
@@ -48,6 +66,35 @@ class UsersController < ApplicationController
authorize @user
end
def update_contestants
authorize :user
total = 0
updated = 0
Contestant.all.each do |contestant|
if contestant.completions.length > 0
total += 1
contestant.completions.each do |completion|
completion.save
end
if extend_completions!(contestant)
updated += 1
end
end
end
redirect_to users_path, notice: "Updated contestants: #{updated}/#{total}"
end
def regenerate_qrcodes
authorize :user
Contestant.all.each do |contestant|
contestant.generate_qrcode
contestant.save
end
end
private
def set_user
@@ -57,4 +104,12 @@ class UsersController < ApplicationController
def user_params
params.expect(user: [ :username, :email_address, :lang, :password ])
end
def user_general_params
params.expect(user: [ :username, :email_address, :lang ])
end
def user_password_params
params.expect(user: [ :password ])
end
end

View File

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

View File

@@ -0,0 +1,5 @@
module StyleHelper
def active_page(path)
request.path.starts_with?(path) ? "active" : ""
end
end

14
app/lib/forms.rb Normal file
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
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "French" } ]
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "Français" } ]
end

3
app/lib/ranking.rb Normal file
View File

@@ -0,0 +1,3 @@
module Ranking
AVAILABLE_RANKING_MODES = [ { id: "actual", name: I18n.t("lib.ranking.actual") }, { id: "theorical", name: I18n.t("lib.ranking.theorical") } ]
end

View File

@@ -1,9 +1,8 @@
# == Schema Information
#
# Table name: puzzles
# Table name: categories
#
# id :integer not null, primary key
# brand :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -11,16 +10,15 @@
#
# Indexes
#
# index_puzzles_on_contest_id (contest_id)
# index_categories_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
require "test_helper"
class Category < ApplicationRecord
belongs_to :contest
has_and_belongs_to_many :contestants
class PuzzleTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
validates :name, presence: true
end

View File

@@ -3,33 +3,101 @@
# Table name: completions
#
# id :integer not null, primary key
# code :string
# completed :boolean
# display_relative_time :string
# display_time_from_start :string
# missing_pieces :integer
# projected_time :integer
# remaining_pieces :integer
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# message_id :integer
# puzzle_id :integer not null
#
# Indexes
#
# index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id)
# index_completions_on_message_id (message_id)
# index_completions_on_puzzle_id (puzzle_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
# message_id (message_id => messages.id)
# puzzle_id (puzzle_id => puzzles.id)
#
class Completion < ApplicationRecord
include ContestsHelper
belongs_to :contest
belongs_to :contestant
belongs_to :puzzle
belongs_to :message, optional: true
validates :time_seconds, presence: true
validates_numericality_of :time_seconds
validates :puzzle_id, uniqueness: { scope: :contestant }
has_one :offline, dependent: :destroy
before_save :clean_pieces
before_save :compute_projected_time
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { completed || offline.present? }
validates :remaining_pieces, presence: true, if: -> { !completed }
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? }
validate :contest_code_is_correct, if: -> { code.present? }
def remaining_pieces_is_correct
if self.remaining_pieces > self.puzzle.pieces
errors.add(:remaining_pieces, I18n.t("activerecord.errors.models.completion.attributes.remaining_pieces.too_large"))
end
end
def add_time_seconds
if display_time_from_start.present?
arr = display_time_from_start.split(":")
if arr.size == 3
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
elsif arr.size == 2
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
elsif arr.size == 1
self.time_seconds = arr[0].to_i
end
else
self.time_seconds = self.contest.duration_seconds
self.display_time_from_start = display_time(self.time_seconds)
end
end
def clean_pieces
if self.completed
self.remaining_pieces = nil
else
self.missing_pieces = nil
end
end
def compute_projected_time
add_time_seconds
if self.completed
self.projected_time = self.time_seconds
else
assembled_time = self.time_seconds
assembled_pieces = self.puzzle.pieces - self.remaining_pieces
pieces_per_second = assembled_pieces.to_f / assembled_time.to_f
self.projected_time = assembled_time + Integer(self.remaining_pieces.to_f / pieces_per_second)
end
end
def contest_code_is_correct
if self.code != self.contest.code
errors.add(:code, I18n.t("activerecord.errors.models.completion.attributes.code.mismatch"))
end
end
end

View File

@@ -4,8 +4,18 @@
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# code :string
# duration :string
# duration_seconds :integer
# lang :string default("en")
# name :string
# offline_form :boolean default(FALSE)
# pause_time :datetime
# public :boolean default(FALSE)
# ranking_mode :string
# show_stopwatch :boolean
# slug :string
# start_time :datetime
# team :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
@@ -24,11 +34,28 @@ class Contest < ApplicationRecord
extend FriendlyId
belongs_to :user
has_many :categories
has_many :completions, dependent: :destroy
has_many :contestants, dependent: :destroy
has_many :puzzles, dependent: :destroy
has_many :messages, dependent: :destroy
has_many :offlines, dependent: :destroy
friendly_id :name, use: :slugged
before_save :add_duration_seconds, if: -> { duration.present? }
validates :name, presence: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
validates :ranking_mode, inclusion: { in: Ranking::AVAILABLE_RANKING_MODES.map { |lang| lang[:id] } }
validates :duration, presence: true, format: { with: /\A(\d\d:\d\d|\d:\d\d)\z/ }
generates_token_for :token
def add_duration_seconds
arr = self.duration.split(":")
if arr.size == 2
self.duration_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60
end
end
end

View File

@@ -2,13 +2,16 @@
#
# Table name: contestants
#
# id :integer not null, primary key
# display_time :string
# email :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# id :integer not null, primary key
# display_time :string
# email :string
# name :string
# projected_time :string
# qrcode :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
@@ -20,7 +23,41 @@
#
class Contestant < ApplicationRecord
belongs_to :contest
has_many :completions
has_many :completions, dependent: :destroy
has_one :offline, dependent: :destroy
has_and_belongs_to_many :categories
before_validation :initialize_time_seconds_if_empty
validates :name, presence: true
validates :time_seconds, presence: true
def form_name
if email.present?
"#{name} - #{email}"
else
name
end
end
def generate_qrcode
host = Rails.application.config.action_controller.default_url_options[:host]
qrcode = RQRCode::QRCode.new("https://#{host}/public/p/#{self.id}")
self.qrcode = qrcode.as_svg(
color: "000",
shape_rendering: "crispEdges",
module_size: 3,
standalone: true,
use_path: true,
viewbox: true
)
end
private
def initialize_time_seconds_if_empty
if !self.time_seconds
self.time_seconds = 0
end
end
end

51
app/models/csv_import.rb Normal file
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
#
# id :integer not null, primary key
# author :string
# display_time :string
# text :string not null
# time_seconds :integer not null
# created_at :datetime not null
@@ -19,6 +21,8 @@
#
class Message < ApplicationRecord
belongs_to :contest
has_many :completions, dependent: :nullify
validates :author, presence: true
validates :text, presence: true
end

85
app/models/offline.rb Normal file
View File

@@ -0,0 +1,85 @@
# == Schema Information
#
# Table name: offlines
#
# id :integer not null, primary key
# completed :boolean
# end_time :datetime
# missing_pieces :integer
# name :string not null
# remaining_pieces :integer
# start_time :datetime not null
# submitted :boolean
# created_at :datetime not null
# updated_at :datetime not null
# completion_id :integer
# contest_id :integer not null
# contestant_id :integer
#
# Indexes
#
# index_offlines_on_completion_id (completion_id)
# index_offlines_on_contest_id (contest_id)
# index_offlines_on_contestant_id (contestant_id)
#
# Foreign Keys
#
# completion_id (completion_id => completions.id)
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
#
class Offline < ApplicationRecord
belongs_to :contest
belongs_to :contestant, optional: true
belongs_to :completion, optional: true
has_many_attached :images, dependent: :destroy
generates_token_for :token
before_save :clean_pieces
validates :name, presence: true
validates :remaining_pieces, presence: true, if: -> { submitted && !completed }
validates :start_time, presence: true
validate :end_image_is_present
validate :start_image_is_present
validates :missing_pieces, numericality: { only_integer: true }, if: -> { missing_pieces.present? }
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
validate :missing_pieces_is_correct, if: -> { missing_pieces.present? }
validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? }
def missing_pieces_is_correct
if self.contest.puzzles.length > 0 && self.missing_pieces > self.contest.puzzles[0].pieces
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
end
end
def remaining_pieces_is_correct
if self.contest.puzzles.length > 0 && self.remaining_pieces > self.contest.puzzles[0].pieces
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
end
end
def end_image_is_present
if self.submitted && self.images.length < 2
errors.add(:end_image, I18n.t("activerecord.errors.models.offline.attributes.end_image.blank"))
end
end
def start_image_is_present
if !self.images.attached?
errors.add(:images, I18n.t("activerecord.errors.models.offline.attributes.start_image.blank"))
end
end
def clean_pieces
if self.completed
self.remaining_pieces = nil
else
self.missing_pieces = nil
end
end
end

View File

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

View File

@@ -2,14 +2,15 @@
#
# Table name: users
#
# id :integer not null, primary key
# admin :boolean default(FALSE), not null
# email_address :string not null
# lang :string default("en")
# password_digest :string not null
# username :string
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# admin :boolean default(FALSE), not null
# email_address :string not null
# lang :string default("en")
# password_change_attempt :boolean
# password_digest :string not null
# username :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
@@ -23,5 +24,6 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :username, presence: true, uniqueness: true
validates :email_address, presence: true, uniqueness: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
end

View File

@@ -1,9 +1,2 @@
class CompletionPolicy < ContestPolicy
def index?
false
end
def show?
false
end
end

View File

@@ -1,33 +1,153 @@
class ContestPolicy < ApplicationPolicy
def owner_or_admin
if record == :contest
true
else
record.user.id == user.id || user.admin?
end
end
def index?
true
owner_or_admin
end
def show?
record.user.id == user.id || user.admin?
owner_or_admin
end
def new?
true
owner_or_admin
end
def create?
true
owner_or_admin
end
def convert?
owner_or_admin
end
def convert_csv?
owner_or_admin
end
def edit?
record.user.id == user.id || user.admin?
owner_or_admin
end
def generate_qrcodes?
owner_or_admin
end
def settings_general_edit?
edit?
end
def settings_general_update?
edit?
end
def settings_public_edit?
edit?
end
def settings_public_update?
edit?
end
def settings_onsite_edit?
edit?
end
def settings_onsite_update?
edit?
end
def settings_online_edit?
edit?
end
def settings_online_update?
edit?
end
def settings_categories_edit?
edit?
end
def stopwatch?
edit?
end
def stopwatch_continue?
edit?
end
def stopwatch_pause?
edit?
end
def stopwatch_reset?
edit?
end
def stopwatch_start?
edit?
end
def finalize_import?
owner_or_admin
end
def update?
record.user.id == user.id || user.admin?
owner_or_admin
end
def destroy?
record.user.id == user.id || user.admin?
owner_or_admin
end
def import?
owner_or_admin
end
def export?
owner_or_admin
end
def generate_qrcodes_pdf?
owner_or_admin
end
def upload_csv?
owner_or_admin
end
def offline?
record.offline_form && record.puzzles.length < 2
end
def offline_new?
offline?
end
def offline_create?
offline?
end
def offline_edit?
offline?
end
def offline_update?
offline?
end
def offline_completed?
offline?
end
def scoreboard?
true
record.public
end
end

View File

@@ -1,9 +1,2 @@
class ContestantPolicy < ContestPolicy
def index?
false
end
def show?
false
end
end

View File

@@ -1,9 +1,2 @@
class PuzzlePolicy < ContestPolicy
def index?
false
end
def show?
false
end
end

View File

@@ -20,10 +20,22 @@ class UserPolicy < ApplicationPolicy
end
def update?
user.admin? || user.id == record.id
edit?
end
def change_password?
edit?
end
def destroy?
user.admin?
end
def update_contestants?
user.admin?
end
def regenerate_qrcodes?
user.admin?
end
end

View File

@@ -0,0 +1,18 @@
.row
.col
ul.nav.nav-tabs.mb-4
li.nav-item
a.nav-link class=active_page(contest_contestants_path(@contest)) href=contest_contestants_path(@contest)
= t("contestants.plural").capitalize
li.nav-item
a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest)
= t("puzzles.plural").capitalize
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/stopwatch") href="/contests/#{@contest.id}/stopwatch"
= t("contests.nav.stopwatch").capitalize
li.nav-item
a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest)
= t("messages.plural").capitalize
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings") href="/contests/#{@contest.id}/settings/general"
= t("contests.nav.settings")

View File

@@ -0,0 +1,18 @@
.row
.col
ul.nav.nav-tabs.mb-4
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
= t("contests.nav.general")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/public") href="/contests/#{@contest.id}/settings/public"
= t("contests.nav.public")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/onsite") href="/contests/#{@contest.id}/settings/onsite"
= t("contests.nav.onsite")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/online") href="/contests/#{@contest.id}/settings/online"
= t("contests.nav.online")
li.nav-item
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
= t("contests.nav.categories")

View File

@@ -1,19 +1,103 @@
= form_with model: completion, url: url, method: method do |form|
.row.mb-3
.col
.form-floating
= form.text_field :time_seconds, autocomplete: "off", class: "form-control"
= form.label :time_seconds, class: "required"
.row.mb-3
.col
.form-floating
= form.select :contestant_id, @contestants.map { |contestant| [contestant.name, contestant.id] }, {}, class: "form-select"
= form.label :contestant_id
.row.mb-3
.col
.form-floating
= form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
= form.label :puzzle_id
.row
.col
= form.submit submit_text, class: "btn btn-primary"
- if @public && @puzzles.length == @contestant.completions.length
h4
= t("completions.form.validate_name", name: @contestant.name)
.mt-3.alert.alert-warning
= t("completions.form.all_finished", name: @contestant.name)
- else
= form_with model: completion, url: url, method: method do |form|
- if @message
= form.hidden_field :message_id, value: @message.id
.row.mb-3
.col
h4 = t("messages.singular").capitalize
.alert.alert-secondary
b
= @message.author
br
= @message.text
.row.mb-2
.col
h4
- if @public
= t("completions.form.validate_name", name: @contestant.name)
- else
= t("completions.singular").capitalize
- if @contestants.present?
.row.mb-3
.col
.form-floating
= form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select"
= form.label :contestant_id
- if @closest_contestant
javascript:
el = document.querySelector('select[name="completion[contestant_id]"]');
el.value = "#{@closest_contestant.id}"
- if @puzzles.size > 1
.row.mb-3
.col
.form-floating
= form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
= form.label :puzzle_id
- elsif @puzzles.size == 1
= form.hidden_field :puzzle_id, value: @puzzles.first.id
- else
= form.hidden_field :puzzle_id
.row.mb-3
.col
.form-check.form-switch
= form.check_box :completed, class: "form-check-input"
= form.label :completed
javascript:
completedEl = document.getElementById('completion_completed');
completedEl.addEventListener('change', (e) => {
const timeEl = document.getElementById('time');
const missingPiecesEl = document.getElementById('missing_pieces');
const remainingPiecesEl = document.getElementById('remaining_pieces');
if (e.target.checked) {
timeEl.value = '#{@completion.display_time_from_start}';
missingPiecesEl.style.display = 'block';
remainingPiecesEl.style.display = 'none';
} else {
timeEl.value = '#{display_time(@contest.duration_seconds)}';
missingPiecesEl.style.display = 'none';
remainingPiecesEl.style.display = 'block';
}
})
.row.mb-3
.col
.form-floating
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time"
= form.label :display_time_from_start, class: "required"
.form-text
= t("activerecord.attributes.completion.display_time_from_start_description")
.row.mb-3 id="missing_pieces"
.col
.form-floating
= form.text_field :missing_pieces, autocomplete: "off", class: "form-control"
= form.label :missing_pieces
.row.mb-3 id="remaining_pieces" style="display: none;"
.col
.form-floating
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control"
= form.label :remaining_pieces
javascript:
completedEl = document.getElementById('completion_completed');
missingPiecesEl = document.getElementById('missing_pieces');
remainingPiecesEl = document.getElementById('remaining_pieces');
if (completedEl.checked) {
missingPiecesEl.style.display = 'block';
remainingPiecesEl.style.display = 'none';
} else {
missingPiecesEl.style.display = 'none';
remainingPiecesEl.style.display = 'block';
}
- if @public
.row.mb-3
.col
.form-floating
= form.text_field :code, autocomplete: "off", class: "form-control"
= form.label :code
= t("completions.form.code")
.row
.col
= form.submit submit_text, class: "btn btn-primary"

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

@@ -1,7 +1,3 @@
.row
.col
h3 Informations
= form_with model: contestant, url: url, method: method do |form|
.row.mb-3
.col
@@ -13,41 +9,109 @@
.form-floating
= form.text_field :email, autocomplete: "off", class: "form-control"
= form.label :email
.form-text Optional. Fill this only if you intend to send emails through this app.
.form-text
= t("activerecord.attributes.contestant.email_description")
- if @contest.categories && method == :patch
.row.mt-4
.col
- @contest.categories.each do |category|
.form-check.form-switch
= form.check_box "category_#{category.id}".to_sym, class: "form-check-input", checked: @contestant.categories.where(id: category.id).any?
= form.label category.name
.row.mt-4
.col
- if method == :patch
= link_to "Delete", contest_contestant_path(contest, contestant), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
= link_to t("helpers.buttons.delete"), contest_contestant_path(contest, contestant), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
= form.submit submit_text, class: "btn btn-primary"
- if method == :patch
.row.mt-5
.col
h3 Completions
table.table.table-striped.table-hover
thead
tr
th scope="col"
| Time since start
th scope="col"
| Relative time
th scope="col"
| Puzzle
tbody
- @completions.each do |completion|
tr scope="row"
td
= completion.display_time_from_start
td
= completion.display_relative_time
td
| #{completion.puzzle.name} - #{completion.puzzle.brand}
td
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant.id)
| Edit
= link_to "Delete", contest_completion_path(contest, completion, contestant_id: contestant.id),
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
.row
- if @contest.puzzles.length == 0
.row
.col
.alert.alert-warning
= t("contestants.edit.no_puzzles_note")
- else
.row
.col
.alert.alert-info
= t("contestants.edit.completions_note")
table.table.table-striped.table-hover
thead
tr
th scope="col"
= t("activerecord.attributes.completion.completed")
- if @contest.puzzles.size > 1
th scope="col"
= t("activerecord.attributes.completion.display_time_from_start")
th scope="col"
= t("activerecord.attributes.completion.display_relative_time")
- else
th scope="col"
= t("activerecord.attributes.completion.display_time")
th scope="col"
= t("activerecord.attributes.completion.projected_time")
th scope="col"
= t("activerecord.attributes.completion.missing_pieces")
th scope="col"
= t("activerecord.attributes.completion.remaining_pieces")
th scope="col"
= t("activerecord.attributes.completion.puzzle")
tbody
- @completions.each do |completion|
tr scope="row"
td
- if completion.completed
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
</svg>
td
= display_time(completion.time_seconds)
- if @contest.puzzles.size > 1
td
= completion.display_relative_time
- else
td
= display_time(completion.projected_time)
td
= completion.missing_pieces
td
= completion.remaining_pieces
td
- if !completion.puzzle.brand.blank?
| #{completion.puzzle.name} - #{completion.puzzle.brand}
- else
| #{completion.puzzle.name}
td
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant_id: contestant.id)
= t("helpers.buttons.edit")
= link_to t("helpers.buttons.delete"), contest_completion_path(contest, completion, contestant_id: contestant.id),
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
.row
.col
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
= t("helpers.buttons.add")
- if contestant.offline.present?
.row.mt-5.mb-2
.col
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
| Add completion
h3 = t("contestants.edit.offline_participation")
.row.mb-5
.col-6
h4 = t("contestants.edit.start_image")
- if contestant.offline.images.length > 0
.mt-3.mb-1
= contestant.offline.start_time.to_fs(:rfc822)
= image_tag(contestant.offline.images[0], class: "img-fluid", style: "max-height: 400px")
.col-6
h4 = t("contestants.edit.end_image")
- if contestant.offline.images.length > 1
.mt-3.mb-1
= contestant.offline.end_time.to_fs(:rfc822)
= image_tag(contestant.offline.images[1], class: "img-fluid", style: "max-height: 400px")
- else
= t("contestants.edit.not_finished")

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,27 @@
- if @contest.code.present?
.mb-4 style="height: calc(100vh - 280px)"
.row
.col
.alert.alert-info
= t("contestants.generate_qrcodes.note")
a.mt-3.mb-3.btn.btn-primary href="#{contest_generate_qrcodes_pdf_path(@contest)}" target="_blank" style="margin-top: -3px"
= t("helpers.buttons.open_raw")
.col.d-flex.flex-column style="height: calc(100% - 200px)"
.d-flex.flex-column style="overflow-y: auto;"
- for row in 0..((@contestants.length - 1) / 4)
.mt-4.d-flex.flex-row
- for col in 0..3
- if row * 4 + col < @contestants.length
.d-flex.flex-column.ms-5 style="align-items: center"
- if @contestants[row * 4 + col].qrcode.present?
.mt-1 style="width: 128px; height: 120px;"
= @contestants[row * 4 + col].qrcode.html_safe
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
= @contestants[row * 4 + col].name
- else
.row
.col
.alert.alert-warning
= t("contestants.generate_qrcodes.no_code_note")

View File

@@ -0,0 +1,13 @@
h1.text-center.mt-4.mb-4 = @contest.name
.d-flex.flex-column.align-items-center
- for row in 0..((@contestants.length - 1) / 4)
.mt-4.d-flex.flex-row
- for col in 0..3
- if row * 4 + col < @contestants.length
.d-flex.flex-column.ms-4.me-4 style="align-items: center"
- if @contestants[row * 4 + col].qrcode.present?
.mt-1 style="width: 128px; height: 120px;"
= @contestants[row * 4 + col].qrcode.html_safe
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
= @contestants[row * 4 + col].name

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

@@ -0,0 +1,64 @@
.row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%"
.row.mb-4
.col
a.btn.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
a.ms-2.btn.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
| #{t("helpers.buttons.import")}
a.ms-2.btn.btn.btn-primary href=contest_generate_qrcodes_path(@contest) style="margin-top: -3px"
| #{t("helpers.buttons.generate_qrcodes")}
a.ms-2.btn.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
| #{t("helpers.buttons.export")}
- if @contest.categories.size > 0
.row
.col
select.mt-2.mb-2 id="categories" style="padding: 5px"
option value=-1
| Tous.tes les participant.e.s
option value=-2
| Participant.e.s sans catégorie
- @contest.categories.each do |category|
option value=category.id
= category.name
javascript:
categorySelectEl = document.getElementById('categories');
urlParams = new URLSearchParams(window.location.search);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
window.location.replace(`#{contest_contestants_path(@contest)}?category=${e.target.value}`);
})
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.contestant.name")
th
= t("activerecord.attributes.contestant.offline")
th
= t("activerecord.attributes.contestant.completions")
th
= t("activerecord.attributes.contestant.display_time")
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= contestant.name
td
- if contestant.offline.present?
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
</svg>
td
= contestant.completions.where(remaining_pieces: nil).length
td
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
= t("helpers.buttons.details")

View File

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

View File

@@ -0,0 +1,2 @@
h4
= "Puzzle validé pour #{@contestant.name} !"

View File

@@ -1,21 +0,0 @@
= form_with model: contest do |form|
.row.mb-3
.col
.form-floating
= form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required"
.row.mb-3
.col
.form-check.form-switch
= form.check_box :team, class: "form-check-input"
= form.label :team
.form-text = t("activerecord.attributes.contest.team_description")
.row.mb-3
.col
.form-check.form-switch
= form.check_box :allow_registration, class: "form-check-input"
= form.label :allow_registration
.form-text = t("activerecord.attributes.contest.allow_registration_description")
.row
.col
= form.submit submit_text, class: "btn btn-primary"

View File

@@ -0,0 +1,50 @@
.d-flex.flex-column style="height: calc(100vh - 180px)"
= render "selectors"
.d-flex.flex-column style="overflow-y: auto"
.table-responsive-md
table.table.table-striped.table-hover
thead
tr
th
- if @contest.show_stopwatch
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400;"
= render "stopwatch_js"
th
th
th
- @puzzles.each do |puzzle|
th scope="col"
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 64px;") if puzzle.image.attached?
tr
th scope="col"
= t("helpers.rank")
th scope="col"
= t("activerecord.attributes.contestant.name")
th scope="col"
= t("activerecord.attributes.contestant.completions")
th scope="col"
= t("activerecord.attributes.contestant.display_time")
- @puzzles.each do |puzzle|
th scope="col"
= puzzle.name
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= index + 1
td
= contestant.name
td
= contestant.completions.where(remaining_pieces: nil).length
td
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
- @puzzles.each do |puzzle|
td
- contestant.completions.each do |completion|
- if completion.puzzle == puzzle
- if completion.completed
= completion.display_relative_time
- elsif completion.remaining_pieces.present?
= "#{puzzle.pieces - completion.remaining_pieces}p"

View File

@@ -0,0 +1,43 @@
.row
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 310px)"
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th
= t("helpers.rank")
th
= t("activerecord.attributes.contestant.name")
- if @contest.puzzles.size > 1
th
= t("activerecord.attributes.contestant.completions")
th style="width: 170px"
= t("activerecord.attributes.contestant.display_time")
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= index + 1
- if contestant.offline.present?
= @space
| (hors-ligne)
td
= contestant.name
- if @contest.puzzles.size > 1
td
= contestant.completions.where(remaining_pieces: nil).length
td style="position: relative"
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|> +
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
= contestant.display_time
.col-1
.col-5
- @puzzles.each do |puzzle|
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
.mt-3.fs-4 style="margin-left: 15px"
= puzzle.name
.fs-6 style="margin-left: 15px"
b
= "#{puzzle.brand} - #{puzzle.pieces}p"

View File

@@ -0,0 +1,46 @@
css:
.container { margin-top: 2rem !important; }
.row
- if @puzzles.size > 0
.d-flex.flex-column.justify-content-center.mb-2
= image_tag(@puzzles[0].image, style: "max-height: 200px; object-fit: contain") if @puzzles[0].image.attached?
.mt-2.fs-6 style="text-align: center"
=> "#{@puzzles[0].name} -"
= "#{@puzzles[0].brand} #{@puzzles[0].pieces}p"
.row
.mt-3.d-flex.flex-column
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th
= t("helpers.rank")
th
= t("activerecord.attributes.contestant.name")
- if @contest.puzzles.size > 1
th
= t("activerecord.attributes.contestant.completions")
th style="width: 170px"
= t("activerecord.attributes.contestant.display_time")
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= index + 1
- if contestant.offline.present?
= @space
| (hors-ligne)
td
= contestant.name
- if @contest.puzzles.size > 1
td
= contestant.completions.where(remaining_pieces: nil).length
td style="position: relative"
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|> +
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
= contestant.display_time
.col-1

View File

@@ -0,0 +1,33 @@
- if @contest.categories.size > 0
.row
.col
select.mb-2 id="categories" style="padding: 5px"
option value=-1
= t("contests.scoreboard.all_categories")
- @contest.categories.each do |category|
- if @category == category.id.to_s
option value=category.id selected=true
= category.name
- else
option value=category.id
= category.name
javascript:
document.getElementById('categories').addEventListener('change', (e) => {
addParam('category', e.target.value);
})
- if @contest.offline_form && @contest.puzzles.length < 2
.row
.col
- if @hide_offline
input type="checkbox" id="offline" style="padding: 5px;" checked=true
- else
input type="checkbox" id="offline" style="padding: 5px;"
label for="offline"
.ms-2
= t("contests.scoreboard.hide_offline")
javascript:
document.getElementById('offline').addEventListener('change', (e) => {
console.log('changed');
if (e.target.checked) addParam('hide_offline', e.target.checked);
else removeParam('hide_offline');
})

View File

@@ -0,0 +1,22 @@
javascript:
startTime = #{@contest.start_time.present? ? @contest.start_time.to_i : "null"};
pauseTime = #{@contest.pause_time.present? ? @contest.pause_time.to_i : "null"};
function updateTime() {
const displayTimeEl = document.getElementById('display-time');
if (displayTimeEl) {
if (startTime) {
let s = Math.floor((Date.now() - 1000 * startTime) / 1000);
if (pauseTime) s = Math.floor(pauseTime - startTime);
let ss = s % 60;
let mm = Math.floor(s / 60) % 60;
let hh = Math.floor(s / 3600);
displayTimeEl.innerHTML = `${hh < 10 ? `0${hh}` : hh}:${mm < 10 ? `0${mm}` : mm}:${ss < 10 ? `0${ss}` : ss}`;
setTimeout(updateTime, 1000);
} else {
displayTimeEl.innerHTML = '00:00:00';
}
} else {
setTimeout(updateTime, 20);
}
}
setTimeout(updateTime, 1);

View File

@@ -1 +0,0 @@
= render "form", contest: @contest, submit_text: t("helpers.buttons.save")

View File

@@ -1 +1,15 @@
= render "form", contest: @contest, submit_text: t("helpers.buttons.create")
= form_with model: @contest do |form|
.row.mb-3
.col
.form-floating
= form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required"
.row.mb-3
.col
.form-floating
= form.text_field :duration, autocomplete: "off", class: "form-control"
= form.label :duration, class: "required"
.form-text = t("activerecord.attributes.contest.duration_description")
.row.mt-4
.col
= form.submit t("helpers.buttons.create"), class: "btn btn-primary"

View File

@@ -0,0 +1,2 @@
.mt-3
= t("offlines.form.already_submitted")

View File

@@ -0,0 +1,5 @@
h3 = t("offlines.form.completed_message")
- if @contest.public
a.mt-3.btn.btn-success href="/public/#{@contest.slug}"
= t("contests.show.open_public_scoreboard")

View File

@@ -0,0 +1,86 @@
= form_with model: @offline, url: "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}" do |form|
h3 = t("offlines.form.start_message", name: @offline.name)
h1 id="display-time" style="font-size: 80px;"
javascript:
startTime = #{@offline.start_time.to_i};
function updateTime() {
const displayTimeEl = document.getElementById('display-time');
if (displayTimeEl) {
const s = Math.floor((Date.now() - 1000 * startTime) / 1000);
let ss = s % 60;
let mm = Math.floor(s / 60) % 60;
let hh = Math.floor(s / 3600);
displayTimeEl.innerHTML = `${hh < 10 ? `0${hh}` : hh}:${mm < 10 ? `0${mm}` : mm}:${ss < 10 ? `0${ss}` : ss}`;
setTimeout(updateTime, 1000);
} else {
setTimeout(updateTime, 20);
}
}
setTimeout(updateTime, 1);
.row.mt-5.mb-3
.col
.form-text.mb-1
= t("offlines.form.end_image_select")
= form.file_field :end_image, accept: "image/*", class: "form-control", capture: "user"
.form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big")
javascript:
function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]');
el.onchange = function() {
if(this.files[0].size > 9 * 1024 * 1024) {
document.getElementById('image-error-message').style.display = 'block';
this.value = "";
} else {
document.getElementById('image-error-message').style.display = 'none';
}
};
}
setMaxUploadSize();
.row.mb-3
.col
.form-check.form-switch
= form.check_box :completed, class: "form-check-input"
= form.label :completed
javascript:
completedEl = document.getElementById('offline_completed');
completedEl.addEventListener('change', (e) => {
missingPiecesEl = document.getElementById('missing_pieces');
remainingPiecesEl = document.getElementById('remaining_pieces');
if (e.target.checked) {
missingPiecesEl.style.display = 'block';
remainingPiecesEl.style.display = 'none';
} else {
missingPiecesEl.style.display = 'none';
remainingPiecesEl.style.display = 'block';
}
})
.row.mb-3 id="missing_pieces" style="display: none;"
.col
.form-floating
= form.text_field :missing_pieces, autocomplete: "off", class: "form-control"
= form.label :missing_pieces
.form-text
= t("offlines.form.missing_pieces")
.row.mb-3 id="remaining_pieces"
.col
.form-floating
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control"
= form.label :remaining_pieces
.form-text
= t("offlines.form.remaining_pieces")
javascript:
completedEl = document.getElementById('offline_completed');
missingPiecesEl = document.getElementById('missing_pieces');
remainingPiecesEl = document.getElementById('remaining_pieces');
if (completedEl.checked) {
missingPiecesEl.style.display = 'block';
remainingPiecesEl.style.display = 'none';
} else {
missingPiecesEl.style.display = 'none';
remainingPiecesEl.style.display = 'block';
}
.row.mt-4
.col
= form.submit t("helpers.buttons.end"), class: "btn btn-primary"

View File

@@ -0,0 +1,30 @@
= form_with model: @offline, url: "/public/#{@contest.friendly_id}/offline" do |form|
.row.mb-3
.col
.form-floating
= form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required"
.row.mb-3
.col
.form-text.mb-1
= t("offlines.form.start_image_select")
= form.file_field :images, accept: "image/*", class: "form-control", capture: "user"
.form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big")
javascript:
function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]');
el.onchange = function() {
if(this.files[0].size > 9 * 1024 * 1024) {
document.getElementById('image-error-message').style.display = 'block';
this.value = "";
} else {
document.getElementById('image-error-message').style.display = 'none';
}
};
}
setMaxUploadSize();
.row.mt-4
.col
= form.submit t("helpers.buttons.start"), class: "btn btn-primary"

View File

@@ -1,22 +1,25 @@
table.table.table-striped.table-hover
thead
tr
th scope="col"
| Rank
th scope="col"
| Name
th scope="col"
| Completed puzzles
th scope="col"
| Total time
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= index + 1
td
= contestant.name
td
= contestant.completions.length
td
= contestant.display_time
css:
@media (max-width: 800px) {
.mobile-single { display: block !important; }
.desktop-single { display: none; }
#scoreboard-switches { display: none; }
.stopwatch { font-size: 50px !important; }
}
- if @contest.puzzles.size < 2
= render "selectors"
turbo-frame id="scoreboard"
- if @contest.show_stopwatch
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400; margin-bottom: 0; text-align: center;"
= render "stopwatch_js"
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
.mobile-single style="display: none;"
= render "scoreboard_mobile_single"
.desktop-single
= render "scoreboard_desktop_single"
- else
turbo-frame id="scoreboard"
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
= render "scoreboard_desktop_marathon"

View File

@@ -0,0 +1,36 @@
= render "params_nav"
.row
.col
.alert.alert-primary role="alert"
= t("contests.nav.categories_description")
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
- if @contest.categories.size > 0
.row
.col-6
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.category.name")
th
= t("activerecord.attributes.category.contestant_count")
tbody
- @contest.categories.each do |category|
tr.align-middle scope="row"
td
= category.name
td
= category.contestants.size
td
= link_to t("helpers.buttons.delete"), contest_category_path(@contest, category), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"
.row.mt-3
.col-4
.form-floating
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
= form.label :name, class: "required"
= t("activerecord.attributes.category.new")
.row.mt-4
.col
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"

View File

@@ -0,0 +1,34 @@
= render "params_nav"
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
.row.mt-2.mb-3
.col
.form-floating
= form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required"
.row.mb-3
.col
.form-floating
= form.text_field :duration, autocomplete: "off", class: "form-control"
= form.label :duration, class: "required"
.form-text = t("activerecord.attributes.contest.duration_description")
.row.mb-3
.col
.form-floating
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang
.row.mb-3 style="display: none"
.col
.form-check.form-switch
= form.check_box :team, class: "form-check-input"
= form.label :team
.form-text = t("activerecord.attributes.contest.team_description")
.row.mb-3 style="display: none"
.col
.form-check.form-switch
= form.check_box :allow_registration, class: "form-check-input"
= form.label :allow_registration
.form-text = t("activerecord.attributes.contest.allow_registration_description")
.row.mt-4
.col
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"

View File

@@ -0,0 +1,46 @@
= render "params_nav"
javascript:
async function copyExtensionUrlToClipboard() {
await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
alert("#{t("contests.show.url_copied")}");
}
.row.mb-4.mt-2
.col
- if @contest.offline_form && @contest.puzzles.length < 2
a.btn.btn-success href="/public/#{@contest.slug}/offline"
= t("contests.show.open_offline_form")
- else
a.btn.btn-success.disabled
= t("contests.show.offline_form_disabled")
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
css:
button > svg {
margin-right: 2px;
margin-top: -3px;
}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
</svg>
=< t("contests.show.copy_extension_url")
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/online" do |form|
.row.mt-2.mb-3
.col
- if @contest.puzzles.length <= 1
.form-check.form-switch
= form.check_box :offline_form, class: "form-check-input"
= form.label :offline_form
.form-text = t("activerecord.attributes.contest.offline_form_warning")
.form-text = t("activerecord.attributes.contest.offline_form_description")
- else
.form-check.form-switch
= form.check_box :offline_form_fake, class: "form-check-input", disabled: true
= form.label :offline_form
.form-text = t("activerecord.attributes.contest.offline_form_warning")
.form-text = t("activerecord.attributes.contest.offline_form_description")
.row.mt-4
.col
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"

View File

@@ -0,0 +1,12 @@
= render "params_nav"
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/onsite" do |form|
.row.mt-2.mb-3
.col
.form-floating
= form.text_field :code, autocomplete: "off", class: "form-control"
= form.label :code, class: "required"
.form-text = t("activerecord.attributes.contest.code_description")
.row.mt-4
.col
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"

View File

@@ -0,0 +1,22 @@
= render "params_nav"
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/public" do |form|
.row.mt-2
.col
.form-check.form-switch
= form.check_box :public, class: "form-check-input"
= form.label :public
.row.mt-2
.col
.form-check.form-switch
= form.check_box :show_stopwatch, class: "form-check-input"
= form.label :show_stopwatch
.row.mt-3
.col
.form-floating
= form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select"
= form.label :ranking_mode
.row.mt-4
.col
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"

View File

@@ -1,67 +0,0 @@
.row.mb-4
.col
css:
.badges { margin-top: -18px; position: absolute; }
.badges
- @badges.each do |badge|
span.badge.text-bg-info.me-2
= badge
.row.mb-4
.col
.float-end
a.btn.btn-primary href=edit_contest_path(@contest)
| Edit contest
p
= t("contests.show.public_scoreboard")
= link_to root_url + "public/#{@contest.slug}", root_url + "public/#{@contest.slug}"
.row.mb-4
.col-6
.row
.col
h4 = t("puzzles.plural").capitalize
.row.row-cols-1.row-cols-md-3.g-4.mb-4
- @puzzles.each do |puzzle|
.col
css:
.card:hover { background-color: lightblue; }
.card.h-100
.card-header
= puzzle.name
= image_tag puzzle.image if puzzle.image.attached?
.card-body
p.card-text
= puzzle.brand
a.stretched-link href=edit_contest_puzzle_path(@contest, puzzle)
.row
.col
a.btn.btn-primary href=new_contest_puzzle_path(@contest)
= t("contests.show.add_puzzle")
.col-6
.row
.col
h4 = t("contestants.plural").capitalize
table.table.table-striped.table-hover
thead
tr
th scope="col"
| Name
th scope="col"
| Completed puzzles
tbody
- @contestants.each do |contestant|
tr scope="row"
td
= contestant.name
td
= contestant.completions.length
td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
| Open
a.btn.btn-sm.btn-secondary.ms-2 href=new_contest_completion_path(@contest, contestant_id: contestant.id)
| Add completion
.row.mt-4
.col
a.btn.btn-primary href=new_contest_contestant_path(@contest)
= t("contests.show.add_participant")

View File

@@ -0,0 +1,16 @@
.row
.col
.alert.alert-primary
= t("contests.stopwatch.info")
h1.mt-3 id="display-time" style="font-size: 80px;"
= render "stopwatch_js"
.row.mt-3
.col.d-flex
- if !@contest.start_time.present?
= button_to t("helpers.buttons.stopwatch_start"), "/contests/#{@contest.id}/stopwatch_start", method: :post, class: "btn btn-primary"
- if @contest.pause_time.present?
= button_to t("helpers.buttons.stopwatch_continue"), "/contests/#{@contest.id}/stopwatch_continue", method: :post, class: "btn btn-primary"
- if @contest.start_time.present? && !@contest.pause_time.present?
= button_to t("helpers.buttons.stopwatch_pause"), "/contests/#{@contest.id}/stopwatch_pause", method: :post, class: "btn btn-primary"
- if @contest.start_time.present?
= button_to t("helpers.buttons.stopwatch_reset"), "/contests/#{@contest.id}/stopwatch_reset", method: :post, class: "ms-3 btn btn-warning"

View File

@@ -5,17 +5,104 @@ html
body
.container.mt-5
- if @current_user
.float-end style="margin-top: -8px;"
.float-end style="margin-top: -5px;"
nav.navbar.bg-body-primary
- if @current_user.admin
a.navbar-brand href=users_path
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
= t("nav.users")
a.navbar-brand href=contests_path
a.navbar-brand href=contests_path class="btn btn-light" style="margin-right: 0"
= t("nav.home")
a.navbar-brand href=user_path(@current_user)
a.navbar-brand href=user_path(@current_user) class="btn btn-light"
= t("nav.settings")
= button_to t("nav.log_out"), session_path, method: :delete
= button_to t("nav.log_out"), session_path, method: :delete, class: "btn btn-danger"
h1.mb-4 = @title
css:
.toast {
opacity: 0;
animation: fadeInAndOut 6s linear;
}
@keyframes fadeInAndOut {
0%, 5%, 100% { opacity: 0 }
7%, 85% { opacity: 1 }
}
javascript:
function closeToast(event) {
event.target.parentElement.parentElement.style.display = 'none';
}
.toast-container.position-fixed.p-3 style="right: 30px; top: 85px"
- flash.each do |type, msg|
.toast role="alert" aria-live="assertive" aria-atomic="true" style="display: block"
.toast-header
strong.me-auto
i.bi-bell-fill.fs-6.text-primary
=< type.humanize
small.text-body-secondary
| Just now
button.btn-close type="button" data-bs-dismiss="toast" aria-label="Close" onclick="closeToast(event)"
.toast-body
= msg
h1.mb-4
- if @contest && @contest.id.present?
- if active_page("/public") == "active" && @action_path
= @contest.name
.float-end style="margin-top: -5px;" id="scoreboard-switches"
.d-inline-flex.align-items-center
.ms-4.form-check.form-switch style="font-size: 16px; font-weight: 300;"
input.form-check-input type="checkbox" id="refresh-checkbox"
label.ms-1 style="font-size: 16px; font-weight: 300;"
= t("contests.scoreboard.auto_refresh")
.js data-turbo="false"
javascript:
function refresh() {
if (document.getElementById('refresh-checkbox').checked) {
addParam('autorefresh', 1);
setTimeout(refresh, 30000);
}
}
function addParam(key, value) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete(key);
urlParams.append(key, value);
const refreshBtn = document.getElementById('refresh-button')
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
refreshBtn.click();
}
function removeParam(key) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete(key);
const refreshBtn = document.getElementById('refresh-button')
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
refreshBtn.click();
}
function autoRefresh() {
if (document.getElementById('refresh-checkbox').checked) setTimeout(refresh, 30000);
document.getElementById('refresh-checkbox').addEventListener('change', (e) => {
if (e.target.checked) refresh();
else removeParam('autorefresh');
});
}
async function startAutoRefresh(count) {
if (count == 0) return;
if (document.getElementById('refresh-button') && document.getElementById('refresh-checkbox')) autoRefresh();
else setTimeout(() => startAutoRefresh(count - 1), 10);
}
startAutoRefresh(200);
- elsif active_page("/contests") == "active"
= @contest.name
- if @contest.public
a.ms-4.btn.btn-success href="/public/#{@contest.slug}" style="margin-top: -6px;"
= t("contests.show.open_public_scoreboard")
- else
a.ms-4.btn.btn-success.disabled style="margin-top: -6px;"
= t("contests.show.public_scoreboard_disabled")
- else
= @contest.name
- else
= @title
- if @contest && active_page("/contests") == "active" && !@nonav
= render "contest_nav"
= yield

View File

@@ -0,0 +1,6 @@
doctype html
html
= render "layouts/header"
body
= yield

View File

@@ -0,0 +1,52 @@
.row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%"
.row.mb-4
.col
.alert.alert-primary
= t("messages.index.info")
- if @messages.length == 0
.alert.alert-warning
= t("messages.index.no_messages")
- else
- if @puzzles.size == 0
.row
.col.alert.alert-danger
= t("messages.warning")
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th scope="col" style="white-space: nowrap"
= t("activerecord.attributes.message.processed")
th scope="col"
= t("activerecord.attributes.message.time")
th scope="col"
= t("activerecord.attributes.message.author")
th.w-25 scope="col"
= t("activerecord.attributes.message.text")
th.w-25 scope="col"
tbody
- @messages.each do |message|
tr.align-middle scope="row"
td style="text-align: center"
- if message.completions.size > 0
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
</svg>
td
= message.display_time
td
= message.author
td
= message.text
td
.d-inline-flex
- if @puzzles.size > 0
a.btn.btn-sm.btn-secondary href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
= t("helpers.buttons.add_completion")
- else
a.btn.btn-sm.btn-secondary.disabled href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
= t("helpers.buttons.add_completion")
= link_to "x", contest_message_path(@contest, message), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"

View File

@@ -1,4 +1,11 @@
= form_with model: puzzle, url: url, method: method do |form|
.row.mb-3
.col.alert.alert-warning
= t("puzzles.form.fake_data_recommendation")
- if puzzle.id && puzzle.image.attached?
.row.mb-3
.col
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px")
.row.mb-3
.col
.form-floating
@@ -11,10 +18,39 @@
= form.label :brand, class: "required"
.row.mb-3
.col
.form-text Select an image
.form-floating
= form.number_field :pieces, autocomplete: "off", class: "form-control"
= form.label :pieces, class: "required"
.row.mb-3
.col
.form-check.form-switch
= form.check_box :hidden, class: "form-check-input"
= form.label :hidden
.form-text
= t("activerecord.attributes.puzzle.hidden_description")
.row.mb-3
.col
.form-text.mb-1
= t("puzzles.image_select")
= form.file_field :image, accept: "image/*", class: "form-control"
.row.mt-4
.form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big")
javascript:
function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]');
el.onchange = function() {
if(this.files[0].size > 9 * 1024 * 1024) {
document.getElementById('image-error-message').style.display = 'block';
this.value = "";
} else {
document.getElementById('image-error-message').style.display = 'none';
}
};
}
setMaxUploadSize();
.row.mt-4.mb-5
.col
- if method == :patch
= link_to "Delete", contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
= link_to t("helpers.buttons.delete"), contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
= form.submit submit_text, class: "btn btn-primary"

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

@@ -0,0 +1,42 @@
.row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%"
.row.mb-4
.col
a.btn.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.puzzle.image")
th
= t("activerecord.attributes.puzzle.name")
th
= t("activerecord.attributes.puzzle.brand")
th
= t("activerecord.attributes.puzzle.pieces")
th
= t("activerecord.attributes.puzzle.hidden")
tbody
- @puzzles.each do |puzzle|
tr.align-middle scope="row"
td
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 128px;") if puzzle.image.attached?
td
= puzzle.name
td
= puzzle.brand
td
= puzzle.pieces
td
- if puzzle.hidden?
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
</svg>
td
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
= t("helpers.buttons.edit")

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.email_field :email_address, autocomplete: "username", required: true, autofocus: true, class: "form-control"
= form.label :email_address, class: "required"
= t("activerecord.attributes.session.email_address")
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "current-password", required: true, autofocus: true, class: "form-control", maxlength: 72
= form.label :password, class: "required"
= t("activerecord.attributes.session.password")
= form.submit "Sign in"
= form.submit t("helpers.buttons.sign_in")

View File

@@ -30,13 +30,13 @@
= form.label :password, class: "required"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
- if method == :patch
h4.mt-5 = t("users.edit.password_section")
- if method == :patch
h4.mt-5 = t("users.edit.password_section")
= form_with model: user, method: method do |form|
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
= form_with model: user, url: user_password_path(user) do |form|
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required"
= form.submit t("helpers.buttons.save_password"), class: "btn btn-primary"

View File

@@ -1,27 +1,39 @@
table.table.table-striped.table-hover
thead
tr
th scope="col"
| Id
th scope="col"
| Name
th scope="col"
| Admin?
th scope="col"
| # contests
tbody
- @users.each do |user|
tr scope="row"
td
= user.id
td
= user.username
td
= user.admin ? "Yes" : "No"
td
= user.contests.length
.row
.col
.d-flex.flex-row.justify-content-start.align-items-center
a.btn.btn-primary href=new_user_path
| New user
| New user
= button_to "Update contestants", "/update_contestants", method: :post, class: "ms-3 btn btn-success"
= button_to "Regenerate QR codes", "/regenerate_qrcodes", method: :post, class: "ms-3 btn btn-success"
- @users.each do |user|
- if user.admin
h3.mt-5 = "#{user.username} (admin)"
- else
h3.mt-5 = user.username
table.table.table-striped.table-hover
thead
tr
th scope="col"
| ID
th scope="col"
| Friendly ID
th scope="col"
| # Puzzles
th scope="col"
| # Participants
tbody
- user.contests.each do |contest|
tr scope="row"
td
= contest.id
td
= contest.friendly_id
td
= contest.puzzles.length
td
= contest.contestants.length
td
a.btn.btn-sm.btn-secondary href=contest_path(contest)
= t("helpers.buttons.open")

73
config/brakeman.ignore Normal file
View File

@@ -0,0 +1,73 @@
{
"ignored_warnings": [
{
"warning_type": "Cross-Site Scripting",
"warning_code": 2,
"fingerprint": "00462a5825f8e46fe0b5167b1c822296cb5d8443117790a04966ba059a260f2b",
"check_name": "CrossSiteScripting",
"message": "Unescaped model attribute",
"file": "app/views/contestants/generate_qrcodes.html.slim",
"line": 20,
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
"render_path": [
{
"type": "controller",
"class": "ContestantsController",
"method": "generate_qrcodes",
"line": 126,
"file": "app/controllers/contestants_controller.rb",
"rendered": {
"name": "contestants/generate_qrcodes",
"file": "app/views/contestants/generate_qrcodes.html.slim"
}
}
],
"location": {
"type": "template",
"template": "contestants/generate_qrcodes"
},
"user_input": "Contest.find(params[:contest_id]).contestants",
"confidence": "Weak",
"cwe_id": [
79
],
"note": "SVG HTML code is generated by the app"
},
{
"warning_type": "Cross-Site Scripting",
"warning_code": 2,
"fingerprint": "d17a497a9b261007930226914a64e99d6f6237c99cc1c33c88745e1341ac4fb7",
"check_name": "CrossSiteScripting",
"message": "Unescaped model attribute",
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim",
"line": 11,
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
"render_path": [
{
"type": "controller",
"class": "ContestantsController",
"method": "generate_qrcodes_pdf",
"line": 135,
"file": "app/controllers/contestants_controller.rb",
"rendered": {
"name": "contestants/generate_qrcodes_pdf",
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim"
}
}
],
"location": {
"type": "template",
"template": "contestants/generate_qrcodes_pdf"
},
"user_input": "Contest.find(params[:contest_id]).contestants",
"confidence": "Weak",
"cwe_id": [
79
],
"note": "SVG HTML code is generated by the app"
}
],
"brakeman_version": "7.1.1"
}

View File

@@ -40,6 +40,8 @@ Rails.application.configure do
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
config.action_controller.default_url_options = { host: "192.168.122.105", port: 3000 }
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log

View File

@@ -60,6 +60,8 @@ Rails.application.configure do
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
config.action_controller.default_url_options = { host: "puzzle-scoreboard.org" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),

View File

@@ -1,5 +1,5 @@
ActionView::Base.field_error_proc = proc do |html_tag, instance|
if html_tag.include? "<label"
if (html_tag.include? "<label") || (html_tag.include? "<input accept=\"image")
appended_html = ""
if instance.error_message.is_a?(Array)
appended_html = "<div class='error-message form-text'>#{instance.error_message.map(&:capitalize).uniq.join(", ")}</div>"

View File

@@ -28,77 +28,337 @@
# enabled: "ON"
en:
activemodel:
errors:
models:
forms/csv_conversion_form:
attributes:
name_column:
blank: "Participant names are required"
greater_than: "Participant names are required"
activerecord:
attributes:
category:
contestant_count: Contestants count
new: New category
name: Category
completion:
completed: Puzzle completed
contestant: Participant
display_time: Time
display_time_from_start: Time since start
display_time_from_start_description: Format mm:ss or h:mm:ss
display_relative_time: Time for this puzzle
puzzle: Puzzle
missing_pieces: Missing pieces
projected_time: Projected time
remaining_pieces: Remaining pieces (not completed puzzle)
contest:
name: "Name"
team: "Team contest"
team_description: "For UI display purposes mainly"
allow_registration: "Allow registration"
allow_registration_description: "Generates a shareable registration form for this contest"
code: Code for onsite judges
code_description: Optional. Used for organizers on onsite contests, when printing QR codes on tables
duration: Duration
duration_description: Format h:mm or hh:mm
lang: Language for the public scoreboard
name: Name
offline_form: Enable the offline participation form
offline_form_description: Offline participants will have to fill the form by providing an image taken of the puzzle before starting solving it, and validate their finish time with an upload of an image of the completed puzzle
offline_form_warning: Only for single-puzzle contests
public: Enable the public scoreboard
ranking_mode: Ranking mode (public scoreboard & CSV exports)
show_stopwatch: Display the stopwatch
team: Team contest
team_description: For UI display purposes mainly
allow_registration: Allow registration
allow_registration_description: Generates a shareable registration form for this contest
contestant:
completions: completions
display_time: Time
email: Email
name: Name
offline: Offline?
email_description: Optional. Used for sending emails through this app, or for identifying participants whose gmeet handle doesn't match their registered name.
csv_import:
file: File
separator: Separator
message:
author: Author
processed: Processed?
text: Content
time: Time
offline:
completed: Puzzle completed
name: Your name
missing_pieces: Missing pieces
remaining_pieces: Remaining pieces
puzzle:
brand: Brand
hidden: hidden
hidden_description: When hidden, a puzzle doesn't appear in the public scoreboard, nor public forms
image: Image
name: Name
pieces: Number of pieces
session:
email_address: Email address
password: Password
user:
username: "Username"
email_address: "Email address"
lang: "Language"
password: "New password"
username: Username
email_address: Email address
lang: Language
password: New password
errors:
models:
completion:
attributes:
code:
mismatch: "Wrong code"
contestant_id:
taken: "This contestant has already completed the puzzle"
display_time_from_start:
blank: Mandatory
invalid: "Allowed formats: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
puzzle_id:
taken: "This contestant has already completed this puzzle"
remaining_pieces:
blank: This is required
not_an_integer: This is not an integer
not_a_number: This is not an integer
too_large: "Cannot be greater than the number of pieces for this puzzle"
contest:
attributes:
duration:
blank: Must be filled
invalid: Invalid duration
name:
blank: The contest name cannot be empty
contestant:
attributes:
name:
blank: The participant name cannot be empty
csv_import:
attributes:
file:
blank: "No file selected"
empty: "This file is empty"
not_a_csv_file: "it must be a CSV file"
offline:
attributes:
end_image:
blank: Please upload an image
missing_pieces:
not_an_integer: This is not an integer
not_a_number: This is not an integer
name:
blank: Please enter a name for your participation
remaining_pieces:
blank: You need to provide the number of remaining pieces to assemble
not_an_integer: This is not an integer
not_a_number: This is not an integer
start_image:
blank: Please upload an image
puzzle:
attributes:
name:
blank: The puzzle name cannot be empty
pieces:
blank: It's mandatory to provide the number of pieces
user:
attributes:
email_address:
blank: Your email cannot be empty
username:
blank: Your username cannot be empty
taken: This username is already taken
password:
blank: Your password cannot be empty
categories:
destroy:
notice: Category deleted
new:
error: The category name can't be empty
notice: Category added
completions:
destroy:
notice: Completion deleted
edit:
title: "Edit completion"
notice: Completion updated
title: Edit completion
form:
all_finished: "All puzzles were already completed by %{name}"
code: Judges code
validate_name: "Validate a puzzle for %{name}"
new:
title: "New completion"
notice: Completion added
title: New completion
singular: completion
contests:
destroy:
notice: Contest deleted
edit:
title: "Edit contest settings"
notice: Contest updated
title: Edit contest settings
form:
offline_single_puzzle_warning: This is not available for contests with more than one puzzle
index:
title: "Welcome %{username}!"
manage_contests: "Manage my contests"
new_contest: "Create a new contest"
title: Welcome %{username}!
manage_contests: Manage my contests
new_contest: Create a new contest
nav:
categories: Participant categories
categories_description: Once you add categories, you will be able to assign them to participants on their profiles, and a filter for categories will be available on the public scoreboard
general: General
online: Online contests
onsite: Onsite contests
public: Public scoreboard
settings: Settings
stopwatch: Stopwatch
new:
title: "New jigsaw puzzle contest"
notice: Contest added
title: New jigsaw puzzle contest
scoreboard:
all_categories: All categories
auto_refresh: Auto refresh (30s)
hide_offline: Hide offline participants
refresh: Activate auto-refresh (every 5s)
title: "%{name}"
show:
title: "%{name}"
add_participant: "Add contestant"
add_puzzle: "Add puzzle"
public_scoreboard: "Public scoreboard: "
add_participant: Add participant
add_puzzle: Add puzzle
copy_extension_url: Copy the URL for connecting from the browser extension
open_offline_form: Open offline form
open_public_scoreboard: Open public scoreboard
offline_form_disabled: The offline form is disabled
public_scoreboard_disabled: The public scoreboard is disabled
url_copied: URL copied to the clipboard
stopwatch:
info: This stopwatch is used for display in the public scoreboard, when allowed in the settings
contestants:
convert_csv:
title: Import participants
destroy:
notice: Participant deleted
edit:
title: "Participant"
team_title: "Teams"
completions_note: The time doesn't automatically account penalties for missing pieces. The ability to specify time penalties will be added later on, stay tuned!
end_image: End image
notice: Participant updated
not_finished: Not yet finished
no_puzzles_note: No puzzles were added yet
offline_participation: Offline participation
start_image: Start image
title: Participant
team_title: Teams
finalize_import:
title: Import participants
generate_qrcodes:
note: These QR codes allow for judges to fill in results without the need of the organizer's account, for example by printing them and placing them on participant tables
no_code_note: Those can't be used until a code for judges has been set up in the general settings
import:
email_column: Participant email
import_column: Import?
name_column: Participant name
notice: Participants imported
title: Import participants
new:
title: "New participant"
team_title: "New team"
singular: "participant"
plural: "participants"
notice: Participant added
title: New participant
team_title: New team
singular: participant
plural: participants
teams:
singular: "team"
plural: "teams"
singular: team
plural: teams
upload_csv:
title: Import participants
helpers:
badges:
registration: "registration"
team: "team"
buttons:
add: "Add"
add_completion: "Add completion"
back: "⬅ Back to the contest"
back_to_contestant: "⬅ Back to the participant"
confirm: "Confirm"
create: "Create"
save: "Save"
details: Open
delete: "Delete"
edit: "Edit"
end: Click here to submit your completion
export: Export
generate_qrcodes: Generate QR codes
import: CSV Import
open: Open
open_raw: Open in a raw page
refresh: Refresh
settings: Settings
sign_in: Sign in
save: Save
save_password: Save password
start: Click here to start your participation
stopwatch_continue: Continue
stopwatch_pause: Pause
stopwatch_reset: Reset
stopwatch_start: Start
update: Save modifications
field: Field
none: No field selected
rank: Rank
lib:
ranking:
actual: First by number of pieces assembled, then by time (recommended if unsure)
theorical: By time only (projected time calculated with the ppm count)
messages:
index:
info: This section is only used for contests that rely on the connection with Google Meet
no_messages: No messages received yet
convert:
title: New completion
destroy:
notice: Message deleted
plural: "messages"
singular: "message"
warning: "You first need to add a puzzle before converting messages to completions."
nav:
users: "Users"
home: "Home"
settings: "Settings"
log_out: "Log out"
users: Users
home: My contests
settings: My account
log_out: Log out
offlines:
form:
already_submitted: You have already completed the puzzle
completed_message: Thanks for your participation!
end_image_select: Take a photo of your completed puzzle, or on the state it is if you decide to give up
missing_pieces: Indicate the number of missing pieces, if any
remaining_pieces: Indicate the number of remaining pieces to assemble
start_image_select: Take a photo of the puzzle with the provided code written on a paper before starting it
start_message: Let's go %{name}!
puzzles:
destroy:
notice: Puzzle deleted
edit:
title: "Edit contest puzzle"
notice: Puzzle updated
title: Edit contest puzzle
form:
fake_data_recommendation: It is recommended to first enter a fake name and image, and to use the real ones only once the contest starts.
file_too_big: File too big! Maximum allowed size is 8M
image_select: Select an image
new:
title: "New contest puzzle"
singular: "puzzle"
plural: "puzzles"
notice: Puzzle added
title: New contest puzzle
singular: puzzle
plural: puzzles
sessions:
new:
notice: Login successful
title: "Login to the Public Scoreboard app"
users:
edit:
notice: Settings updated
title: "My settings"
general_section: "General settings"
password_section: "Change password"
password_section: "Password"
index:
title: "All users"
new:
notice: User created
title: "New user"

View File

@@ -1,75 +1,335 @@
fr:
activemodel:
errors:
models:
forms/csv_conversion_form:
attributes:
name_column:
blank: "Choisir une colonne pour les noms des participant.e.s est nécessaire"
greater_than: "Choisir une colonne pour les noms des participant.e.s est nécessaire"
activerecord:
attributes:
category:
contestant_count: Nombre de participant.e.s
new: Nouvelle catégorie
name: Catégorie
completion:
completed: Puzzle terminé
contestant_id: Participant.e
display_time: Temps
display_time_from_start: Temps depuis le début
display_time_from_start_description: Format mm:ss ou h:mm:ss
display_relative_time: Temps pour ce puzzle
puzzle: Puzzle
missing_pieces: Pièces manquantes
projected_time: Temps projeté
remaining_pieces: Pièces restantes (puzzle non fini)
contest:
name: "Nom"
team: "Concours par équipes"
team_description: "Principalement pour des raisons d'affichage"
allow_registration: "Autoriser l'inscription via l'interface"
allow_registration_description: "Génère un formulaire d'inscription pour ce concours"
code: Code pour les organisateur.ice.s
code_description: Optionnel. Utilisé pour les organisateur.ices dans les concours en présentiel lorsque sont imprimés des QR codes à placer sur les tables
duration: Durée
duration_description: Format h:mm ou hh:mm
lang: Langue pour le classement public
name: Nom
offline_form: Activer le formulaire de participation hors-ligne
offline_form_description: Les participant.e.s hors-ligne pourront participer en prenant une photo du puzzle avant de le commencer, puis valider leur temps avec une photo du puzzle une fois complété
offline_form_warning: Activable uniquement pour les concours avec un seul puzzle
public: Activer le classement public
ranking_mode: Mode de classement (classement public & exports CSV)
show_stopwatch: Afficher le chronomètre
team: Concours par équipes
team_description: Principalement pour des raisons d'affichage
allow_registration: Autoriser l'inscription via l'interface
allow_registration_description: Génère un formulaire d'inscription pour ce concours
contestant:
completions: Complétions
display_time: Temps
email: Email
name: Nom
offline: Hors-ligne ?
email_description: Optionnel. Utile pour envoyer des emails aux participant.e.s depuis cette app, ou pour reconnaître les pseudos gmeet quand ils ne correspondent pas au nom préalablement entré.
csv_import:
file: Fichier
separator: Délimiteur
message:
author: Auteur.ice
processed: Traité ?
text: Contenu
time: Temps
offline:
completed: Puzzle terminé
name: Ton nom ou pseudo
missing_pieces: Pièces manquantes
remaining_pieces: Pièces restantes
puzzle:
brand: Marque
hidden: Non découvert
hidden_description: Un puzzle non découvert n'apparaît pas dans le classement public, ni dans les formulaires publics
image: Image
name: Nom
pieces: Nombre de pièces
session:
email_address: Adresse email
password: Mot de passe
user:
username: "Nom d'utilisateur.ice"
email_address: "Adresse email"
lang: "Langue de l'interface"
password: "Nouveau mot de passe"
username: Nom d'utilisateur.ice
email_address: Adresse email
lang: Langue de l'interface
password: Nouveau mot de passe
errors:
models:
completion:
attributes:
code:
mismatch: "Code non valide"
contestant_id:
taken: "Ce.tte participant.e a déjà complété le puzzle"
display_time_from_start:
blank: Obligatoire
invalid: "Formats autorisés: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
puzzle_id:
taken: "Ce.tte participant.e a déjà complété ce puzzle"
remaining_pieces:
blank: Ce champ est obligatoire
not_an_integer: Ce n'est pas un nombre entier
not_a_number: Ce n'est pas un nombre entier
too_large: "Ne peut pas être plus grand que le nombre de pièces du puzzle"
contest:
attributes:
duration:
blank: Obligatoire
invalid: Durée invalide
name:
blank: Le nom du concours ne peut pas être vide
contestant:
attributes:
name:
blank: Le nom du ou de la participant.e ne peut pas être vide
csv_import:
attributes:
file:
blank: "Aucun fichier sélectionné"
empty: "Ce fichier est vide"
not_a_csv_file: "Le fichier doit être au format CSV"
offline:
attributes:
end_image:
blank: Tu dois inclure cette image pour pouvoir valider ta participation
missing_pieces:
not_an_integer: Ce n'est pas un entier
not_a_number: Ce n'est pas un entier
name:
blank: Tu dois entrer un nom pour pouvoir participer
remaining_pieces:
blank: Tu dois renseigner le nombre de pièces restantes à assembler
not_an_integer: Ce n'est pas un entier
not_a_number: Ce n'est pas un entier
start_image:
blank: Tu dois inclure cette image pour pouvoir participer
puzzle:
attributes:
name:
blank: Le nom du puzzle est obligatoire
pieces:
blank: Il est obligatoire d'indiquer le nombre de pièces
user:
attributes:
email_address:
blank: L'email est obligatoire
username:
blank: Le nom d'utilisateur.ice est obligatoire
taken: Ce nom d'utilisateur.ice est déjà utilisé
password:
blank: Le mot de passe ne peut pas être vide
categories:
destroy:
notice: Catégorie supprimée
new:
error: Le nom de la catégorie ne peut pas être vide
notice: Catégorie ajoutée
completions:
destroy:
notice: Complétion supprimée
edit:
title: "Modifier la complétion"
notice: Complétion modifiée
title: Modifier la complétion
form:
all_finished: Tous les puzzles ont déjà été complétés par %{name}
code: Code organisateur.ice
validate_name: "Valider un puzzle pour %{name}"
new:
title: "Nouvelle complétion"
notice: Complétion ajoutée
title: Ajout d'une complétion
singular: complétion
contests:
destroy:
notice: Concours supprimé
edit:
title: "Paramètres du concours"
notice: Concours modifié
title: Paramètres du concours
form:
offline_single_puzzle_warning: Ce n'est pas activable pour les concours avec plusieurs puzzles
index:
title: "Bienvenue %{username} !"
manage_contests: "Mes concours de puzzle"
new_contest: "Créer un nouveau concours"
title: Bienvenue %{username} !
manage_contests: Mes concours de puzzle
new_contest: Créer un nouveau concours
nav:
categories: Catégories de participant.e.s
categories_description: Après avoir ajouté des catégories, elles pourront être attributées aux participant.e.s sur leurs profils, et un filtre sera disponible sur le classement public
general: Général
online: Concours en ligne
onsite: Concours en présentiel
public: Classement public
settings: Paramètres
stopwatch: Chronomètre
new:
title: "Nouveau concours"
notice: Concours ajouté
title: Nouveau concours
scoreboard:
all_categories: Toutes les catégories
auto_refresh: Auto-rafraichissement (30s)
hide_offline: Cacher les participant.e.s hors-ligne
refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
title: "%{name}"
show:
title: "%{name}"
add_participant: "Ajouter un.e participant.e"
add_puzzle: "Ajouter un puzzle"
public_scoreboard: "Classement public : "
add_participant: Ajouter un.e participant.e
add_puzzle: Ajouter un puzzle
copy_extension_url: Copier l'URL pour la connexion depuis l'extension web
open_offline_form: Ouvrir le formulaire hors-ligne
open_public_scoreboard: Ouvrir le classement public
offline_form_disabled: Le formulaire hors-ligne n'est pas activé
public_scoreboard_disabled: Le classement public n'est pas activé
url_copied: LURL a été copiée dans le presse-papier
stopwatch:
info: Ce chronomètre est utilisé pour être affiché dans le classement public, quand autorisé dans les paramètres
contestants:
convert_csv:
title: Importer des participant.e.s
destroy:
notice: Participant.e supprimé.e
edit:
title: "Participant.e"
team_title: "Équipe"
completions_note: Le temps n'inclut actuellement pas de pénalité pour les pièces manquantes. La possibilité de spécifier des pénalités en temps sera ajouté plus tard à l'interface !
end_image: Image de fin
notice: Participant.e modifié.e
not_finished: Non terminé
no_puzzles_note: Aucun puzzle n'a été défini encore pour ce concours
offline_participation: Participation hors-ligne
start_image: Image de début
title: Participant.e
team_title: Équipe
finalize_import:
title: Importer des participant.e.s
generate_qrcodes:
note: Ces QR codes permettent, quand imprimés et placés sur les tables des participant.e.s, aux organisateur.ice.s de valider les temps de complétion des puzzles
no_code_note: Les codes ne seront générés qu'une fois un code pour les organisateur.ice.s défini dans les paramètres généraux
import:
email_column: Email des participant.e.s
import_column: Importer ?
name_column: Noms des participant.e.s
notice: Participant.e.s importé.e.s
title: Importer des participant.e.s
new:
title: "Nouveau.elle participant.e"
team_title: "Nouvelle équipe"
singular: "participant.e"
plural: "participant.e.s"
notice: Participant.e ajouté.e
title: Nouveau.elle participant.e
team_title: Nouvelle équipe
singular: participant.e
plural: participant.e.s
teams:
singular: "équipe"
plural: "équipes"
singular: équipe
plural: équipes
upload_csv:
title: Importer des participant.e.s
helpers:
badges:
registration: "auto-inscription"
team: "équipes"
buttons:
add: "Ajouter"
add_completion: "Convertir"
back: "⬅ Revenir au concours"
back_to_contestant: "⬅ Revenir au/à la participant.e"
confirm: "Confirmer"
create: "Créer"
save: "Modifier"
delete: "Supprimer"
details: Détails
edit: "Modifier"
end: Clique ici pour valider ta complétion du puzzle
export: Exporter
generate_qrcodes: Générer des QR codes
import: Importer un CSV
open: Ouvrir
open_raw: Ouvrir dans un format imprimable
refresh: Rafraîchir
settings: Paramètres
sign_in: Se connecter
save: Modifier
save_password: Modifier le mot de passe
start: Clique ici pour démarrer ta participation
stopwatch_continue: Reprendre
stopwatch_pause: Pause
stopwatch_reset: Ré-initialiser
stopwatch_start: Démarrer
update: Enregistrer les modifications
field: Champ
none: Aucun champ sélectionné
rank: Rang
lib:
ranking:
actual: Par nombre de pièces assemblées, puis par temps (recommandé)
theorical: Par temps uniquement (temps projeté calculé à partir de la vitesse d'assemblage)
messages:
index:
info: Cette section n'est pertinente que pour les concours en ligne qui utilisent la connexion depuis Google Meet
no_messages: Pas de messages reçus pour le moment
convert:
title: Ajout d'une complétion
destroy:
notice: Message supprimé
plural: "messages"
singular: "message"
warning: "Au moins un puzzle doit être ajouté avant de pouvoir convertir des messages en complétions."
nav:
users: "Utilisateur.ices"
home: "Accueil"
settings: "Paramètres"
log_out: "Déconnexion"
users: Utilisateur.ices
home: Mes concours
settings: Mon compte
log_out: Déconnexion
offlines:
form:
already_submitted: Tu as déjà complété ton puzzle
completed_message: Merci pour ta participation !
end_image_select: Prends une photo du puzzle une fois complété, ou de l'état actuel si tu choisis de t'arrêter là
missing_pieces: Indique le nombre de pièces manquantes s'il y en a
remaining_pieces: Indique ici le nombre de pièces restantes à assembler
start_image_select: Prends une photo du puzzle avant de le commencer, avec le code donné par l'organisateur.ice écrit sur du papier
start_message: C'est parti %{name} !
puzzles:
destroy:
notice: Puzzle supprimé
edit:
title: "Modifier le puzzle"
notice: Puzzle modifié
title: Modifier le puzzle
form:
fake_data_recommendation: Il est recommendé d'entrer de faux noms et images, et de mettre les vrais uniquement quand le concours démarre.
file_too_big: La taille de l'image dépasse la taille maximum autorisée de 8M
image_select: Choisis une image
new:
title: "Nouveau puzzle"
singular: "puzzle"
plural: "puzzles"
notice: Puzzle ajouté
title: Nouveau puzzle
singular: puzzle
plural: puzzles
sessions:
new:
notice: Connection réussie
title: "Se connecter à l'app Public Scoreboard"
users:
edit:
notice: Paramètres modifiés
title: "Mes paramètres"
general_section: "Paramètres globaux"
password_section: "Modifier mon mot de passe"
password_section: "Mot de passe"
index:
title: "Tous.tes les utilisateur.ices"
new:
notice: Utilisateur.ice ajouté.e
title: "Nouveau.elle utilisateur.ice"

View File

@@ -9,13 +9,76 @@ Rails.application.routes.draw do
root "contests#index"
resources :contests do
get "settings/general", to: "contests#settings_general_edit"
patch "settings/general", to: "contests#settings_general_update"
get "settings/public", to: "contests#settings_public_edit"
patch "settings/public", to: "contests#settings_public_update"
get "settings/onsite", to: "contests#settings_onsite_edit"
patch "settings/onsite", to: "contests#settings_onsite_update"
get "settings/online", to: "contests#settings_online_edit"
patch "settings/online", to: "contests#settings_online_update"
get "settings/categories", to: "contests#settings_categories_edit"
get "stopwatch", to: "contests#stopwatch"
post "stopwatch_continue", to: "contests#stopwatch_continue"
post "stopwatch_pause", to: "contests#stopwatch_pause"
post "stopwatch_reset", to: "contests#stopwatch_reset"
post "stopwatch_start", to: "contests#stopwatch_start"
resources :categories, only: [ :create, :destroy ]
resources :completions
resources :contestants
resources :puzzles
resources :messages, only: [ :destroy, :index ] do
get "convert", to: "messages#convert"
end
get "import", to: "contestants#import"
post "import", to: "contestants#upload_csv"
get "import/:id", to: "contestants#convert_csv"
post "import/:id", to: "contestants#finalize_import"
get "export", to: "contestants#export"
get "generate_qrcodes", to: "contestants#generate_qrcodes"
get "generate_qrcodes_pdf", to: "contestants#generate_qrcodes_pdf"
end
resources :passwords, param: :token
resource :session
resources :users
resources :users do
patch "password", to: "users#change_password"
end
options "connect", to: "messages#cors_preflight_check"
options "message", to: "messages#cors_preflight_check"
post "connect", to: "messages#connect"
post "message", to: "messages#create"
post "regenerate_qrcodes", to: "users#regenerate_qrcodes"
post "update_contestants", to: "users#update_contestants"
get "public/:id", to: "contests#scoreboard"
get "public/:id/offline", to: "contests#offline_new"
post "public/:id/offline", to: "contests#offline_create"
get "public/:id/offline/:token", to: "contests#offline_edit"
patch "public/:id/offline/:token", to: "contests#offline_update"
get "public/:id/offline/:token/completed", to: "contests#offline_completed"
get "public/p/:contestant_id", to: "contestants#get_public_completion"
post "public/p/:contestant_id", to: "contestants#post_public_completion"
get "public/p/:contestant_id/updated", to: "contestants#public_completion_updated"
direct :direct_test do
"https://lol.com"
end
direct :public_scoreboard do |contest|
"/public/#{contest.friendly_id}/public"
end
direct :offline_form do |contest|
"/public/#{contest.friendly_id}/offline"
end
direct :offline_form_edit do |contest, offline|
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}"
end
direct :offline_form_completed do |contest, offline|
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}/completed"
end
end

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

View File

@@ -0,0 +1,10 @@
class CreateOfflines < ActiveRecord::Migration[8.0]
def change
create_table :offlines do |t|
t.timestamps
t.belongs_to :contest, null: false, foreign_key: true
t.datetime :start_time, null: false
t.datetime :end_time
end
end
end

View File

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

View File

@@ -0,0 +1,5 @@
class AddNameToOffline < ActiveRecord::Migration[8.0]
def change
add_column :offlines, :name, :string, null: false
end
end

Some files were not shown because too many files have changed in this diff Show More