Compare commits
46 Commits
f91145637f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 279e7eaf3f | |||
| 683d99ab12 | |||
| 76553d4cbc | |||
| 31fe8789ce | |||
| 360157e0c8 | |||
| 5345e419df | |||
| 2c87a5b63c | |||
| 8cea403dc9 | |||
| cce090587a | |||
| ee250b96ad | |||
| 1fc05bea63 | |||
| 7bd1dce1ea | |||
| e2c50515b1 | |||
| 51e55f0828 | |||
| a2a8a9fcef | |||
| d08370f5f8 | |||
| cd41d83429 | |||
| 3a6ee2ea98 | |||
| d01775471e | |||
| 66d968fca8 | |||
| 7a80c434af | |||
| 768af7c3e9 | |||
| c0f2358a36 | |||
| 7a64fa181a | |||
| 024b254808 | |||
| 6ec4a89907 | |||
| d59090cded | |||
| ae3c7c73e1 | |||
| db6f732e63 | |||
| fdd47c231a | |||
| b2429b71f4 | |||
| 710953919c | |||
| 94e725d20a | |||
| b43a801e3c | |||
| c4f0f603f6 | |||
| 709719b801 | |||
| 3e071f9281 | |||
| b87800f6bd | |||
| bf127bb932 | |||
| 3dd153d587 | |||
| 63a88ea113 | |||
| ecb36e19ed | |||
| bc96b16bcb | |||
| 0f725e2eef | |||
| e67ee92838 | |||
| b88460ae71 |
@@ -16,7 +16,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 chromium && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Set production environment
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -46,6 +46,7 @@ gem "bootstrap", "~> 5.3.3"
|
||||
gem "friendly_id", "~> 5.5.0"
|
||||
gem "csv"
|
||||
gem "damerau-levenshtein"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
@@ -73,6 +74,7 @@ group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
gem "so_many_devices"
|
||||
end
|
||||
|
||||
gem "pundit", "~> 2.5"
|
||||
|
||||
49
Gemfile.lock
49
Gemfile.lock
@@ -84,7 +84,7 @@ GEM
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
bootsnap (1.19.0)
|
||||
msgpack (~> 1.2)
|
||||
bootstrap (5.3.5)
|
||||
popper_js (>= 2.11.8, < 3)
|
||||
@@ -100,6 +100,7 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
crass (1.0.6)
|
||||
@@ -116,7 +117,7 @@ GEM
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.3)
|
||||
erb (6.0.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
@@ -134,19 +135,19 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.33.0)
|
||||
google-protobuf (4.33.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-aarch64-linux-gnu)
|
||||
google-protobuf (4.33.1-aarch64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-aarch64-linux-musl)
|
||||
google-protobuf (4.33.1-aarch64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-x86_64-linux-gnu)
|
||||
google-protobuf (4.33.1-x86_64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-x86_64-linux-musl)
|
||||
google-protobuf (4.33.1-x86_64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
i18n (1.14.7)
|
||||
@@ -163,7 +164,7 @@ GEM
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.2)
|
||||
json (2.16.0)
|
||||
kamal (2.8.2)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -190,7 +191,7 @@ GEM
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.26.0)
|
||||
minitest (5.26.2)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
@@ -288,9 +289,13 @@ GEM
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
@@ -319,14 +324,14 @@ GEM
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.47.1)
|
||||
rubocop-ast (1.48.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.33.4)
|
||||
rubocop-rails (2.34.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -338,17 +343,17 @@ GEM
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (3.2.2)
|
||||
sass-embedded (1.93.3-aarch64-linux-gnu)
|
||||
sass-embedded (1.94.2-aarch64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-aarch64-linux-musl)
|
||||
sass-embedded (1.94.2-aarch64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-arm-linux-gnueabihf)
|
||||
sass-embedded (1.94.2-arm-linux-gnueabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-arm-linux-musleabihf)
|
||||
sass-embedded (1.94.2-arm-linux-musleabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-x86_64-linux-gnu)
|
||||
sass-embedded (1.94.2-x86_64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-x86_64-linux-musl)
|
||||
sass-embedded (1.94.2-x86_64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.38.0)
|
||||
@@ -360,12 +365,14 @@ GEM
|
||||
slim (5.2.1)
|
||||
temple (~> 0.10.0)
|
||||
tilt (>= 2.1.0)
|
||||
so_many_devices (1.0.0)
|
||||
capybara (>= 3.0)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.9)
|
||||
solid_cache (1.0.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
@@ -391,7 +398,7 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
stringio (3.1.8)
|
||||
temple (0.10.4)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
@@ -455,10 +462,12 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
pundit (~> 2.5)
|
||||
rails (~> 8.0.2)
|
||||
rqrcode (~> 3.0)
|
||||
rspec-rails
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
slim
|
||||
so_many_devices
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
|
||||
@@ -24,11 +24,9 @@ class CompletionsController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@completion = Completion.new(completion_params)
|
||||
@completion.contest_id = @contest.id
|
||||
@completion.contest = @contest
|
||||
if @completion.save
|
||||
if @completion.display_time_from_start.present?
|
||||
extend_completions!(@completion.contestant)
|
||||
end
|
||||
if @contestant && !params[:completion].key?(:message_id)
|
||||
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
|
||||
else
|
||||
|
||||
@@ -35,7 +35,7 @@ module Authentication
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
session.delete(:return_to_after_authenticating) || "/contests"
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
|
||||
@@ -9,24 +9,38 @@ module CompletionsConcern
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
if time == nil
|
||||
return ""
|
||||
end
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
elsif m > 0
|
||||
return m.to_s + ":" + pad(s)
|
||||
end
|
||||
s.to_s
|
||||
m.to_s + ":" + pad(s)
|
||||
end
|
||||
|
||||
def extend_completions!(contestant)
|
||||
completions = contestant.completions
|
||||
puzzles = contestant.contest.puzzles
|
||||
if puzzles.length > 1
|
||||
current_time_from_start = 0
|
||||
contestant.completions.order(:time_seconds).each do |completion|
|
||||
completions.order(:time_seconds).each do |completion|
|
||||
completion.update(display_time_from_start: display_time(completion.time_seconds),
|
||||
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
|
||||
current_time_from_start = completion.time_seconds
|
||||
end
|
||||
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
|
||||
elsif puzzles.length == 1 && completions.length >= 1
|
||||
if completions[0].remaining_pieces != nil
|
||||
contestant.update(
|
||||
display_time: "#{display_time(completions[0].time_seconds)} - #{puzzles[0].pieces - completions[0].remaining_pieces}p",
|
||||
time_seconds: completions[0].projected_time
|
||||
)
|
||||
else
|
||||
contestant.update(display_time: display_time(completions[0].time_seconds), time_seconds: completions[0].time_seconds)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,11 +2,15 @@ module ContestantsConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ranked_contestants(contest)
|
||||
if contest.ranking_mode == "actual"
|
||||
contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
elsif contest.ranking_mode == "theorical"
|
||||
contest.contestants.sort_by { |contestant| contestant.completions.map { |completion| completion.projected_time }.sum }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
class ContestantsController < ApplicationController
|
||||
include CompletionsConcern
|
||||
include ContestantsConcern
|
||||
|
||||
before_action :set_contest
|
||||
before_action :set_contest, only: %i[ index edit new create update destroy import upload_csv convert_csv finalize_import export generate_qrcodes generate_qrcodes_pdf ]
|
||||
before_action :set_contestant, only: %i[ destroy edit update]
|
||||
before_action :set_completions, only: %i[edit update ]
|
||||
skip_before_action :require_authentication, only: %i[ get_public_completion post_public_completion public_completion_updated ]
|
||||
|
||||
def index
|
||||
authorize @contest
|
||||
@@ -40,7 +42,7 @@ class ContestantsController < ApplicationController
|
||||
|
||||
if @contestant.update(contestant_params)
|
||||
update_contestant_categories
|
||||
redirect_to @contest, notice: t("contestants.edit.notice")
|
||||
redirect_to contest_contestants_path(@contest), notice: t("contestants.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -94,7 +96,6 @@ class ContestantsController < ApplicationController
|
||||
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
|
||||
@@ -113,11 +114,89 @@ class ContestantsController < ApplicationController
|
||||
respond_to do |format|
|
||||
format.csv do
|
||||
response.headers["Content-Type"] = "text/csv"
|
||||
response.headers["Content-Disposition"] = "attachment; filename=export.csv"
|
||||
response.headers["Content-Disposition"] = "attachment; filename=#{@contest.friendly_id}_results.csv"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_qrcodes
|
||||
authorize @contest
|
||||
|
||||
generate_contestants_qrcodes(@contest)
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
end
|
||||
|
||||
def generate_qrcodes_pdf
|
||||
authorize @contest
|
||||
|
||||
generate_contestants_qrcodes(@contest)
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
@nonav = true
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render layout: "blank" }
|
||||
end
|
||||
end
|
||||
|
||||
def get_public_completion
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
@contest = @contestant.contest
|
||||
I18n.locale = @contest.lang
|
||||
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
|
||||
@completion = Completion.new
|
||||
@completion.completed = true
|
||||
@public = true
|
||||
|
||||
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:token]}" }
|
||||
end
|
||||
|
||||
def post_public_completion
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
@contest = @contestant.contest
|
||||
I18n.locale = @contest.lang
|
||||
|
||||
@completion = Completion.new(completion_params)
|
||||
@completion.contest = @contest
|
||||
@completion.contestant = @contestant
|
||||
if !@completion.code.present?
|
||||
to_modify = true
|
||||
@completion.code = "incorrect-xZy"
|
||||
end
|
||||
if @completion.save
|
||||
extend_completions!(@completion.contestant)
|
||||
redirect_to "/public/p/#{params[:token]}/updated"
|
||||
else
|
||||
@puzzles = @contest.puzzles
|
||||
@public = true
|
||||
if to_modify
|
||||
@completion.code = nil
|
||||
end
|
||||
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:token]}" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def public_completion_updated
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
I18n.locale = @contestant.contest.lang
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contest
|
||||
@@ -156,4 +235,15 @@ class ContestantsController < ApplicationController
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def completion_params
|
||||
params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :puzzle_id, :code ])
|
||||
end
|
||||
|
||||
def generate_contestants_qrcodes(contest)
|
||||
contest.contestants.where(qrcode: nil).each do |contestant|
|
||||
contestant.generate_qrcode
|
||||
contestant.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class ContestsController < ApplicationController
|
||||
include ContestantsConcern
|
||||
|
||||
before_action :set_contest, only: %i[ destroy show ]
|
||||
before_action :set_settings_contest, only: %i[ settings_general_edit settings_general_update settings_offline_edit settings_offline_update settings_categories_edit ]
|
||||
before_action :set_settings_contest, only: %i[ stopwatch stopwatch_continue stopwatch_pause stopwatch_reset stopwatch_start settings_general_edit settings_general_update settings_public_edit settings_public_update settings_onsite_edit settings_onsite_update settings_online_edit settings_online_update settings_categories_edit ]
|
||||
before_action :offline_setup, only: %i[ offline_new offline_create offline_edit offline_update offline_completed ]
|
||||
skip_before_action :require_authentication, only: %i[ scoreboard offline_new offline_create offline_edit offline_update offline_completed ]
|
||||
|
||||
@@ -24,7 +24,15 @@ class ContestsController < ApplicationController
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_offline_edit
|
||||
def settings_public_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_onsite_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_online_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
@@ -42,30 +50,97 @@ class ContestsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def settings_offline_update
|
||||
def settings_public_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_offline_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/offline", notice: t("contests.edit.notice")
|
||||
if @contest.update(settings_public_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/public", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_offline_edit, status: :unprocessable_entity
|
||||
render :settings_public_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_onsite_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_onsite_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/onsite", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_onsite_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_online_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_online_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/online", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_online_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def stopwatch
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def stopwatch_continue
|
||||
authorize @contest
|
||||
|
||||
pause_duration = Time.now() - @contest.pause_time
|
||||
@contest.start_time = @contest.start_time + pause_duration
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_pause
|
||||
authorize @contest
|
||||
authorize @contest
|
||||
|
||||
@contest.pause_time = Time.now()
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_reset
|
||||
authorize @contest
|
||||
|
||||
@contest.start_time = nil
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_start
|
||||
authorize @contest
|
||||
|
||||
@contest.start_time = Time.now()
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def new
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new
|
||||
@title = I18n.t("contests.new.title")
|
||||
@nonav = true
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new(new_contest_params)
|
||||
@contest.lang = @current_user.lang
|
||||
@contest.ranking_mode = "actual"
|
||||
@contest.user_id = current_user.id
|
||||
if @contest.save
|
||||
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice")
|
||||
else
|
||||
@title = I18n.t("contests.new.title")
|
||||
@nonav = true
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -89,20 +164,19 @@ class ContestsController < ApplicationController
|
||||
|
||||
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
|
||||
@contestants = ranked_contestants(@contest)
|
||||
if params.key?(:category)
|
||||
@category = params[:category]
|
||||
filter_contestants_per_category
|
||||
end
|
||||
if params.key?(:hide_offline) && params[:hide_offline] == "true"
|
||||
@contestants = @contestants.select { |contestant| !contestant.offline.present? }
|
||||
@hide_offline = true
|
||||
end
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
if params.key?(:hide_offline) && params.key?(:category)
|
||||
@action_path = "/public/#{@contest.friendly_id}?hide_offline=#{params[:hide_offline]}&category=#{params[:category]}"
|
||||
elsif params.key?(:category)
|
||||
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
|
||||
elsif params.key?(:hide_offline)
|
||||
@action_path = "/public/#{@contest.friendly_id}?hide_offline=#{params[:hide_offline]}"
|
||||
else
|
||||
if params.key?(:autorefresh)
|
||||
@autorefresh = true
|
||||
end
|
||||
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
|
||||
@action_path = "/public/#{@contest.friendly_id}"
|
||||
end
|
||||
@space = " "
|
||||
render :scoreboard
|
||||
end
|
||||
@@ -118,7 +192,7 @@ class ContestsController < ApplicationController
|
||||
@offline.contest = @contest
|
||||
@offline.start_time = Time.now()
|
||||
if @offline.save
|
||||
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}"
|
||||
redirect_to offline_form_edit_path(@contest, @offline)
|
||||
else
|
||||
render :offline_new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -160,6 +234,7 @@ class ContestsController < ApplicationController
|
||||
contestant = Contestant.create(contest: @contest, name: @offline.name, offline: @offline)
|
||||
Completion.create(contest: @contest,
|
||||
contestant: contestant,
|
||||
offline: @offline,
|
||||
puzzle: @contest.puzzles[0],
|
||||
completed: @offline.completed,
|
||||
display_time_from_start: dp,
|
||||
@@ -167,7 +242,7 @@ class ContestsController < ApplicationController
|
||||
remaining_pieces: @offline.remaining_pieces)
|
||||
extend_completions!(contestant)
|
||||
end
|
||||
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}/completed"
|
||||
redirect_to offline_form_completed_path(@contest, @offline)
|
||||
else
|
||||
render :offline_edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -199,23 +274,31 @@ class ContestsController < ApplicationController
|
||||
end
|
||||
|
||||
def new_contest_params
|
||||
params.expect(contest: [ :name ])
|
||||
params.expect(contest: [ :name, :duration ])
|
||||
end
|
||||
|
||||
def settings_general_params
|
||||
params.expect(contest: [ :lang, :name, :public, :ranking_mode, :team, :allow_registration ])
|
||||
params.expect(contest: [ :lang, :name, :duration, :team, :allow_registration ])
|
||||
end
|
||||
|
||||
def settings_offline_params
|
||||
def settings_public_params
|
||||
params.expect(contest: [ :public, :ranking_mode, :show_stopwatch ])
|
||||
end
|
||||
|
||||
def settings_onsite_params
|
||||
params.expect(contest: [ :code ])
|
||||
end
|
||||
|
||||
def settings_online_params
|
||||
params.expect(contest: [ :offline_form ])
|
||||
end
|
||||
|
||||
def filter_contestants_per_category
|
||||
if params.key?(:category) && params[:category] != "-1"
|
||||
if params[:category] == "-2"
|
||||
if @category != "-1"
|
||||
if @category == "-2"
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
|
||||
else
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.where(id: @category).any? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -91,7 +91,7 @@ class MessagesController < ApplicationController
|
||||
|
||||
@message = Message.find(params[:id])
|
||||
@message.destroy
|
||||
redirect_to contest_path(@contest), notice: t("messages.destroy.notice")
|
||||
redirect_to contest_messages_path(@contest), notice: t("messages.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -25,7 +25,7 @@ class PuzzlesController < ApplicationController
|
||||
@puzzle = Puzzle.new(puzzle_params)
|
||||
@puzzle.contest_id = @contest.id
|
||||
if @puzzle.save
|
||||
redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.new.notice")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -35,7 +35,7 @@ class PuzzlesController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
if @puzzle.update(puzzle_params)
|
||||
redirect_to @contest, notice: t("puzzles.edit.notice")
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -45,7 +45,7 @@ class PuzzlesController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@puzzle.destroy
|
||||
redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice")
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -59,6 +59,6 @@ class PuzzlesController < ApplicationController
|
||||
end
|
||||
|
||||
def puzzle_params
|
||||
params.expect(puzzle: [ :brand, :name, :image, :pieces ])
|
||||
params.expect(puzzle: [ :brand, :name, :image, :pieces, :hidden ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class SessionsController < ApplicationController
|
||||
before_action :skip_authorization
|
||||
|
||||
def new
|
||||
@title = "Puzzle scoreboard"
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -11,6 +12,7 @@ class SessionsController < ApplicationController
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: t("sessions.new.notice")
|
||||
else
|
||||
@title = "Puzzle scoreboard"
|
||||
redirect_to new_session_path, alert: "Try another email address or password."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class UsersController < ApplicationController
|
||||
include CompletionsConcern
|
||||
|
||||
before_action :set_user, only: %i[ destroy edit show update ]
|
||||
|
||||
def index
|
||||
@@ -17,7 +19,20 @@ class UsersController < ApplicationController
|
||||
def update
|
||||
authorize @user
|
||||
|
||||
if @user.update(user_params)
|
||||
@user.password_change_attempt = false
|
||||
if @user.update(user_general_params)
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def change_password
|
||||
@user = User.find(params[:user_id])
|
||||
authorize @user
|
||||
|
||||
@user.password_change_attempt = true
|
||||
if @user.update(user_password_params)
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
@@ -51,6 +66,35 @@ class UsersController < ApplicationController
|
||||
authorize @user
|
||||
end
|
||||
|
||||
def update_contestants
|
||||
authorize :user
|
||||
|
||||
total = 0
|
||||
updated = 0
|
||||
Contestant.all.each do |contestant|
|
||||
if contestant.completions.length > 0
|
||||
total += 1
|
||||
contestant.completions.each do |completion|
|
||||
completion.save
|
||||
end
|
||||
if extend_completions!(contestant)
|
||||
updated += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to users_path, notice: "Updated contestants: #{updated}/#{total}"
|
||||
end
|
||||
|
||||
def regenerate_qrcodes
|
||||
authorize :user
|
||||
|
||||
Contestant.all.each do |contestant|
|
||||
contestant.generate_qrcode
|
||||
contestant.save
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@@ -60,4 +104,12 @@ class UsersController < ApplicationController
|
||||
def user_params
|
||||
params.expect(user: [ :username, :email_address, :lang, :password ])
|
||||
end
|
||||
|
||||
def user_general_params
|
||||
params.expect(user: [ :username, :email_address, :lang ])
|
||||
end
|
||||
|
||||
def user_password_params
|
||||
params.expect(user: [ :password ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,14 +7,15 @@ module ContestsHelper
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
if time == nil
|
||||
return ""
|
||||
end
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
elsif m > 0
|
||||
return m.to_s + ":" + pad(s)
|
||||
end
|
||||
"0:" + pad(s)
|
||||
m.to_s + ":" + pad(s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
# Table name: completions
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# code :string
|
||||
# completed :boolean
|
||||
# display_relative_time :string
|
||||
# display_time_from_start :string
|
||||
# missing_pieces :integer
|
||||
# projected_time :integer
|
||||
# remaining_pieces :integer
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
@@ -31,36 +33,34 @@
|
||||
# puzzle_id (puzzle_id => puzzles.id)
|
||||
#
|
||||
class Completion < ApplicationRecord
|
||||
include ContestsHelper
|
||||
|
||||
belongs_to :contest
|
||||
belongs_to :contestant
|
||||
belongs_to :puzzle
|
||||
belongs_to :message, optional: true
|
||||
|
||||
before_save :add_time_seconds, if: -> { display_time_from_start.present? }
|
||||
before_save :nullify_display_time
|
||||
before_save :clean_pieces
|
||||
has_one :offline, dependent: :destroy
|
||||
|
||||
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { completed }
|
||||
before_save :clean_pieces
|
||||
before_save :compute_projected_time
|
||||
|
||||
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { completed || offline.present? }
|
||||
validates :remaining_pieces, presence: true, if: -> { !completed }
|
||||
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
|
||||
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
|
||||
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
|
||||
validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? }
|
||||
validate :contest_code_is_correct, if: -> { code.present? }
|
||||
|
||||
def remaining_pieces_is_correct
|
||||
if self.remaining_pieces > self.puzzle.pieces
|
||||
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle")
|
||||
end
|
||||
end
|
||||
|
||||
def nullify_display_time
|
||||
if self.remaining_pieces
|
||||
self.display_time_from_start = nil
|
||||
self.display_relative_time = nil
|
||||
errors.add(:remaining_pieces, I18n.t("activerecord.errors.models.completion.attributes.remaining_pieces.too_large"))
|
||||
end
|
||||
end
|
||||
|
||||
def add_time_seconds
|
||||
if display_time_from_start.present?
|
||||
arr = display_time_from_start.split(":")
|
||||
if arr.size == 3
|
||||
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
|
||||
@@ -69,6 +69,10 @@ class Completion < ApplicationRecord
|
||||
elsif arr.size == 1
|
||||
self.time_seconds = arr[0].to_i
|
||||
end
|
||||
else
|
||||
self.time_seconds = self.contest.duration_seconds
|
||||
self.display_time_from_start = display_time(self.time_seconds)
|
||||
end
|
||||
end
|
||||
|
||||
def clean_pieces
|
||||
@@ -78,4 +82,22 @@ class Completion < ApplicationRecord
|
||||
self.missing_pieces = nil
|
||||
end
|
||||
end
|
||||
|
||||
def compute_projected_time
|
||||
add_time_seconds
|
||||
if self.completed
|
||||
self.projected_time = self.time_seconds
|
||||
else
|
||||
assembled_time = self.time_seconds
|
||||
assembled_pieces = self.puzzle.pieces - self.remaining_pieces
|
||||
pieces_per_second = assembled_pieces.to_f / assembled_time.to_f
|
||||
self.projected_time = assembled_time + Integer(self.remaining_pieces.to_f / pieces_per_second)
|
||||
end
|
||||
end
|
||||
|
||||
def contest_code_is_correct
|
||||
if self.code != self.contest.code
|
||||
errors.add(:code, I18n.t("activerecord.errors.models.completion.attributes.code.mismatch"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# code :string
|
||||
# duration :string
|
||||
# duration_seconds :integer
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# pause_time :datetime
|
||||
# public :boolean default(FALSE)
|
||||
# ranking_mode :string
|
||||
# show_stopwatch :boolean
|
||||
# slug :string
|
||||
# start_time :datetime
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -37,9 +43,19 @@ class Contest < ApplicationRecord
|
||||
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
before_save :add_duration_seconds, if: -> { duration.present? }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
||||
validates :ranking_mode, inclusion: { in: Ranking::AVAILABLE_RANKING_MODES.map { |lang| lang[:id] } }
|
||||
validates :duration, presence: true, format: { with: /\A(\d\d:\d\d|\d:\d\d)\z/ }
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
def add_duration_seconds
|
||||
arr = self.duration.split(":")
|
||||
if arr.size == 2
|
||||
self.duration_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
# display_time :string
|
||||
# email :string
|
||||
# name :string
|
||||
# projected_time :string
|
||||
# qrcode :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -22,7 +24,7 @@
|
||||
class Contestant < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one :offline
|
||||
has_one :offline, dependent: :destroy
|
||||
has_and_belongs_to_many :categories
|
||||
|
||||
before_validation :initialize_time_seconds_if_empty
|
||||
@@ -38,6 +40,19 @@ class Contestant < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def generate_qrcode
|
||||
host = Rails.application.config.action_controller.default_url_options[:host]
|
||||
qrcode = RQRCode::QRCode.new("https://#{host}/public/p/#{self.id}")
|
||||
self.qrcode = qrcode.as_svg(
|
||||
color: "000",
|
||||
shape_rendering: "crispEdges",
|
||||
module_size: 3,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
viewbox: true
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_time_seconds_if_empty
|
||||
|
||||
@@ -12,24 +12,28 @@
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# completion_id :integer
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_completion_id (completion_id)
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# completion_id (completion_id => completions.id)
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
class Offline < ApplicationRecord
|
||||
belongs_to :contest
|
||||
belongs_to :contestant, optional: true
|
||||
belongs_to :completion, optional: true
|
||||
|
||||
has_many_attached :images
|
||||
has_many_attached :images, dependent: :destroy
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# brand :string
|
||||
# hidden :boolean
|
||||
# name :string
|
||||
# pieces :integer not null
|
||||
# created_at :datetime not null
|
||||
@@ -22,7 +23,7 @@ class Puzzle < ApplicationRecord
|
||||
belongs_to :contest
|
||||
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one_attached :image
|
||||
has_one_attached :image, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :pieces, presence: true
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
|
||||
@@ -35,6 +35,10 @@ class ContestPolicy < ApplicationPolicy
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def generate_qrcodes?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def settings_general_edit?
|
||||
edit?
|
||||
end
|
||||
@@ -43,11 +47,27 @@ class ContestPolicy < ApplicationPolicy
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_offline_edit?
|
||||
def settings_public_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_offline_update?
|
||||
def settings_public_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_onsite_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_onsite_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_online_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_online_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
@@ -55,6 +75,26 @@ class ContestPolicy < ApplicationPolicy
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_continue?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_pause?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_reset?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_start?
|
||||
edit?
|
||||
end
|
||||
|
||||
def finalize_import?
|
||||
owner_or_admin
|
||||
end
|
||||
@@ -75,6 +115,10 @@ class ContestPolicy < ApplicationPolicy
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def generate_qrcodes_pdf?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def upload_csv?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
@@ -20,10 +20,22 @@ class UserPolicy < ApplicationPolicy
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin? || user.id == record.id
|
||||
edit?
|
||||
end
|
||||
|
||||
def change_password?
|
||||
edit?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def update_contestants?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def regenerate_qrcodes?
|
||||
user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,35 +1,3 @@
|
||||
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
|
||||
- if @contest.public
|
||||
a.btn.btn-success href="/public/#{@contest.slug}"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
- else
|
||||
a.btn.btn-success.disabled
|
||||
= t("contests.show.public_scoreboard_disabled")
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
a.ms-3.btn.btn-success href="/public/#{@contest.slug}/offline"
|
||||
= t("contests.show.open_offline_form")
|
||||
- else
|
||||
a.ms-3.btn.btn-success.disabled
|
||||
= t("contests.show.offline_form_disabled")
|
||||
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
|
||||
css:
|
||||
button > svg {
|
||||
margin-right: 2px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
|
||||
</svg>
|
||||
=< t("contests.show.copy_extension_url")
|
||||
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
@@ -39,15 +7,12 @@ javascript:
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest)
|
||||
= t("puzzles.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/stopwatch") href="/contests/#{@contest.id}/stopwatch"
|
||||
= t("contests.nav.stopwatch").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest)
|
||||
= t("messages.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.form.general")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/offline") href="/contests/#{@contest.id}/settings/offline"
|
||||
= t("contests.form.offline")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
|
||||
= t("contests.form.categories")
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.nav.settings")
|
||||
18
app/views/application/_params_nav.html.slim
Normal file
18
app/views/application/_params_nav.html.slim
Normal file
@@ -0,0 +1,18 @@
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.nav.general")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/public") href="/contests/#{@contest.id}/settings/public"
|
||||
= t("contests.nav.public")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/onsite") href="/contests/#{@contest.id}/settings/onsite"
|
||||
= t("contests.nav.onsite")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/online") href="/contests/#{@contest.id}/settings/online"
|
||||
= t("contests.nav.online")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
|
||||
= t("contests.nav.categories")
|
||||
@@ -1,4 +1,10 @@
|
||||
= form_with model: completion, url: url, method: method do |form|
|
||||
- if @public && @puzzles.length == @contestant.completions.length
|
||||
h4
|
||||
= t("completions.form.validate_name", name: @contestant.name)
|
||||
.mt-3.alert.alert-warning
|
||||
= t("completions.form.all_finished", name: @contestant.name)
|
||||
- else
|
||||
= form_with model: completion, url: url, method: method do |form|
|
||||
- if @message
|
||||
= form.hidden_field :message_id, value: @message.id
|
||||
.row.mb-3
|
||||
@@ -9,9 +15,14 @@
|
||||
= @message.author
|
||||
br
|
||||
= @message.text
|
||||
.row
|
||||
.row.mb-2
|
||||
.col
|
||||
h4 = t("completions.singular").capitalize
|
||||
h4
|
||||
- if @public
|
||||
= t("completions.form.validate_name", name: @contestant.name)
|
||||
- else
|
||||
= t("completions.singular").capitalize
|
||||
- if @contestants.present?
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
@@ -43,20 +54,22 @@
|
||||
const missingPiecesEl = document.getElementById('missing_pieces');
|
||||
const remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (e.target.checked) {
|
||||
timeEl.style.display = 'block';
|
||||
timeEl.value = '#{@completion.display_time_from_start}';
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
timeEl.value = '#{display_time(@contest.duration_seconds)}';
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.row.mb-3 id="time"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control"
|
||||
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time"
|
||||
= form.label :display_time_from_start, class: "required"
|
||||
.form-text
|
||||
= t("activerecord.attributes.completion.display_time_from_start_description")
|
||||
.row.mb-3 id="missing_pieces"
|
||||
.col
|
||||
.form-floating
|
||||
@@ -69,18 +82,22 @@
|
||||
= form.label :remaining_pieces
|
||||
javascript:
|
||||
completedEl = document.getElementById('completion_completed');
|
||||
timeEl = document.getElementById('time');
|
||||
missingPiecesEl = document.getElementById('missing_pieces');
|
||||
remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (completedEl.checked) {
|
||||
timeEl.style.display = 'block';
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
- if @public
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :code, autocomplete: "off", class: "form-control"
|
||||
= form.label :code
|
||||
= t("completions.form.code")
|
||||
.row
|
||||
.col
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
@@ -29,6 +29,12 @@
|
||||
.row.mt-5
|
||||
.col
|
||||
h3 Completions
|
||||
- if @contest.puzzles.length == 0
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contestants.edit.no_puzzles_note")
|
||||
- else
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
@@ -36,6 +42,8 @@
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.completed")
|
||||
- if @contest.puzzles.size > 1
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time_from_start")
|
||||
@@ -44,6 +52,8 @@
|
||||
- else
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.projected_time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.missing_pieces")
|
||||
th scope="col"
|
||||
@@ -54,10 +64,19 @@
|
||||
- @completions.each do |completion|
|
||||
tr scope="row"
|
||||
td
|
||||
= completion.display_time_from_start
|
||||
- if completion.completed
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
|
||||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
|
||||
</svg>
|
||||
td
|
||||
= display_time(completion.time_seconds)
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= completion.display_relative_time
|
||||
- else
|
||||
td
|
||||
= display_time(completion.projected_time)
|
||||
td
|
||||
= completion.missing_pieces
|
||||
td
|
||||
|
||||
27
app/views/contestants/generate_qrcodes.html.slim
Normal file
27
app/views/contestants/generate_qrcodes.html.slim
Normal file
@@ -0,0 +1,27 @@
|
||||
- if @contest.code.present?
|
||||
.mb-4 style="height: calc(100vh - 280px)"
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
= t("contestants.generate_qrcodes.note")
|
||||
|
||||
a.mt-3.mb-3.btn.btn-primary href="#{contest_generate_qrcodes_pdf_path(@contest)}" target="_blank" style="margin-top: -3px"
|
||||
= t("helpers.buttons.open_raw")
|
||||
|
||||
.col.d-flex.flex-column style="height: calc(100% - 200px)"
|
||||
.d-flex.flex-column style="overflow-y: auto;"
|
||||
- for row in 0..((@contestants.length - 1) / 4)
|
||||
.mt-4.d-flex.flex-row
|
||||
- for col in 0..3
|
||||
- if row * 4 + col < @contestants.length
|
||||
.d-flex.flex-column.ms-5 style="align-items: center"
|
||||
- if @contestants[row * 4 + col].qrcode.present?
|
||||
.mt-1 style="width: 128px; height: 120px;"
|
||||
= @contestants[row * 4 + col].qrcode.html_safe
|
||||
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
|
||||
= @contestants[row * 4 + col].name
|
||||
- else
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contestants.generate_qrcodes.no_code_note")
|
||||
13
app/views/contestants/generate_qrcodes_pdf.html.slim
Normal file
13
app/views/contestants/generate_qrcodes_pdf.html.slim
Normal file
@@ -0,0 +1,13 @@
|
||||
h1.text-center.mt-4.mb-4 = @contest.name
|
||||
|
||||
.d-flex.flex-column.align-items-center
|
||||
- for row in 0..((@contestants.length - 1) / 4)
|
||||
.mt-4.d-flex.flex-row
|
||||
- for col in 0..3
|
||||
- if row * 4 + col < @contestants.length
|
||||
.d-flex.flex-column.ms-4.me-4 style="align-items: center"
|
||||
- if @contestants[row * 4 + col].qrcode.present?
|
||||
.mt-1 style="width: 128px; height: 120px;"
|
||||
= @contestants[row * 4 + col].qrcode.html_safe
|
||||
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
|
||||
= @contestants[row * 4 + col].name
|
||||
@@ -6,6 +6,8 @@
|
||||
| + #{t("helpers.buttons.add")}
|
||||
a.ms-2.btn.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.import")}
|
||||
a.ms-2.btn.btn.btn-primary href=contest_generate_qrcodes_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.generate_qrcodes")}
|
||||
a.ms-2.btn.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.export")}
|
||||
|
||||
@@ -28,7 +30,7 @@
|
||||
if (option.value == selectedCategory) option.selected = true;
|
||||
});
|
||||
categorySelectEl.addEventListener('change', (e) => {
|
||||
window.location.replace(`#{contest_path(@contest)}?category=${e.target.value}`);
|
||||
window.location.replace(`#{contest_contestants_path(@contest)}?category=${e.target.value}`);
|
||||
})
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
@@ -59,4 +61,4 @@
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
|
||||
= t("helpers.buttons.open")
|
||||
= t("helpers.buttons.details")
|
||||
@@ -0,0 +1,2 @@
|
||||
h4
|
||||
= "Puzzle validé pour #{@contestant.name} !"
|
||||
50
app/views/contests/_scoreboard_desktop_marathon.html.slim
Normal file
50
app/views/contests/_scoreboard_desktop_marathon.html.slim
Normal file
@@ -0,0 +1,50 @@
|
||||
.d-flex.flex-column style="height: calc(100vh - 180px)"
|
||||
|
||||
= render "selectors"
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
.table-responsive-md
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
- if @contest.show_stopwatch
|
||||
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400;"
|
||||
= render "stopwatch_js"
|
||||
th
|
||||
th
|
||||
th
|
||||
- @puzzles.each do |puzzle|
|
||||
th scope="col"
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 64px;") if puzzle.image.attached?
|
||||
tr
|
||||
th scope="col"
|
||||
= t("helpers.rank")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
- @puzzles.each do |puzzle|
|
||||
th scope="col"
|
||||
= puzzle.name
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
- @puzzles.each do |puzzle|
|
||||
td
|
||||
- contestant.completions.each do |completion|
|
||||
- if completion.puzzle == puzzle
|
||||
- if completion.completed
|
||||
= completion.display_relative_time
|
||||
- elsif completion.remaining_pieces.present?
|
||||
= "#{puzzle.pieces - completion.remaining_pieces}p"
|
||||
43
app/views/contests/_scoreboard_desktop_single.html.slim
Normal file
43
app/views/contests/_scoreboard_desktop_single.html.slim
Normal file
@@ -0,0 +1,43 @@
|
||||
.row
|
||||
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 310px)"
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.display_time
|
||||
.col-1
|
||||
.col-5
|
||||
- @puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
|
||||
.mt-3.fs-4 style="margin-left: 15px"
|
||||
= puzzle.name
|
||||
.fs-6 style="margin-left: 15px"
|
||||
b
|
||||
= "#{puzzle.brand} - #{puzzle.pieces}p"
|
||||
46
app/views/contests/_scoreboard_mobile_single.html.slim
Normal file
46
app/views/contests/_scoreboard_mobile_single.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
css:
|
||||
.container { margin-top: 2rem !important; }
|
||||
|
||||
.row
|
||||
- if @puzzles.size > 0
|
||||
.d-flex.flex-column.justify-content-center.mb-2
|
||||
= image_tag(@puzzles[0].image, style: "max-height: 200px; object-fit: contain") if @puzzles[0].image.attached?
|
||||
.mt-2.fs-6 style="text-align: center"
|
||||
=> "#{@puzzles[0].name} -"
|
||||
= "#{@puzzles[0].brand} #{@puzzles[0].pieces}p"
|
||||
|
||||
.row
|
||||
.mt-3.d-flex.flex-column
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.display_time
|
||||
.col-1
|
||||
@@ -1,15 +1,3 @@
|
||||
javascript:
|
||||
function updateParams() {
|
||||
categorySelectEl = document.getElementById('categories');
|
||||
offlineInputEl = document.getElementById('offline');
|
||||
if (categorySelectEl && !offlineInputEl) {
|
||||
window.location.replace(`/public/#{@contest.slug}?category=${categorySelectEl.value}`);
|
||||
} else if (!categorySelectEl) {
|
||||
window.location.replace(`/public/#{@contest.slug}?hide_offline=${offlineInputEl.checked}`);
|
||||
} else {
|
||||
window.location.replace(`/public/#{@contest.slug}?category=${categorySelectEl.value}&hide_offline=${offlineInputEl.checked}`);
|
||||
}
|
||||
}
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col
|
||||
@@ -17,29 +5,29 @@ javascript:
|
||||
option value=-1
|
||||
= t("contests.scoreboard.all_categories")
|
||||
- @contest.categories.each do |category|
|
||||
- if @category == category.id.to_s
|
||||
option value=category.id selected=true
|
||||
= category.name
|
||||
- else
|
||||
option value=category.id
|
||||
= category.name
|
||||
javascript:
|
||||
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) => {
|
||||
updateParams();
|
||||
document.getElementById('categories').addEventListener('change', (e) => {
|
||||
addParam('category', e.target.value);
|
||||
})
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
.row
|
||||
.col
|
||||
- if @hide_offline
|
||||
input type="checkbox" id="offline" style="padding: 5px;" checked=true
|
||||
- else
|
||||
input type="checkbox" id="offline" style="padding: 5px;"
|
||||
label for="offline"
|
||||
.ms-2
|
||||
= t("contests.scoreboard.hide_offline")
|
||||
javascript:
|
||||
offlineInputEl = document.getElementById('offline');
|
||||
urlParams = new URLSearchParams(window.location.search);
|
||||
offlineInputEl.checked = urlParams.get('hide_offline') == "true";
|
||||
offlineInputEl.addEventListener('change', (e) => {
|
||||
updateParams();
|
||||
document.getElementById('offline').addEventListener('change', (e) => {
|
||||
console.log('changed');
|
||||
if (e.target.checked) addParam('hide_offline', e.target.checked);
|
||||
else removeParam('hide_offline');
|
||||
})
|
||||
22
app/views/contests/_stopwatch_js.html.slim
Normal file
22
app/views/contests/_stopwatch_js.html.slim
Normal file
@@ -0,0 +1,22 @@
|
||||
javascript:
|
||||
startTime = #{@contest.start_time.present? ? @contest.start_time.to_i : "null"};
|
||||
pauseTime = #{@contest.pause_time.present? ? @contest.pause_time.to_i : "null"};
|
||||
function updateTime() {
|
||||
const displayTimeEl = document.getElementById('display-time');
|
||||
if (displayTimeEl) {
|
||||
if (startTime) {
|
||||
let s = Math.floor((Date.now() - 1000 * startTime) / 1000);
|
||||
if (pauseTime) s = Math.floor(pauseTime - startTime);
|
||||
let ss = s % 60;
|
||||
let mm = Math.floor(s / 60) % 60;
|
||||
let hh = Math.floor(s / 3600);
|
||||
displayTimeEl.innerHTML = `${hh < 10 ? `0${hh}` : hh}:${mm < 10 ? `0${mm}` : mm}:${ss < 10 ? `0${ss}` : ss}`;
|
||||
setTimeout(updateTime, 1000);
|
||||
} else {
|
||||
displayTimeEl.innerHTML = '00:00:00';
|
||||
}
|
||||
} else {
|
||||
setTimeout(updateTime, 20);
|
||||
}
|
||||
}
|
||||
setTimeout(updateTime, 1);
|
||||
@@ -4,6 +4,12 @@
|
||||
.form-floating
|
||||
= form.text_field :name, autocomplete: "off", class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :duration, autocomplete: "off", class: "form-control"
|
||||
= form.label :duration, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.duration_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.create"), class: "btn btn-primary"
|
||||
@@ -21,14 +21,14 @@
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("offlines.form.end_image_select")
|
||||
= form.file_field :end_image, accept: "image/*", class: "form-control"
|
||||
= form.file_field :end_image, accept: "image/*", class: "form-control", capture: "user"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 5.3 * 1024 * 1024) {
|
||||
if(this.files[0].size > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("offlines.form.start_image_select")
|
||||
= form.file_field :images, accept: "image/*", class: "form-control"
|
||||
= form.file_field :images, accept: "image/*", class: "form-control", capture: "user"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 5.3 * 1024 * 1024) {
|
||||
if(this.files[0].size > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
|
||||
@@ -1,97 +1,25 @@
|
||||
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; }
|
||||
.mobile-single { display: block !important; }
|
||||
.desktop-single { display: none; }
|
||||
#scoreboard-switches { display: none; }
|
||||
.stopwatch { font-size: 50px !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"
|
||||
|
||||
- if @contest.puzzles.size < 2
|
||||
= render "selectors"
|
||||
|
||||
.row
|
||||
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 250px)"
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(remaining_pieces: nil).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 64px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
.col-1
|
||||
.col-5
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
|
||||
.mt-3.fs-4 style="margin-left: 15px"
|
||||
= puzzle.name
|
||||
.fs-6 style="margin-left: 15px"
|
||||
b
|
||||
= "#{puzzle.brand} - #{puzzle.pieces}p"
|
||||
turbo-frame id="scoreboard"
|
||||
- if @contest.show_stopwatch
|
||||
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400; margin-bottom: 0; text-align: center;"
|
||||
= render "stopwatch_js"
|
||||
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
|
||||
.mobile-single style="display: none;"
|
||||
= render "scoreboard_mobile_single"
|
||||
.desktop-single
|
||||
= render "scoreboard_desktop_single"
|
||||
|
||||
- else
|
||||
.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 "selectors"
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
= t("helpers.rank")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
turbo-frame id="scoreboard"
|
||||
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
|
||||
= render "scoreboard_desktop_marathon"
|
||||
@@ -1,3 +1,10 @@
|
||||
= render "params_nav"
|
||||
|
||||
.row
|
||||
.col
|
||||
.alert.alert-primary role="alert"
|
||||
= t("contests.nav.categories_description")
|
||||
|
||||
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
@@ -24,6 +31,6 @@
|
||||
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
= t("activerecord.attributes.category.new")
|
||||
.row.mt-3
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"
|
||||
@@ -1,3 +1,5 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
@@ -7,19 +9,21 @@
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
|
||||
= form.label :lang
|
||||
.row.mt-4.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :public, class: "form-check-input"
|
||||
= form.label :public
|
||||
= form.text_field :duration, autocomplete: "off", class: "form-control"
|
||||
= form.label :duration, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.duration_description")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select"
|
||||
= form.label :ranking_mode
|
||||
.row.mb-3
|
||||
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
|
||||
= form.label :lang
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :code, autocomplete: "off", class: "form-control"
|
||||
= form.label :code, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.code_description")
|
||||
.row.mb-3 style="display: none"
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :team, class: "form-check-input"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
- if @contest.puzzles.length > 1
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contests.form.offline_single_puzzle_warning")
|
||||
|
||||
- if @contest.puzzles.length <= 1
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/offline" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
- if @contest.puzzles.length <= 1
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form, class: "form-check-input"
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
- else
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form_fake, class: "form-check-input", disabled: true
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
46
app/views/contests/settings_online_edit.html.slim
Normal file
46
app/views/contests/settings_online_edit.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
= render "params_nav"
|
||||
|
||||
javascript:
|
||||
async function copyExtensionUrlToClipboard() {
|
||||
await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
|
||||
alert("#{t("contests.show.url_copied")}");
|
||||
}
|
||||
|
||||
.row.mb-4.mt-2
|
||||
.col
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
a.btn.btn-success href="/public/#{@contest.slug}/offline"
|
||||
= t("contests.show.open_offline_form")
|
||||
- else
|
||||
a.btn.btn-success.disabled
|
||||
= t("contests.show.offline_form_disabled")
|
||||
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
|
||||
css:
|
||||
button > svg {
|
||||
margin-right: 2px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
|
||||
</svg>
|
||||
=< t("contests.show.copy_extension_url")
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/online" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
- if @contest.puzzles.length <= 1
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form, class: "form-check-input"
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
- else
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form_fake, class: "form-check-input", disabled: true
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
12
app/views/contests/settings_onsite_edit.html.slim
Normal file
12
app/views/contests/settings_onsite_edit.html.slim
Normal file
@@ -0,0 +1,12 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :code, autocomplete: "off", class: "form-control"
|
||||
= form.label :code, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.code_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
22
app/views/contests/settings_public_edit.html.slim
Normal file
22
app/views/contests/settings_public_edit.html.slim
Normal file
@@ -0,0 +1,22 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/public" do |form|
|
||||
.row.mt-2
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :public, class: "form-check-input"
|
||||
= form.label :public
|
||||
.row.mt-2
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :show_stopwatch, class: "form-check-input"
|
||||
= form.label :show_stopwatch
|
||||
.row.mt-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select"
|
||||
= form.label :ranking_mode
|
||||
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
16
app/views/contests/stopwatch.html.slim
Normal file
16
app/views/contests/stopwatch.html.slim
Normal file
@@ -0,0 +1,16 @@
|
||||
.row
|
||||
.col
|
||||
.alert.alert-primary
|
||||
= t("contests.stopwatch.info")
|
||||
h1.mt-3 id="display-time" style="font-size: 80px;"
|
||||
= render "stopwatch_js"
|
||||
.row.mt-3
|
||||
.col.d-flex
|
||||
- if !@contest.start_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_start"), "/contests/#{@contest.id}/stopwatch_start", method: :post, class: "btn btn-primary"
|
||||
- if @contest.pause_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_continue"), "/contests/#{@contest.id}/stopwatch_continue", method: :post, class: "btn btn-primary"
|
||||
- if @contest.start_time.present? && !@contest.pause_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_pause"), "/contests/#{@contest.id}/stopwatch_pause", method: :post, class: "btn btn-primary"
|
||||
- if @contest.start_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_reset"), "/contests/#{@contest.id}/stopwatch_reset", method: :post, class: "ms-3 btn btn-warning"
|
||||
@@ -5,7 +5,7 @@ html
|
||||
body
|
||||
.container.mt-5
|
||||
- if @current_user
|
||||
.float-end style="margin-top: -8px;"
|
||||
.float-end style="margin-top: -5px;"
|
||||
nav.navbar.bg-body-primary
|
||||
- if @current_user.admin
|
||||
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
|
||||
@@ -44,15 +44,65 @@ html
|
||||
= msg
|
||||
|
||||
h1.mb-4
|
||||
- if @contest
|
||||
= @contest.name
|
||||
- if @contest && @contest.id.present?
|
||||
- if active_page("/public") == "active" && @action_path
|
||||
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
|
||||
= t("helpers.buttons.refresh")
|
||||
= @contest.name
|
||||
.float-end style="margin-top: -5px;" id="scoreboard-switches"
|
||||
.d-inline-flex.align-items-center
|
||||
.ms-4.form-check.form-switch style="font-size: 16px; font-weight: 300;"
|
||||
input.form-check-input type="checkbox" id="refresh-checkbox"
|
||||
label.ms-1 style="font-size: 16px; font-weight: 300;"
|
||||
= t("contests.scoreboard.auto_refresh")
|
||||
.js data-turbo="false"
|
||||
javascript:
|
||||
function refresh() {
|
||||
if (document.getElementById('refresh-checkbox').checked) {
|
||||
addParam('autorefresh', 1);
|
||||
setTimeout(refresh, 30000);
|
||||
}
|
||||
}
|
||||
function addParam(key, value) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete(key);
|
||||
urlParams.append(key, value);
|
||||
const refreshBtn = document.getElementById('refresh-button')
|
||||
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
|
||||
refreshBtn.click();
|
||||
}
|
||||
function removeParam(key) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete(key);
|
||||
const refreshBtn = document.getElementById('refresh-button')
|
||||
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
|
||||
refreshBtn.click();
|
||||
}
|
||||
function autoRefresh() {
|
||||
if (document.getElementById('refresh-checkbox').checked) setTimeout(refresh, 30000);
|
||||
document.getElementById('refresh-checkbox').addEventListener('change', (e) => {
|
||||
if (e.target.checked) refresh();
|
||||
else removeParam('autorefresh');
|
||||
});
|
||||
}
|
||||
async function startAutoRefresh(count) {
|
||||
if (count == 0) return;
|
||||
if (document.getElementById('refresh-button') && document.getElementById('refresh-checkbox')) autoRefresh();
|
||||
else setTimeout(() => startAutoRefresh(count - 1), 10);
|
||||
}
|
||||
startAutoRefresh(200);
|
||||
- elsif active_page("/contests") == "active"
|
||||
= @contest.name
|
||||
- if @contest.public
|
||||
a.ms-4.btn.btn-success href="/public/#{@contest.slug}" style="margin-top: -6px;"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
- else
|
||||
a.ms-4.btn.btn-success.disabled style="margin-top: -6px;"
|
||||
= t("contests.show.public_scoreboard_disabled")
|
||||
- else
|
||||
= @contest.name
|
||||
- else
|
||||
= @title
|
||||
|
||||
- if @contest && active_page("/public") != "active"
|
||||
- if @contest && active_page("/contests") == "active" && !@nonav
|
||||
= render "contest_nav"
|
||||
|
||||
= yield
|
||||
6
app/views/layouts/blank.html.slim
Normal file
6
app/views/layouts/blank.html.slim
Normal file
@@ -0,0 +1,6 @@
|
||||
doctype html
|
||||
html
|
||||
= render "layouts/header"
|
||||
|
||||
body
|
||||
= yield
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
.row.mb-4
|
||||
.col
|
||||
.alert.alert-primary
|
||||
= t("messages.index.info")
|
||||
- if @messages.length == 0
|
||||
.alert.alert-warning
|
||||
= t("messages.index.no_messages")
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
.row.mb-3
|
||||
.col.alert.alert-warning
|
||||
= t("puzzles.form.fake_data_recommendation")
|
||||
- if puzzle.id && puzzle.image.attached?
|
||||
.row.mb-3
|
||||
.col
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px") if puzzle.image.attached?
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
@@ -20,6 +21,13 @@
|
||||
.form-floating
|
||||
= form.number_field :pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :pieces, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :hidden, class: "form-check-input"
|
||||
= form.label :hidden
|
||||
.form-text
|
||||
= t("activerecord.attributes.puzzle.hidden_description")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-text.mb-1
|
||||
@@ -31,7 +39,7 @@
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 5.3 * 1024 * 1024) {
|
||||
if(this.files[0].size > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
@@ -41,7 +49,7 @@
|
||||
}
|
||||
|
||||
setMaxUploadSize();
|
||||
.row.mt-4
|
||||
.row.mt-4.mb-5
|
||||
.col
|
||||
- if method == :patch
|
||||
= link_to t("helpers.buttons.delete"), contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
|
||||
| + #{t("helpers.buttons.add")}
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
@@ -16,6 +18,8 @@
|
||||
= t("activerecord.attributes.puzzle.brand")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.pieces")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.hidden")
|
||||
tbody
|
||||
- @puzzles.each do |puzzle|
|
||||
tr.align-middle scope="row"
|
||||
@@ -27,6 +31,12 @@
|
||||
= puzzle.brand
|
||||
td
|
||||
= puzzle.pieces
|
||||
td
|
||||
- if puzzle.hidden?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
|
||||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
|
||||
</svg>
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
|
||||
= t("helpers.buttons.edit")
|
||||
@@ -30,13 +30,13 @@
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
|
||||
- if method == :patch
|
||||
- if method == :patch
|
||||
h4.mt-5 = t("users.edit.password_section")
|
||||
|
||||
= form_with model: user, method: method do |form|
|
||||
= form_with model: user, url: user_password_path(user) do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
= form.submit t("helpers.buttons.save_password"), class: "btn btn-primary"
|
||||
@@ -1,27 +1,39 @@
|
||||
table.table.table-striped.table-hover
|
||||
.row
|
||||
.d-flex.flex-row.justify-content-start.align-items-center
|
||||
a.btn.btn-primary href=new_user_path
|
||||
| New user
|
||||
= button_to "Update contestants", "/update_contestants", method: :post, class: "ms-3 btn btn-success"
|
||||
= button_to "Regenerate QR codes", "/regenerate_qrcodes", method: :post, class: "ms-3 btn btn-success"
|
||||
|
||||
- @users.each do |user|
|
||||
- if user.admin
|
||||
h3.mt-5 = "#{user.username} (admin)"
|
||||
- else
|
||||
h3.mt-5 = user.username
|
||||
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Id
|
||||
| ID
|
||||
th scope="col"
|
||||
| Name
|
||||
| Friendly ID
|
||||
th scope="col"
|
||||
| Admin?
|
||||
| # Puzzles
|
||||
th scope="col"
|
||||
| # contests
|
||||
| # Participants
|
||||
tbody
|
||||
- @users.each do |user|
|
||||
- user.contests.each do |contest|
|
||||
tr scope="row"
|
||||
td
|
||||
= user.id
|
||||
= contest.id
|
||||
td
|
||||
= user.username
|
||||
= contest.friendly_id
|
||||
td
|
||||
= user.admin ? "Yes" : "No"
|
||||
= contest.puzzles.length
|
||||
td
|
||||
= user.contests.length
|
||||
= contest.contestants.length
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=contest_path(contest)
|
||||
= t("helpers.buttons.open")
|
||||
|
||||
.row
|
||||
.col
|
||||
a.btn.btn-primary href=new_user_path
|
||||
| New user
|
||||
73
config/brakeman.ignore
Normal file
73
config/brakeman.ignore
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"ignored_warnings": [
|
||||
{
|
||||
"warning_type": "Cross-Site Scripting",
|
||||
"warning_code": 2,
|
||||
"fingerprint": "00462a5825f8e46fe0b5167b1c822296cb5d8443117790a04966ba059a260f2b",
|
||||
"check_name": "CrossSiteScripting",
|
||||
"message": "Unescaped model attribute",
|
||||
"file": "app/views/contestants/generate_qrcodes.html.slim",
|
||||
"line": 20,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
|
||||
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "ContestantsController",
|
||||
"method": "generate_qrcodes",
|
||||
"line": 126,
|
||||
"file": "app/controllers/contestants_controller.rb",
|
||||
"rendered": {
|
||||
"name": "contestants/generate_qrcodes",
|
||||
"file": "app/views/contestants/generate_qrcodes.html.slim"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "contestants/generate_qrcodes"
|
||||
},
|
||||
"user_input": "Contest.find(params[:contest_id]).contestants",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
79
|
||||
],
|
||||
"note": "SVG HTML code is generated by the app"
|
||||
},
|
||||
{
|
||||
"warning_type": "Cross-Site Scripting",
|
||||
"warning_code": 2,
|
||||
"fingerprint": "d17a497a9b261007930226914a64e99d6f6237c99cc1c33c88745e1341ac4fb7",
|
||||
"check_name": "CrossSiteScripting",
|
||||
"message": "Unescaped model attribute",
|
||||
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim",
|
||||
"line": 11,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
|
||||
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "ContestantsController",
|
||||
"method": "generate_qrcodes_pdf",
|
||||
"line": 135,
|
||||
"file": "app/controllers/contestants_controller.rb",
|
||||
"rendered": {
|
||||
"name": "contestants/generate_qrcodes_pdf",
|
||||
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "contestants/generate_qrcodes_pdf"
|
||||
},
|
||||
"user_input": "Contest.find(params[:contest_id]).contestants",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
79
|
||||
],
|
||||
"note": "SVG HTML code is generated by the app"
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.1.1"
|
||||
}
|
||||
@@ -40,6 +40,8 @@ Rails.application.configure do
|
||||
# Set localhost to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
||||
|
||||
config.action_controller.default_url_options = { host: "192.168.122.105", port: 3000 }
|
||||
|
||||
# Print deprecation notices to the Rails logger.
|
||||
config.active_support.deprecation = :log
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ Rails.application.configure do
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
|
||||
config.action_controller.default_url_options = { host: "puzzle-scoreboard.org" }
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
|
||||
|
||||
@@ -47,11 +47,17 @@ en:
|
||||
contestant: Participant
|
||||
display_time: Time
|
||||
display_time_from_start: Time since start
|
||||
display_time_from_start_description: Format mm:ss or h:mm:ss
|
||||
display_relative_time: Time for this puzzle
|
||||
puzzle: Puzzle
|
||||
missing_pieces: Missing pieces
|
||||
projected_time: Projected time
|
||||
remaining_pieces: Remaining pieces (not completed puzzle)
|
||||
contest:
|
||||
code: Code for onsite judges
|
||||
code_description: Optional. Used for organizers on onsite contests, when printing QR codes on tables
|
||||
duration: Duration
|
||||
duration_description: Format h:mm or hh:mm
|
||||
lang: Language for the public scoreboard
|
||||
name: Name
|
||||
offline_form: Enable the offline participation form
|
||||
@@ -59,6 +65,7 @@ en:
|
||||
offline_form_warning: Only for single-puzzle contests
|
||||
public: Enable the public scoreboard
|
||||
ranking_mode: Ranking mode (public scoreboard & CSV exports)
|
||||
show_stopwatch: Display the stopwatch
|
||||
team: Team contest
|
||||
team_description: For UI display purposes mainly
|
||||
allow_registration: Allow registration
|
||||
@@ -85,6 +92,8 @@ en:
|
||||
remaining_pieces: Remaining pieces
|
||||
puzzle:
|
||||
brand: Brand
|
||||
hidden: hidden
|
||||
hidden_description: When hidden, a puzzle doesn't appear in the public scoreboard, nor public forms
|
||||
image: Image
|
||||
name: Name
|
||||
pieces: Number of pieces
|
||||
@@ -100,6 +109,8 @@ en:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
code:
|
||||
mismatch: "Wrong code"
|
||||
contestant_id:
|
||||
taken: "This contestant has already completed the puzzle"
|
||||
display_time_from_start:
|
||||
@@ -111,8 +122,12 @@ en:
|
||||
blank: This is required
|
||||
not_an_integer: This is not an integer
|
||||
not_a_number: This is not an integer
|
||||
too_large: "Cannot be greater than the number of pieces for this puzzle"
|
||||
contest:
|
||||
attributes:
|
||||
duration:
|
||||
blank: Must be filled
|
||||
invalid: Invalid duration
|
||||
name:
|
||||
blank: The contest name cannot be empty
|
||||
contestant:
|
||||
@@ -152,6 +167,9 @@ en:
|
||||
blank: Your email cannot be empty
|
||||
username:
|
||||
blank: Your username cannot be empty
|
||||
taken: This username is already taken
|
||||
password:
|
||||
blank: Your password cannot be empty
|
||||
categories:
|
||||
destroy:
|
||||
notice: Category deleted
|
||||
@@ -164,6 +182,10 @@ en:
|
||||
edit:
|
||||
notice: Completion updated
|
||||
title: Edit completion
|
||||
form:
|
||||
all_finished: "All puzzles were already completed by %{name}"
|
||||
code: Judges code
|
||||
validate_name: "Validate a puzzle for %{name}"
|
||||
new:
|
||||
notice: Completion added
|
||||
title: New completion
|
||||
@@ -175,19 +197,26 @@ en:
|
||||
notice: Contest updated
|
||||
title: Edit contest settings
|
||||
form:
|
||||
categories: Participant categories
|
||||
general: General parameters
|
||||
offline: Offline participation
|
||||
offline_single_puzzle_warning: This is not available for contests with more than one puzzle
|
||||
index:
|
||||
title: Welcome %{username}!
|
||||
manage_contests: Manage my contests
|
||||
new_contest: Create a new contest
|
||||
nav:
|
||||
categories: Participant categories
|
||||
categories_description: Once you add categories, you will be able to assign them to participants on their profiles, and a filter for categories will be available on the public scoreboard
|
||||
general: General
|
||||
online: Online contests
|
||||
onsite: Onsite contests
|
||||
public: Public scoreboard
|
||||
settings: Settings
|
||||
stopwatch: Stopwatch
|
||||
new:
|
||||
notice: Contest added
|
||||
title: New jigsaw puzzle contest
|
||||
scoreboard:
|
||||
all_categories: All categories
|
||||
auto_refresh: Auto refresh (30s)
|
||||
hide_offline: Hide offline participants
|
||||
refresh: Activate auto-refresh (every 5s)
|
||||
title: "%{name}"
|
||||
@@ -201,6 +230,8 @@ en:
|
||||
offline_form_disabled: The offline form is disabled
|
||||
public_scoreboard_disabled: The public scoreboard is disabled
|
||||
url_copied: URL copied to the clipboard
|
||||
stopwatch:
|
||||
info: This stopwatch is used for display in the public scoreboard, when allowed in the settings
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Import participants
|
||||
@@ -211,12 +242,16 @@ en:
|
||||
end_image: End image
|
||||
notice: Participant updated
|
||||
not_finished: Not yet finished
|
||||
no_puzzles_note: No puzzles were added yet
|
||||
offline_participation: Offline participation
|
||||
start_image: Start image
|
||||
title: Participant
|
||||
team_title: Teams
|
||||
finalize_import:
|
||||
title: Import participants
|
||||
generate_qrcodes:
|
||||
note: These QR codes allow for judges to fill in results without the need of the organizer's account, for example by printing them and placing them on participant tables
|
||||
no_code_note: Those can't be used until a code for judges has been set up in the general settings
|
||||
import:
|
||||
email_column: Participant email
|
||||
import_column: Import?
|
||||
@@ -245,27 +280,36 @@ en:
|
||||
back_to_contestant: "⬅ Back to the participant"
|
||||
confirm: "Confirm"
|
||||
create: "Create"
|
||||
details: Open
|
||||
delete: "Delete"
|
||||
edit: "Edit"
|
||||
end: Click here to submit your completion
|
||||
export: Export
|
||||
generate_qrcodes: Generate QR codes
|
||||
import: CSV Import
|
||||
open: Open
|
||||
open_raw: Open in a raw page
|
||||
refresh: Refresh
|
||||
settings: Settings
|
||||
sign_in: Sign in
|
||||
save: Save
|
||||
save_password: Save password
|
||||
start: Click here to start your participation
|
||||
stopwatch_continue: Continue
|
||||
stopwatch_pause: Pause
|
||||
stopwatch_reset: Reset
|
||||
stopwatch_start: Start
|
||||
update: Save modifications
|
||||
field: Field
|
||||
none: No field selected
|
||||
rank: Rank
|
||||
lib:
|
||||
ranking:
|
||||
actual: First by time if completed, then by number of pieces assembled
|
||||
actual: First by number of pieces assembled, then by time (recommended if unsure)
|
||||
theorical: By time only (projected time calculated with the ppm count)
|
||||
messages:
|
||||
index:
|
||||
info: This section is only used for contests that rely on the connection with Google Meet
|
||||
no_messages: No messages received yet
|
||||
convert:
|
||||
title: New completion
|
||||
@@ -296,7 +340,7 @@ en:
|
||||
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 5M
|
||||
file_too_big: File too big! Maximum allowed size is 8M
|
||||
image_select: Select an image
|
||||
new:
|
||||
notice: Puzzle added
|
||||
@@ -312,7 +356,7 @@ en:
|
||||
notice: Settings updated
|
||||
title: "My settings"
|
||||
general_section: "General settings"
|
||||
password_section: "Change password"
|
||||
password_section: "Password"
|
||||
index:
|
||||
title: "All users"
|
||||
new:
|
||||
|
||||
@@ -18,11 +18,17 @@ fr:
|
||||
contestant_id: Participant.e
|
||||
display_time: Temps
|
||||
display_time_from_start: Temps depuis le début
|
||||
display_time_from_start_description: Format mm:ss ou h:mm:ss
|
||||
display_relative_time: Temps pour ce puzzle
|
||||
puzzle: Puzzle
|
||||
missing_pieces: Pièces manquantes
|
||||
projected_time: Temps projeté
|
||||
remaining_pieces: Pièces restantes (puzzle non fini)
|
||||
contest:
|
||||
code: Code pour les organisateur.ice.s
|
||||
code_description: Optionnel. Utilisé pour les organisateur.ices dans les concours en présentiel lorsque sont imprimés des QR codes à placer sur les tables
|
||||
duration: Durée
|
||||
duration_description: Format h:mm ou hh:mm
|
||||
lang: Langue pour le classement public
|
||||
name: Nom
|
||||
offline_form: Activer le formulaire de participation hors-ligne
|
||||
@@ -30,6 +36,7 @@ fr:
|
||||
offline_form_warning: Activable uniquement pour les concours avec un seul puzzle
|
||||
public: Activer le classement public
|
||||
ranking_mode: Mode de classement (classement public & exports CSV)
|
||||
show_stopwatch: Afficher le chronomètre
|
||||
team: Concours par équipes
|
||||
team_description: Principalement pour des raisons d'affichage
|
||||
allow_registration: Autoriser l'inscription via l'interface
|
||||
@@ -56,6 +63,8 @@ fr:
|
||||
remaining_pieces: Pièces restantes
|
||||
puzzle:
|
||||
brand: Marque
|
||||
hidden: Non découvert
|
||||
hidden_description: Un puzzle non découvert n'apparaît pas dans le classement public, ni dans les formulaires publics
|
||||
image: Image
|
||||
name: Nom
|
||||
pieces: Nombre de pièces
|
||||
@@ -71,6 +80,8 @@ fr:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
code:
|
||||
mismatch: "Code non valide"
|
||||
contestant_id:
|
||||
taken: "Ce.tte participant.e a déjà complété le puzzle"
|
||||
display_time_from_start:
|
||||
@@ -82,8 +93,12 @@ fr:
|
||||
blank: Ce champ est obligatoire
|
||||
not_an_integer: Ce n'est pas un nombre entier
|
||||
not_a_number: Ce n'est pas un nombre entier
|
||||
too_large: "Ne peut pas être plus grand que le nombre de pièces du puzzle"
|
||||
contest:
|
||||
attributes:
|
||||
duration:
|
||||
blank: Obligatoire
|
||||
invalid: Durée invalide
|
||||
name:
|
||||
blank: Le nom du concours ne peut pas être vide
|
||||
contestant:
|
||||
@@ -123,6 +138,9 @@ fr:
|
||||
blank: L'email est obligatoire
|
||||
username:
|
||||
blank: Le nom d'utilisateur.ice est obligatoire
|
||||
taken: Ce nom d'utilisateur.ice est déjà utilisé
|
||||
password:
|
||||
blank: Le mot de passe ne peut pas être vide
|
||||
categories:
|
||||
destroy:
|
||||
notice: Catégorie supprimée
|
||||
@@ -135,6 +153,10 @@ fr:
|
||||
edit:
|
||||
notice: Complétion modifiée
|
||||
title: Modifier la complétion
|
||||
form:
|
||||
all_finished: Tous les puzzles ont déjà été complétés par %{name}
|
||||
code: Code organisateur.ice
|
||||
validate_name: "Valider un puzzle pour %{name}"
|
||||
new:
|
||||
notice: Complétion ajoutée
|
||||
title: Ajout d'une complétion
|
||||
@@ -146,19 +168,26 @@ fr:
|
||||
notice: Concours modifié
|
||||
title: Paramètres du concours
|
||||
form:
|
||||
categories: Catégories de participant.e.s
|
||||
general: Paramètres généraux
|
||||
offline: Participation hors-ligne
|
||||
offline_single_puzzle_warning: Ce n'est pas activable pour les concours avec plusieurs puzzles
|
||||
index:
|
||||
title: Bienvenue %{username} !
|
||||
manage_contests: Mes concours de puzzle
|
||||
new_contest: Créer un nouveau concours
|
||||
nav:
|
||||
categories: Catégories de participant.e.s
|
||||
categories_description: Après avoir ajouté des catégories, elles pourront être attributées aux participant.e.s sur leurs profils, et un filtre sera disponible sur le classement public
|
||||
general: Général
|
||||
online: Concours en ligne
|
||||
onsite: Concours en présentiel
|
||||
public: Classement public
|
||||
settings: Paramètres
|
||||
stopwatch: Chronomètre
|
||||
new:
|
||||
notice: Concours ajouté
|
||||
title: Nouveau concours
|
||||
scoreboard:
|
||||
all_categories: Toutes les catégories
|
||||
auto_refresh: Auto-rafraichissement (30s)
|
||||
hide_offline: Cacher les participant.e.s hors-ligne
|
||||
refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
|
||||
title: "%{name}"
|
||||
@@ -172,6 +201,8 @@ fr:
|
||||
offline_form_disabled: Le formulaire hors-ligne n'est pas activé
|
||||
public_scoreboard_disabled: Le classement public n'est pas activé
|
||||
url_copied: L’URL a été copiée dans le presse-papier
|
||||
stopwatch:
|
||||
info: Ce chronomètre est utilisé pour être affiché dans le classement public, quand autorisé dans les paramètres
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Importer des participant.e.s
|
||||
@@ -182,12 +213,16 @@ fr:
|
||||
end_image: Image de fin
|
||||
notice: Participant.e modifié.e
|
||||
not_finished: Non terminé
|
||||
no_puzzles_note: Aucun puzzle n'a été défini encore pour ce concours
|
||||
offline_participation: Participation hors-ligne
|
||||
start_time: Image de début
|
||||
start_image: Image de début
|
||||
title: Participant.e
|
||||
team_title: Équipe
|
||||
finalize_import:
|
||||
title: Importer des participant.e.s
|
||||
generate_qrcodes:
|
||||
note: Ces QR codes permettent, quand imprimés et placés sur les tables des participant.e.s, aux organisateur.ice.s de valider les temps de complétion des puzzles
|
||||
no_code_note: Les codes ne seront générés qu'une fois un code pour les organisateur.ice.s défini dans les paramètres généraux
|
||||
import:
|
||||
email_column: Email des participant.e.s
|
||||
import_column: Importer ?
|
||||
@@ -217,26 +252,35 @@ fr:
|
||||
confirm: "Confirmer"
|
||||
create: "Créer"
|
||||
delete: "Supprimer"
|
||||
details: Détails
|
||||
edit: "Modifier"
|
||||
end: Clique ici pour valider ta complétion du puzzle
|
||||
export: Exporter
|
||||
generate_qrcodes: Générer des QR codes
|
||||
import: Importer un CSV
|
||||
open: Détails
|
||||
open: Ouvrir
|
||||
open_raw: Ouvrir dans un format imprimable
|
||||
refresh: Rafraîchir
|
||||
settings: Paramètres
|
||||
sign_in: Se connecter
|
||||
save: Modifier
|
||||
save_password: Modifier le mot de passe
|
||||
start: Clique ici pour démarrer ta participation
|
||||
stopwatch_continue: Reprendre
|
||||
stopwatch_pause: Pause
|
||||
stopwatch_reset: Ré-initialiser
|
||||
stopwatch_start: Démarrer
|
||||
update: Enregistrer les modifications
|
||||
field: Champ
|
||||
none: Aucun champ sélectionné
|
||||
rank: Rang
|
||||
lib:
|
||||
ranking:
|
||||
actual: Par temps d'abord, puis par nombre de pièces assemblées
|
||||
actual: Par nombre de pièces assemblées, puis par temps (recommandé)
|
||||
theorical: Par temps uniquement (temps projeté calculé à partir de la vitesse d'assemblage)
|
||||
messages:
|
||||
index:
|
||||
info: Cette section n'est pertinente que pour les concours en ligne qui utilisent la connexion depuis Google Meet
|
||||
no_messages: Pas de messages reçus pour le moment
|
||||
convert:
|
||||
title: Ajout d'une complétion
|
||||
@@ -267,7 +311,7 @@ fr:
|
||||
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 5M
|
||||
file_too_big: La taille de l'image dépasse la taille maximum autorisée de 8M
|
||||
image_select: Choisis une image
|
||||
new:
|
||||
notice: Puzzle ajouté
|
||||
@@ -283,7 +327,7 @@ fr:
|
||||
notice: Paramètres modifiés
|
||||
title: "Mes paramètres"
|
||||
general_section: "Paramètres globaux"
|
||||
password_section: "Modifier mon mot de passe"
|
||||
password_section: "Mot de passe"
|
||||
index:
|
||||
title: "Tous.tes les utilisateur.ices"
|
||||
new:
|
||||
|
||||
@@ -11,9 +11,18 @@ Rails.application.routes.draw do
|
||||
resources :contests do
|
||||
get "settings/general", to: "contests#settings_general_edit"
|
||||
patch "settings/general", to: "contests#settings_general_update"
|
||||
get "settings/offline", to: "contests#settings_offline_edit"
|
||||
patch "settings/offline", to: "contests#settings_offline_update"
|
||||
get "settings/public", to: "contests#settings_public_edit"
|
||||
patch "settings/public", to: "contests#settings_public_update"
|
||||
get "settings/onsite", to: "contests#settings_onsite_edit"
|
||||
patch "settings/onsite", to: "contests#settings_onsite_update"
|
||||
get "settings/online", to: "contests#settings_online_edit"
|
||||
patch "settings/online", to: "contests#settings_online_update"
|
||||
get "settings/categories", to: "contests#settings_categories_edit"
|
||||
get "stopwatch", to: "contests#stopwatch"
|
||||
post "stopwatch_continue", to: "contests#stopwatch_continue"
|
||||
post "stopwatch_pause", to: "contests#stopwatch_pause"
|
||||
post "stopwatch_reset", to: "contests#stopwatch_reset"
|
||||
post "stopwatch_start", to: "contests#stopwatch_start"
|
||||
resources :categories, only: [ :create, :destroy ]
|
||||
resources :completions
|
||||
resources :contestants
|
||||
@@ -26,20 +35,50 @@ Rails.application.routes.draw do
|
||||
get "import/:id", to: "contestants#convert_csv"
|
||||
post "import/:id", to: "contestants#finalize_import"
|
||||
get "export", to: "contestants#export"
|
||||
get "generate_qrcodes", to: "contestants#generate_qrcodes"
|
||||
get "generate_qrcodes_pdf", to: "contestants#generate_qrcodes_pdf"
|
||||
end
|
||||
resources :passwords, param: :token
|
||||
resource :session
|
||||
resources :users
|
||||
resources :users do
|
||||
patch "password", to: "users#change_password"
|
||||
end
|
||||
|
||||
options "connect", to: "messages#cors_preflight_check"
|
||||
options "message", to: "messages#cors_preflight_check"
|
||||
post "connect", to: "messages#connect"
|
||||
post "message", to: "messages#create"
|
||||
|
||||
post "regenerate_qrcodes", to: "users#regenerate_qrcodes"
|
||||
post "update_contestants", to: "users#update_contestants"
|
||||
|
||||
get "public/:id", to: "contests#scoreboard"
|
||||
get "public/:id/offline", to: "contests#offline_new"
|
||||
post "public/:id/offline", to: "contests#offline_create"
|
||||
get "public/:id/offline/:token", to: "contests#offline_edit"
|
||||
patch "public/:id/offline/:token", to: "contests#offline_update"
|
||||
get "public/:id/offline/:token/completed", to: "contests#offline_completed"
|
||||
get "public/p/:contestant_id", to: "contestants#get_public_completion"
|
||||
post "public/p/:contestant_id", to: "contestants#post_public_completion"
|
||||
get "public/p/:contestant_id/updated", to: "contestants#public_completion_updated"
|
||||
|
||||
direct :direct_test do
|
||||
"https://lol.com"
|
||||
end
|
||||
|
||||
direct :public_scoreboard do |contest|
|
||||
"/public/#{contest.friendly_id}/public"
|
||||
end
|
||||
|
||||
direct :offline_form do |contest|
|
||||
"/public/#{contest.friendly_id}/offline"
|
||||
end
|
||||
|
||||
direct :offline_form_edit do |contest, offline|
|
||||
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}"
|
||||
end
|
||||
|
||||
direct :offline_form_completed do |contest, offline|
|
||||
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}/completed"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddProjectedTimeToCompletion < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :completions, :projected_time, :integer
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251114093213_add_completion_to_offline.rb
Normal file
5
db/migrate/20251114093213_add_completion_to_offline.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCompletionToOffline < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_reference :offlines, :completion, foreign_key: true
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251118074900_add_duration_to_contest.rb
Normal file
5
db/migrate/20251118074900_add_duration_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddDurationToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :duration, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddDurationSecondsToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :duration_seconds, :integer
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251120100813_add_qrcode_to_contestant.rb
Normal file
5
db/migrate/20251120100813_add_qrcode_to_contestant.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddQrcodeToContestant < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contestants, :qrcode, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251120150744_add_code_to_contest.rb
Normal file
5
db/migrate/20251120150744_add_code_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCodeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :code, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251120152211_add_code_to_completion.rb
Normal file
5
db/migrate/20251120152211_add_code_to_completion.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCodeToCompletion < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :completions, :code, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251204100550_add_hidden_to_puzzle.rb
Normal file
5
db/migrate/20251204100550_add_hidden_to_puzzle.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddHiddenToPuzzle < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :puzzles, :hidden, :boolean
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251209073711_add_start_time_to_contest.rb
Normal file
5
db/migrate/20251209073711_add_start_time_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddStartTimeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :start_time, :datetime
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251209075739_add_pause_time_to_contest.rb
Normal file
5
db/migrate/20251209075739_add_pause_time_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddPauseTimeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :pause_time, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddShowStopwatchToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :show_stopwatch, :boolean
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddPasswordChangeAttemptToUser < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :password_change_attempt, :boolean
|
||||
end
|
||||
end
|
||||
17
db/schema.rb
generated
17
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_10_092658) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -67,6 +67,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
t.integer "remaining_pieces"
|
||||
t.integer "missing_pieces"
|
||||
t.boolean "completed"
|
||||
t.integer "projected_time"
|
||||
t.string "code"
|
||||
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"
|
||||
@@ -81,6 +83,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "display_time"
|
||||
t.integer "time_seconds"
|
||||
t.string "projected_time"
|
||||
t.string "qrcode"
|
||||
t.index ["contest_id"], name: "index_contestants_on_contest_id"
|
||||
end
|
||||
|
||||
@@ -96,6 +100,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
t.boolean "public", default: false
|
||||
t.boolean "offline_form", default: false
|
||||
t.string "ranking_mode"
|
||||
t.string "duration"
|
||||
t.integer "duration_seconds"
|
||||
t.string "code"
|
||||
t.datetime "start_time"
|
||||
t.datetime "pause_time"
|
||||
t.boolean "show_stopwatch"
|
||||
t.index ["slug"], name: "index_contests_on_slug", unique: true
|
||||
t.index ["user_id"], name: "index_contests_on_user_id"
|
||||
end
|
||||
@@ -141,6 +151,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
t.integer "missing_pieces"
|
||||
t.integer "remaining_pieces"
|
||||
t.boolean "submitted"
|
||||
t.integer "completion_id"
|
||||
t.index ["completion_id"], name: "index_offlines_on_completion_id"
|
||||
t.index ["contest_id"], name: "index_offlines_on_contest_id"
|
||||
t.index ["contestant_id"], name: "index_offlines_on_contestant_id"
|
||||
end
|
||||
@@ -152,6 +164,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
t.integer "contest_id", null: false
|
||||
t.string "brand"
|
||||
t.integer "pieces", null: false
|
||||
t.boolean "hidden"
|
||||
t.index ["contest_id"], name: "index_puzzles_on_contest_id"
|
||||
end
|
||||
|
||||
@@ -172,6 +185,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
t.string "username"
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.string "lang", default: "en"
|
||||
t.boolean "password_change_attempt"
|
||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||
end
|
||||
|
||||
@@ -185,6 +199,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_14_085123) do
|
||||
add_foreign_key "contestants", "contests"
|
||||
add_foreign_key "contests", "users"
|
||||
add_foreign_key "messages", "contests"
|
||||
add_foreign_key "offlines", "completions"
|
||||
add_foreign_key "offlines", "contestants"
|
||||
add_foreign_key "offlines", "contests"
|
||||
add_foreign_key "puzzles", "contests"
|
||||
|
||||
29
spec/factories/contestant.rb
Normal file
29
spec/factories/contestant.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: contestants
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# display_time :string
|
||||
# email :string
|
||||
# name :string
|
||||
# projected_time :string
|
||||
# qrcode :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_contestants_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :contestant do
|
||||
name { Faker::Name.name }
|
||||
email { Faker::Internet.unique.email }
|
||||
end
|
||||
end
|
||||
@@ -4,12 +4,18 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# code :string
|
||||
# duration :string
|
||||
# duration_seconds :integer
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# pause_time :datetime
|
||||
# public :boolean default(FALSE)
|
||||
# ranking_mode :string
|
||||
# show_stopwatch :boolean
|
||||
# slug :string
|
||||
# start_time :datetime
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -27,5 +33,11 @@
|
||||
FactoryBot.define do
|
||||
factory :contest do
|
||||
name { Faker::Company.unique.name }
|
||||
duration { "2:00" }
|
||||
ranking_mode { "actual" }
|
||||
end
|
||||
|
||||
trait :offline do
|
||||
offline_form { true }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,16 +12,19 @@
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# completion_id :integer
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_completion_id (completion_id)
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# completion_id (completion_id => completions.id)
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
|
||||
@@ -20,7 +20,7 @@ RSpec.feature "Contests", type: :feature do
|
||||
|
||||
find(".stretched-link[href=\"/contests/#{first_contest.friendly_id}\"]").click
|
||||
|
||||
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}")
|
||||
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}/contestants")
|
||||
end
|
||||
|
||||
it "should offer to create a new contest" do
|
||||
@@ -48,31 +48,7 @@ RSpec.feature "Contests", type: :feature do
|
||||
|
||||
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))
|
||||
expect(page).to have_current_path("/contests")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ RSpec.feature "Login", type: :feature do
|
||||
fill_in "Password", with: user.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).to have_content(I18n.t("sessions.new.title"))
|
||||
expect(page).to have_content("Puzzle scoreboard")
|
||||
end
|
||||
|
||||
it "should fail to log in the user with an incorrect password" do
|
||||
@@ -28,7 +28,7 @@ RSpec.feature "Login", type: :feature do
|
||||
fill_in "Password", with: Faker::Internet.unique.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).to have_content(I18n.t("sessions.new.title"))
|
||||
expect(page).to have_content("Puzzle scoreboard")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
145
spec/features/offline_spec.rb
Normal file
145
spec/features/offline_spec.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.feature "Users", type: :feature do
|
||||
context "when the contest doesn't allow offline participation" do
|
||||
let!(:contest) { create(:contest, user: create(:user)) }
|
||||
|
||||
it "shouldn't be possible to load the offline participation form" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
expect(page).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the contest allows offline participation" do
|
||||
let!(:contest) { create(:contest, :offline, user: create(:user)) }
|
||||
|
||||
it "should be possible to load the offline participation form" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_content(contest.name)
|
||||
end
|
||||
|
||||
it "shouldn't be possible to validate the form without a pseudo" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: ""
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.name.blank"))
|
||||
end
|
||||
|
||||
it "shouldn't be possible to validate the form without an image" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.start") }.not_to change { contest.reload.offlines.size }
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.start_image.blank"))
|
||||
end
|
||||
|
||||
it "should be possible to start the offline participation with a valid name and start image" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.start") }.to change { contest.reload.offlines.size }.to(1)
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_current_path(offline_form_edit_path(contest, contest.offlines[0]))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "shouldn't be possible to complete the offline participation without an end image" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.not_to change { contest.offlines[0].images.size }
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.end_image.blank"))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "shouldn't be possible to complete the offline participation without remaining pieces count when the puzzle isn't completed" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.not_to change { contest.offlines[0].images.size }
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.remaining_pieces.blank"))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "should be possible to complete the offline participation with the end image and remaining pieces count when the puzzle isn't completed" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
end_image_file = Tempfile.new('end_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.remaining_pieces"), with: "10"
|
||||
attach_file("offline[end_image]", end_image_file.path)
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.to change { contest.offlines[0].images.size }.to(2)
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_current_path(offline_form_completed_path(contest, contest.offlines[0]))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end_image_file.close
|
||||
end_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "should be possible to complete the offline participation with the end image solely when the puzzle is completed" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
end_image_file = Tempfile.new('end_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
check I18n.t("activerecord.attributes.offline.completed")
|
||||
attach_file("offline[end_image]", end_image_file.path)
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.to change { contest.offlines[0].images.size }.to(2)
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_current_path(offline_form_completed_path(contest, contest.offlines[0]))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end_image_file.close
|
||||
end_image_file.unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -21,21 +21,54 @@ RSpec.feature "Users", type: :feature do
|
||||
expect(page).not_to have_content(I18n.t("users.index.title"))
|
||||
end
|
||||
|
||||
it "should be able to create a new contest" do
|
||||
it "should be able to open their account info" do
|
||||
visit root_path
|
||||
|
||||
click_link "Create a new contest"
|
||||
click_link I18n.t("nav.settings")
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.new.title"))
|
||||
expect(page).to have_current_path(edit_user_path(user))
|
||||
end
|
||||
|
||||
it "should be able to open an existing contest" do
|
||||
visit root_path
|
||||
context "when updating their account info" do
|
||||
let!(:existing_user) { create(:user, username: "taken_username") }
|
||||
|
||||
expect(page).to have_content(contest.name)
|
||||
find("div.card", text: contest.name).find("a").click
|
||||
it "should allow changing to an untaken username" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.show.title", name: contest.name))
|
||||
fill_in I18n.t("activerecord.attributes.user.username"), with: "untaken_username"
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.to change(user, :username).to("untaken_username")
|
||||
end
|
||||
|
||||
it "should prevent changing to an already taken username" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.user.username"), with: "taken_username"
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.not_to change(user, :username)
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.username.taken"))
|
||||
end
|
||||
|
||||
it "should prevent changing to a blank username" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.user.username"), with: ""
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.not_to change(user, :username)
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.username.blank"))
|
||||
end
|
||||
|
||||
it "should allow changing to a non-blank password" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.user.password"), with: "new_password"
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save_password")); user.reload }
|
||||
.to change(user, :password_digest)
|
||||
.and change { user.authenticate("new_password") }.from(false).to(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: categories
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# name :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_categories_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Category, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
# == 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
|
||||
@@ -1,26 +0,0 @@
|
||||
# == 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
|
||||
@@ -1,32 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: offlines
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# completed :boolean
|
||||
# end_time :datetime
|
||||
# missing_pieces :integer
|
||||
# name :string not null
|
||||
# remaining_pieces :integer
|
||||
# start_time :datetime not null
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Offline, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
@@ -6,6 +6,7 @@
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
|
||||
@@ -67,4 +67,6 @@ RSpec.configure do |config|
|
||||
config.filter_rails_from_backtrace!
|
||||
# arbitrary gems may also be filtered via:
|
||||
# config.filter_gems_from_backtrace("gem name")
|
||||
|
||||
config.include SoManyDevices::DownloadsHelper, type: :system
|
||||
end
|
||||
|
||||
40
spec/system/export_spec.rb
Normal file
40
spec/system/export_spec.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
=begin Keeping this commented until resolving the ChromeDriver Gitea runner issue.
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe "Exports", type: :system do
|
||||
let!(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
driven_by :selenium_chrome_with_download_headless
|
||||
end
|
||||
|
||||
after do
|
||||
clear_downloads
|
||||
end
|
||||
|
||||
context "when in a contest with at least one contestant" do
|
||||
let!(:contest) { create(:contest, user: user) }
|
||||
let!(:first_contestant) { create(:contestant, contest: contest) }
|
||||
let!(:second_contestant) { create(:contestant, contest: contest) }
|
||||
|
||||
it "should be possible to export the list of contestants", :with_downloads do
|
||||
login(user)
|
||||
|
||||
sleep 0.5
|
||||
|
||||
visit contest_contestants_path(contest)
|
||||
|
||||
click_link I18n.t("helpers.buttons.export")
|
||||
|
||||
wait_for_download
|
||||
|
||||
expect(downloads.length).to eq(1)
|
||||
expect(last_download).to include("#{contest.friendly_id}_results.csv")
|
||||
|
||||
results_csv = File.read(last_download)
|
||||
expect(results_csv).to include(first_contestant.name)
|
||||
expect(results_csv).to include(second_contestant.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
=end
|
||||
Reference in New Issue
Block a user