Mattias Bodlund 1 month ago
parent
commit
899566a0b1
26 changed files with 341 additions and 296 deletions
  1. +68
    -76
      Gemfile.lock
  2. +1
    -2
      app/assets/stylesheets/application.css
  3. +1
    -1
      app/controllers/admin/nodes_controller.rb
  4. +1
    -6
      app/controllers/application_controller.rb
  5. +1
    -1
      app/controllers/concerns/quiz_helper_methods.rb
  6. +13
    -1
      app/controllers/players_controller.rb
  7. +84
    -14
      app/controllers/stages_controller.rb
  8. +0
    -23
      app/javascript/image_controller.js
  9. +0
    -117
      app/javascript/quiz_preloader.js
  10. +14
    -3
      app/models/node.rb
  11. +20
    -13
      app/models/player.rb
  12. +13
    -14
      app/models/user.rb
  13. +1
    -5
      app/views/layouts/application.html.erb
  14. +1
    -1
      app/views/stages/choice.html.erb
  15. +18
    -0
      app/views/stages/game_over.html.erb
  16. +25
    -0
      app/views/stages/result.html.erb
  17. +0
    -2
      config/importmap.rb
  18. +6
    -0
      config/locales/en.yml
  19. +10
    -2
      config/routes.rb
  20. +2
    -0
      db/migrate/20250527114853_create_players.rb
  21. +0
    -9
      db/migrate/20260323110134_add_missing_columns_to_players.rb
  22. +5
    -6
      db/schema.rb
  23. +25
    -0
      test/models/node_whitespace_test.rb
  24. +17
    -0
      vendor/javascript/@orchidjs--sifter.js
  25. +11
    -0
      vendor/javascript/@orchidjs--unicode-variants.js
  26. +4
    -0
      vendor/javascript/tom-select.js

+ 68
- 76
Gemfile.lock View File

@ -3,29 +3,29 @@ GEM
specs:
action_text-trix (2.1.17)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.2.1)
actionpack (= 8.1.2.1)
activesupport (= 8.1.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.2.1)
actionpack (= 8.1.2.1)
activejob (= 8.1.2.1)
activerecord (= 8.1.2.1)
activestorage (= 8.1.2.1)
activesupport (= 8.1.2.1)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.2.1)
actionpack (= 8.1.2.1)
actionview (= 8.1.2.1)
activejob (= 8.1.2.1)
activesupport (= 8.1.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.2.1)
actionview (= 8.1.2.1)
activesupport (= 8.1.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.2.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.2.1)
activerecord (= 8.1.2.1)
activestorage (= 8.1.2.1)
activesupport (= 8.1.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.2.1)
activesupport (= 8.1.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.2.1)
activesupport (= 8.1.2.1)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.2.1)
activesupport (= 8.1.2.1)
activerecord (8.1.2.1)
activemodel (= 8.1.2.1)
activesupport (= 8.1.2.1)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.2.1)
actionpack (= 8.1.2.1)
activejob (= 8.1.2.1)
activerecord (= 8.1.2.1)
activesupport (= 8.1.2.1)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@ -138,9 +138,6 @@ GEM
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.19.2)
json-schema (6.2.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -168,8 +165,6 @@ GEM
net-pop
net-smtp
marcel (1.1.0)
mcp (0.9.0)
json-schema (>= 4.1)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
@ -252,20 +247,20 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
rails (8.1.2.1)
actioncable (= 8.1.2.1)
actionmailbox (= 8.1.2.1)
actionmailer (= 8.1.2.1)
actionpack (= 8.1.2.1)
actiontext (= 8.1.2.1)
actionview (= 8.1.2.1)
activejob (= 8.1.2.1)
activemodel (= 8.1.2.1)
activerecord (= 8.1.2.1)
activestorage (= 8.1.2.1)
activesupport (= 8.1.2.1)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@ -273,9 +268,9 @@ GEM
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.2.1)
actionpack (= 8.1.2.1)
activesupport (= 8.1.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@ -297,11 +292,10 @@ GEM
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
rubocop (1.85.1)
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
mcp (~> 0.6)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
@ -411,17 +405,17 @@ DEPENDENCIES
CHECKSUMS
action_text-trix (2.1.17) sha256=b44691639d77e67169dc054ceacd1edc04d44dc3e4c6a427aa155a2beb4cc951
actioncable (8.1.2) sha256=dc31efc34cca9cdefc5c691ddb8b4b214c0ea5cd1372108cbc1377767fb91969
actionmailbox (8.1.2) sha256=058b2fb1980e5d5a894f675475fcfa45c62631103d5a2596d9610ec81581889b
actionmailer (8.1.2) sha256=f4c1d2060f653bfe908aa7fdc5a61c0e5279670de992146582f2e36f8b9175e9
actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423
actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d
actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b
activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825
activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e
activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44
activestorage (8.1.2) sha256=8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76
activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae
actioncable (8.1.2.1) sha256=a2f88cecce148b3fcb63d2e517d7694e119830a85baa7d6cf59e5453dcf32e8d
actionmailbox (8.1.2.1) sha256=c2e45c0c1e5687e35e050838c94a8aed0d954c56a32ea411d54cd848c338c54e
actionmailer (8.1.2.1) sha256=d7d62fbc2197f1a7006bb5af4c665edf999adf79ab6c10337c088d27e6622071
actionpack (8.1.2.1) sha256=a6b69cd10ec4c8d978c8eee51206e34152b1c1be017e534236dbc89a3d00ffb8
actiontext (8.1.2.1) sha256=1e503ce600a6ab2e12a46f999959a7d8e2fdaff910ca01dcf3b968934b55d957
actionview (8.1.2.1) sha256=38daa7b87bca427e2967f139e5b7f0d1081271bdafd0e015d8ef97a006f570a6
activejob (8.1.2.1) sha256=c89c311d07fd358b76c581ed8fee87c5b4351fb44994f3389385c014d22182fe
activemodel (8.1.2.1) sha256=8f31a6f9c12fecb8e5a0fce8a8950cfd94f0d75829322935f99e8217a3e5f3c6
activerecord (8.1.2.1) sha256=3f79140318ff6d23376f5d9b1b5b5e2c7d3cc8979dd71367e9a8394378ca630a
activestorage (8.1.2.1) sha256=36794c9b8853ac9276b0386cb1f8973374d8e71e8a9666bb02e70f5b7c9c5391
activesupport (8.1.2.1) sha256=beec20ced12ad569194554399449a6372fdab03061b8f48a9ed6ef9b7dc251b2
acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
ancestry (5.1.0) sha256=8a073cf6f7e306eeed36af72595abd19602ef4a197bf4beda2f31cf8f55de27b
@ -461,7 +455,6 @@ CHECKSUMS
irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42
json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf
json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666
kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e
kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909
kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430
@ -473,7 +466,6 @@ CHECKSUMS
loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
mcp (0.9.0) sha256=a0a3737b0ac9df0772f4ef7e2b013c260ddbcf217a5d50a66bff0baeddf03e47
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
@ -517,10 +509,10 @@ CHECKSUMS
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rails (8.1.2) sha256=5069061b23dfa8706b9f0159ae8b9d35727359103178a26962b868a680ba7d95
rails (8.1.2.1) sha256=93ebf1efc792c9bc47e9795259c920312d3920008dad3ae634b7a0457ffe0af8
rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89
railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
railties (8.1.2.1) sha256=f4d902869541af4e5b5552d726062fa59ec0fd9078f7ab87720dbd93f22c43ee
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
@ -529,7 +521,7 @@ CHECKSUMS
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77
rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2


+ 1
- 2
app/assets/stylesheets/application.css View File

@ -100,8 +100,7 @@
bottom: 0;
left: 0;
font: 0.75rem ui-monospace;
padding: 1em;
max-width: 40ch;
padding: 1em;
}
.turbo-progress-bar {


+ 1
- 1
app/controllers/admin/nodes_controller.rb View File

@ -33,7 +33,7 @@ class Admin::NodesController < Admin::AdminController
# POST /nodes or /nodes.json
def create
@node = Node.new(node_params)
@node.status = :status_draft
@node.status = :status_published
base_title = t("ui.untitled")
title = base_title


+ 1
- 6
app/controllers/application_controller.rb View File

@ -1,9 +1,6 @@
class ApplicationController < ActionController::Base
http_basic_authenticate_with name: "stupid", password: "studio" if Rails.env.production?
private
@ -13,8 +10,6 @@ private
def not_found
raise ActionController::RoutingError.new('Not Found')
raise ActionController::RoutingError.new("Not Found")
end
end

+ 1
- 1
app/controllers/concerns/quiz_helper_methods.rb View File

@ -14,7 +14,7 @@ private
def require_player!
unless player_present?
redirect_to url_for(controller: "languages", action: "index")
redirect_to url_for(controller: "languages", action: "index") and return
end
end


+ 13
- 1
app/controllers/players_controller.rb View File

@ -1,8 +1,9 @@
class PlayersController < ApplicationController
include QuizHelperMethods
skip_before_action :require_player!
skip_before_action :require_player!, only: [ :create ]
# POST
def create
# reset_session
@ -11,4 +12,15 @@ class PlayersController < ApplicationController
redirect_to stage_path(id: 1)
end
# POST
def go_again
@player = Player.create(
locale: current_player.locale,
extra_lives_stage: current_player.extra_lives_stage
)
session[:player_id] = @player.id
redirect_to stage_path(id: 1)
end
end

+ 84
- 14
app/controllers/stages_controller.rb View File

@ -4,19 +4,29 @@ class StagesController < ApplicationController
before_action :set_locale
before_action :require_player!
before_action :set_stage
before_action :check_stage_access, expect: [ :game_over ]
before_action :set_stage, expect: [ :game_over ]
before_action :set_outcome, only: [ :reveal, :pick, :result ]
helper_method :stage_index
# GET
def show
end
# GET
def game_over
@node = Node.at_depth(1).game_over.viewable&.first
end
# POST
def flip
outcome = rand < 0.5 ? "chance" : "choice"
current_player.record_flip(params[:id], outcome)
current_player.record_flip(stage_index, outcome)
redirect_to url_for(action: "reveal")
end
@ -38,13 +48,13 @@ class StagesController < ApplicationController
@node = @stage.children.viewable.find_by(template: @outcome)
possible_answers = @node.children.viewable
if @outcome == "chance"
result = possible_answers.pluck(:template).sample
elsif @outcome == "choice"
result = possible_answers.find_by(id: params[:option])&.template
end
current_player.record_pick_result(params[:id], result) unless result.blank?
current_player.record_pick_result(stage_index, result) unless result.blank?
redirect_to action: "result"
end
@ -54,30 +64,90 @@ class StagesController < ApplicationController
def result
redirect_to root_path and return if @outcome.blank?
@result = current_player.progress[params[:id].to_s]["result"]
@result = current_player.progress[stage_index.to_s]["result"]
@node = @stage.children.viewable.find_by(template: @outcome)&.children&.viewable&.find_by(template: @result)
question_node = @stage.children.viewable.find_by(template: @outcome)
@node = question_node.children.viewable.find_by(template: @result)
current_player.update(is_done: true) if @node.game_over?
redirect_to root_path and return if @node.blank?
end
logger.info(current_player.inspect)
# POST
def next
if player_can_proceed?
current_player.update(current_stage: stage_index + 1)
redirect_to stage_path(id: current_player.current_stage)
else
redirect_to root_path
end
end
private
# POST
def bonus
@result = current_player.progress[stage_index.to_s]["result"]
redirect_to root_path unless @result == "bonus"
# Set extra lives stage
current_player.update(
extra_lives_stage: ((rand < 0.5) ? [ stage_index - 1, 0 ].max : stage_index),
is_done: true
)
def set_stage
@stage = Node.at_depth(1).viewable.ordered[params[:id].to_i - 1]
# Reset progress
# current_player.reset_when_game_over
if @stage.nil?
redirect_to root_path
redirect_to action: "game_over"
end
# POST
def get_extra_life
current_player.update(
extra_lives_stage: [ stage_index - 1, 0 ].max,
is_done: true
)
redirect_to action: "game_over"
end
private
def check_stage_access
if stage_index > current_player.current_stage
redirect_to root_path and return
end
end
def player_can_proceed?
result = current_player.progress[stage_index.to_s]&.dig("result")
return false if stage_index != current_player.current_stage
return true if %w[good best].include?(result)
return true if result == "bad" and current_player.extra_lives_stage >= stage_index
false
end
def set_stage
@stage = Node.at_depth(1).stage.viewable.ordered[stage_index - 1]
redirect_to root_path and return if @stage.nil?
end
def set_outcome
@outcome ||= current_player.progress[params[:id].to_s]["flip"]
@outcome ||= current_player.progress[stage_index.to_s]&.dig("flip")
end
def stage_index
@stage_index ||= params[:id].to_i
end
end

+ 0
- 23
app/javascript/image_controller.js View File

@ -1,23 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
//static targets = ["img"]
connect() {
if (this.element.complete && this.element.naturalHeight !== 0) {
this.element.classList.add('loaded')
} else {
this.element.addEventListener('load', function() {
this.classList.add('loaded')
})
}
}
disconnect() {
}
}

+ 0
- 117
app/javascript/quiz_preloader.js View File

@ -1,117 +0,0 @@
export default class QuizImagePreloader {
constructor() {
this.isPreloading = false
this.preloadedImages = new Map()
}
init() {
// Start preloading on initial page load
document.addEventListener('DOMContentLoaded', () => {
sessionStorage.removeItem('quiz_images_preloaded')
// this.startPreloadingFromHeader()
// console.info('startPreloadingFromHeader');
})
// Continue preloading on Turbo navigation
document.addEventListener('turbo:load', () => {
this.startPreloadingFromHeader()
console.info('startPreloadingFromHeader');
})
}
startPreloadingFromHeader() {
if (sessionStorage.getItem('quiz_images_preloaded') === 'true') {
return
}
// Skip if already preloading
if (this.isPreloading) {
return
}
const imageUrls = this.getImageUrlsFromHeader()
if (imageUrls.length > 0) {
this.preloadImages(imageUrls)
}
}
getImageUrlsFromHeader() {
const headerElement = document.querySelector('header[data-quiz-images]') ||
document.querySelector('[data-quiz-images]')
if (!headerElement) return []
try {
const urls = JSON.parse(headerElement.dataset.quizImages || '[]')
return urls.filter(url => url && url.trim())
} catch (e) {
console.warn('Failed to parse quiz image URLs from header:', e)
return []
}
}
preloadImages(imageUrls) {
this.isPreloading = true
let loadedCount = 0
const totalImages = imageUrls.length
imageUrls.forEach((url, index) => {
// Skip if already preloaded
if (this.preloadedImages.has(url)) {
loadedCount++
if (loadedCount === totalImages) {
this.onPreloadComplete()
}
return
}
const img = new Image()
img.onload = () => {
this.preloadedImages.set(url, img)
loadedCount++
if (loadedCount === totalImages) {
this.onPreloadComplete()
}
}
img.onerror = () => {
console.warn(`Failed to preload image: ${url}`)
loadedCount++
if (loadedCount === totalImages) {
this.onPreloadComplete()
}
}
// Stagger requests to avoid overwhelming server
setTimeout(() => {
img.src = url
}, index * 30)
})
}
onPreloadComplete() {
this.isPreloading = false
sessionStorage.setItem('quiz_images_preloaded', 'true')
console.info('PreloadComplete');
// Dispatch completion event for components that need to know
document.dispatchEvent(new CustomEvent('quiz:preload:complete', {
detail: { totalImages: this.preloadedImages.size }
}))
}
// Check if preloading is complete
isComplete() {
return sessionStorage.getItem('quiz_images_preloaded') === 'true'
}
}

+ 14
- 3
app/models/node.rb View File

@ -42,12 +42,12 @@ class Node < ApplicationRecord
case depth
when 1
[ :stage ]
[ :stage, :game_over ]
when 2
[ :choice, :chance, :bonus ]
[ :choice, :chance ]
when 3
if parent&.chance?
[ :good, :bad, :game_over ]
[ :good, :bad, :game_over, :bonus ]
elsif parent&.choice?
[ :best, :good, :bad ]
else
@ -66,6 +66,7 @@ class Node < ApplicationRecord
}
before_validation :remove_empty_tags
before_validation :normalize_translations
validates_presence_of :title
@ -161,4 +162,14 @@ private
self.send "#{k}=", self.send(k).reject { |v| v.blank? } unless self.send(k).blank?
end
end
def normalize_translations
%w[title slug].each do |attr|
value = read_attribute(attr)
if value.is_a?(Hash)
write_attribute(attr, value.transform_values { |v| v.is_a?(String) ? v.strip : v })
end
end
end
end

+ 20
- 13
app/models/player.rb View File

@ -1,7 +1,23 @@
class Player < ApplicationRecord
attribute :progress, :json, default: {}
attribute :current_stage, :integer, default: 1
attribute :score, :integer, default: 0
attribute :extra_lives_stage, :integer, default: 0
attribute :progress, :json, default: {}
attribute :is_done, :boolean, default: false
scope :playing, -> { where(is_done: false) }
scope :done, -> { where(is_done: true) }
def is_playing?
!self.is_done?
end
def reset_when_game_over
self.progress = {}
self.current_stage = 1
self.save
end
# stage_index: 1, 2, 3...
# outcome: 'chance' or 'choice'
@ -11,13 +27,7 @@ class Player < ApplicationRecord
save
end
# stage_index: 1, 2, 3...
# option_index: 1, 2, or 3
def record_option(stage_index, option_index)
self.progress[stage_index.to_s] ||= {}
self.progress[stage_index.to_s]["option"] = option_index
save
end
# stage_index: 1, 2, 3...
# result: 'good', 'bad', 'game_over'
@ -27,8 +37,5 @@ class Player < ApplicationRecord
save
end
def advance_to_next_stage!
self.current_stage += 1
save
end
end

+ 13
- 14
app/models/user.rb View File

@ -1,10 +1,9 @@
class User < ApplicationRecord
enum :role, { user: 'user', admin: 'admin' }, suffix: true
enum :role, { user: "user", admin: "admin" }, suffix: true
include PgSearch::Model
pg_search_scope :pg_search, against: [:lastname, :firstname, :email, :phone, :title],
using: {tsearch: {dictionary: "danish"}}
pg_search_scope :pg_search, against: [ :lastname, :firstname, :email, :phone, :title ],
using: { tsearch: { dictionary: "danish" } }
has_secure_password
has_many :verification_codes, dependent: :destroy
@ -16,7 +15,7 @@ class User < ApplicationRecord
validates_uniqueness_of :email
validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
normalizes :email, with: -> email { email.strip.downcase }
normalizes :email, with: ->(email) { email.strip.downcase }
validate :cant_change_admin, on: :update
@ -31,18 +30,18 @@ class User < ApplicationRecord
def su?
email == 'mattias@oncotype.dk'
email == "mattias@oncotype.dk"
end
def name
return email if lastname.blank? and firstname.blank?
[firstname, lastname].select{ |v| !v.blank? }.join(' ')
[ firstname, lastname ].select { |v| !v.blank? }.join(" ")
end
def initials
name.split(' ').map{ |s| s[0] }.join('').mb_chars.upcase
name.split(" ").map { |s| s[0] }.join("").mb_chars.upcase
end
@ -50,21 +49,21 @@ class User < ApplicationRecord
!self.enabled_at.nil?
end
protected
#Prevent the user admins from beeing changed
# Prevent the user admins from beeing changed
def cant_change_admin
user = self.class.find(self.id)
errors.add(:email, I18n.t(:you_cant_change_the_email_on_this_user, scope: 'users')) if user.su? and self.email != user.email
errors.add(:email, I18n.t(:you_cant_change_this_on_this_user, scope: 'users')) if user.su? and !self.admin_role? and self.role_changed?
errors.add(:email, I18n.t(:you_cant_disable_this_user, scope: 'users')) if user.su? and self.enabled_at.nil?
errors.add(:email, I18n.t(:you_cant_change_the_email_on_this_user, scope: "users")) if user.su? and self.email != user.email
errors.add(:email, I18n.t(:you_cant_change_this_on_this_user, scope: "users")) if user.su? and !self.admin_role? and self.role_changed?
errors.add(:email, I18n.t(:you_cant_disable_this_user, scope: "users")) if user.su? and self.enabled_at.nil?
end
# Prevents the super user admin to be removed"
def dont_destroy_admin
raise I18n.t(:cant_destroy_this_user, scope: 'users') if self.su?
raise I18n.t(:cant_destroy_this_user, scope: "users") if self.su?
end
end

+ 1
- 5
app/views/layouts/application.html.erb View File

@ -8,7 +8,6 @@
<meta name="theme-color" content="#F0B902">
<%= tag.meta name: 'description', content: content_for?(:meta_description) ? yield(:meta_description) : (@node.present?? @node.page_description : '') %>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@ -16,11 +15,8 @@
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" sizes="16x16 32x32">
<link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png">
<%= stylesheet_link_tag "reset", "application" %>
<%= stylesheet_link_tag "reset", "application" %>
</head>
<body>
<header>


+ 1
- 1
app/views/stages/choice.html.erb View File

@ -12,7 +12,7 @@
<%= attachment.body.html_safe %>
<% end %>
<div class="action-container">
<div class="action-container">
<% @node.children.viewable.ordered.each do |answer_option| %>
<%= button_to answer_option.title, url_for(action: 'pick', option: answer_option.id), method: :post %>
<% end %>


+ 18
- 0
app/views/stages/game_over.html.erb View File

@ -0,0 +1,18 @@
<%-
content_for :title, @node.page_title.blank? ? @node.title : @node.page_title
content_for :meta_description, @node.page_description
content_for :debug, current_player.inspect
%>
<article>
<% @node.attachments.each do |attachment| %>
<%= attachment.body.html_safe %>
<% end %>
<div class="action-container">
<%= button_to t("go_again"), go_again_path(), method: :post %>
</div>
</article>

+ 25
- 0
app/views/stages/result.html.erb View File

@ -12,4 +12,29 @@
<%= attachment.body.html_safe %>
<% end %>
<div class="action-container">
<% case @result %>
<% when "best", "good" %>
<%= button_to t("next_stage"), url_for(action: 'next'), method: :post %>
<% when "bad" %>
<% if current_player.extra_lives_stage >= stage_index %>
<%= button_to t("use_extra_life"), url_for(action: 'next'), method: :post %>
<% else %>
<% if stage_index > 1 %>
<%= button_to t("get_extra_life"), url_for(action: 'get_extra_life'), method: :post %>
<% else %>
<%= button_to t("go_again"), go_again_path(), method: :post %>
<% end %>
<% end %>
<% when "bonus" %>
<%= button_to t("bonus"), url_for(action: 'bonus'), method: :post %>
<% when "game_over" %>
<%= button_to t("go_again"), go_again_path(), method: :post %>
<% end %>
</div>
</article>

+ 0
- 2
config/importmap.rb View File

@ -15,6 +15,4 @@ pin "tom-select/dist/js/tom-select.base.min.js", to: "tom-select--dist--js--tom-
# site_helper
pin "application", preload: false
pin "locale_controller", preload: false
pin "image_controller", preload: false
pin "quiz_preloader", preload: false
pin "plausible_controller", preload: false

+ 6
- 0
config/locales/en.yml View File

@ -4,6 +4,11 @@ en:
client_name: IKEA Foundation
let_me_try: Let me try!
next_stage: Proceed to the next stage
bonus: Bonus
go_again: Go again
use_extra_life: Use extra life
get_extra_life: Get extra life
languages:
@ -205,6 +210,7 @@ en:
bad: Bad
game_over: Game Over
good: Good
bonus: Bonus
categories:
box: Box


+ 10
- 2
config/routes.rb View File

@ -50,18 +50,26 @@ Rails.application.routes.draw do
end
scope ":locale", constraints: { locale: /en|zh|hr|cs|da|nl|fi|fr|fr-CA|de|hu|it|ja|ko|nb|pl|pt|ro|sr|sk|sl|es|sv|uk/ } do
post "start", to: "players#create", as: "start"
post "start", to: "players#create", as: :start
post "go_again", to: "players#go_again", as: :go_again
scope "stages/:id", constraints: { id: /\d+/ } do
get "", to: "stages#show", as: "pan>stage"
get "", to: "stages#show", as: :stage
post "flip", to: "stages#flip"
get "reveal", to: "stages#reveal"
post "pick(/:option)", to: "stages#pick"
get "result", to: "stages#result"
post "next", to: "stages#next"
post "bonus", to: "stages#bonus"
post "get_extra_life", to: "stages#get_extra_life"
end
get "game_over", to: "stages#game_over"
get "", to: "languages#show"
# get "*url", to: "site#page", constraints: lambda { |req| req.path.exclude?("storage") }
end


+ 2
- 0
db/migrate/20250527114853_create_players.rb View File

@ -3,7 +3,9 @@ class CreatePlayers < ActiveRecord::Migration[8.1]
create_table :players do |t|
t.text :locale
t.integer :current_stage, index: true
t.integer :extra_lives_stage, index: true
t.jsonb :progress, default: {}
t.boolean :is_done, default: false, index: true
t.timestamps
end
end


+ 0
- 9
db/migrate/20260323110134_add_missing_columns_to_players.rb View File

@ -1,9 +0,0 @@
class AddMissingColumnsToPlayers < ActiveRecord::Migration[8.1]
def change
add_column :players, :name, :text
add_column :players, :score, :integer
add_column :players, :answer_cache, :integer, array: true, default: []
add_index :players, :answer_cache, using: :gin
add_index :players, :score
end
end

+ 5
- 6
db/schema.rb View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_23_110134) do
ActiveRecord::Schema[8.1].define(version: 2025_05_27_114853) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -115,17 +115,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_23_110134) do
end
create_table "players", force: :cascade do |t|
t.integer "answer_cache", default: [], array: true
t.datetime "created_at", null: false
t.integer "current_stage"
t.integer "extra_lives_stage"
t.boolean "is_done", default: false
t.text "locale"
t.text "name"
t.jsonb "progress", default: {}
t.integer "score"
t.datetime "updated_at", null: false
t.index ["answer_cache"], name: "index_players_on_answer_cache", using: :gin
t.index ["current_stage"], name: "index_players_on_current_stage"
t.index ["score"], name: "index_players_on_score"
t.index ["extra_lives_stage"], name: "index_players_on_extra_lives_stage"
t.index ["is_done"], name: "index_players_on_is_done"
end
create_table "quiz_results", force: :cascade do |t|


+ 25
- 0
test/models/node_whitespace_test.rb View File

@ -0,0 +1,25 @@
require "test_helper"
class NodeWhitespaceTest < ActiveSupport::TestCase
test "title and slug should strip whitespace" do
node = Node.new(
title_en: " English Title ",
slug_en: " english-slug ",
title_fr: " Titre Français ",
slug_fr: " titre-francais "
)
node.valid?
# These should be stripped after implemention
# assert_equal "English Title", node.title_en
# assert_equal "english-slug", node.slug_en
# assert_equal "Titre Français", node.title_fr
# assert_equal "titre-francais", node.slug_fr
# Current behavior (before implementation)
puts "Current title_en: '#{node.title_en}'"
puts "Current slug_en: '#{node.slug_en}'"
puts "Current title_fr: '#{node.title_fr}'"
puts "Current slug_fr: '#{node.slug_fr}'"
end
end

+ 17
- 0
vendor/javascript/@orchidjs--sifter.js View File

@ -0,0 +1,17 @@
// @orchidjs/sifter@1.1.0 downloaded from https://ga.jspm.io/npm:@orchidjs/sifter@1.1.0/dist/esm/sifter.js
import{scoreValue as t,iterate as e,cmp as r,propToArray as i,getAttrNesting as n,getAttr as s}from"./utils.js";import{escape_regex as o,getPattern as c}from"@orchidjs/unicode-variants";export{getPattern}from"@orchidjs/unicode-variants";import"./types.js";class Sifter{items;settings;constructor(t,e){this.items=t;this.settings=e||{diacritics:true}}tokenize(t,e,r){if(!t||!t.length)return[];const i=[];const n=t.split(/\s+/);var s;r&&(s=new RegExp("^("+Object.keys(r).map(o).join("|")+"):(.*)$"));n.forEach((t=>{let r;let n=null;let u=null;if(s&&(r=t.match(s))){n=r[1];t=r[2]}if(t.length>0){u=this.settings.diacritics?c(t)||null:o(t);u&&e&&(u="\\b"+u)}i.push({string:t,regex:u?new RegExp(u,"iu"):null,field:n})}));return i}
/**
* Returns a function to be used to score individual results.
*
* Good matches will have a higher score than poor matches.
* If an item is not a match, 0 will be returned by the function.
*
* @returns {T.ScoreFn}
*/
getScoreFunction(t,e){var r=this.prepareSearch(t,e);return this._getScoreFunction(r)}
/**
* @returns {T.ScoreFn}
*
*/_getScoreFunction(r){const i=r.tokens,n=i.length;if(!n)return function(){return 0};const s=r.options.fields,o=r.weights,c=s.length,u=r.getAttrFn;if(!c)return function(){return 1};const f=function(){return c===1?function(e,r){const i=s[0].field;return t(u(r,i),e,o[i]||1)}:function(r,i){var n=0;if(r.field){const e=u(i,r.field);!r.regex&&e?n+=1/c:n+=t(e,r,1)}else e(o,((e,s)=>{n+=t(u(i,s),r,e)}));return n/c}}();return n===1?function(t){return f(i[0],t)}:r.options.conjunction==="and"?function(t){var e,r=0;for(let n of i){e=f(n,t);if(e<=0)return 0;r+=e}return r/n}:function(t){var r=0;e(i,(e=>{r+=f(e,t)}));return r/n}}getSortFunction(t,e){var r=this.prepareSearch(t,e);return this._getSortFunction(r)}_getSortFunction(t){var e,i=[];const n=this,s=t.options,o=!t.query&&s.sort_empty?s.sort_empty:s.sort;if(typeof o=="function")return o.bind(this);const get_field=function(e,r){return e==="$score"?r.score:t.getAttrFn(n.items[r.id],e)};if(o)for(let e of o)(t.query||e.field!=="$score")&&i.push(e);if(t.query){e=true;for(let t of i)if(t.field==="$score"){e=false;break}e&&i.unshift({field:"$score",direction:"desc"})}else i=i.filter((t=>t.field!=="$score"));const c=i.length;return c?function(t,e){var n,s;for(let o of i){s=o.field;let i=o.direction==="desc"?-1:1;n=i*r(get_field(s,t),get_field(s,e));if(n)return n}return 0}:null}prepareSearch(t,e){const r={};var o=Object.assign({},e);i(o,"sort");i(o,"sort_empty");if(o.fields){i(o,"fields");const t=[];o.fields.forEach((e=>{typeof e=="string"&&(e={field:e,weight:1});t.push(e);r[e.field]="weight"in e?e.weight:1}));o.fields=t}return{options:o,query:t.toLowerCase().trim(),tokens:this.tokenize(t,o.respect_word_boundaries,r),total:0,items:[],weights:r,getAttrFn:o.nesting?n:s}}search(t,r){var i,n,s=this;n=this.prepareSearch(t,r);r=n.options;t=n.query;const o=r.score||s._getScoreFunction(n);t.length?e(s.items,((t,e)=>{i=o(t);(r.filter===false||i>0)&&n.items.push({score:i,id:e})})):e(s.items,((t,e)=>{n.items.push({score:1,id:e})}));const c=s._getSortFunction(n);c&&n.items.sort(c);n.total=n.items.length;typeof r.limit==="number"&&(n.items=n.items.slice(0,r.limit));return n}}export{Sifter,r as cmp,s as getAttr,n as getAttrNesting,e as iterate,i as propToArray,t as scoreValue};

+ 11
- 0
vendor/javascript/@orchidjs--unicode-variants.js View File

@ -0,0 +1,11 @@
// @orchidjs/unicode-variants@1.1.2 downloaded from https://ga.jspm.io/npm:@orchidjs/unicode-variants@1.1.2/dist/esm/index.js
import{setToPattern as t,escape_regex as e,arrayToPattern as s,sequencePattern as n}from"./regex.js";import{allSubstrings as r}from"./strings.js";const o=[[0,65535]];const l="[̀-ͯ·ʾʼ]";let a;let u;const h=3;const c={};const i={"/":"⁄∕",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"};for(let t in i){let e=i[t]||"";for(let s=0;s<e.length;s++){let n=e.substring(s,s+1);c[n]=t}}const d=new RegExp(Object.keys(c).join("|")+"|"+l,"gu");const initialize=t=>{a===void 0&&(a=generateMap(t||o))};const normalize=(t,e="NFKD")=>t.normalize(e);const asciifold=t=>Array.from(t).reduce((
/**
* @param {string} result
* @param {string} char
*/
(t,e)=>t+_asciifold(e)),"");const _asciifold=t=>{t=normalize(t).toLowerCase().replace(d,(/** @type {string} */t=>c[t]||""));return normalize(t,"NFC")};function*generator(t){for(const[e,s]of t)for(let t=e;t<=s;t++){let e=String.fromCharCode(t);let s=asciifold(e);s!=e.toLowerCase()&&(s.length>h||s.length!=0&&(yield{folded:s,composed:e,code_point:t}))}}const generateSets=s=>{const n={};const addMatching=(s,r)=>{
/** @type {Set<string>} */
const o=n[s]||new Set;const l=new RegExp("^"+t(o)+"$","iu");if(!r.match(l)){o.add(e(r));n[s]=o}};for(let t of generator(s)){addMatching(t.folded,t.folded);addMatching(t.folded,t.composed)}return n};const generateMap=n=>{const r=generateSets(n);const o={};let l=[];for(let s in r){let n=r[s];n&&(o[s]=t(n));s.length>1&&l.push(e(s))}l.sort(((t,e)=>e.length-t.length));const a=s(l);u=new RegExp("^"+a,"u");return o};const mapSequence=(t,e=1)=>{let s=0;t=t.map((t=>{a[t]&&(s+=t.length);return a[t]||t}));return s>=e?n(t):""};const substringsToPattern=(t,e=1)=>{e=Math.max(e,t.length-1);return s(r(t).map((t=>mapSequence(t,e))))};const sequencesToPattern=(t,e=true)=>{let r=t.length>1?1:0;return s(t.map((t=>{let s=[];const o=e?t.length():t.length()-1;for(let e=0;e<o;e++)s.push(substringsToPattern(t.substrs[e]||"",r));return n(s)})))};const inSequences=(t,e)=>{for(const s of e){if(s.start!=t.start||s.end!=t.end)continue;if(s.substrs.join("")!==t.substrs.join(""))continue;let e=t.parts;const filter=t=>{for(const s of e){if(s.start===t.start&&s.substr===t.substr)return false;if(t.length!=1&&s.length!=1){if(t.start<s.start&&t.end>s.start)return true;if(s.start<t.start&&s.end>t.start)return true}}return false};let n=s.parts.filter(filter);if(!(n.length>0))return true}return false};class Sequence{parts;substrs;start;end;constructor(){this.parts=[];this.substrs=[];this.start=0;this.end=0}add(t){if(t){this.parts.push(t);this.substrs.push(t.substr);this.start=Math.min(t.start,this.start);this.end=Math.max(t.end,this.end)}}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(t,e){let s=new Sequence;let n=JSON.parse(JSON.stringify(this.parts));let r=n.pop();for(const t of n)s.add(t);let o=e.substr.substring(0,t-r.start);let l=o.length;s.add({start:r.start,end:r.start+l,length:l,substr:o});return s}}const getPattern=t=>{initialize();t=asciifold(t);let e="";let s=[new Sequence];for(let n=0;n<t.length;n++){let r=t.substring(n);let o=r.match(u);const l=t.substring(n,n+1);const a=o?o[0]:null;let h=[];let c=new Set;for(const t of s){const e=t.last();if(!e||e.length==1||e.end<=n)if(a){const e=a.length;t.add({start:n,end:n+e,length:e,substr:a});c.add("1")}else{t.add({start:n,end:n+1,length:1,substr:l});c.add("2")}else if(a){let s=t.clone(n,e);const r=a.length;s.add({start:n,end:n+r,length:r,substr:a});h.push(s)}else c.add("3")}if(h.length>0){h=h.sort(((t,e)=>t.length()-e.length()));for(let t of h)inSequences(t,s)||s.push(t)}else if(n>0&&c.size==1&&!c.has("3")){e+=sequencesToPattern(s,false);let t=new Sequence;const n=s[0];n&&t.add(n.last());s=[t]}}e+=sequencesToPattern(s,true);return e};export{_asciifold,asciifold,o as code_points,e as escape_regex,generateMap,generateSets,generator,getPattern,initialize,mapSequence,normalize,substringsToPattern,a as unicode_map};

+ 4
- 0
vendor/javascript/tom-select.js View File

@ -0,0 +1,4 @@
// tom-select@2.4.3 downloaded from https://ga.jspm.io/npm:tom-select@2.4.3/dist/esm/tom-select.complete.js
import i from"./tom-select.js";import o from"./plugins/change_listener/plugin.js";import n from"./plugins/checkbox_options/plugin.js";import r from"./plugins/clear_button/plugin.js";import e from"./plugins/drag_drop/plugin.js";import p from"./plugins/dropdown_header/plugin.js";import t from"./plugins/caret_position/plugin.js";import s from"./plugins/dropdown_input/plugin.js";import l from"./plugins/input_autogrow/plugin.js";import m from"./plugins/no_backspace_delete/plugin.js";import u from"./plugins/no_active_items/plugin.js";import g from"./plugins/optgroup_columns/plugin.js";import d from"./plugins/remove_button/plugin.js";import c from"./plugins/restore_on_backspace/plugin.js";import _ from"./plugins/virtual_scroll/plugin.js";import"./contrib/microevent.js";import"./contrib/microplugin.js";import"@orchidjs/sifter";import"@orchidjs/unicode-variants";import"./contrib/highlight.js";import"./vanilla.js";import"./utils.js";import"./constants.js";import"./getSettings.js";import"./defaults.js";i.define("change_listener",o);i.define("checkbox_options",n);i.define("clear_button",r);i.define("drag_drop",e);i.define("dropdown_header",p);i.define("caret_position",t);i.define("dropdown_input",s);i.define("input_autogrow",l);i.define("no_backspace_delete",m);i.define("no_active_items",u);i.define("optgroup_columns",g);i.define("remove_button",d);i.define("restore_on_backspace",c);i.define("virtual_scroll",_);export{i as default};

Loading…
Cancel
Save