Compare commits

..

74 Commits

Author SHA1 Message Date
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
sto
2f23938e81 Add message model
Some checks failed
CI / scan_ruby (push) Failing after 14s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 27s
2025-05-11 19:40:30 +02:00
sto
378c3011ef Add prod instructions in README
Some checks failed
CI / scan_ruby (push) Failing after 14s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 27s
2025-05-11 17:19:14 +02:00
sto
a421cd496d Fix SCSS compiled file inclusion in Dockerfile
Some checks failed
CI / scan_ruby (push) Failing after 47s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
2025-05-11 16:19:18 +02:00
sto
21f71f9d32 More translations, incl. attributes
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 33s
2025-03-28 14:26:57 +01:00
sto
10fa821f19 Some contest pages translations
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 38s
2025-03-27 14:51:25 +01:00
sto
8b0b1c6745 Add language settings for users, and translate titles to French
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-03-27 12:55:12 +01:00
sto
497768610d Setup I18n for titles
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-03-27 12:15:27 +01:00
sto
26b8064553 Add login & user tests
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 35s
2025-03-27 10:26:03 +01:00
sto
7023600cd1 Setup Faker and Factorybot
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 33s
2025-03-27 09:27:25 +01:00
sto
12f9f33034 Setup Rspec
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-03-26 19:58:11 +01:00
sto
2144c22bd9 Use the friendly ID gem for contest slugs
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 10s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 33s
2025-03-26 17:40:56 +01:00
sto
a5d165c4b3 Save display times in the db
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 41s
2025-03-26 17:00:06 +01:00
sto
c98caeea92 Initialize tests and make them pass
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 25s
2025-03-23 13:40:27 +01:00
sto
f8bfb020bc Fully remove .gitea Chrome dep 2025-03-23 13:40:14 +01:00
109 changed files with 2576 additions and 599 deletions

View File

@@ -58,16 +58,7 @@ jobs:
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config software-properties-common apt-transport-https ca-certificates
#- name: Import Chrome GPG key
# run: curl -fSsL https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor | sudo tee /usr/share/keyrings/google-chrome.gpg >> /dev/null
#- name: Import Chrome APT repository
# run: echo deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main | sudo tee /etc/apt/sources.list.d/google-chrome.list
#- name: Install Chrome
# run: sudo apt update && sudo apt install google-chrome-stable
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config
- name: Checkout code
uses: actions/checkout@v4
@@ -78,16 +69,12 @@ jobs:
ruby-version: .ruby-version
bundler-cache: true
- name: Run tests
- name: Setup test database
env:
RAILS_ENV: test
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test test:system
run: bin/rails db:test:prepare
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: ${{ github.workspace }}/tmp/screenshots
if-no-files-found: ignore
- name: Run rspec
env:
RAILS_ENV: test
run: bundle exec rspec

1
.rspec Normal file
View File

@@ -0,0 +1 @@
--require spec_helper

View File

@@ -48,9 +48,6 @@ RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
@@ -58,6 +55,9 @@ FROM base
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# TODO: find how not to depend on this hack to include the compiled SCSS.
RUN cp app/assets/builds/application.css `ls public/assets/application-*.css`
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \

View File

@@ -43,6 +43,9 @@ gem "thruster", require: false
gem "slim"
gem "dartsass-rails"
gem "bootstrap", "~> 5.3.3"
gem "friendly_id", "~> 5.5.0"
gem "csv"
gem "damerau-levenshtein"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
@@ -53,6 +56,10 @@ group :development, :test do
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
end
group :development do

View File

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

View File

@@ -3,3 +3,44 @@
## Dependencies
Rails installation guide: https://guides.rubyonrails.org/install_ruby_on_rails.html.
## Put in production
### Create a master key
```
bin/rails credentials:edit
```
### Build docker image
```
docker build -t puzzle_scoreboard .
```
### Run docker container
```
sudo docker run -d -p 3000:80 -e RAILS_MASTER_KEY=... -v puzzle-data:/rails/storage --name puzzle_scoreboard puzzle_scoreboard
```
### Access command line
```
sudo docker exec -it puzzle_scoreboard /bin/bash
sudo docker exec -it puzzle_scoreboard /rails/bin/rails console
```
### Logs
Attach to the docker container to see live logs:
```
sudo docker container attach puzzle_scoreboard
```
Or look at the logs saved on the host machine:
```
sudo docker logs puzzle_scoreboard
```

View File

@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
include Authentication
include Pundit::Authorization
before_action :set_title, :set_current_user
before_action :set_title, :set_current_user, :set_lang
after_action :verify_authorized
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
@@ -13,13 +13,20 @@ class ApplicationController < ActionController::Base
private
def set_title
@title = "Public scoreboard"
t_action_name = action_name
t_action_name = "new" if action_name == "create"
t_action_name = "edit" if action_name == "update"
@title = I18n.t("#{controller_name}.#{t_action_name}.title")
end
def set_current_user
@current_user = current_user
end
def set_lang
I18n.locale = @current_user.lang if @current_user
end
def user_not_authorized(exception)
policy_name = exception.policy.class.to_s.underscore

View File

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

View File

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

View File

@@ -8,23 +8,25 @@ 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)
h = time / 3600
m = (time % 3600) / 60
s = (time % 3600) % 60
if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s)
elsif m > 0
return m.to_s + ":" + pad(s)
end
pad(seconds)
s.to_s
end
def extend_completions!(completions)
def extend_completions!(contestant)
current_time_from_start = 0
@completions.each do |completion|
completion.display_time_from_start = display_time(completion.time_seconds)
completion.display_relative_time = display_time(completion.time_seconds - current_time_from_start)
current_time_from_start += completion.time_seconds
contestant.completions.order(:time_seconds).each do |completion|
completion.update(display_time_from_start: display_time(completion.time_seconds),
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
current_time_from_start = completion.time_seconds
end
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
end
end

View File

@@ -1,6 +1,4 @@
class ContestantsController < ApplicationController
include CompletionsConcern
before_action :set_contest
before_action :set_contestant, only: %i[ destroy edit update]
before_action :set_completions, only: %i[edit update ]
@@ -8,14 +6,16 @@ class ContestantsController < ApplicationController
def edit
authorize @contest
@title = "Contestant"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end
def new
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@contestant = Contestant.new
@title = "New contestant"
end
def create
@@ -24,9 +24,11 @@ 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
@title = "New contestant"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity
end
end
@@ -35,9 +37,11 @@ class ContestantsController < ApplicationController
authorize @contest
if @contestant.update(contestant_params)
redirect_to @contest
update_contestant_categories
redirect_to @contest, notice: t("contestants.edit.notice")
else
@title = "Contestant"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity
end
end
@@ -46,7 +50,65 @@ 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
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :import, status: :unprocessable_entity
end
end
def convert_csv
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
@form = Forms::CsvConversionForm.new
end
def finalize_import
authorize @contest
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
all_params = params.require(:forms_csv_conversion_form)
@form = Forms::CsvConversionForm.new(params.require(:forms_csv_conversion_form).permit(:email_column, :name_column))
if @form.valid?
@content.each_with_index do |row, i|
if all_params["row_#{i}".to_sym] == "1"
if @form.email_column == -1
Contestant.create(name: row[@form.name_column], contest: @contest)
else
logger.info("Email")
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
end
end
end
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :convert_csv, status: :unprocessable_entity
end
end
private
@@ -61,11 +123,20 @@ class ContestantsController < ApplicationController
def set_completions
@completions = @contestant.completions.order(:time_seconds)
extend_completions!(@completions)
@completions
end
def contestant_params
params.expect(contestant: [ :email, :name ])
end
def update_contestant_categories
@contestant.categories.clear
@contest.categories.each do |category|
logger.info(params[:contestant]["category_#{category.id}".to_sym] == "1")
if params[:contestant].key?("category_#{category.id}".to_sym) && params[:contestant]["category_#{category.id}".to_sym] == "1"
@contestant.categories << category
end
end
@contestant.save
end
end

View File

@@ -1,6 +1,4 @@
class ContestsController < ApplicationController
include CompletionsConcern
before_action :set_contest, only: %i[ destroy edit show update ]
skip_before_action :require_authentication, only: %i[ scoreboard ]
@@ -8,29 +6,33 @@ class ContestsController < ApplicationController
authorize :contest
@contests = current_user.contests
@title = "Welcome #{current_user.username}!"
@title = I18n.t("contests.index.title", username: current_user.username)
end
def show
authorize @contest
@title = @contest.name
@contestants = @contest.contestants.order(:name)
@title = I18n.t("contests.show.title", name: @contest.name)
@action_name = t("helpers.buttons.edit")
@action_path = edit_contest_path(@contest)
@contestants = @contest.contestants.sort_by { |contestant| [ -contestant.completions.size, contestant.time_seconds ] }
filter_contestants_per_category
@puzzles = @contest.puzzles.order(:id)
@messages = @contest.messages.order(:time_seconds)
set_badges
end
def edit
authorize @contest
@title = "Edit contest settings"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end
def new
authorize :contest
@contest = Contest.new
@title = "New jigsaw puzzle competition"
end
def create
@@ -39,7 +41,7 @@ class ContestsController < ApplicationController
@contest = Contest.new(contest_params)
@contest.user_id = current_user.id
if @contest.save
redirect_to @contest
redirect_to @contest, notice: t("contests.new.notice")
else
render :new, status: :unprocessable_entity
end
@@ -49,28 +51,41 @@ class ContestsController < ApplicationController
authorize @contest
if @contest.update(contest_params)
redirect_to @contest
redirect_to @contest, notice: t("contests.edit.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @contest
@contest.destroy
redirect_to contests_path, notice: t("contests.destroy.notice")
end
def scoreboard
@contest = Contest.find_by(slug: params[:id])
unless @contest
unless @contest && @contest.public
skip_authorization
not_found and return
end
authorize @contest
@title = @contest.name
@contestants = @contest.contestants.order(:name)
I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
@contestants = @contest.contestants.sort_by { |contestant| [ -contestant.completions.size, contestant.time_seconds ] }
filter_contestants_per_category
@puzzles = @contest.puzzles.order(:id)
extend_completions!(@contest.completions)
@action_name = t("helpers.buttons.refresh")
if params.key?(:category)
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
else
@action_path = "/public/#{@contest.friendly_id}"
end
render :scoreboard
end
@@ -78,8 +93,8 @@ class ContestsController < ApplicationController
def set_badges
@badges = []
@badges.push("team") if @contest.team
@badges.push("registration") if @contest.allow_registration
@badges.push(t("helpers.badges.team")) if @contest.team
@badges.push(t("helpers.badges.registration")) if @contest.allow_registration
end
def set_contest
@@ -87,6 +102,16 @@ class ContestsController < ApplicationController
end
def contest_params
params.expect(contest: [ :name, :team, :allow_registration, :slug ])
params.expect(contest: [ :lang, :name, :public, :team, :allow_registration ])
end
def filter_contestants_per_category
if params.key?(:category) && params[:category] != "-1"
if params[:category] == "-2"
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
else
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
end
end
end
end

View File

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

View File

@@ -5,14 +5,16 @@ class PuzzlesController < ApplicationController
def edit
authorize @contest
@title = "Edit contest puzzle"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end
def new
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@puzzle = Puzzle.new
@title = "New contest puzzle"
end
def create
@@ -21,9 +23,10 @@ class PuzzlesController < ApplicationController
@puzzle = Puzzle.new(puzzle_params)
@puzzle.contest_id = @contest.id
if @puzzle.save
redirect_to contest_path(@contest)
redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
else
@title = "New contest puzzle"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity
end
end
@@ -32,9 +35,10 @@ class PuzzlesController < ApplicationController
authorize @contest
if @puzzle.update(puzzle_params)
redirect_to @contest
redirect_to @contest, notice: t("puzzles.edit.notice")
else
@title = "Edit contest puzzle"
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity
end
end
@@ -43,7 +47,7 @@ class PuzzlesController < ApplicationController
authorize @contest
@puzzle.destroy
redirect_to contest_path(@contest)
redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice")
end
private
@@ -57,6 +61,6 @@ class PuzzlesController < ApplicationController
end
def puzzle_params
params.expect(puzzle: [ :brand, :name, :image ])
params.expect(puzzle: [ :brand, :name, :image, :pieces ])
end
end

View File

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

View File

@@ -4,21 +4,18 @@ class UsersController < ApplicationController
def index
authorize :user
@title = "All users"
@users = User.all
end
def edit
authorize @user
@title = "My settings"
end
def update
authorize @user
if @user.update(user_params)
redirect_to contests_path
redirect_to contests_path, notice: t("users.edit.notice")
else
render :edit, status: :unprocessable_entity
end
@@ -33,7 +30,6 @@ class UsersController < ApplicationController
def new
authorize :user
@title = "New user"
@user = User.new()
end
@@ -42,9 +38,8 @@ class UsersController < ApplicationController
@user = User.new(user_params)
if @user.save
redirect_to users_path
redirect_to users_path, notice: t("users.new.notice")
else
@title = "New user"
render :new, status: :unprocessable_entity
end
end
@@ -60,6 +55,6 @@ class UsersController < ApplicationController
end
def user_params
params.expect(user: [ :username, :email_address, :password ])
params.expect(user: [ :username, :email_address, :lang, :password ])
end
end

View File

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

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

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

@@ -0,0 +1,3 @@
module Languages
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "Français" } ]
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

@@ -2,34 +2,51 @@
#
# Table name: completions
#
# id :integer not null, primary key
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# puzzle_id :integer not null
# id :integer not null, primary key
# display_relative_time :string
# display_time_from_start :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# message_id :integer
# puzzle_id :integer not null
#
# Indexes
#
# index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id)
# index_completions_on_message_id (message_id)
# index_completions_on_puzzle_id (puzzle_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
# message_id (message_id => messages.id)
# puzzle_id (puzzle_id => puzzles.id)
#
class Completion < ApplicationRecord
belongs_to :contest
belongs_to :contestant
belongs_to :puzzle
belongs_to :message, optional: true
attr_accessor :display_time_from_start, :display_relative_time
before_save :add_time_seconds
validates :time_seconds, presence: true
validates_numericality_of :time_seconds
validates :puzzle_id, uniqueness: { scope: :contestant }
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
def add_time_seconds
arr = display_time_from_start.split(":")
if arr.size == 3
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
elsif arr.size == 2
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
elsif arr.size == 1
self.time_seconds = arr[0].to_i
end
end
end

View File

@@ -4,7 +4,9 @@
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# lang :string default("en")
# name :string
# public :boolean default(FALSE)
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
@@ -13,6 +15,7 @@
#
# Indexes
#
# index_contests_on_slug (slug) UNIQUE
# index_contests_on_user_id (user_id)
#
# Foreign Keys
@@ -20,12 +23,19 @@
# user_id (user_id => users.id)
#
class Contest < ApplicationRecord
belongs_to :user
extend FriendlyId
belongs_to :user
has_many :categories
has_many :completions, dependent: :destroy
has_many :contestants, dependent: :destroy
has_many :puzzles, dependent: :destroy
has_many :messages, dependent: :destroy
friendly_id :name, use: :slugged
validates :name, presence: true
validates :slug, presence: true, uniqueness: true, format: { with: /\A(\w|-)*\z/, message: 'Only alphanumeric characters, "-" and "_" allowed.' }
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
generates_token_for :token
end

View File

@@ -2,12 +2,14 @@
#
# Table name: contestants
#
# id :integer not null, primary key
# email :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# id :integer not null, primary key
# display_time :string
# email :string
# name :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
@@ -19,8 +21,27 @@
#
class Contestant < ApplicationRecord
belongs_to :contest
has_many :completions, dependent: :destroy
has_and_belongs_to_many :categories
has_many :completions
before_validation :initialize_time_seconds_if_empty
validates :name, presence: true
validates :time_seconds, presence: true
def form_name
if email.present?
"#{name} - #{email}"
else
name
end
end
private
def initialize_time_seconds_if_empty
if !self.time_seconds
self.time_seconds = 0
end
end
end

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

28
app/models/message.rb Normal file
View File

@@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: messages
#
# id :integer not null, primary key
# author :string
# display_time :string
# text :string not null
# time_seconds :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_messages_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
class Message < ApplicationRecord
belongs_to :contest
has_many :completions, dependent: :nullify
validates :author, presence: true
validates :text, presence: true
end

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
class UserPolicy < ApplicationPolicy
def index
def index?
user.admin?
end

View File

@@ -1,19 +1,41 @@
= form_with model: completion, url: url, method: method do |form|
- if @message
= form.hidden_field :message_id, value: @message.id
.row.mb-3
.col
h4 = t("messages.singular").capitalize
.alert.alert-secondary
b
= @message.author
br
= @message.text
.row
.col
h4 = t("completions.singular").capitalize
.row.mb-3
.col
.form-floating
= form.text_field :time_seconds, autocomplete: "off", class: "form-control"
= form.label :time_seconds, class: "required"
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control"
= form.label :display_time_from_start, class: "required"
.row.mb-3
.col
.form-floating
= form.select :contestant_id, @contestants.map { |contestant| [contestant.name, contestant.id] }, {}, class: "form-select"
= form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select"
= form.label :contestant_id
.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
- 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
.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

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

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,19 @@
= form_with(model: @csv_import, url: contest_import_path(@contest), html: { novalidate: true }) do |form|
.row.g-3
.col
.mb-3
.form-floating
= form.file_field :file, class: "form-control", accept: ".csv, text/csv"
= form.label :file
.row.g-3
.col
.mb-3
.form-floating
= form.select :separator, @csv_import.options_for_separator, {}, class: "form-select"
= form.label :separator
.row.g-3
.col
.mb-3
= form.submit t("helpers.buttons.import"), class: "btn btn-primary"

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
h4.mt-5 = t("contests.form.general")
= form_with model: contest do |form|
.row.mb-3
.col
@@ -7,22 +8,58 @@
.row.mb-3
.col
.form-floating
= form.text_field :slug, autocomplete: "off", class: "form-control"
= form.label :slug, class: "required"
.form-text This will be used for building the public scoreboard URL: https://puzzle-scoreboard.org/public/&lt;slug&gt;.
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang
.row.mb-3
.col
.form-check.form-switch
= form.check_box :public, class: "form-check-input"
= form.label :public
.row.mb-3
.col
.form-check.form-switch
= form.check_box :team, class: "form-check-input"
= form.label :team
| Team contest
.form-text For UI display purposes mainly
.row.mb-3
.form-text = t("activerecord.attributes.contest.team_description")
.row.mb-3 style="display: none"
.col
.form-check.form-switch
= form.check_box :allow_registration, class: "form-check-input"
= form.label :allow_registration
.form-text Generates a shareable registration form for this contest
.form-text = t("activerecord.attributes.contest.allow_registration_description")
.row
.col
= form.submit submit_text, class: "btn btn-primary"
h4.mt-5 = t("contests.form.categories")
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
- if @contest.categories.size > 0
.row
.col-6
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.category.name")
th
= t("activerecord.attributes.category.contestant_count")
tbody
- @contest.categories.each do |category|
tr.align-middle scope="row"
td
= category.name
td
= category.contestants.size
td
= link_to t("helpers.buttons.delete"), contest_category_path(@contest, category), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"
.row.mt-3
.col-4
.form-floating
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
= form.label :name, class: "required"
= t("activerecord.attributes.category.new")
.row.mt-3
.col
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"

View File

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

View File

@@ -1,10 +1,10 @@
.row
.col
h4.mb-3
| Manage your contests
= t("contests.index.manage_contests")
.float-end
a.btn.btn-primary.mb-4 href=new_contest_path
| Create a new contest
= t("contests.index.new_contest")
.row.row-cols-1.row-cols-md-3.g-4
- @contests.each do |contest|
@@ -15,7 +15,11 @@
.card-header
= contest.name
.card-body
.card-text.mb-2 = "#{contest.puzzles.length} puzzles - #{contest.contestants.length} participants"
.card-text.mb-2
= "#{contest.puzzles.length} #{t('puzzles.singular')}" if contest.puzzles.length <= 1
= "#{contest.puzzles.length} #{t('puzzles.plural')}" if contest.puzzles.length > 1
= " - #{contest.contestants.length} #{t('contestants.singular')}" if contest.contestants.length <= 1
= " - #{contest.contestants.length} #{t('contestants.plural')}" if contest.contestants.length > 1
.row
.col
- contest.puzzles.each do |puzzle|

View File

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

View File

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

View File

@@ -1,69 +1,168 @@
.row.mb-4
.col
css:
.badges { margin-top: -18px; position: absolute; }
.badges
- @badges.each do |badge|
span.badge.text-bg-info.me-2
= badge
- if @badges.size > 0 && false
.row.mb-4
.col
.badges style="margin-top: -18px; position: absolute"
- @badges.each do |badge|
span.badge.text-bg-info.me-2
= badge
.row.mb-4
.col
.float-end
a.btn.btn-primary href=edit_contest_path(@contest)
| Edit contest
p
|> Public scoreboard:
= link_to root_url + "public/#{@contest.slug}", root_url + "public/#{@contest.slug}"
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
.col-6
.row.mb-5
.col
- if @contest.public
a.btn.btn-success href="/public/#{@contest.slug}"
= t("contests.show.open_public_scoreboard")
- else
a.btn.btn-success.disabled
= t("contests.show.public_scoreboard_disabled")
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
css:
button > svg {
margin-right: 2px;
margin-top: -3px;
}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
</svg>
=< t("contests.show.copy_extension_url")
.row.mb-4 style="height: calc(100vh - 280px)"
.col-6.d-flex.flex-column style="height: 100%"
.row
.col
h4
| Puzzles
.row.row-cols-1.row-cols-md-3.g-4.mb-4
- @puzzles.each do |puzzle|
= t("contestants.plural").capitalize
a.ms-3.btn.btn-sm.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
a.ms-2.btn-sm.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
| #{t("helpers.buttons.import")}
- if @contest.categories.size > 0
.row
.col
css:
.card:hover { background-color: lightblue; }
.card.h-100
.card-header
= puzzle.name
= image_tag puzzle.image if puzzle.image.attached?
.card-body
p.card-text
= puzzle.brand
a.stretched-link href=edit_contest_puzzle_path(@contest, puzzle)
.row
.col
a.btn.btn-primary href=new_contest_puzzle_path(@contest)
| Add puzzle
.col-6
select.mt-2.mb-2 id="categories" style="padding: 5px"
option value=-1
| Tous.tes les participant.e.s
option value=-2
| Participant.e.s sans catégorie
- @contest.categories.each do |category|
option value=category.id
= category.name
javascript:
categorySelectEl = document.getElementById('categories');
urlParams = new URLSearchParams(window.location.search);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
window.location.replace(`#{contest_path(@contest)}?category=${e.target.value}`);
})
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th
= t("helpers.rank")
th
= t("activerecord.attributes.contestant.name")
th
= t("activerecord.attributes.contestant.completions")
th
= t("activerecord.attributes.contestant.display_time")
tbody
- @contestants.each_with_index do |contestant, index|
tr scope="row"
td
= index + 1
td
= contestant.name
td
= contestant.completions.length
td
= contestant.display_time
td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
= t("helpers.buttons.open")
.col-6.d-flex.flex-column style="height: 100%"
.row
.col
h4
| Contestants
= t("puzzles.plural").capitalize
a.ms-3.btn.btn-sm.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
table.table.table-striped.table-hover
thead
tr
th scope="col"
| Name
th scope="col"
| Completed puzzles
th
= t("activerecord.attributes.puzzle.image")
th
= t("activerecord.attributes.puzzle.name")
th
= t("activerecord.attributes.puzzle.brand")
th
= t("activerecord.attributes.puzzle.pieces")
tbody
- @contestants.each do |contestant|
tr scope="row"
- @puzzles.each do |puzzle|
tr.align-middle scope="row"
td
= contestant.name
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 48px;") if puzzle.image.attached?
td
= contestant.completions.length
= puzzle.name
td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
| Open
a.btn.btn-sm.btn-secondary.ms-2 href=new_contest_completion_path(@contest, contestant_id: contestant.id)
| Add completion
.row.mt-4
.col
a.btn.btn-primary href=new_contest_contestant_path(@contest)
| Add contestant
= puzzle.brand
td
= puzzle.pieces
td
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
= t("helpers.buttons.edit")
- if @messages
.row.mt-5
.col
h4 = t("messages.plural").capitalize
- if @puzzles.size == 0
.row
.col.alert.alert-danger
= t("messages.warning")
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th scope="col" style="white-space: nowrap"
= t("activerecord.attributes.message.processed")
th scope="col"
= t("activerecord.attributes.message.time")
th scope="col"
= t("activerecord.attributes.message.author")
th.w-25 scope="col"
= t("activerecord.attributes.message.text")
th.w-25 scope="col"
tbody
- @messages.each do |message|
tr.align-middle scope="row"
td style="text-align: center"
- if message.completions.size > 0
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
</svg>
td
= message.display_time
td
= message.author
td
= message.text
td
.d-inline-flex
- if @puzzles.size > 0
a.btn.btn-sm.btn-secondary href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
= t("helpers.buttons.add_completion")
- else
a.btn.btn-sm.btn-secondary.disabled href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
= t("helpers.buttons.add_completion")
= link_to "x", contest_message_path(@contest, message), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"

View File

@@ -8,14 +8,45 @@ html
.float-end style="margin-top: -8px;"
nav.navbar.bg-body-primary
- if @current_user.admin
a.navbar-brand href=users_path
| Users
a.navbar-brand href=contests_path
| Home
a.navbar-brand href=user_path(@current_user)
| Settings
= button_to "Log out", session_path, method: :delete
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
= t("nav.users")
a.navbar-brand href=contests_path class="btn btn-light" style="margin-right: 0"
= t("nav.home")
a.navbar-brand href=user_path(@current_user) class="btn btn-light"
= t("nav.settings")
= button_to t("nav.log_out"), session_path, method: :delete, class: "btn btn-danger"
h1.mb-4 = @title
css:
.toast {
opacity: 0;
animation: fadeInAndOut 6s linear;
}
@keyframes fadeInAndOut {
0%, 5%, 100% { opacity: 0 }
7%, 85% { opacity: 1 }
}
javascript:
function closeToast(event) {
event.target.parentElement.parentElement.style.display = 'none';
}
.toast-container.position-fixed.p-3 style="right: 30px; top: 85px"
- flash.each do |type, msg|
.toast role="alert" aria-live="assertive" aria-atomic="true" style="display: block"
.toast-header
strong.me-auto
i.bi-bell-fill.fs-6.text-primary
=< type.humanize
small.text-body-secondary
| Just now
button.btn-close type="button" data-bs-dismiss="toast" aria-label="Close" onclick="closeToast(event)"
.toast-body
= msg
h1.mb-4
= @title
- if @action_path
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
= @action_name
= yield

View File

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

View File

@@ -1 +1 @@
= render "form", contest: @contest, puzzle: @puzzle, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/puzzles/#{@puzzle.id}"
= render "form", contest: @contest, puzzle: @puzzle, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/puzzles/#{@puzzle.id}"

View File

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

View File

@@ -1,11 +0,0 @@
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
<%= form_with url: session_path do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %><br>
<%= form.submit "Sign in" %>
<% end %>
<br>
<%= link_to "Forgot password?", new_password_path %>

View File

@@ -0,0 +1,15 @@
= form_with url: session_path do |form|
.row.mb-3
.col
.form-floating
= form.email_field :email_address, autocomplete: "username", required: true, autofocus: true, class: "form-control"
= form.label :email_address, class: "required"
= t("activerecord.attributes.session.email_address")
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "current-password", required: true, autofocus: true, class: "form-control", maxlength: 72
= form.label :password, class: "required"
= t("activerecord.attributes.session.password")
= form.submit t("helpers.buttons.sign_in")

View File

@@ -1,6 +1,7 @@
= form_with model: user, method: method do |form|
- if method == :patch
h4 General settings
h4 = t("users.edit.general_section")
.row.mb-3
.col
.input-group
@@ -8,26 +9,34 @@
.form-floating
= form.text_field :username, autocomplete: "off", class: "form-control"
= form.label :username, class: "required"
.row.mb-3
.col
.form-floating
= form.text_field :email_address, autocomplete: "off", class: "form-control"
= form.label :email_address, class: "required"
.row.mb-3
.col
.form-floating
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang
- if method == :post
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required"
= form.submit "Save", class: "btn btn-primary"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
- if method == :patch
h4.mt-5 Change password
h4.mt-5 = t("users.edit.password_section")
= form_with model: user, method: method do |form|
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required"
| New password
= form.submit "Save new password", class: "btn btn-primary"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"

View File

@@ -14,6 +14,7 @@ module PuzzleScoreboard
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_paths << Rails.root.join("lib")
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
@@ -23,5 +24,8 @@ module PuzzleScoreboard
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
config.i18n.default_locale = :en
config.i18n.available_locales = [ :en, :fr ]
end
end

View File

@@ -0,0 +1,107 @@
# FriendlyId Global Configuration
#
# Use this to set up shared configuration options for your entire application.
# Any of the configuration options shown here can also be applied to single
# models by passing arguments to the `friendly_id` class method or defining
# methods in your model.
#
# To learn more, check out the guide:
#
# http://norman.github.io/friendly_id/file.Guide.html
FriendlyId.defaults do |config|
# ## Reserved Words
#
# Some words could conflict with Rails's routes when used as slugs, or are
# undesirable to allow as slugs. Edit this list as needed for your app.
config.use :reserved
config.reserved_words = %w[new edit index session login logout users admin
stylesheets assets javascripts images]
# This adds an option to treat reserved words as conflicts rather than exceptions.
# When there is no good candidate, a UUID will be appended, matching the existing
# conflict behavior.
config.treat_reserved_as_conflict = true
# ## Friendly Finders
#
# Uncomment this to use friendly finders in all models. By default, if
# you wish to find a record by its friendly id, you must do:
#
# MyModel.friendly.find('foo')
#
# If you uncomment this, you can do:
#
# MyModel.find('foo')
#
# This is significantly more convenient but may not be appropriate for
# all applications, so you must explicitly opt-in to this behavior. You can
# always also configure it on a per-model basis if you prefer.
#
# Something else to consider is that using the :finders addon boosts
# performance because it will avoid Rails-internal code that makes runtime
# calls to `Module.extend`.
config.use :finders
# ## Slugs
#
# Most applications will use the :slugged module everywhere. If you wish
# to do so, uncomment the following line.
config.use :slugged
# By default, FriendlyId's :slugged addon expects the slug column to be named
# 'slug', but you can change it if you wish.
#
# config.slug_column = 'slug'
#
# By default, slug has no size limit, but you can change it if you wish.
#
# config.slug_limit = 255
#
# When FriendlyId can not generate a unique ID from your base method, it appends
# a UUID, separated by a single dash. You can configure the character used as the
# separator. If you're upgrading from FriendlyId 4, you may wish to replace this
# with two dashes.
#
# config.sequence_separator = '-'
#
# Note that you must use the :slugged addon **prior** to the line which
# configures the sequence separator, or else FriendlyId will raise an undefined
# method error.
#
# ## Tips and Tricks
#
# ### Controlling when slugs are generated
#
# As of FriendlyId 5.0, new slugs are generated only when the slug field is
# nil, but if you're using a column as your base method can change this
# behavior by overriding the `should_generate_new_friendly_id?` method that
# FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave
# more like 4.0.
# Note: Use(include) Slugged module in the config if using the anonymous module.
# If you have `friendly_id :name, use: slugged` in the model, Slugged module
# is included after the anonymous module defined in the initializer, so it
# overrides the `should_generate_new_friendly_id?` method from the anonymous module.
#
# config.use :slugged
# config.use Module.new {
# def should_generate_new_friendly_id?
# slug.blank? || <your_column_name_here>_changed?
# end
# }
#
# FriendlyId uses Rails's `parameterize` method to generate slugs, but for
# languages that don't use the Roman alphabet, that's not usually sufficient.
# Here we use the Babosa library to transliterate Russian Cyrillic slugs to
# ASCII. If you use this, don't forget to add "babosa" to your Gemfile.
#
# config.use Module.new {
# def normalize_friendly_id(text)
# text.to_slug.normalize! :transliterations => [:russian, :latin]
# end
# }
end

View File

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

228
config/locales/fr.yml Normal file
View File

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

View File

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

View File

@@ -0,0 +1,6 @@
class AddDisplayTimesToCompletions < ActiveRecord::Migration[8.0]
def change
add_column :completions, :display_time_from_start, :string
add_column :completions, :display_relative_time, :string
end
end

View File

@@ -0,0 +1,5 @@
class AddDisplayTimeToContestants < ActiveRecord::Migration[8.0]
def change
add_column :contestants, :display_time, :string
end
end

View File

@@ -0,0 +1,5 @@
class RemoveSlugFromContests < ActiveRecord::Migration[8.0]
def change
remove_column :contests, :slug, :string
end
end

View File

@@ -0,0 +1,6 @@
class AddSlugToContests < ActiveRecord::Migration[8.0]
def change
add_column :contests, :slug, :string
add_index :contests, :slug, unique: true
end
end

View File

@@ -0,0 +1,21 @@
MIGRATION_CLASS =
if ActiveRecord::VERSION::MAJOR >= 5
ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
else
ActiveRecord::Migration
end
class CreateFriendlyIdSlugs < MIGRATION_CLASS
def change
create_table :friendly_id_slugs do |t|
t.string :slug, null: false
t.integer :sluggable_id, null: false
t.string :sluggable_type, limit: 50
t.string :scope
t.datetime :created_at
end
add_index :friendly_id_slugs, [ :sluggable_type, :sluggable_id ]
add_index :friendly_id_slugs, [ :slug, :sluggable_type ], length: { slug: 140, sluggable_type: 50 }
add_index :friendly_id_slugs, [ :slug, :sluggable_type, :scope ], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true
end
end

View File

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

View File

@@ -0,0 +1,11 @@
class CreateMessages < ActiveRecord::Migration[8.0]
def change
create_table :messages do |t|
t.integer :time_seconds, null: false
t.belongs_to :contest, null: false, foreign_key: true
t.string :text, null: false
t.timestamps
end
end
end

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

60
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_22_164205) do
ActiveRecord::Schema[8.0].define(version: 2025_07_14_115208) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -39,6 +39,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_164205) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "categories", force: :cascade do |t|
t.string "name"
t.integer "contest_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["contest_id"], name: "index_categories_on_contest_id"
end
create_table "categories_contestants", id: false, force: :cascade do |t|
t.integer "category_id", null: false
t.integer "contestant_id", null: false
t.index ["category_id"], name: "index_categories_contestants_on_category_id"
t.index ["contestant_id"], name: "index_categories_contestants_on_contestant_id"
end
create_table "completions", force: :cascade do |t|
t.integer "time_seconds"
t.integer "contestant_id", null: false
@@ -46,8 +61,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_164205) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contest_id", null: false
t.string "display_time_from_start"
t.string "display_relative_time"
t.integer "message_id"
t.index ["contest_id"], name: "index_completions_on_contest_id"
t.index ["contestant_id"], name: "index_completions_on_contestant_id"
t.index ["message_id"], name: "index_completions_on_message_id"
t.index ["puzzle_id"], name: "index_completions_on_puzzle_id"
end
@@ -57,6 +76,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_164205) do
t.integer "contest_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "display_time"
t.integer "time_seconds"
t.index ["contest_id"], name: "index_contestants_on_contest_id"
end
@@ -68,15 +89,48 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_164205) do
t.boolean "team", default: false
t.boolean "allow_registration", default: false
t.string "slug"
t.string "lang", default: "en"
t.boolean "public", default: false
t.index ["slug"], name: "index_contests_on_slug", unique: true
t.index ["user_id"], name: "index_contests_on_user_id"
end
create_table "csv_imports", force: :cascade do |t|
t.string "separator", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "content", null: false
end
create_table "friendly_id_slugs", force: :cascade do |t|
t.string "slug", null: false
t.integer "sluggable_id", null: false
t.string "sluggable_type", limit: 50
t.string "scope"
t.datetime "created_at"
t.index ["slug", "sluggable_type", "scope"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope", unique: true
t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type"
t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id"
end
create_table "messages", force: :cascade do |t|
t.integer "time_seconds", null: false
t.integer "contest_id", null: false
t.string "text", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "author"
t.string "display_time"
t.index ["contest_id"], name: "index_messages_on_contest_id"
end
create_table "puzzles", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contest_id", null: false
t.string "brand"
t.integer "pieces", null: false
t.index ["contest_id"], name: "index_puzzles_on_contest_id"
end
@@ -96,16 +150,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_22_164205) do
t.datetime "updated_at", null: false
t.string "username"
t.boolean "admin", default: false, null: false
t.string "lang", default: "en"
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "categories", "contests"
add_foreign_key "completions", "contestants"
add_foreign_key "completions", "contests"
add_foreign_key "completions", "messages"
add_foreign_key "completions", "puzzles"
add_foreign_key "contestants", "contests"
add_foreign_key "contests", "users"
add_foreign_key "messages", "contests"
add_foreign_key "puzzles", "contests"
add_foreign_key "sessions", "users"
end

View File

@@ -1,11 +1,8 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: puzzles
# Table name: categories
#
# id :integer not null, primary key
# brand :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -13,14 +10,14 @@
#
# Indexes
#
# index_puzzles_on_contest_id (contest_id)
# index_categories_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
one:
name: MyString
two:
name: MyString
FactoryBot.define do
factory :category do
name { "MyString" }
end
end

View File

@@ -4,7 +4,9 @@
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# lang :string default("en")
# name :string
# public :boolean default(FALSE)
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
@@ -13,16 +15,15 @@
#
# Indexes
#
# index_contests_on_slug (slug) UNIQUE
# index_contests_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
require "test_helper"
class ContestTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
FactoryBot.define do
factory :contest do
name { Faker::Company.unique.name }
end
end

View File

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

View File

@@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: messages
#
# id :integer not null, primary key
# author :string
# display_time :string
# text :string not null
# time_seconds :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_messages_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
FactoryBot.define do
factory :message do
time_seconds { 1 }
contest { nil }
text { "MyString" }
end
end

View File

@@ -1,5 +1,3 @@
<% password_digest = BCrypt::Password.create("password") %>
# == Schema Information
#
# Table name: users
@@ -7,6 +5,7 @@
# id :integer not null, primary key
# admin :boolean default(FALSE), not null
# email_address :string not null
# lang :string default("en")
# password_digest :string not null
# username :string
# created_at :datetime not null
@@ -16,10 +15,14 @@
#
# index_users_on_email_address (email_address) UNIQUE
#
one:
email_address: one@example.com
password_digest: <%= password_digest %>
FactoryBot.define do
factory :user do
username { Faker::Internet.unique.username }
email_address { Faker::Internet.unique.email }
password { Faker::Internet.unique.password(min_length: 12, max_length: 18) }
end
two:
email_address: two@example.com
password_digest: <%= password_digest %>
trait :admin do
admin { true }
end
end

View File

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

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.feature "Login", type: :feature do
context "visiting the login page" do
let!(:user) { create(:user) }
it "should log in the user with the correct credentials" do
visit '/'
fill_in "Email address", with: user.email_address
fill_in "Password", with: user.password
click_button "Sign in"
expect(page).not_to have_content(I18n.t("sessions.new.title"))
end
it "should fail to log in the user with an incorrect email address" do
visit '/'
fill_in "Email address", with: Faker::Internet.unique.email
fill_in "Password", with: user.password
click_button "Sign in"
expect(page).to have_content(I18n.t("sessions.new.title"))
end
it "should fail to log in the user with an incorrect password" do
visit '/'
fill_in "Email address", with: user.email_address
fill_in "Password", with: Faker::Internet.unique.password
click_button "Sign in"
expect(page).to have_content(I18n.t("sessions.new.title"))
end
end
end

View File

@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.feature "Users", type: :feature do
context "when the user is a regular user" do
let!(:user) { create(:user) }
let!(:contest) { create(:contest, user: user) }
before do
login(user)
end
it "should not see a link to all users" do
visit root_path
expect(page).not_to have_content(I18n.t("nav.users"))
end
it "should not be able to see the user list" do
visit users_path
expect(page).not_to have_content(I18n.t("users.index.title"))
end
it "should be able to create a new contest" do
visit root_path
click_link "Create a new contest"
expect(page).to have_content(I18n.t("contests.new.title"))
end
it "should be able to open an existing contest" do
visit root_path
expect(page).to have_content(contest.name)
find("div.card", text: contest.name).find("a").click
expect(page).to have_content(I18n.t("contests.show.title", name: contest.name))
end
end
context "when the user is an admin" do
let!(:admin) { create(:user, :admin) }
let!(:user) { create(:user) }
before do
login(admin)
end
it "should see a link to all users" do
visit root_path
expect(page).to have_content(I18n.t("nav.users"))
end
it "should be able to see the user list" do
visit users_path
expect(page).to have_content(I18n.t("users.index.title"))
expect(page).to have_content(user.username)
end
end
end

View File

@@ -1,9 +1,8 @@
# == Schema Information
#
# Table name: contestants
# Table name: categories
#
# id :integer not null, primary key
# email :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -11,16 +10,14 @@
#
# Indexes
#
# index_contestants_on_contest_id (contest_id)
# index_categories_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
require "test_helper"
require 'rails_helper'
class ContestantTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
RSpec.describe Category, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
# id :integer not null, primary key
# admin :boolean default(FALSE), not null
# email_address :string not null
# lang :string default("en")
# password_digest :string not null
# username :string
# created_at :datetime not null
@@ -14,10 +15,12 @@
#
# index_users_on_email_address (email_address) UNIQUE
#
require "test_helper"
require 'rails_helper'
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
RSpec.describe User, type: :model do
context "1" do
it "equals 1" do
expect(1).to eq(1)
end
end
end

70
spec/rails_helper.rb Normal file
View File

@@ -0,0 +1,70 @@
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file
# that will avoid rails generators crashing because migrations haven't been run yet
# return unless Rails.env.test?
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
# run as spec files by default. This means that files in spec/support that end
# in _spec.rb will both be required and run as specs, causing the specs to be
# run twice. It is recommended that you do not name files matching this glob to
# end with _spec.rb. You can configure this pattern with the --pattern
# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
#
# The following line is provided for convenience purposes. It has the downside
# of increasing the boot-up time by auto-requiring all files in the support
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [
Rails.root.join('spec/fixtures')
]
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
# You can uncomment this line to turn off ActiveRecord support entirely.
# config.use_active_record = false
# RSpec Rails uses metadata to mix in different behaviours to your tests,
# for example enabling you to call `get` and `post` in request specs. e.g.:
#
# RSpec.describe UsersController, type: :request do
# # ...
# end
#
# The different available types are documented in the features, such as in
# https://rspec.info/features/7-1/rspec-rails
#
# You can also this infer these behaviours automatically by location, e.g.
# /spec/models would pull in the same behaviour as `type: :model` but this
# behaviour is considered legacy and will be removed in a future version.
#
# To enable this behaviour uncomment the line below.
# config.infer_spec_type_from_file_location!
# Filter lines from Rails gems in backtraces.
config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
end

94
spec/spec_helper.rb Normal file
View File

@@ -0,0 +1,94 @@
# This file was generated by the `rails generate rspec:install` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest
# assertions if you prefer.
config.expect_with :rspec do |expectations|
# This option will default to `true` in RSpec 4. It makes the `description`
# and `failure_message` of custom matchers include text for helper methods
# defined using `chain`, e.g.:
# be_bigger_than(2).and_smaller_than(4).description
# # => "be bigger than 2 and smaller than 4"
# ...rather than:
# # => "be bigger than 2"
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
# rspec-mocks config goes here. You can use an alternate test double
# library (such as bogus or mocha) by changing the `mock_with` option here.
config.mock_with :rspec do |mocks|
# Prevents you from mocking or stubbing a method that does not exist on
# a real object. This is generally recommended, and will default to
# `true` in RSpec 4.
mocks.verify_partial_doubles = true
end
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
# have no way to turn it off -- the option exists only for backwards
# compatibility in RSpec 3). It causes shared context metadata to be
# inherited by the metadata hash of host groups and examples, rather than
# triggering implicit auto-inclusion in groups with matching metadata.
config.shared_context_metadata_behavior = :apply_to_host_groups
# The settings below are suggested to provide a good initial experience
# with RSpec, but feel free to customize to your heart's content.
=begin
# This allows you to limit a spec run to individual examples or groups
# you care about by tagging them with `:focus` metadata. When nothing
# is tagged with `:focus`, all examples get run. RSpec also provides
# aliases for `it`, `describe`, and `context` that include `:focus`
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
config.filter_run_when_matching :focus
# Allows RSpec to persist some state between runs in order to support
# the `--only-failures` and `--next-failure` CLI options. We recommend
# you configure your source control system to ignore this file.
config.example_status_persistence_file_path = "spec/examples.txt"
# Limits the available syntax to the non-monkey patched syntax that is
# recommended. For more details, see:
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
config.disable_monkey_patching!
# Many RSpec users commonly either run the entire suite or an individual
# file, and it's useful to allow more verbose output when running an
# individual spec file.
if config.files_to_run.one?
# Use the documentation formatter for detailed output,
# unless a formatter has already been configured
# (e.g. via a command-line flag).
config.default_formatter = "doc"
end
# Print the 10 slowest examples and example groups at the
# end of the spec run, to help surface which specs are running
# particularly slow.
config.profile_examples = 10
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = :random
# Seed global randomization in this process using the `--seed` CLI option.
# Setting this allows you to use `--seed` to deterministically reproduce
# test failures related to randomization by passing the same `--seed` value
# as the one that triggered the failure.
Kernel.srand config.seed
=end
end

View File

@@ -0,0 +1,5 @@
require "factory_bot_rails"
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end

12
spec/support/helpers.rb Normal file
View File

@@ -0,0 +1,12 @@
module Helpers
def login(user)
visit '/'
fill_in "Email address", with: user.email_address
fill_in "Password", with: user.password
click_button "Sign in"
end
end
RSpec.configure do |c|
c.include Helpers
end

View File

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

View File

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
require "test_helper"
class ContestsControllerTest < ActionDispatch::IntegrationTest
test "should get create" do
get contests_create_url
assert_response :success
end
end

View File

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

View File

@@ -1,28 +0,0 @@
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get users_index_url
assert_response :success
end
test "should get edit" do
get users_edit_url
assert_response :success
end
test "should get show" do
get users_show_url
assert_response :success
end
test "should get new" do
get users_new_url
assert_response :success
end
test "should get destroy" do
get users_destroy_url
assert_response :success
end
end

View File

@@ -1,35 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: completions
#
# id :integer not null, primary key
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# puzzle_id :integer not null
#
# Indexes
#
# index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id)
# index_completions_on_puzzle_id (puzzle_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
# puzzle_id (puzzle_id => puzzles.id)
#
one:
time_seconds: 1
contestant: one
puzzle: one
two:
time_seconds: 1
contestant: two
puzzle: two

View File

@@ -1,30 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: contestants
#
# id :integer not null, primary key
# email :string
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_contestants_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
one:
name: MyString
email: MyString
contest: one
two:
name: MyString
email: MyString
contest: two

View File

@@ -1,30 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# == Schema Information
#
# Table name: contests
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# name :string
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_contests_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
one:
name: MyString
user: one
two:
name: MyString
user: two

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