diff --git a/.browserslistrc b/.browserslistrc
deleted file mode 100644
index e94f814..0000000
--- a/.browserslistrc
+++ /dev/null
@@ -1 +0,0 @@
-defaults
diff --git a/.env.core.sample b/.env.core.sample
index 7f5dd74..7150220 100644
--- a/.env.core.sample
+++ b/.env.core.sample
@@ -1,10 +1,13 @@
RAILS_ENV=development
RAILS_LOG_TO_STDOUT=1
WJR_HOST=localhost:3000
+WJR_CORE_URL=http://localhost:3000
+WJR_GOOGLE_MAPS_API_KEY=
+WJR_FLICKR_API_KEY=
WJR_PASSWORD_SALT=
WJR_SECRET_TOKEN=secret-token
WJR_SECRET_BASE=secret-base
-WJR_DEVISE_SECRET_KEY=a3ce9067696ac6eb83a065b0298ec025a97af52d84872e84c9e9e622991fd89f5a2d4b9d20d2e391858ae07240d0c4600c3900a15158e7b90ddae4c20e68d92dm
+WJR_DEVISE_SECRET_KEY=generate-with-bin-rails-secret-and-keep-out-of-git
WJR_FACEBOOK_APP_ID=
WJR_FACEBOOK_APP_SECRET=
WJR_GOOGLE_APP_ID=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c7d391c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,176 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+env:
+ IMAGE_NAME: core
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ services:
+ db:
+ image: mariadb:10.11
+ env:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: whyjustrun-test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="healthcheck.sh --connect --innodb_initialized"
+ --health-interval=5s
+ --health-timeout=5s
+ --health-retries=10
+
+ env:
+ RAILS_ENV: test
+ WJR_DATABASE_NAME: whyjustrun
+ WJR_DATABASE_TEST_NAME: whyjustrun-test
+ WJR_DATABASE_HOST: 127.0.0.1
+ WJR_DATABASE_USERNAME: root
+ WJR_DATABASE_PASSWORD: root
+ WJR_PASSWORD_SALT: test-only-salt
+ WJR_SECRET_BASE: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ WJR_SECRET_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ WJR_DEVISE_SECRET_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ WJR_RECAPTCHA_SITE_KEY: test
+ WJR_RECAPTCHA_SECRET_KEY: test
+ WJR_SMTP_ADDRESS: localhost
+ WJR_SMTP_DOMAIN: example.com
+ WJR_SMTP_USER_NAME: test
+ WJR_SMTP_PASSWORD: test
+ WJR_DATA_FOLDER: /tmp/whyjustrun-data/
+ WJR_DATA_URL: http://localhost/data/
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install MySQL client headers
+ run: sudo apt-get update && sudo apt-get install -y default-libmysqlclient-dev imagemagick ghostscript
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Prepare test database
+ run: bundle exec rails db:test:prepare
+
+ - name: Run tests
+ run: bundle exec rails test
+
+ system-test:
+ runs-on: ubuntu-latest
+
+ services:
+ db:
+ image: mariadb:10.11
+ env:
+ MARIADB_ROOT_PASSWORD: root
+ MARIADB_DATABASE: whyjustrun-test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="healthcheck.sh --connect --innodb_initialized"
+ --health-interval=5s
+ --health-timeout=5s
+ --health-retries=10
+
+ env:
+ RAILS_ENV: test
+ WJR_DATABASE_NAME: whyjustrun
+ WJR_DATABASE_TEST_NAME: whyjustrun-test
+ WJR_DATABASE_HOST: 127.0.0.1
+ WJR_DATABASE_USERNAME: root
+ WJR_DATABASE_PASSWORD: root
+ WJR_PASSWORD_SALT: test-only-salt
+ WJR_SECRET_BASE: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ WJR_SECRET_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ WJR_DEVISE_SECRET_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
+ WJR_RECAPTCHA_SITE_KEY: test
+ WJR_RECAPTCHA_SECRET_KEY: test
+ WJR_SMTP_ADDRESS: localhost
+ WJR_SMTP_DOMAIN: example.com
+ WJR_SMTP_USER_NAME: test
+ WJR_SMTP_PASSWORD: test
+ WJR_DATA_FOLDER: /tmp/whyjustrun-data/
+ WJR_DATA_URL: http://localhost/data/
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install MySQL client headers
+ run: sudo apt-get update && sudo apt-get install -y default-libmysqlclient-dev imagemagick ghostscript
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Prepare test database
+ run: bundle exec rails db:test:prepare
+
+ - name: Run system tests
+ run: bundle exec rails test:system
+
+ - name: Upload failure screenshots and pages
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: system-test-failures
+ path: tmp/screenshots/
+ if-no-files-found: ignore
+
+ brakeman:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install MySQL client headers
+ run: sudo apt-get update && sudo apt-get install -y default-libmysqlclient-dev
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Run Brakeman
+ run: bundle exec brakeman --no-pager
+
+ push:
+ runs-on: ubuntu-latest
+ needs: [test, system-test, brakeman]
+ if: github.event_name == 'push'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build image
+ run: docker build . --file Dockerfile --tag $IMAGE_NAME
+
+ - name: Log into GitHub Container Registry
+ run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ secrets.CR_USER }} --password-stdin
+
+ - name: Push image to GitHub Container Registry
+ run: |
+ IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
+
+ # Change all uppercase to lowercase
+ IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
+
+ # Strip git ref prefix from version
+ VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
+
+ # Strip "v" prefix from tag name
+ [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
+
+ # Use Docker `latest` tag convention
+ [ "$VERSION" == "master" ] && VERSION=latest
+
+ echo IMAGE_ID=$IMAGE_ID
+ echo VERSION=$VERSION
+
+ docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
+ docker push $IMAGE_ID:$VERSION
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
deleted file mode 100644
index 145e72a..0000000
--- a/.github/workflows/docker-publish.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Docker
-
-on:
- push:
- # Publish `master` as Docker `latest` image.
- branches:
- - master
-
- # Run tests for any PRs.
- pull_request:
-
-env:
- IMAGE_NAME: core
-
-jobs:
- # Push image to GitHub Packages.
- # See also https://docs.docker.com/docker-hub/builds/
- push:
- runs-on: ubuntu-latest
- if: github.event_name == 'push'
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Build image
- run: docker build . --file Dockerfile --tag $IMAGE_NAME
-
- - name: Log into GitHub Container Registry
- run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ secrets.CR_USER }} --password-stdin
-
- - name: Push image to GitHub Container Registry
- run: |
- IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
-
- # Change all uppercase to lowercase
- IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
-
- # Strip git ref prefix from version
- VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
-
- # Strip "v" prefix from tag name
- [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
-
- # Use Docker `latest` tag convention
- [ "$VERSION" == "master" ] && VERSION=latest
-
- echo IMAGE_ID=$IMAGE_ID
- echo VERSION=$VERSION
-
- docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
- docker push $IMAGE_ID:$VERSION
diff --git a/.gitignore b/.gitignore
index fc83913..4ecabf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,8 +17,6 @@
/.env
/.env.core
-/public/packs
-/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
@@ -26,3 +24,4 @@ yarn-debug.log*
vendor/
public/assets/
+/public/redactor/
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..a0891f5
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.3.4
diff --git a/CLAUDE.md b/CLAUDE.md
index 9aebc42..86cd3d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-WhyJustRun Core is a Ruby on Rails application providing authentication, IOF XML APIs, and cross-club pages for [whyjustrun.ca](https://whyjustrun.ca). It works alongside the separate [WhyJustRun Clubsite](https://github.com/WhyJustRun/Clubsite) app.
+WhyJustRun Core is a Ruby on Rails application serving [whyjustrun.ca](https://whyjustrun.ca) (authentication, IOF XML APIs, cross-club pages) and every club's website. Club domains are resolved from the Host header into a `Clubsite::` controller namespace (`app/controllers/clubsite/base_controller.rb`); requests on club domains run in the club's timezone and club-scoped data is always accessed through `current_club` associations. The legacy [WhyJustRun Clubsite](https://github.com/WhyJustRun/Clubsite) (CakePHP) is being decommissioned; until cutover it still serves club domains in production against the same database, so cross-app SSO endpoints and permissive CORS must not be removed from master (they are removed on the `rails-cutover` and `post-cutover-cleanup` branches).
## Development Commands
@@ -44,5 +44,5 @@ Test users (password: "password"): admin@example.com, webmaster@example.com, exe
- **Cross-app sessions**: Shared authentication with the Clubsite app via `CrossAppSession` model
- **Privilege system**: Role-based access with numeric levels (0-100) defined in `config/settings.yml`
- **Cron jobs**: Managed by the `whenever` gem
-- **Frontend**: Bootstrap with CoffeeScript, Webpacker, jQuery, Leaflet for maps
+- **Frontend**: Bootstrap with CoffeeScript, Sprockets asset pipeline, jQuery, Leaflet for maps
- **Testing**: Minitest with fixtures in `test/fixtures/`
diff --git a/Dockerfile b/Dockerfile
index 41747c6..c549ea3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,8 +5,11 @@ RUN apk add --no-cache \
build-base \
autoconf \
nodejs \
- yarn \
mariadb-dev \
+ imagemagick \
+ ghostscript \
+ chromium \
+ chromium-chromedriver \
bash # used in crontab generated by whenever
RUN mkdir /application
@@ -26,12 +29,8 @@ CMD ["rails", "server", "-b", "0.0.0.0"]
FROM dev AS prod
-COPY package.json yarn.lock /application/
-RUN yarn install --frozen-lockfile
-
COPY . /application
RUN RAILS_ENV=production \
- NODE_OPTIONS=--openssl-legacy-provider \
SECRET_KEY_BASE=placeholder \
WJR_DATABASE_NAME=placeholder \
WJR_DATABASE_TEST_NAME=placeholder \
diff --git a/Gemfile b/Gemfile
index 11972f1..5516700 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,7 +1,5 @@
source 'http://rubygems.org'
-gem 'bundle'
-
gem 'rails', '~> 7.1'
gem 'mysql2', '~> 0.5.3'
@@ -9,14 +7,12 @@ gem 'mysql2', '~> 0.5.3'
gem 'puma', '~> 6.4'
# MySQL Session store
-gem "activerecord-session_store", "~> 2.1.0"
# Asset pipeline
gem 'sass-rails', '~> 6.0.0'
gem "coffee-script", "~> 2.4.1"
gem "coffee-rails", "~> 5.0.0"
gem "uglifier", "~> 4.2.0"
-gem "webpacker", ">= 5.4.4"
gem 'tzinfo-data'
# Authentication
@@ -56,5 +52,17 @@ gem "exception_notification", "~> 4.5"
# Authorization
gem "pundit", "~> 2.3.2"
+# Image thumbnail generation (requires ImageMagick)
+gem 'image_processing', '~> 1.13'
+
# Geo
gem 'haversine', '~> 0.3.2'
+
+group :development, :test do
+ gem 'brakeman', require: false
+end
+
+group :test do
+ gem 'capybara'
+ gem 'selenium-webdriver'
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index efda3d5..7fda6fc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -56,13 +56,6 @@ GEM
activemodel (= 7.2.3.1)
activesupport (= 7.2.3.1)
timeout (>= 0.4.0)
- activerecord-session_store (2.1.0)
- actionpack (>= 6.1)
- activerecord (>= 6.1)
- cgi (>= 0.3.6)
- multi_json (~> 1.11, >= 1.11.2)
- rack (>= 2.0.8, < 4)
- railties (>= 6.1)
activestorage (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
@@ -81,6 +74,8 @@ GEM
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
autoprefixer-rails (10.4.19.0)
execjs (~> 2)
base64 (0.3.0)
@@ -90,9 +85,18 @@ GEM
bootstrap-sass (3.4.1)
autoprefixer-rails (>= 5.2.1)
sassc (>= 2.0.0)
+ brakeman (8.0.5)
+ racc
builder (3.3.0)
- bundle (0.0.1)
- bundler
+ capybara (3.40.0)
+ addressable
+ matrix
+ mini_mime (>= 0.1.3)
+ nokogiri (~> 1.11)
+ rack (>= 1.6.0)
+ rack-test (>= 0.6.3)
+ regexp_parser (>= 1.5, < 3.0)
+ xpath (~> 3.2)
cgi (0.4.2)
chronic (0.10.2)
coffee-rails (5.0.0)
@@ -141,6 +145,9 @@ GEM
logger
ostruct
ice_cube (0.17.0)
+ image_processing (1.14.0)
+ mini_magick (>= 4.9.5, < 6)
+ ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
@@ -167,10 +174,12 @@ GEM
net-pop
net-smtp
marcel (1.1.0)
+ matrix (0.4.3)
+ mini_magick (5.3.2)
+ logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.27.0)
- multi_json (1.15.0)
mysql2 (0.5.6)
net-imap (0.4.20)
date
@@ -198,14 +207,13 @@ GEM
psych (5.3.1)
date
stringio
+ public_suffix (7.0.5)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.2)
activesupport (>= 3.0.0)
racc (1.8.1)
rack (3.2.6)
- rack-proxy (0.7.7)
- rack
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -250,11 +258,17 @@ GEM
psych (>= 4.0.0)
tsort
recaptcha (5.17.0)
+ regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
+ rexml (3.4.4)
+ ruby-vips (2.3.0)
+ ffi (~> 1.12)
+ logger
+ rubyzip (3.4.1)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.4.0)
@@ -266,7 +280,12 @@ GEM
sprockets-rails
tilt
securerandom (0.4.1)
- semantic_range (3.0.0)
+ selenium-webdriver (4.46.0)
+ base64 (~> 0.2)
+ logger (~> 1.4)
+ rexml (~> 3.2, >= 3.2.5)
+ rubyzip (>= 1.2.2, < 4.0)
+ websocket (~> 1.0)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
@@ -288,16 +307,14 @@ GEM
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
- webpacker (5.4.4)
- activesupport (>= 5.2)
- rack-proxy (>= 0.6.1)
- railties (>= 5.2)
- semantic_range (>= 2.3.0)
+ websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.0.0)
chronic (>= 0.6.3)
+ xpath (3.2.0)
+ nokogiri (~> 1.8)
zeitwerk (2.7.5)
PLATFORMS
@@ -305,10 +322,10 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
- activerecord-session_store (~> 2.1.0)
bcrypt (~> 3.1.22)
bootstrap-sass (~> 3.4.1)
- bundle
+ brakeman
+ capybara
coffee-rails (~> 5.0.0)
coffee-script (~> 2.4.1)
config (~> 5.5.1)
@@ -318,6 +335,7 @@ DEPENDENCIES
geocoder (~> 1.8.3)
haversine (~> 0.3.2)
icalendar (~> 2.12.2)
+ image_processing (~> 1.13)
jbuilder (~> 2.12.0)
jquery-rails (~> 4.6.0)
leaflet-rails (~> 1.9.3)
@@ -329,9 +347,9 @@ DEPENDENCIES
rails (~> 7.1)
recaptcha (~> 5.17.0)
sass-rails (~> 6.0.0)
+ selenium-webdriver
tzinfo-data
uglifier (~> 4.2.0)
- webpacker (>= 5.4.4)
whenever (~> 1.0.0)
BUNDLED WITH
diff --git a/app/assets/images/clubsite/bootstrap-colorpicker/alpha-horizontal.png b/app/assets/images/clubsite/bootstrap-colorpicker/alpha-horizontal.png
new file mode 100755
index 0000000..d0a65c0
Binary files /dev/null and b/app/assets/images/clubsite/bootstrap-colorpicker/alpha-horizontal.png differ
diff --git a/app/assets/images/clubsite/bootstrap-colorpicker/alpha.png b/app/assets/images/clubsite/bootstrap-colorpicker/alpha.png
new file mode 100755
index 0000000..38043f1
Binary files /dev/null and b/app/assets/images/clubsite/bootstrap-colorpicker/alpha.png differ
diff --git a/app/assets/images/clubsite/bootstrap-colorpicker/hue-horizontal.png b/app/assets/images/clubsite/bootstrap-colorpicker/hue-horizontal.png
new file mode 100755
index 0000000..a0d9add
Binary files /dev/null and b/app/assets/images/clubsite/bootstrap-colorpicker/hue-horizontal.png differ
diff --git a/app/assets/images/clubsite/bootstrap-colorpicker/hue.png b/app/assets/images/clubsite/bootstrap-colorpicker/hue.png
new file mode 100755
index 0000000..d89560e
Binary files /dev/null and b/app/assets/images/clubsite/bootstrap-colorpicker/hue.png differ
diff --git a/app/assets/images/clubsite/bootstrap-colorpicker/saturation.png b/app/assets/images/clubsite/bootstrap-colorpicker/saturation.png
new file mode 100755
index 0000000..594ae50
Binary files /dev/null and b/app/assets/images/clubsite/bootstrap-colorpicker/saturation.png differ
diff --git a/app/assets/images/clubsite/fancybox/blank.gif b/app/assets/images/clubsite/fancybox/blank.gif
new file mode 100644
index 0000000..35d42e8
Binary files /dev/null and b/app/assets/images/clubsite/fancybox/blank.gif differ
diff --git a/app/assets/images/clubsite/fancybox/fancybox_loading.gif b/app/assets/images/clubsite/fancybox/fancybox_loading.gif
new file mode 100644
index 0000000..0158617
Binary files /dev/null and b/app/assets/images/clubsite/fancybox/fancybox_loading.gif differ
diff --git a/app/assets/images/clubsite/fancybox/fancybox_overlay.png b/app/assets/images/clubsite/fancybox/fancybox_overlay.png
new file mode 100644
index 0000000..a439139
Binary files /dev/null and b/app/assets/images/clubsite/fancybox/fancybox_overlay.png differ
diff --git a/app/assets/images/clubsite/fancybox/fancybox_sprite.png b/app/assets/images/clubsite/fancybox/fancybox_sprite.png
new file mode 100644
index 0000000..fd8d5ca
Binary files /dev/null and b/app/assets/images/clubsite/fancybox/fancybox_sprite.png differ
diff --git a/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.eot b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.eot
new file mode 100644
index 0000000..423bd5d
Binary files /dev/null and b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.eot differ
diff --git a/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.svg b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.svg
new file mode 100644
index 0000000..4469488
--- /dev/null
+++ b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.svg
@@ -0,0 +1,229 @@
+
+
+
\ No newline at end of file
diff --git a/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.ttf b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.ttf
new file mode 100644
index 0000000..a498ef4
Binary files /dev/null and b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.ttf differ
diff --git a/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.woff b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.woff
new file mode 100644
index 0000000..d83c539
Binary files /dev/null and b/app/assets/images/clubsite/fonts/glyphicons-halflings-regular.woff differ
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 8d8189c..97b91c5 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -7,5 +7,23 @@
//= require jquery
//= require jquery_ujs
//= require bootstrap
-//= require_tree .
+//= require bootstrap_and_overrides
+//= require clubs
+//= require content_blocks
+//= require courses
+//= require events
+//= require groups
+//= require home
+//= require map_standards
+//= require maps
+//= require memberships
+//= require organizers
+//= require pages
+//= require privileges
+//= require results
+//= require roles
+//= require series
+//= require tokens
+//= require users
+//= require whyjustrun
diff --git a/app/assets/javascripts/clubsite.js b/app/assets/javascripts/clubsite.js
new file mode 100644
index 0000000..8015cc2
--- /dev/null
+++ b/app/assets/javascripts/clubsite.js
@@ -0,0 +1,38 @@
+// Clubsite bundle: vendored libraries followed by the wjr modules in
+// dependency order. Loaded by the clubsite layouts only.
+//
+//= require clubsite/jquery
+//= require clubsite/underscore
+//= require clubsite/knockout
+//= require clubsite/moment
+//= require clubsite/bootstrap
+//= require clubsite/fullcalendar
+//= require clubsite/jquery.fancybox
+//= require clubsite/jquery.jeditable
+//= require clubsite/jquery.ketchup
+//= require clubsite/ketchup-bootstrap
+//= require clubsite/jquery.placeholder
+//= require clubsite/bootstrap-datetimepicker
+//= require clubsite/bootstrap-colorpicker
+//= require clubsite/bootstrap-typeahead
+//= require clubsite/spin
+//= require clubsite/ladda
+//= require clubsite/markerclusterer
+//= require clubsite/wjr/wjr
+//= require clubsite/wjr/club
+//= require clubsite/wjr/utils
+//= require clubsite/wjr/binding
+//= require clubsite/wjr/forms
+//= require clubsite/wjr/iof
+//= require clubsite/wjr/editable
+//= require clubsite/wjr/map
+//= require clubsite/wjr/calendar
+//= require clubsite/wjr/event-list
+//= require clubsite/wjr/result-list
+//= require clubsite/wjr/result-editor
+//= require clubsite/wjr/register-others
+//= require clubsite/wjr/edit-event-courses
+//= require clubsite/wjr/edit-event-organizers
+//= require clubsite/wjr/flickr-photos
+//= require clubsite/wjr/wysiwyg.redactor
+//= require clubsite/wjr/clubsite
diff --git a/app/assets/javascripts/clubsite/wjr/binding.js b/app/assets/javascripts/clubsite/wjr/binding.js
new file mode 100644
index 0000000..f1955db
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/binding.js
@@ -0,0 +1,28 @@
+/*jslint browser: true indent: 2*/
+
+// Handy extensions to the knockout data binding framework
+window.WJR = window.WJR || {};
+WJR.binding = (function ($, ko) {
+ 'use strict';
+ ko.bindingHandlers.tooltip = {
+ init: function (element, valueAccessor) {
+ var local = ko.utils.unwrapObservable(valueAccessor()),
+ options = { container: 'body' };
+
+ ko.utils.extend(options, ko.bindingHandlers.tooltip.options);
+ ko.utils.extend(options, local);
+
+ $(element).tooltip(options);
+
+ ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
+ $(element).tooltip("destroy");
+ });
+ },
+ options: {
+ placement: "right",
+ trigger: "click"
+ }
+ };
+
+ return ko;
+}(jQuery, ko));
diff --git a/app/assets/javascripts/clubsite/wjr/calendar.js b/app/assets/javascripts/clubsite/wjr/calendar.js
new file mode 100644
index 0000000..e758a2b
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/calendar.js
@@ -0,0 +1,56 @@
+/*jslint browser: true indent: 2*/
+window.WJR = window.WJR || {};
+WJR.calendar = (function (wjr, club, $) {
+ 'use strict';
+ var Calendar = {};
+ Calendar.Initialize = function (element) {
+ element = $(element);
+ var initialLoad = true,
+ poppingState = false;
+
+ element.fullCalendar({
+ eventSources: [wjr.core.domain + '/club/' + club.id + '/events.json?external_significant_events=all&prefix_club_acronym=external_only'],
+ year: element.attr('data-calendar-year'),
+ month: element.attr('data-calendar-month'),
+ date: element.attr('data-calendar-day'),
+ timeFormat: 'h:mmtt',
+ firstDay: 1,
+ // From google code discussion
+ viewDisplay : function (view) {
+ var url, options, date;
+ date = $.fullCalendar.formatDate(view.start, 'dd-MM-yyyy');
+ url = "/events/index/" + date;
+ options = { viewMode: view.name, start: view.start };
+ // IE doesn't support replaceState..
+ if (window.history.replaceState) {
+ //I had to do a little bit of juggling to get it to only run items when necessary
+ //There might be a better way to do this but I couldn't find one
+ if (initialLoad) { //Replace the current state to set up state variables. URL should be identical
+ history.replaceState(options, "Event Calendar", url);
+ window.onpopstate = function (event) { //set up onpopstate handler
+ if (!initialLoad) { //the browser kept trying to pop the state on intial load
+ var start = event.state.start;
+ if (typeof start === 'string') { //even though i stored a date object, it was coming back as a string for some reason
+ start = $.fullCalendar.parseDate(start);
+ }
+ poppingState = true; //don't re-push state
+ $('#calendar').fullCalendar('gotoDate', start);
+ poppingState = true; //don't re-push state
+ $('#calendar').fullCalendar('changeView', event.state.viewMode);
+ }
+ initialLoad = false;
+ };
+ } else {
+ if (!poppingState) {
+ history.pushState(options, "Event Calendar", url);
+ } else {
+ poppingState = false;
+ }
+ }
+ }
+ }
+ });
+ };
+
+ return Calendar;
+}(WJR.wjr, WJR.club, jQuery));
diff --git a/app/assets/javascripts/clubsite/wjr/club.js b/app/assets/javascripts/clubsite/wjr/club.js
new file mode 100644
index 0000000..a543de5
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/club.js
@@ -0,0 +1,8 @@
+ /*jslint browser: true indent: 2*/
+
+ window.WJR = window.WJR || {};
+ WJR.club = (function ($) {
+ var club = {};
+ club.id = $('meta[name="wjr.clubsite.club.id"]').attr("content");
+ return club;
+ }(jQuery));
diff --git a/app/assets/javascripts/clubsite/wjr/clubsite.js b/app/assets/javascripts/clubsite/wjr/clubsite.js
new file mode 100644
index 0000000..d8dfadd
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/clubsite.js
@@ -0,0 +1,156 @@
+/*jslint browser: true, indent: 2, nomen: true*/
+
+window.WJR = window.WJR || {};
+WJR.clubsite = (function ($, _, moment, forms, editable) {
+ 'use strict';
+ var Clubsite = {};
+
+ Clubsite.initialize = function () {
+ // Components that we initialize conditionally if needed for the current page
+ var components = [
+ {
+ selector: 'a.lightbox',
+ load: function (lightboxes) {
+ lightboxes.fancybox();
+ }
+ }, {
+ selector: '.date-picker',
+ load: function (datePickers) {
+ datePickers.datetimepicker({
+ pickTime: false,
+ format: "YYYY-MM-DD"
+ });
+ }
+ }, {
+ selector: '.event-list',
+ module: WJR['event-list'],
+ loadEach: function (element, EventList) {
+ var eventList = new EventList();
+ eventList.initialize(element);
+ }
+ }, {
+ selector: '.result-list',
+ module: WJR['result-list'],
+ loadEach: function (element, ResultList) {
+ var resultList = new ResultList();
+ resultList.initialize(element);
+ }
+ }, {
+ selector: '.color-picker',
+ load: function (colorPickers) {
+ colorPickers.colorpicker();
+ }
+ }, {
+ selector: '.result-editor',
+ module: WJR['result-editor'],
+ loadEach: function (element, ResultEditor) {
+ var resultEditor = new ResultEditor();
+ resultEditor.initialize(element);
+ }
+ }, {
+ selector: '.register-others',
+ module: WJR['register-others'],
+ loadEach: function (element, RegisterOthers) {
+ var registerOthers = new RegisterOthers();
+ registerOthers.initialize(element);
+ }
+ }, {
+ selector: '.edit-event-courses',
+ module: WJR['edit-event-courses'],
+ loadEach: function (element, EventCoursesEditor) {
+ var editor = new EventCoursesEditor();
+ editor.initialize(element);
+ }
+ }, {
+ selector: '.edit-event-organizers',
+ module: WJR['edit-event-organizers'],
+ loadEach: function (element, EventOrganizersEditor) {
+ var editor = new EventOrganizersEditor();
+ editor.initialize(element);
+ }
+ }, {
+ selector: '.simple-marker-map',
+ module: WJR.map,
+ loadEach: function (element, map) {
+ var markerMap = new map.SimpleMarkerMap();
+ markerMap.initialize(element);
+ }
+ }, {
+ selector: '.draggable-marker-map',
+ module: WJR.map,
+ loadEach: function (element, map) {
+ var markerMap = new map.DraggableMarkerMap();
+ markerMap.initialize(element);
+ }
+ }, {
+ selector: '.multi-marker-map',
+ module: WJR.map,
+ loadEach: function (element, map) {
+ var markerMap = new map.MultiMarkerMap();
+ markerMap.initialize(element);
+ }
+ }, {
+ selector: '.simple-person-picker',
+ module: WJR.forms,
+ loadEach: function (element, forms) {
+ var personPicker = new forms.SimplePersonPicker();
+ personPicker.initialize(element);
+ }
+ }
+ ];
+
+ _.each(components, function (component) {
+ var results = $(component.selector);
+ if (results.length !== 0) {
+ if (component.load !== undefined) {
+ component.load(results, component.module);
+ } else {
+ results.each(function () {
+ component.loadEach(this, component.module);
+ });
+ }
+ }
+ });
+
+ $('time.timeago').each(function () {
+ var time = moment($(this).attr('datetime'));
+ $(this).html(time.fromNow());
+ });
+
+ $('input, textarea').placeholder();
+
+ forms.validation.checkKetchupFormsAreValidOnSubmit();
+
+ $('.wjr-wysiwyg').each(function () {
+ var element = this;
+ WJR.wysiwyg.createRichTextArea(element);
+ });
+
+ $("[data-toggle='tooltip']").tooltip();
+
+ $('.wjr-calendar').each(function () {
+ var element = this;
+ WJR.calendar.Initialize(element);
+ });
+
+ $('.flickr-photos-container').each(function () {
+ var element = this;
+ WJR['flickr-photos'].Initialize(element);
+ });
+
+ editable.initialize();
+
+ // Event editing form
+ // Fix for CakePHP form security - exclude the knockout inputs
+ $("#EventEditForm").submit(function () {
+ $(this).find('[name ^= "ko_unique"]').attr("name", null);
+ });
+ };
+
+ return Clubsite;
+}(jQuery, _, moment, WJR.forms, WJR.editable));
+
+jQuery(function () {
+ 'use strict';
+ WJR.clubsite.initialize();
+});
diff --git a/app/assets/javascripts/clubsite/wjr/edit-event-courses.js b/app/assets/javascripts/clubsite/wjr/edit-event-courses.js
new file mode 100644
index 0000000..ebbe185
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/edit-event-courses.js
@@ -0,0 +1,55 @@
+/*jslint nomen: true, browser: true indent: 2*/
+/*global confirm*/
+
+window.WJR = window.WJR || {};
+WJR['edit-event-courses'] = (function ($, _, ko) {
+ 'use strict';
+ var EventCoursesEditor = function () {
+ var viewModel, Course;
+
+ viewModel = {
+ courses: ko.observableArray([]),
+ addCourse: function (id, name, distance, climb, isScoreO, description) {
+ var course = new Course(id, name, distance, climb, isScoreO, description);
+ this.courses.push(course);
+ },
+ addNewCourse: function () {
+ this.courses.push(new Course(null, null, null, null, false, null));
+ }
+ };
+
+ Course = function (id, name, distance, climb, isScoreO, description) {
+ this.id = id;
+ this.name = ko.observable(name);
+ this.distance = ko.observable(distance);
+ this.climb = ko.observable(climb);
+ this.rankBy = ko.observable(isScoreO ? "points" : "time");
+ this.isScoreO = ko.computed(function () {
+ return (this.rankBy() === "points");
+ }, this);
+ this.description = ko.observable(description);
+ this.remove = function () {
+ // Only need to confirm for deletion if the course hasn't been created locally
+ if (this.id !== null) {
+ if (confirm("Are you sure you want to delete this course? This will also delete any results associated to the course.")) {
+ $.ajax('/courses/delete/' + this.id, { method: 'POST' });
+ viewModel.courses.remove(this);
+ }
+ } else {
+ viewModel.courses.remove(this);
+ }
+ };
+ };
+
+ this.initialize = function (element) {
+ var courseJson = $(element.getAttribute('data-course-json-element')).val();
+ _.each(JSON.parse(courseJson), function (originalCourse) {
+ viewModel.addCourse(originalCourse.id, originalCourse.name, originalCourse.distance, originalCourse.climb, originalCourse.isScoreO, originalCourse.description);
+ });
+
+ ko.applyBindings(viewModel, element);
+ };
+ };
+
+ return EventCoursesEditor;
+}(jQuery, _, ko));
diff --git a/app/assets/javascripts/clubsite/wjr/edit-event-organizers.js b/app/assets/javascripts/clubsite/wjr/edit-event-organizers.js
new file mode 100644
index 0000000..e0607ed
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/edit-event-organizers.js
@@ -0,0 +1,65 @@
+/*jslint nomen: true, browser: true indent: 2*/
+/*global confirm*/
+
+window.WJR = window.WJR || {};
+WJR['edit-event-organizers'] = (function ($, _, ko, forms) {
+ 'use strict';
+ var EventOrganizersEditor = function () {
+ var availableRoles = [], finishLoadingOrganizers,
+ roleById, viewModel, Organizer;
+
+ viewModel = {
+ organizers: ko.observableArray([]),
+ addOrganizer: function (id, name, roleId) {
+ this.organizers.push(new Organizer(id, name, roleId));
+ }
+ };
+
+ Organizer = function (id, name, roleId) {
+ this.id = id;
+ this.name = ko.observable(name);
+ this.availableRoles = ko.observableArray(availableRoles);
+ this.role = ko.observable(roleById(roleId));
+ this.remove = function () {
+ viewModel.organizers.remove(this);
+ };
+ };
+
+ this.initialize = function (element) {
+ $.getJSON('/roles/index.json', function (data) {
+ _.each(data, function (role) {
+ var availableRole = {};
+ availableRole.id = role.id;
+ availableRole.name = role.name;
+ availableRoles.push(availableRole);
+ });
+
+ finishLoadingOrganizers(element);
+ });
+ };
+
+ roleById = function (id) {
+ return _.find(availableRoles, function (role) {
+ return (role.id === id);
+ });
+ };
+
+ finishLoadingOrganizers = function (element) {
+ var organizerJson, organizerElement;
+ organizerElement = $(element.getAttribute('data-organizer-input'));
+ organizerJson = $(element.getAttribute('data-organizer-json-element')).val();
+ _.each(JSON.parse(organizerJson), function (originalOrganizer) {
+ viewModel.addOrganizer(originalOrganizer.id, originalOrganizer.name, originalOrganizer.role.id);
+ });
+
+ forms.personPicker(organizerElement, { maintainInput: false, allowFake: false }, function (person) {
+ if (person !== null) {
+ organizerElement.val(null);
+ viewModel.addOrganizer(person.id, person.name);
+ }
+ });
+ ko.applyBindings(viewModel, element);
+ };
+ };
+ return EventOrganizersEditor;
+}(jQuery, _, ko, WJR.forms));
diff --git a/app/assets/javascripts/clubsite/wjr/editable.js b/app/assets/javascripts/clubsite/wjr/editable.js
new file mode 100644
index 0000000..b68e210
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/editable.js
@@ -0,0 +1,76 @@
+/*jslint browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR.editable = (function ($) {
+ 'use strict';
+ var editable = {};
+ editable.initialize = function () {
+ var editableContent = $('.page-resource.wjr-editable, .page-resource-title.wjr-editable, .content-block.wjr-editable');
+ // Only load jeditable if we actually need to.
+ if (editableContent.length !== 0) {
+ (function () {
+ /*jslint unparam: true*/
+ $.editable.addInputType('wysiwyg', {
+ element: function (settings, original) {
+ var textarea = $('');
+ if (settings.rows) {
+ textarea.attr('rows', settings.rows);
+ } else {
+ textarea.height(settings.height);
+ }
+ if (settings.cols) {
+ textarea.attr('cols', settings.cols);
+ } else {
+ textarea.width(settings.width);
+ }
+ $(this).append(textarea);
+ return textarea;
+ },
+ submit: function (settings, original) {
+ WJR.wysiwyg.updateTextareas();
+ },
+ plugin: function (settings, original) {
+ // Hacktastic. This fixes an issue with the scroll position jumping on chrome when opening the textarea..
+ var textarea = $('textarea', this);
+ setTimeout(function () {
+ WJR.wysiwyg.createRichTextArea(textarea);
+ }, 1);
+ }
+ });
+ /*jslint unparam: false*/
+
+ // Allow link clicks to pass through without opening editor. NOTE: doesn't fix the case when the content is edited.. seems like it would be really hacky to get that working because jeditable doesn't have enough callback hooks.
+ editableContent.find('a').click(function (e) {
+ e.stopPropagation();
+ });
+
+ $('.content-block.wjr-editable').editable('/contentBlocks/edit', {
+ type : 'wysiwyg',
+ cancel : 'Cancel',
+ submit : 'Save',
+ tooltip : 'Click to edit…',
+ onblur : 'ignore'
+ });
+
+ $('.page-resource.wjr-editable').editable('/pages/edit', {
+ type : 'wysiwyg',
+ cancel : 'Cancel',
+ submit : 'Save',
+ tooltip : 'Click to edit…',
+ onblur : 'ignore'
+ });
+
+ $('.page-resource-title.wjr-editable').editable('/pages/edit', {
+ type : 'text',
+ cancel : 'Cancel',
+ submit : 'Save',
+ name : 'name',
+ tooltip : 'Click to edit…',
+ onblur : 'ignore'
+ });
+ }());
+ }
+ };
+
+ return editable;
+}(jQuery));
diff --git a/app/assets/javascripts/clubsite/wjr/event-list.js b/app/assets/javascripts/clubsite/wjr/event-list.js
new file mode 100644
index 0000000..3dbbaaf
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/event-list.js
@@ -0,0 +1,244 @@
+/*jslint nomen: true, browser: true, indent: 2*/
+/*global Ladda*/
+
+window.WJR = window.WJR || {};
+WJR['event-list'] = (function ($, moment, ko, club) {
+ 'use strict';
+ var EventList, SingleFetcher, TimeFetcher, IOF;
+ SingleFetcher = function (url, coloringStyle) {
+ this.fetch = function (options) {
+ $.ajax({
+ type: "GET",
+ url: url,
+ dataType: "xml",
+ cache: false,
+ success: function (xml) {
+ options.onSuccess(IOF.loadEventsList(xml, coloringStyle));
+ }
+ });
+ };
+ };
+
+ // A fetcher that fetches a time interval of events
+ TimeFetcher = function (url, coloringStyle) {
+ var startTime = null, endTime = null;
+
+ /**
+ * Params: options
+ * - direction ('newer', or 'older')
+ * - onSuccess (callback, params: newEvents, startTime, endTime, newer)
+ * - onComplete (callback, called regardless of success or failure)
+ */
+ this.fetch = function (options) {
+ var fetchingNewer,
+ fetchStartTime,
+ fetchEndTime;
+
+ if (startTime === null || endTime === null) {
+ // Initial fetch
+ fetchStartTime = moment.utc().subtract('months', 1);
+ fetchEndTime = moment.utc().add('months', 2);
+ } else if (options.direction === 'newer') {
+ fetchStartTime = endTime;
+ fetchEndTime = endTime.clone().add('months', 6);
+ } else if (options.direction === 'older') {
+ fetchStartTime = startTime.clone().subtract('months', 6);
+ fetchEndTime = startTime;
+ }
+
+ if (startTime === null || fetchStartTime.isBefore(startTime)) {
+ startTime = fetchStartTime;
+ }
+
+ fetchingNewer = false;
+ if (endTime === null || fetchEndTime.isAfter(endTime)) {
+ fetchingNewer = true;
+ endTime = fetchEndTime;
+ }
+
+ $.ajax({
+ type: 'GET',
+ url: url,
+ data: {
+ start: fetchStartTime.unix(),
+ end: fetchEndTime.unix()
+ },
+ dataType: 'xml',
+ cache: false,
+ success: function (xml) {
+ var events = IOF.loadEventsList(xml, coloringStyle);
+ options.onSuccess(events, startTime, endTime, fetchingNewer);
+ },
+ complete: function () {
+ if (options.onComplete !== undefined) {
+ options.onComplete();
+ }
+ }
+ });
+ };
+ };
+
+ EventList = function () {
+ var fetcher,
+ viewModel = {
+ startTime : ko.observable(),
+ endTime : ko.observable(),
+ events : ko.observableArray()
+ };
+
+ viewModel.formattedStartTime = ko.computed(function () {
+ var hasStartTime = (this.startTime() !== null && this.startTime() !== undefined);
+ return hasStartTime ? this.startTime().format('MMMM Do YYYY') : null;
+ }, viewModel);
+
+ viewModel.formattedEndTime = ko.computed(function () {
+ var hasEndTime = (this.endTime() !== null && this.endTime() !== undefined);
+ return hasEndTime ? this.endTime().format('MMMM Do YYYY') : null;
+ }, viewModel);
+
+ // Assumes the DOM is ready
+ this.initialize = function (element) {
+ var hasTimeWindow = false,
+ url,
+ olderButton,
+ newerButton,
+ successHandler,
+ coloringStyle = "default";
+ if (element.getAttribute("data-event-list-type") === 'time-window') {
+ hasTimeWindow = true;
+ }
+
+ if (element.hasAttribute("data-event-list-coloring")) {
+ coloringStyle = element.getAttribute("data-event-list-coloring");
+ }
+
+ url = element.getAttribute("data-event-list-url");
+ ko.applyBindings(viewModel, element);
+
+ if (hasTimeWindow) {
+ fetcher = new TimeFetcher(url, coloringStyle);
+ successHandler = function (newEvents, newStartTime, newEndTime, newer) {
+ var index = newer ? viewModel.events().length : 0;
+ // This adds the new events to the view model.
+ viewModel.events.splice.apply(viewModel.events, [index, 0].concat(newEvents));
+ viewModel.startTime(newStartTime);
+ viewModel.endTime(newEndTime);
+ };
+
+ // Fetch the initial events
+ fetcher.fetch({ onSuccess: successHandler });
+
+ (function (Ladda) {
+ // Set up fetching when the buttons are pressed
+ olderButton = $(element.getAttribute('data-event-list-older-button'));
+ olderButton.click(function () {
+ var ladda = Ladda.create(olderButton.get(0)).start();
+ fetcher.fetch({
+ direction: 'older',
+ onSuccess: successHandler,
+ onComplete: function () {
+ ladda.stop();
+ }
+ });
+ });
+
+ newerButton = $(element.getAttribute('data-event-list-newer-button'));
+ newerButton.click(function () {
+ var ladda = Ladda.create(newerButton.get(0)).start();
+ fetcher.fetch({
+ direction: 'newer',
+ onSuccess: successHandler,
+ onComplete: function () {
+ ladda.stop();
+ }
+ });
+ });
+ }(window.Ladda));
+ } else {
+ fetcher = new SingleFetcher(url, coloringStyle);
+ fetcher.fetch({
+ onSuccess: function (events) {
+ viewModel.events(events);
+ }
+ });
+ }
+ };
+ };
+
+ IOF = {};
+ IOF.Event = function (id, name, url, startTime, endTime, classification, series, club) {
+ var ymd = 'MMMM Do YYYY',
+ md = 'MMMM Do',
+ sep = ' - ';
+ this.id = id;
+ this.name = name;
+ this.startTime = startTime;
+ this.endTime = endTime;
+ this.classification = classification;
+ this.series = series;
+ this.url = url;
+ this.clubAcronym = club.acronym;
+ if (!startTime.isSame(endTime, 'day')) {
+ if (!startTime.isSame(endTime, 'month')) {
+ if (!startTime.isSame(endTime, 'year')) {
+ this.date = startTime.format(ymd) + sep + endTime.format(ymd);
+ } else {
+ this.date = startTime.format(md) + sep + endTime.format(ymd);
+ }
+ } else {
+ this.date = startTime.format(md) + sep + endTime.format('Do, YYYY');
+ }
+ } else {
+ this.date = startTime.format(ymd);
+ }
+ };
+
+ IOF.loadEventsList = function (xml, coloringStyle) {
+ var events = [];
+ /*jslint unparam: true*/
+ $(xml.documentElement).children("Event").each(function (index, element) {
+ var eventID = $(element).children("Id").text(),
+ eventName = $(element).children("Name").text(),
+ extensions = $(element).children("Extensions"),
+ series = extensions.children('Series'),
+ color,
+ startTimeDate = $(element).children("StartTime"),
+ startDate = startTimeDate.children("ISODate").text(),
+ endTimeDate = $(element).children("EndTime"),
+ endDate = endTimeDate.children("ISODate").text(),
+ url = $(element).children('URL').text(),
+ organizerElement = $(element).children('Organiser'),
+ clubAcronym = organizerElement.children('ShortName').text(),
+ clubId = organizerElement.children('Id').text(),
+ classification = $(element).children('Classification').text(),
+ event;
+ // Need to get dates to be of format: 2011-10-10T14:48:00.000z
+
+ if (coloringStyle === "club-only") {
+ if (clubId == club.id) {
+ color = series.children('Color').text() ? series.children('Color').text() : '#000000';
+ } else {
+ color = '#6d6d6d';
+ }
+ } else {
+ color = series.children('Color').text();
+ }
+
+ event = new IOF.Event(
+ eventID,
+ eventName,
+ url,
+ moment(startDate),
+ moment(endDate),
+ classification,
+ { color: color },
+ { acronym: clubAcronym }
+ );
+ events.push(event);
+ });
+ /*jslint unparam: false*/
+ return events;
+ };
+
+ return EventList;
+}(jQuery, moment, ko, WJR.club));
diff --git a/app/assets/javascripts/clubsite/wjr/flickr-photos.js b/app/assets/javascripts/clubsite/wjr/flickr-photos.js
new file mode 100644
index 0000000..bb71e18
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/flickr-photos.js
@@ -0,0 +1,67 @@
+/*jslint nomen: true, browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR['flickr-photos'] = (function ($, _, ko) {
+ 'use strict';
+ var FlickrPhotos = {}, photoViewModel;
+
+ photoViewModel = {
+ photos: ko.observableArray()
+ };
+
+
+ FlickrPhotos.Initialize = function (element) {
+ var count = 2,
+ apiKey = element.getAttribute('data-flickr-api-key'),
+ tags = element.getAttribute('data-flickr-tags');
+
+ function loadMorePhotos(page) {
+ var url = "https://api.flickr.com/services/rest/?method=flickr.photos.search&nojsoncallback=1&api_key=" + apiKey + "&tags=" + tags + "&format=json&extras=date_taken,description,owner_name&per_page=30&page=" + page;
+ $.get(url, function (results) {
+ if (results.stat === "ok") {
+ _.each(results.photos.photo, function (photo) {
+ photoViewModel.photos.push({
+ id: photo.id,
+ page: "https://www.flickr.com/photos/" + photo.owner + "/" + photo.id,
+ thumbnailUrl: "https://farm" + photo.farm + ".staticflickr.com/" + photo.server + "/" + photo.id + "_" + photo.secret + ".jpg",
+ largeUrl: "https://farm" + photo.farm + ".staticflickr.com/" + photo.server + "/" + photo.id + "_" + photo.secret + "_b.jpg",
+ ownerName: photo.ownername,
+ description: photo.description,
+ dateTaken: photo.datetaken,
+ clicked: function () {
+ var id = this.id,
+ largeUrl = this.largeUrl;
+ (function () {
+ if ($("#flickrPhoto" + id + " img").attr('src') === "") {
+ $("#flickrPhoto" + id + " img").attr('src', largeUrl);
+ $.fancybox.showLoading();
+
+ $("#flickrPhoto" + id + " img").load(function () {
+ $.fancybox.hideLoading();
+ $.fancybox.open("#flickrPhoto" + id);
+ });
+ } else {
+ $.fancybox.open("#flickrPhoto" + id);
+ }
+ }());
+ }
+ });
+ });
+ }
+ });
+
+ }
+
+ $(window).scroll(function () {
+ if ($(window).scrollTop() === $(document).height() - $(window).height()) {
+ loadMorePhotos(count);
+ count += 1;
+ }
+ });
+
+ loadMorePhotos(1);
+ ko.applyBindings(photoViewModel, element);
+ };
+
+ return FlickrPhotos;
+}(jQuery, _, ko));
diff --git a/app/assets/javascripts/clubsite/wjr/forms.js b/app/assets/javascripts/clubsite/wjr/forms.js
new file mode 100644
index 0000000..df39194
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/forms.js
@@ -0,0 +1,109 @@
+/*jslint nomen: true, browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR.forms = (function ($, _) {
+ "use strict";
+ var forms = {};
+
+ forms.SimplePersonPicker = function () {
+ this.initialize = function (element) {
+ var options = {}, callback, targetElement, configs;
+ targetElement = $(element.getAttribute('data-user-id-target'));
+
+ configs = [['data-maintain-input', 'maintainInput'],
+ ['data-allow-fake', 'allowFake'],
+ ['data-create-new', 'createNew'],
+ ['data-allow-new', 'allowNew']];
+
+ _.each(configs, function (config) {
+ var dataAttribute = config[0], option = config[1], setting;
+ if (element.hasAttribute(dataAttribute)) {
+ setting = element.getAttribute(dataAttribute);
+ options[option] = (setting === 'true') ? true : false;
+ }
+ });
+
+ callback = function (person) {
+ // REVIEW: Should we set val to null when person is null?
+ if (person !== null) {
+ targetElement.val(person.id);
+ }
+ };
+
+ forms.personPicker(element, options, callback);
+ };
+ };
+
+ // Callback should take a person object with id, name. Callback can also be called with null (no person selected)
+ // Maintain input will keep the selected user's name in the input after the input loses focus. "allowNew" will keep the input populated even if there is no user in the system with the given name. "createNew" will add an option to create a new user with the given name.
+ forms.personPicker = function (element, options, callback) {
+ (function () {
+ var displayName = null,
+ defaults = {
+ 'allowNew': false,
+ 'allowFake': true,
+ 'createNew': false
+ };
+ options = options || {};
+ options = $.extend({}, defaults, options);
+
+ $(element).typeahead({
+ source: function (typeahead, query) {
+ $.ajax({
+ url: "/users/index.json?term=" + query + "&allowFake=" + (options.allowFake ? 'true' : 'false'),
+ success: function (data) {
+ // Responses can arrive out of order while the user types; a
+ // stale response would hide the menu again. Only the response
+ // for what is in the input now may be shown.
+ if ($(element).val() !== query) {
+ return;
+ }
+ if (options.createNew) {
+ data.push({
+ position: "bottom",
+ name: query,
+ identifiableName: 'Create New User (' + query + ')'
+ });
+ }
+ typeahead.process(data);
+ }
+ });
+ },
+ onselect: function (person) {
+ callback(person);
+ if (options.maintainInput) {
+ displayName = person.name;
+ $(element).val(displayName);
+ }
+ $(element).blur();
+ },
+ property: "identifiableName"
+ });
+
+ $(element).blur(function () {
+ if (!options.allowNew) {
+ if ($(element).val() === "") {
+ callback(null);
+ } else if ($(element).val() !== displayName) {
+ $(element).val(displayName);
+ }
+ }
+ });
+ }());
+ };
+
+ forms.validation = {};
+ forms.validation.checkKetchupFormsAreValidOnSubmit = function () {
+ $('[data-validate="ketchup"]').each(function () {
+ (function () {
+ var jqe = $(this);
+ jqe.submit(function () {
+ jqe.ketchup();
+ return jqe.ketchup('isValid');
+ });
+ }());
+ });
+ };
+
+ return forms;
+}(jQuery, _));
diff --git a/app/assets/javascripts/clubsite/wjr/iof.js b/app/assets/javascripts/clubsite/wjr/iof.js
new file mode 100644
index 0000000..76eaf12
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/iof.js
@@ -0,0 +1,142 @@
+/*jslint nomen: true, browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR.iof = (function ($, _, ko, moment, utils) {
+ 'use strict';
+ var IOF = {};
+ IOF.ResultList = function (event, creationTime) {
+ this.event = ko.observable(event);
+ this.creationTime = ko.observable(creationTime);
+ this.formattedCreationTime = ko.computed(function () {
+ var time = this.creationTime();
+ return time ? time.format("dddd, MMMM Do YYYY [at] h:mm:ss a") : null;
+ }, this);
+ };
+
+ IOF.Event = function (id, name, startTime, courses) {
+ this.id = id;
+ this.name = name;
+ this.startTime = startTime;
+ this.courses = courses;
+ };
+
+ IOF.Course = function (id, name, results, scoringType, millisecondTiming) {
+ this.id = id;
+ this.name = name;
+ this.results = ko.observableArray(results);
+ this.hasComments = ko.computed(function () {
+ return _.some(this.results(), function (result) {
+ return (result.officialComment !== "");
+ });
+ }, this);
+ this.hasWhyJustRunPoints = ko.computed(function () {
+ return _.some(this.results(), function (result) {
+ return (result.scores.WhyJustRun !== undefined);
+ });
+ }, this);
+ this.scoringType = scoringType;
+ this.isScore = (scoringType === 'Points');
+ this.isTimed = (scoringType === 'Timed');
+ this.millisecondTiming = millisecondTiming;
+ };
+
+ IOF.friendlyStatuses = {
+ 'OK': '',
+ 'Inactive': 'Inactive',
+ 'DidNotStart': 'DNS',
+ 'Active': 'In Progress',
+ 'Finished': 'Unofficial',
+ 'MissingPunch': 'MP',
+ 'DidNotFinish': 'DNF',
+ 'DidNotEnter': 'DNE',
+ 'Disqualified': 'DSQ',
+ 'NotCompeting': 'NC',
+ 'SportWithdrawal': 'Sport Withdrawal',
+ 'OverTime': 'Over Time',
+ 'Moved': 'Moved',
+ 'MovedUp': 'Moved Up',
+ 'Cancelled': 'Cancelled'
+ };
+
+ IOF.Result = function (time, position, status, scores, officialComment, person) {
+ if (time !== null && !isNaN(time)) {
+ this.time = time;
+ this.hours = utils.zeroFill((time - time % 3600) / 3600, 2);
+ this.minutes = utils.zeroFill((time - time % 60) / 60 - this.hours * 60, 2);
+ var millis = Math.round((time % 1) * 1000);
+ this.seconds = utils.zeroFill(Math.floor(time) % 60, 2);
+ this.milliseconds = utils.zeroFill(millis, 3);
+ } else {
+ this.time = this.hours = this.minutes = this.seconds = this.milliseconds = null;
+ }
+
+ this.status = status;
+ this.friendlyStatus = IOF.friendlyStatuses[status];
+ this.position = position;
+ this.scores = scores;
+ this.person = person;
+ this.officialComment = officialComment;
+ };
+
+ IOF.Person = function (id, givenName, familyName, profileUrl) {
+ this.id = id;
+ this.givenName = givenName;
+ this.familyName = familyName;
+ this.profileUrl = profileUrl;
+ };
+
+ IOF.loadResultsList = function (xml) {
+ var resultList, date, event, courses, iofEvent;
+
+ resultList = $(xml.documentElement);
+ date = moment(resultList.attr('createTime'));
+ event = resultList.children("Event").first();
+ courses = [];
+ resultList.children('ClassResult').each(function () {
+ var element, classElement, courseId, courseName, courseExtensions,
+ scoringType, results, millisecondTiming;
+ element = $(this);
+ classElement = element.children('Class');
+ courseId = classElement.children("Id").text();
+ courseName = classElement.children('Name').text();
+ courseExtensions = element.children("Course").children('Extensions');
+ scoringType = courseExtensions.children('ScoringType').text();
+ results = [];
+ millisecondTiming = false;
+ element.children('PersonResult').each(function () {
+ var prElement = $(this), person, personGivenName, personFamilyName,
+ personProfileUrl, personId, resultElement, resultTime,
+ resultStatus, resultExtensions, officialComment, resultPosition,
+ resultScores, result;
+ person = prElement.children("Person").first();
+ personGivenName = person.children("Name").children("Given").text();
+ personFamilyName = person.children("Name").children("Family").text();
+ personProfileUrl = person.children("Contact[type='WebAddress']").text();
+ personId = person.children("Id").text();
+ resultElement = prElement.children("Result");
+ resultTime = parseFloat(resultElement.children("Time").text());
+ resultStatus = resultElement.children("Status").text();
+ resultExtensions = resultElement.children("Extensions");
+ officialComment = resultExtensions.children("OfficialComment").text();
+ resultPosition = resultElement.children("Position").text();
+ resultScores = {};
+ prElement.children("Result").children("Score").each(function () {
+ resultScores[$(this).attr("type")] = $(this).text();
+ });
+ result = new IOF.Result(resultTime, resultPosition, resultStatus, resultScores, officialComment, new IOF.Person(personId, personGivenName, personFamilyName, personProfileUrl));
+ if (!millisecondTiming &&
+ result.milliseconds !== '000' &&
+ result.milliseconds !== null) {
+ millisecondTiming = true;
+ }
+ results.push(result);
+ });
+ courses.push(new IOF.Course(courseId, courseName, results, scoringType, millisecondTiming));
+ });
+
+ iofEvent = new IOF.Event(event.children("Id").text(), event.children("Name").text(), null, courses);
+ return new IOF.ResultList(iofEvent, date);
+ };
+
+ return IOF;
+}(jQuery, _, ko, moment, WJR.utils));
diff --git a/app/assets/javascripts/clubsite/wjr/map.js b/app/assets/javascripts/clubsite/wjr/map.js
new file mode 100644
index 0000000..21cc5fb
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/map.js
@@ -0,0 +1,190 @@
+/*jslint browser: true, indent: 2, nomen: true*/
+/*global google*/
+
+window.WJR = window.WJR || {};
+WJR.map = (function ($, _) {
+ 'use strict';
+ var map = {}, googleMapsPromise = null, loadGoogleMaps;
+
+ // Loads the Google Maps API on first use. The API key is provided by the
+ // page via a meta tag, and the returned promise resolves once the API's
+ // callback= parameter fires.
+ loadGoogleMaps = function () {
+ if (googleMapsPromise === null) {
+ googleMapsPromise = new Promise(function (resolve) {
+ var key = document.querySelector('meta[name="wjr.google-maps-key"]').content,
+ script = document.createElement('script');
+ window.wjrGoogleMapsLoaded = resolve;
+ script.src = 'https://maps.googleapis.com/maps/api/js?key=' + encodeURIComponent(key) + '&callback=wjrGoogleMapsLoaded';
+ document.head.appendChild(script);
+ });
+ }
+ return googleMapsPromise;
+ };
+
+ map.SimpleMarkerMap = function () {
+ this.initialize = function (element) {
+ loadGoogleMaps().then(function () {
+ var lat, lng, zoom, mobileUrl, modalEvents,
+ mapOptions, marker, center, googleMap, clickHandler, useMobileUrl;
+ lat = +(element.getAttribute('data-lat'));
+ lng = +(element.getAttribute('data-lng'));
+ zoom = +(element.getAttribute('data-zoom'));
+ mobileUrl = element.getAttribute('data-mobile-url');
+ useMobileUrl = ($(window).width() < 768) && mobileUrl !== null;
+ center = new google.maps.LatLng(lat, lng);
+ mapOptions = {
+ zoom: zoom,
+ center: center,
+ scrollwheel: false,
+ draggable: !useMobileUrl,
+ disableDefaultUI: useMobileUrl,
+ mapTypeId: google.maps.MapTypeId.HYBRID
+ };
+
+ googleMap = new google.maps.Map(element, mapOptions);
+
+ marker = new google.maps.Marker({
+ position: center
+ });
+ marker.setMap(googleMap);
+
+ if (useMobileUrl) {
+ modalEvents = ['click'];
+ clickHandler = function () {
+ window.location = mobileUrl;
+ };
+
+ _.each(modalEvents, function (modalEvent) {
+ google.maps.event.addListener(googleMap, modalEvent, clickHandler);
+ });
+ }
+ });
+ };
+ };
+
+ map.DraggableMarkerMap = function () {
+ this.initialize = function (element) {
+ loadGoogleMaps().then(function () {
+ var latElement, lngElement, zoom, googleMap, options, center, marker, updateUrl;
+ latElement = $(element.getAttribute('data-lat-element'));
+ lngElement = $(element.getAttribute('data-lng-element'));
+ zoom = +(element.getAttribute('data-zoom'));
+ updateUrl = element.getAttribute('data-update-url');
+
+ center = new google.maps.LatLng(latElement.val(), lngElement.val());
+ options = {
+ center: center,
+ zoom: zoom,
+ mapTypeId: google.maps.MapTypeId.HYBRID
+ };
+ googleMap = new google.maps.Map(element, options);
+
+ marker = new google.maps.Marker({
+ position: center,
+ draggable: true
+ });
+
+ google.maps.event.addListener(marker, 'dragend', function () {
+ var position = marker.getPosition();
+ latElement.val(position.lat());
+ lngElement.val(position.lng());
+ if (updateUrl) {
+ // Persist immediately; the CSRF header comes from the global
+ // $.ajaxSetup in wjr/wjr.js.
+ $.post(updateUrl + '/' + position.lat() + '/' + position.lng());
+ }
+ });
+
+ marker.setMap(googleMap);
+ });
+ };
+ };
+
+ map.MultiMarkerMap = function () {
+ this.initialize = function (element) {
+ loadGoogleMaps().then(function () {
+ var lat, fetchUrl, fetchEntity, fetchMarkers, fetchMarkerImages, fetchBounds,
+ lng, zoom, googleMap, markerClusterer, options, center, markers, fetchInProgress;
+ lat = +(element.getAttribute('data-lat'));
+ lng = +(element.getAttribute('data-lng'));
+ zoom = +(element.getAttribute('data-zoom'));
+ fetchUrl = element.getAttribute('data-fetch-url');
+ fetchEntity = element.getAttribute('data-fetch-entity');
+ fetchMarkerImages = element.getAttribute('data-fetch-markerimages');
+ center = new google.maps.LatLng(lat, lng);
+ options = {
+ center: center,
+ zoom: zoom,
+ mapTypeId: google.maps.MapTypeId.HYBRID
+ };
+
+ googleMap = new google.maps.Map(element, options);
+ markers = {};
+ markerClusterer = new MarkerClusterer(googleMap, markers,
+ {imagePath: fetchMarkerImages, maxZoom: 11});
+
+ fetchInProgress = false;
+ fetchBounds = null;
+ fetchMarkers = function () {
+ if (!fetchInProgress) {
+ fetchInProgress = true;
+ var bounds, ne, sw, url;
+ bounds = googleMap.getBounds();
+ fetchBounds = bounds;
+ ne = bounds.getNorthEast();
+ sw = bounds.getSouthWest();
+ url = fetchUrl;
+ url += '?max_lat=' + ne.lat() + '&min_lat=' + sw.lat();
+ url += '&max_lng=' + ne.lng() + '&min_lng=' + sw.lng();
+ $.ajax({
+ url: url,
+ success: function (data) {
+ _.each(data[fetchEntity], function (entity) {
+ if (_.has(markers, entity.id)) {
+ return;
+ }
+ var marker, color, infoWindow;
+ infoWindow = new google.maps.InfoWindow({
+ content: '' + entity.name + '
'
+ });
+ color = (entity.map_standard && entity.map_standard.color) ? entity.map_standard.color : 'rgba(0,0,0,1)';
+ marker = new google.maps.Marker({
+ position: new google.maps.LatLng(entity.lat, entity.lng),
+ icon: {
+ path: "M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z",
+ fillColor: color,
+ fillOpacity: 1.0,
+ anchor: new google.maps.Point(12, 24),
+ strokeOpacity: 0.5,
+ strokeWeight: 2.0,
+ strokeColor: '#000000',
+ scale: 2.0
+ },
+ });
+ google.maps.event.addListener(marker, 'click', function () {
+ infoWindow.open(googleMap, marker);
+ });
+ markerClusterer.addMarker(marker);
+ markers[entity.id] = marker;
+ });
+ },
+ complete: function () {
+ fetchInProgress = false;
+ // If the bounds have changed since the last fetch, start another fetch
+ if (!fetchBounds.equals(googleMap.getBounds())) {
+ fetchMarkers();
+ }
+ }
+ });
+ }
+ };
+
+ google.maps.event.addListener(googleMap, 'bounds_changed', fetchMarkers);
+ });
+ };
+ };
+
+ return map;
+}(jQuery, _));
diff --git a/app/assets/javascripts/clubsite/wjr/register-others.js b/app/assets/javascripts/clubsite/wjr/register-others.js
new file mode 100644
index 0000000..15c0f39
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/register-others.js
@@ -0,0 +1,60 @@
+/*jslint browser: true, indent: 2*/
+/*global confirm, alert*/
+
+window.WJR = window.WJR || {};
+WJR['register-others'] = (function ($, forms) {
+ 'use strict';
+ return function () {
+ this.initialize = function (element) {
+ element = $(element);
+ element.find('#RegisterOthersUserId').val(null);
+
+ var options = { maintainInput: true, allowNew: true };
+ forms.personPicker(element.find('#RegisterOthersUserName'), options, function (person) {
+ element.find('#RegisterOthersUserId').val((person !== null) ? person.id : null);
+ });
+
+ // Registration is a POST; build and submit a form so the browser
+ // follows the redirect back to the event page.
+ function completeSubmit(courseId, userId) {
+ var form = $('
', {
+ method: 'post',
+ action: '/courses/register/' + courseId + '/' + userId
+ }),
+ token = $('meta[name="csrf-token"]').attr('content');
+ if (token) {
+ form.append($('', { type: 'hidden', name: 'authenticity_token', value: token }));
+ }
+ form.appendTo('body').submit();
+ }
+
+ element.find('#RegisterOthersSubmit').click(function () {
+ var userId, courseId, userName;
+ userId = element.find('#RegisterOthersUserId').val();
+ courseId = element.find('#RegisterOthersCourse').val();
+ if (!userId) {
+ userName = element.find('#RegisterOthersUserName').val();
+ if (userName) {
+ if (userName.indexOf(" ") !== -1) {
+ if (confirm("This registration will create a new user in the system. Are you sure " + userName + " isn't already an WhyJustRun user?")) {
+ $.post('/users/add', { userName: userName }, function (data) {
+ // The endpoint responds with the new user id as JSON,
+ // which jQuery has already parsed.
+ completeSubmit(courseId, data);
+ });
+ } else {
+ alert("Thanks! Please re-enter the participant's name and choose the matching person from the dropdown.");
+ }
+ } else {
+ alert("Please enter the participant's full name.");
+ }
+ } else {
+ alert("Please enter the participant name");
+ }
+ } else {
+ completeSubmit(courseId, userId);
+ }
+ });
+ };
+ };
+}(jQuery, WJR.forms));
diff --git a/app/assets/javascripts/clubsite/wjr/result-editor.js b/app/assets/javascripts/clubsite/wjr/result-editor.js
new file mode 100644
index 0000000..10750b1
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/result-editor.js
@@ -0,0 +1,178 @@
+/*jslint browser: true, indent: 2, nomen: true*/
+/*global confirm, alert*/
+
+window.WJR = window.WJR || {};
+WJR['result-editor'] = (function ($, _, ko, utils, forms) {
+ 'use strict';
+ return function () {
+ var _statuses = null, statuses, sortedStatuses,
+ ResultStatus, Course, Result, User, Event,
+ viewModel, loadObjects, eventId;
+ statuses = function () {
+ var map;
+ if (_statuses === null) {
+ // TODO: We shouldn't need this non-standard mapping after we start using IOF standard statuses..
+ map = {
+ 'ok': 'Finished',
+ 'inactive': 'Inactive',
+ 'did_not_start': 'DNS',
+ 'active': 'In Progress',
+ 'finished': 'Unofficial',
+ 'mis_punch': 'MP',
+ 'did_not_enter': 'DNE',
+ 'did_not_finish': 'DNF',
+ 'disqualified': 'DSQ',
+ 'not_competing': 'NC',
+ 'sport_withdrawal': 'Sport Withdrawal',
+ 'over_time': 'Over Time',
+ 'moved': 'Moved',
+ 'moved_up': 'Moved Up',
+ 'cancelled': 'Cancelled'
+ };
+
+ _statuses = {};
+ _.each(map, function (k, v) {
+ _statuses[k] = new ResultStatus(v, k);
+ });
+ }
+
+ return _statuses;
+ };
+
+ sortedStatuses = function () {
+ return _.values(statuses()).sort(function (a, b) { return a.name.localeCompare(b.name); });
+ };
+
+ ResultStatus = function (id, name) {
+ this.id = id;
+ this.name = (name === null) ? statuses()[id] : name;
+ };
+
+ Course = function (id, name, distance, climb, event_id, description, results, is_score_o) {
+ this.id = id;
+ this.name = name;
+ this.distance = distance;
+ this.climb = climb;
+ this.event_id = event_id;
+ this.description = description;
+ this.results = ko.observableArray(results);
+ this.is_score_o = ko.observable(is_score_o);
+ this.rankBy = ko.computed({
+ read: function () {
+ return this.is_score_o() ? "points" : "time";
+ },
+ write: function (value) {
+ this.is_score_o((value === "points"));
+ },
+ owner: this
+ });
+ };
+
+ Result = function (id, user, course_id, time_seconds, status, registrant_comment, official_comment, score_points) {
+ var hoursCount = Math.floor(time_seconds / 3600),
+ minutesCount = Math.floor((time_seconds - hoursCount * 3600) / 60),
+ secondsCount = time_seconds - hoursCount * 3600 - minutesCount * 60,
+ millisecondsCount = Math.round((secondsCount % 1) * 1000);
+
+ this.id = id;
+ this.user = user;
+ this.course_id = course_id;
+ this.hours = ko.observable(time_seconds ? utils.zeroFill(hoursCount, 2) : '00');
+ this.minutes = ko.observable(time_seconds ? utils.zeroFill(minutesCount, 2) : '00');
+ this.seconds = ko.observable(time_seconds ? utils.zeroFill(Math.floor(secondsCount), 2) : '00');
+ this.milliseconds = ko.observable(time_seconds ? utils.zeroFill(millisecondsCount, 3) : '000');
+ this.statuses = sortedStatuses;
+ this.status = ko.observable(status || 'ok');
+ this.registrant_comment = ko.observable(registrant_comment);
+ this.official_comment = ko.observable(official_comment);
+ this.score_points = ko.observable(score_points);
+
+ this.remove = function () {
+ if (this.id) {
+ if (!confirm("Are you sure you want to delete this competitor? The entry be deleted from the server immediately.")) {
+ return;
+ }
+ $.ajax('/results/delete/' + this.id, { method: 'POST' });
+ }
+
+ var courses = viewModel.courses(),
+ i;
+ for (i = 0; i < viewModel.courses().length; i += 1) {
+ courses[i].results.remove(this);
+ }
+ };
+ };
+
+ User = function (id, name) {
+ this.id = id;
+ this.name = name;
+ };
+
+ Event = function (id, name, date) {
+ this.id = id;
+ this.name = name;
+ this.date = date;
+ };
+
+ viewModel = {
+ courses : ko.observableArray(),
+ event : ko.observable(),
+ userName: ko.observable(),
+ selectedCourse: ko.observable(),
+ addCompetitorToCourse: function (id, name) {
+ var options, successHandler, user;
+ if (!viewModel.selectedCourse()) {
+ alert("Adding competitor failed, because a course wasn't selected.");
+ return;
+ }
+
+ if (id === undefined) {
+ // create user asynchronously, then add to the UI once we've go the user ID.
+ options = {
+ userName: name,
+ eventId: viewModel.event().id
+ };
+ successHandler = function (userID) {
+ viewModel.addCompetitorToCourse($.parseJSON(userID), name);
+ };
+ $.post("/users/add", options, successHandler);
+ } else {
+ user = new User(id, name);
+ viewModel.selectedCourse().results.push(new Result(undefined, user, viewModel.selectedCourse().id, undefined, undefined, undefined, undefined, undefined, undefined));
+ }
+ }
+ };
+
+ loadObjects = function () {
+ // TODO: We should use the result list xml api.
+ $.getJSON('/events/view/' + eventId + '.json', function (data) {
+ viewModel.event(new Event(data.id, data.name, data.date));
+ var courses = data.courses;
+ _.each(courses, function (course) {
+ var results = course.results,
+ importedResults = [];
+
+ _.each(results, function (result) {
+ var user = new User(result.user.id, result.user.name);
+ importedResults.push(new Result(result.id, user, result.course_id, result.time_seconds, result.status, result.registrant_comment, result.official_comment, result.score_points));
+ });
+
+ viewModel.courses.push(new Course(course.id, course.name, course.distance, course.climb, course.event_id, course.description, importedResults, course.is_score_o));
+ });
+ });
+ };
+
+ this.initialize = function (element) {
+ eventId = element.getAttribute('data-event-id');
+ loadObjects();
+ ko.applyBindings(viewModel, element);
+ var options = { maintainInput: false, createNew: true },
+ pickerElement = $('#competitorResults');
+ forms.personPicker(pickerElement, options, function (person) {
+ if (person !== null) {
+ viewModel.addCompetitorToCourse(person.id, person.name);
+ }
+ });
+ };
+ };
+}(jQuery, _, ko, WJR.utils, WJR.forms));
diff --git a/app/assets/javascripts/clubsite/wjr/result-list.js b/app/assets/javascripts/clubsite/wjr/result-list.js
new file mode 100644
index 0000000..b8ea254
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/result-list.js
@@ -0,0 +1,43 @@
+/*jslint browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR['result-list'] = (function ($, IOF, ko) {
+ 'use strict';
+ return function () {
+ var viewModel, fetchResults, url;
+
+ viewModel = {
+ resultList: ko.observable()
+ };
+
+ fetchResults = function () {
+ $.ajax({
+ type: "GET",
+ url: url,
+ dataType: "xml",
+ ifModified: true,
+ success: function (xml) {
+ var resultList = IOF.loadResultsList(xml);
+ viewModel.resultList(resultList);
+ }
+ });
+ };
+
+ this.initialize = function (element) {
+ var modeAttr = 'data-result-list-mode',
+ mode = element.hasAttribute(modeAttr) ? element.getAttribute(modeAttr) : 'normal';
+
+ url = element.getAttribute("data-result-list-url");
+
+ fetchResults();
+
+ if (mode === 'live') {
+ window.setInterval(fetchResults, 5000);
+ }
+
+ ko.applyBindings(viewModel, element);
+ };
+
+ return this;
+ };
+}(jQuery, WJR.iof, ko));
diff --git a/app/assets/javascripts/clubsite/wjr/utils.js b/app/assets/javascripts/clubsite/wjr/utils.js
new file mode 100644
index 0000000..5294f36
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/utils.js
@@ -0,0 +1,16 @@
+/*jslint browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR.utils = (function () {
+ "use strict";
+ var utils = {};
+ utils.zeroFill = function (number, width) {
+ width -= number.toString().length;
+ if (width > 0) {
+ return new Array(width + (/\./.test(number) ? 2 : 1) ).join('0') + number;
+ }
+ return number;
+ };
+
+ return utils;
+}());
diff --git a/app/assets/javascripts/clubsite/wjr/wjr.js b/app/assets/javascripts/clubsite/wjr/wjr.js
new file mode 100644
index 0000000..f8db2b0
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/wjr.js
@@ -0,0 +1,22 @@
+/*jslint browser: true indent: 2*/
+
+window.WJR = window.WJR || {};
+WJR.wjr = (function ($) {
+ 'use strict';
+ var wjr = {};
+ wjr.core = {};
+ wjr.core.domain = $('meta[name="wjr.core.domain"]').attr("content");
+
+ $.ajaxSetup({
+ beforeSend: function (xhr, settings) {
+ if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type)) {
+ var token = $('meta[name="csrf-token"]').attr('content');
+ if (token) {
+ xhr.setRequestHeader('X-CSRF-Token', token);
+ }
+ }
+ }
+ });
+
+ return wjr;
+}(jQuery));
diff --git a/app/assets/javascripts/clubsite/wjr/wysiwyg.redactor.js b/app/assets/javascripts/clubsite/wjr/wysiwyg.redactor.js
new file mode 100644
index 0000000..5670007
--- /dev/null
+++ b/app/assets/javascripts/clubsite/wjr/wysiwyg.redactor.js
@@ -0,0 +1,25 @@
+/*jslint browser: true indent: 2*/
+
+// NOTE: Requires the redactor library ($.fn.redactor), which is not part of
+// this bundle (it was injected at deploy time in the original clubsite app).
+window.WJR = window.WJR || {};
+WJR.wysiwyg = (function ($) {
+ 'use strict';
+ var wysiwyg = {};
+ wysiwyg.updateTextareas = function () { return; };
+ wysiwyg.createRichTextArea = function (element) {
+ // Degrade to a plain textarea when the redactor library isn't mounted;
+ // without this guard every page with a rich-text area throws on load.
+ if ($.fn.redactor === undefined) {
+ return;
+ }
+ $(element).redactor({
+ toolbarFixed: true,
+ toolbarFixedBox: true,
+ imageUpload: '/api/redactor/uploadImage.json',
+ fileUpload: '/api/redactor/uploadFile.json'
+ });
+ };
+
+ return wysiwyg;
+}(jQuery));
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 42fe63d..6ff683b 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -3,5 +3,22 @@
* and any sub-directories. You're free to add application-wide styles to this file and they'll appear at
* the top of the compiled file, but it's generally better to create a new file per style scope.
*= require_self
- *= require_tree .
+ *= require bootstrap_and_overrides
+ *= require clubs
+ *= require content_blocks
+ *= require courses
+ *= require events
+ *= require groups
+ *= require home
+ *= require map_standards
+ *= require maps
+ *= require memberships
+ *= require nav
+ *= require organizers
+ *= require pages
+ *= require privileges
+ *= require results
+ *= require roles
+ *= require series
+ *= require users
*/
diff --git a/app/assets/stylesheets/clubsite.css b/app/assets/stylesheets/clubsite.css
new file mode 100644
index 0000000..5aecea2
--- /dev/null
+++ b/app/assets/stylesheets/clubsite.css
@@ -0,0 +1,12 @@
+/*
+ * Clubsite stylesheet bundle: bootstrap, plugin CSS (formerly loaded on
+ * demand via the RequireJS css! plugin), then the first-party styles.
+ *
+ *= require clubsite/bootstrap.min
+ *= require clubsite/fullcalendar
+ *= require clubsite/jquery.fancybox
+ *= require clubsite/ladda-themeless.min
+ *= require clubsite/bootstrap-datetimepicker.min
+ *= require clubsite/bootstrap-colorpicker
+ *= require clubsite/whyjustrun
+ */
diff --git a/app/assets/stylesheets/clubsite/embed.css b/app/assets/stylesheets/clubsite/embed.css
new file mode 100644
index 0000000..f5bae2c
--- /dev/null
+++ b/app/assets/stylesheets/clubsite/embed.css
@@ -0,0 +1,3 @@
+body {
+ background: none;
+}
diff --git a/app/assets/stylesheets/clubsite/other.css b/app/assets/stylesheets/clubsite/other.css
new file mode 100644
index 0000000..7b21ca0
--- /dev/null
+++ b/app/assets/stylesheets/clubsite/other.css
@@ -0,0 +1,23 @@
+/* Need this for the floating menu bar */
+body {
+ padding-top: 60px;
+}
+@media (max-width: 979px) {
+ body {
+ padding-top: 0px;
+ }
+}
+
+#content {
+ padding-left: 20px;
+ padding-bottom: 15px;
+ min-height: 0%;
+ padding-top: 0;
+ background-color: #fff;
+}
+
+.page-header {
+ border-bottom: 1px solid #AAA;
+ margin: 0px;
+}
+
diff --git a/app/assets/stylesheets/clubsite/printable.css b/app/assets/stylesheets/clubsite/printable.css
new file mode 100644
index 0000000..d215a81
--- /dev/null
+++ b/app/assets/stylesheets/clubsite/printable.css
@@ -0,0 +1,54 @@
+body {
+ font-family: "Helvetica","Arial",sans-serif;
+ font-size: 80%;
+ margin: 0;
+ padding: 0;
+ text-align: left;
+}
+
+/** Tables **/
+table {
+ padding: 2px;
+ margin: 0px;
+ clear: both;
+ color: #000;
+ width: 100%;
+ font-size: 15px;
+ border-spacing: 0px;
+ border: 2px solid #000;
+}
+
+thead th {
+ font-weight: bold;
+ text-decoration: none;
+ border-left: 1px solid #000;
+ border-bottom: 2px solid #000;
+ border-top: 2px solid #000;
+ padding: 5px;
+}
+
+/*
+th a {
+ display: block;
+ padding: 2px 4px;
+ text-decoration: none;
+}
+*/
+table tr td {
+ text-align: left;
+ border-bottom: 1px solid #000;
+ border-left: 1px solid #000;
+ padding: 5px;
+ margin: 0px;
+}
+td {
+ background: #fff;
+}
+td.actions {
+ text-align: center;
+ white-space: nowrap;
+}
+table td.actions a {
+ margin: 0px 6px;
+ padding: 2px 5px;
+}
diff --git a/app/assets/stylesheets/clubsite/whyjustrun.css b/app/assets/stylesheets/clubsite/whyjustrun.css
new file mode 100644
index 0000000..1c85bd5
--- /dev/null
+++ b/app/assets/stylesheets/clubsite/whyjustrun.css
@@ -0,0 +1,503 @@
+/**
+ *
+ * Generic CSS for CakePHP
+ *
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright 2005-2010, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright Copyright 2005-2010, Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link http://cakephp.org CakePHP(tm) Project
+ * @package cake
+ * @subpackage cake.app.webroot.css
+ * @since CakePHP(tm)
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+
+/* Registration deadline additional classes */
+.reg-deadline-header {
+ color: #d2322d;
+ padding: 5px;
+}
+
+/* Use to ensure the image fits within the responsive column*/
+.fitting-image {
+ max-width: 100%;
+}
+
+/* inputs that are ketchup validated will not be aligned properly if there is a validation error. This works around that issue. */
+.form-group.inline-validated {
+ vertical-align: top;
+}
+
+.inline-validated-help {
+ vertical-align: -webkit-baseline-middle;
+}
+/* Fix for nesting of inline form inside horizontal form (http://stackoverflow.com/questions/18429121/inline-form-nested-within-horizontal-form-in-bootstrap-3) */
+.form-inline .form-group {
+ margin-left: 0;
+ margin-right: 0;
+}
+
+.ketchup-custom {
+ line-height: 1.5em;
+ padding: 0;
+ margin: 0;
+ margin-top: 5px;
+ display: none;
+}
+
+.ketchup-custom li {
+ font-size: 12px;
+ text-transform: uppercase;
+ text-shadow: 1px 1px 0 #9F4631;
+ border: 0;
+ color: white;
+ background: #F46644;
+ padding: 1px 10px;
+ margin: 0;
+}
+
+/** Placeholder polyfill **/
+input, textarea {
+ color: #000;
+}
+.placeholder {
+ color: #aaa;
+}
+
+/** Facebook bugfix **/
+.fb-like-box iframe {
+ height: 100px;
+}
+
+/** Thinner select input, useful for tables**/
+input.thin-control, select.thin-control, form.thin-form select, form.thin-form input {
+ height: 18px;
+ min-height: 18px;
+ padding: 0;
+ padding-left: 2px;
+ padding-right: 2px;
+ margin-bottom: 0;
+}
+
+input.spanning-control {
+ width: 99%;
+}
+
+form.thin-form {
+ margin: 0;
+}
+
+/** Result ride sharing **/
+#ResultEditRideForm {
+ margin: 0;
+}
+
+/** General Style Info **/
+
+#content {
+ padding-bottom: 15px;
+}
+
+/** Event page **/
+
+.social-event-button {
+ padding: 7px;
+ padding-left: 10px;
+ padding-right: 10px;
+ margin-bottom: 15px;
+}
+
+.social-event-text {
+ padding-left: 3px;
+ vertical-align: bottom;
+}
+
+.event-header {
+ padding: 0;
+ margin: 0;
+}
+
+.course-info h3 {
+ text-align: left;
+ padding-left: 0px;
+}
+
+.column-box {
+ border-radius: 10px;
+ overflow: hidden;
+ margin: 10px;
+ padding: 10px;
+ background-color: #f8f8f8;
+}
+
+.column-box h2 {
+ padding-top: 0px;
+}
+
+#location-map {
+ height: 500px;
+}
+
+.course-editing input {
+ font-size: 100%;
+}
+
+.content-block img {
+ max-width: 100%;
+}
+
+.content-block textarea {
+ font-size: 100%;
+}
+
+.wjr-editable:hover {
+ background-color: #fffdd7;
+ cursor: pointer;
+ border-radius: 10px;
+}
+
+.wjr-editable:hover > form {
+ cursor: initial;
+}
+
+.wjr-editable .btn {
+ margin-right: 7px;
+}
+
+/**
+Results editing
+ **/
+
+.results-editing input {
+ font-size: small;
+
+}
+
+.results-editing {
+ padding: 0;
+ width: 5%;
+ padding-left: 5px;
+}
+
+/** Navigation bar styling **/
+
+.navbar-constrained-width {
+ max-width: 100%;
+ width: 1200px;
+ margin:0 auto 0 auto;
+}
+
+.navbar-squared-top {
+ border-top-left-radius: 0px;
+ border-top-right-radius: 0px;
+}
+
+.navbar-nav-main-text {
+ font-weight: bold;
+ font-size: 16px;
+}
+
+/* Custom navbar colouring style (generated from http://jsfiddle.net/drSbw/102/) */
+.navbar-colored {
+ background-color: #238216;
+ border-color: #1e6613;
+}
+.navbar-colored .navbar-brand {
+ color: #ecf0f1;
+}
+.navbar-colored .navbar-brand:hover, .navbar-colored .navbar-brand:focus {
+ color: white;
+}
+.navbar-colored .navbar-nav > li > a {
+ color: #ecf0f1;
+}
+.navbar-colored .navbar-nav > li > a:hover, .navbar-colored .navbar-nav > li > a:focus {
+ background-color: #238216;
+ color: white;
+}
+.navbar-colored .navbar-nav .active > a, .navbar-colored .navbar-nav .active > a:hover, .navbar-colored .navbar-nav .active > a:focus {
+ color: white;
+ background-color: #1e6613;
+}
+.navbar-colored .navbar-nav .open > a, .navbar-colored .navbar-nav .open > a:hover, .navbar-colored .navbar-nav .open > a:focus {
+ color: white;
+ background-color: #1e6613;
+}
+.navbar-colored .navbar-nav .open > a .caret, .navbar-colored .navbar-nav .open > a:hover .caret, .navbar-colored .navbar-nav .open > a:focus .caret {
+ border-top-color: white;
+ border-bottom-color: white;
+}
+.navbar-colored .navbar-nav > .dropdown > a .caret {
+ border-top-color: #ecf0f1;
+ border-bottom-color: #ecf0f1;
+}
+.navbar-colored .navbar-nav > .dropdown > a:hover .caret, .navbar-colored .navbar-nav > .dropdown > a:focus .caret {
+ border-top-color: white;
+ border-bottom-color: white;
+}
+.navbar-colored .navbar-toggle {
+ border-color: #1e6613;
+}
+.navbar-colored .navbar-toggle:hover, .navbar-colored .navbar-toggle:focus {
+ background-color: #1e6613;
+}
+.navbar-colored .navbar-toggle .icon-bar {
+ background-color: #ecf0f1;
+}
+
+@media (max-width: 767px) {
+ .navbar-colored .navbar-nav .open .dropdown-menu > li > a {
+ color: #ecf0f1;
+ }
+ .navbar-colored .navbar-nav .open .dropdown-menu > li > a:hover, .navbar-colored .navbar-nav .open .dropdown-menu > li > a:focus {
+ color: white;
+ background-color: #1e6613;
+ }
+}
+
+header.header h1 {
+ text-align: center;
+ font-size: 40px;
+ line-height: 50px;
+}
+
+/** Footer **/
+
+#credits {
+ text-align: right;
+}
+
+footer.other-footer, footer.main-footer,
+footer.other-footer a, footer.other-footer a:hover,
+footer.main-footer a, footer.main-footer a:hover {
+ color: #fff;
+}
+
+footer.other-footer {
+ padding: 5px;
+ background-color: black;
+}
+
+footer.main-footer, footer.other-footer {
+ color: #fff;
+}
+
+footer.main-footer {
+ padding-bottom: 5px;
+ background-color: #213449;
+}
+
+footer.main-footer div {
+ padding: 5px;
+ padding-left: 12px;
+}
+
+/** General styling **/
+
+.page-header > h1 {
+ text-align: left;
+}
+
+/** Layout **/
+
+.three-column {
+ /** Needed so three-column surrounds the enclosed divs **/
+ overflow:hidden;
+}
+
+.three-column h2, .three-column h3 {
+ text-align: center;
+}
+
+.three-column .events-by-series h4 {
+ font-weight: bold;
+}
+
+.event-box {
+ margin-bottom: 10px;
+ margin-top: 10px;
+}
+
+/** Outer event-box is needed to safely override the a style and still allow for block clicking. **/
+.event-box a {
+ color: #000000;
+ text-decoration: none;
+
+}
+.event-box-icon {
+ position: absolute;
+ right: 0;
+ top: 0;
+ height: 60px;
+}
+
+.event-box-icon > img {
+ width: 60px;
+ height: 60px;
+ border-top-right-radius: 10px;
+ border-bottom-right-radius: 10px;
+}
+
+.event-box-inner {
+ position: relative;
+}
+
+.event-box-inner {
+ background-color: #f8f8f8;
+ border-radius: 10px;
+ padding: 6px;
+ padding-left: 11px;
+ padding-right: 11px;
+ display: block;
+ color: #000000;
+ text-decoration: none;
+ font-weight: normal;
+ clear: both;
+ overflow: hidden;
+}
+
+.event-box .location {
+ font-size: 18px;
+}
+
+.event-box .location, .event-box .city, .event-box .date-text {
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.event-box .city {
+ color: #000000;
+ text-decoration: none;
+}
+
+.event-box .date {
+ padding-top: 3px;
+ font-size: 100%;
+ font-weight: bold;
+}
+
+@media (min-width: 980px) {
+ .event-box.has-picture .location, .event-box.has-picture .city, .event-box.has-picture .date {
+ margin-right: 50px;
+}
+}
+
+@media (max-width: 767px) {
+ .event-box.has-picture .location, .event-box.has-picture .city, .event-box.has-picture .date {
+ margin-right: 50px;
+}
+}
+
+.event-box-classification, .event-box-club-acronym {
+ vertical-align: 3px;
+}
+
+#home_left {
+ padding-right: 4px;
+ width: 500px;
+ float: left;
+ text-align: left;
+}
+#home_right {
+ padding-left: 4px;
+ width: 227px;
+ float: right;
+ text-align: left;
+ border-left: 2px solid #325174;
+}
+
+.flickr-photos .thumbnail div {
+ background-position: center;
+ background-size: cover;
+ width: 100%;
+ height: 180px;
+}
+
+@media (max-width: 767px) {
+ .flickr-photos {
+ width: 100%;
+}
+
+.flickr-photos > li {
+ width: 46%;
+}
+
+.input-xxxlarge, {
+ display: block;
+ width: 100%;
+ min-height: 30px;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+}
+
+@media (max-width: 600px) {
+ .flickr-photos > li {
+ width: 100%;
+}
+}
+
+
+/** Notices and Errors **/
+.alert {
+ margin: 5px;
+}
+
+.typeahead{margin-top:2px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
+
+.typeahead .active > a {
+ color: #fff;
+}
+
+.full-width {
+ width: 100%;
+}
+
+span.thin-control {
+ height: 18px;
+ line-height: 18px;
+ min-height: 18px;
+ padding: 0;
+ padding-left: 2px;
+ padding-right: 2px;
+ margin-bottom: 0;
+}
+
+.input-group .time-spacer {
+ padding: 0;
+}
+
+.input-group .time-segment {
+ width: 30px;
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+.input-group .millisecond-time-segment {
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+.wjr-help-tooltip:hover {
+ text-decoration: none;
+}
+
+.wjr-help-tooltip > i {
+ vertical-align: middle;
+ font-size: 20px;
+ color: #888888;
+ padding-left: 4px;
+}
+
+/* Hide the built in referral as we show it below the first feed items.
+ Also increase CSS specificity so this is actually used. */
+.juicer-feed .referral.referral {
+ display: none;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/clubsite/wysiwyg.css b/app/assets/stylesheets/clubsite/wysiwyg.css
new file mode 100644
index 0000000..5253421
--- /dev/null
+++ b/app/assets/stylesheets/clubsite/wysiwyg.css
@@ -0,0 +1,6 @@
+@import url("bootstrap.min.css");
+@import url("whyjustrun.css");
+
+body {
+ margin: 10px;
+}
diff --git a/app/assets/stylesheets/clubsite_embed.css b/app/assets/stylesheets/clubsite_embed.css
new file mode 100644
index 0000000..0fbcb20
--- /dev/null
+++ b/app/assets/stylesheets/clubsite_embed.css
@@ -0,0 +1,6 @@
+/*
+ * Styles for the embeddable widget layout (embed.ctp). The layout also loads
+ * the main clubsite bundle (embed.ctp loaded layout_dependencies).
+ *
+ *= require clubsite/embed
+ */
diff --git a/app/assets/stylesheets/clubsite_other.css b/app/assets/stylesheets/clubsite_other.css
new file mode 100644
index 0000000..73f0540
--- /dev/null
+++ b/app/assets/stylesheets/clubsite_other.css
@@ -0,0 +1,7 @@
+/*
+ * Additions for the "other" clubsite layout. The layout also loads the main
+ * clubsite bundle (mirrors other.ctp, which loaded layout_dependencies plus
+ * other.css).
+ *
+ *= require clubsite/other
+ */
diff --git a/app/assets/stylesheets/clubsite_printable.css b/app/assets/stylesheets/clubsite_printable.css
new file mode 100644
index 0000000..3af63f4
--- /dev/null
+++ b/app/assets/stylesheets/clubsite_printable.css
@@ -0,0 +1,6 @@
+/*
+ * Styles for the printable layout. Mirrors printable.ctp, which loaded only
+ * printable.css (no bootstrap or whyjustrun styles).
+ *
+ *= require clubsite/printable
+ */
diff --git a/app/constraints/club_domain_constraint.rb b/app/constraints/club_domain_constraint.rb
new file mode 100644
index 0000000..6aa59a9
--- /dev/null
+++ b/app/constraints/club_domain_constraint.rb
@@ -0,0 +1,10 @@
+# Routes inside this constraint serve club websites: any host other than the
+# apex (Settings.host) is assumed to be a club domain. Club lookup happens in
+# Clubsite::BaseController so routing stays free of database access.
+class ClubDomainConstraint
+ def matches?(request)
+ # Settings.host may be configured with or without a port (e.g. localhost:3000)
+ apex = Settings.host
+ request.host != apex && request.host_with_port != apex
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 4cbd47c..34f13f4 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,6 +1,6 @@
class ApplicationController < ActionController::Base
- include Pundit
- protect_from_forgery
+ include Pundit::Authorization
+ protect_from_forgery with: :exception
before_action :clear_redirect_club_if_necessary
before_action :configure_permitted_parameters, if: :devise_controller?
@@ -17,7 +17,13 @@ def cors
def clear_redirect_club_if_necessary
# if the user gets distracted while logging in and does something else and then goes back later to log in, we don't want to redirect them to the source club in that case
- session.delete(:redirect_club_id) unless request.fullpath =~ /\/users/
+ unless request.fullpath =~ /\/users/
+ session.delete(:redirect_club_id)
+ unless request.fullpath.start_with?('/sso')
+ session.delete(:sso_return_host)
+ session.delete(:sso_return_path)
+ end
+ end
end
def configure_permitted_parameters
@@ -34,6 +40,12 @@ def store_location
end
def after_sign_in_path_for(resource)
+ sso_return_host = session.delete(:sso_return_host)
+ if sso_return_host.present?
+ query = { return_host: sso_return_host, return_path: session.delete(:sso_return_path) }.compact.to_query
+ return "/sso/authorize?#{query}"
+ end
+
redirect_club_id = session[:redirect_club_id]
unless redirect_club_id.nil? then
club = Club.find_by_id(redirect_club_id)
diff --git a/app/controllers/clubsite/base_controller.rb b/app/controllers/clubsite/base_controller.rb
new file mode 100644
index 0000000..d2d49cb
--- /dev/null
+++ b/app/controllers/clubsite/base_controller.rb
@@ -0,0 +1,64 @@
+module Clubsite
+ # Base for all controllers served on club domains. Resolves the club from the
+ # request host, runs the request in the club's timezone, and picks the club's
+ # layout.
+ class BaseController < ApplicationController
+ LAYOUTS = %w[default other].freeze
+
+ before_action :set_current_club
+ around_action :use_club_time_zone
+ layout :club_layout
+
+ helper_method :current_club, :club_resources
+
+ rescue_from Pundit::NotAuthorizedError, with: :not_authorized
+
+ private
+
+ def set_current_club
+ # Match with the port first so development domains like
+ # demo.localhost:3000 work, then without it (production domains carry no
+ # port, whatever port the server actually runs on).
+ @current_club = Club.find_by(domain: request.host_with_port) || Club.find_by(domain: request.host)
+ return if @current_club.present?
+
+ redirected = Club.find_by(redirect_domain: request.host_with_port) ||
+ Club.find_by(redirect_domain: request.host)
+ if redirected
+ redirect_to "#{redirected.domain_protocol}://#{redirected.domain}#{request.fullpath}",
+ status: :moved_permanently, allow_other_host: true
+ else
+ render plain: "No club website exists for this domain. Contact support@whyjustrun.ca for help.",
+ status: :not_found
+ end
+ end
+
+ def current_club
+ @current_club
+ end
+
+ def club_resources
+ @club_resources ||= Resource.for_club(current_club)
+ end
+
+ def use_club_time_zone(&block)
+ if current_club
+ Time.use_zone(current_club.timezone, &block)
+ else
+ yield
+ end
+ end
+
+ def club_layout
+ return 'clubsite/embed' if request.format == :embed
+
+ name = current_club&.layout
+ name = 'default' unless LAYOUTS.include?(name)
+ "clubsite/#{name}"
+ end
+
+ def not_authorized
+ redirect_to '/', alert: 'You are not authorized to access that page. Please switch to a different account or ask your club webmaster for permissions'
+ end
+ end
+end
diff --git a/app/controllers/clubsite/clubs_controller.rb b/app/controllers/clubsite/clubs_controller.rb
new file mode 100644
index 0000000..c839446
--- /dev/null
+++ b/app/controllers/clubsite/clubs_controller.rb
@@ -0,0 +1,43 @@
+module Clubsite
+ # The public club list and the club settings form for webmasters.
+ class ClubsController < BaseController
+ def index
+ @clubs = Club.visible.order(:name)
+ end
+
+ def edit
+ authorize current_club, :edit?
+ @club = current_club
+ set_form_collections
+ end
+
+ def update
+ authorize current_club, :update?
+ @club = current_club
+ if @club.update(club_params)
+ flash[:success] = 'Updated club'
+ redirect_to '/pages/admin'
+ else
+ set_form_collections
+ render :edit
+ end
+ end
+
+ private
+
+ def set_form_collections
+ @clubs = Club.order(:name)
+ @club_categories = ClubCategory.all
+ # tz database identifiers, matching what the legacy PHP form offered and
+ # what the timezone column stores (e.g. America/Vancouver).
+ @timezones = TZInfo::Timezone.all_identifiers.sort
+ end
+
+ def club_params
+ params.require(:club).permit(:name, :acronym, :location, :description, :url,
+ :facebook_page_url, :juicer_feed_url, :layout,
+ :timezone, :parent_id, :club_category_id,
+ :visible, :lat, :lng)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/content_blocks_controller.rb b/app/controllers/clubsite/content_blocks_controller.rb
new file mode 100644
index 0000000..717e15c
--- /dev/null
+++ b/app/controllers/clubsite/content_blocks_controller.rb
@@ -0,0 +1,22 @@
+module Clubsite
+ # jEditable POST target for editing content blocks in place.
+ class ContentBlocksController < BaseController
+ def update
+ content_block = current_club.content_blocks.find_by(id: entity_id(params[:id].to_s))
+ return render plain: 'Not Found', status: :not_found if content_block.nil?
+
+ authorize content_block
+ content_block.update(content: params[:value])
+ # jEditable swaps the response into the block, so return the stored HTML.
+ # Sanitize it (script stripped, formatting kept) so stored content can't
+ # execute in the editor's or a visitor's browser.
+ render html: helpers.sanitize(content_block.content.to_s)
+ end
+
+ private
+
+ def entity_id(id)
+ id.sub('content-block-', '')
+ end
+ end
+end
diff --git a/app/controllers/clubsite/courses_controller.rb b/app/controllers/clubsite/courses_controller.rb
new file mode 100644
index 0000000..2977a4c
--- /dev/null
+++ b/app/controllers/clubsite/courses_controller.rb
@@ -0,0 +1,115 @@
+module Clubsite
+ # Course pages and course actions: the detail page with results, serving
+ # uploaded course maps, registering and unregistering participants, and the
+ # course admin actions used from the event editors. Courses are reached
+ # through their event's club.
+ class CoursesController < BaseController
+ include MapsController::MediaServing
+
+ before_action :require_sign_in, only: %i[register unregister]
+
+ def show
+ @course = Course.for_club(current_club)
+ .includes(:event, results: :user)
+ .find(params[:id])
+ end
+
+ # Displays the uploaded course map
+ def map
+ serve_media('Course', params[:id], params[:thumbnail])
+ end
+
+ # Registers a user on a course. A missing or zero user id registers the
+ # signed-in user; any signed-in user may register someone else. An
+ # already-registered user silently redirects back to the event.
+ def register
+ course = find_course(params[:course_id])
+ user = target_user
+
+ # NOTE: The registration deadline is deliberately not enforced here.
+ # The legacy CakePHP action never rejected late registrations
+ # server-side; the UI only hides the buttons once registration closes.
+ unless course.results.exists?(user_id: user.id)
+ Result.create!(course: course, user: user, registrant: current_user)
+ flash[:success] = 'Registration successful!'
+ end
+ redirect_to "/events/view/#{course.event_id}"
+ end
+
+ # Removes a user's registration row for this course only. You can always
+ # unregister yourself; you can unregister someone else only if their
+ # registration *on this course* was made by you. Scoping the relationship
+ # to this course stops a registration made on one course from being used to
+ # unregister that person from unrelated courses on other clubs' sites.
+ def unregister
+ course = find_course(params[:course_id])
+ user_id = target_user_id
+ unless user_id == current_user.id ||
+ Result.exists?(course_id: course.id, user_id: user_id, registrant_id: current_user.id)
+ raise Pundit::NotAuthorizedError
+ end
+
+ Result.where(course_id: course.id, user_id: user_id).destroy_all
+ flash[:success] = 'Unregistration successful!'
+ redirect_to "/events/view/#{course.event_id}"
+ end
+
+ # Deletes a course and its results, for people who may edit the event.
+ # Called via AJAX from the event courses editor.
+ def destroy
+ course = find_course(params[:id])
+ authorize course.event, :update?
+
+ Course.transaction do
+ course.results.destroy_all
+ course.destroy!
+ end
+ head :ok
+ end
+
+ # Stores an uploaded course map, for people who may edit the event
+ def upload_map
+ course = find_course(params[:id])
+ unless policy(course.event).update?
+ flash[:alert] = "You aren't authorized to upload a map"
+ return redirect_to "/events/view/#{course.event_id}"
+ end
+
+ error =
+ if params[:file].blank?
+ 'No file selected!'
+ else
+ MediaStore.new(current_club.id, 'Course').store(course.id, params[:file])
+ end
+
+ if error
+ flash[:alert] = error
+ else
+ flash[:success] = 'Course map uploaded!'
+ end
+ redirect_to "/events/uploadMaps/#{course.event_id}"
+ end
+
+ private
+
+ def find_course(id)
+ Course.for_club(current_club).find(id)
+ end
+
+ # A missing or zero user id in the URL means the signed-in user (legacy
+ # CakePHP URL shape)
+ def target_user_id
+ id = params[:user_id].to_i
+ id.zero? ? current_user.id : id
+ end
+
+ def target_user
+ id = params[:user_id].to_i
+ id.zero? ? current_user : User.find(id)
+ end
+
+ def require_sign_in
+ redirect_to '/users/login' unless user_signed_in?
+ end
+ end
+end
diff --git a/app/controllers/clubsite/errors_controller.rb b/app/controllers/clubsite/errors_controller.rb
new file mode 100644
index 0000000..6c2971a
--- /dev/null
+++ b/app/controllers/clubsite/errors_controller.rb
@@ -0,0 +1,8 @@
+module Clubsite
+ # Catch-all for club domains so requests never fall through to apex routes.
+ class ErrorsController < BaseController
+ def not_found
+ render plain: 'Not Found', status: :not_found
+ end
+ end
+end
diff --git a/app/controllers/clubsite/events_controller.rb b/app/controllers/clubsite/events_controller.rb
new file mode 100644
index 0000000..418045e
--- /dev/null
+++ b/app/controllers/clubsite/events_controller.rb
@@ -0,0 +1,422 @@
+module Clubsite
+ # Event pages: the calendar, the event list, individual event pages with
+ # registrations, results and course maps, plus the event editor, results
+ # editor and planner.
+ class EventsController < BaseController
+ # Number of extra blank rows on the printable entries list
+ NUM_BLANK_ENTRIES = 5
+
+ # Planner thresholds: people who attended at least ATTENDANCE_THRESHOLD
+ # events since DATE_THRESHOLD ago without organizing are suggested as
+ # volunteers.
+ PLANNER_DATE_THRESHOLD = 5.months
+ PLANNER_ATTENDANCE_THRESHOLD = 5
+
+ def index
+ if params[:date].present? && params[:date].include?('-')
+ # Day-first date, e.g. /events/index/15-06-2025
+ @day, @month, @year = params[:date].split('-')
+ else
+ today = Time.zone.today
+ @day = today.day
+ @month = today.month
+ @year = today.year
+ end
+
+ @series = current_club.series.where(is_current: true)
+ end
+
+ def listing
+ end
+
+ def show
+ if request.format.xml?
+ redirect_to "/iof/3.0/events/#{params[:id]}/result_list.xml", status: :moved_permanently
+ return
+ end
+
+ @event = current_club.events
+ .includes(:series, :map, :event_classification, :result_list,
+ organizers: %i[user role],
+ courses: { results: :user })
+ .find(params[:id])
+ @can_edit = policy(@event).update?
+
+ # Events with an external page redirect everybody except editors, who
+ # instead see the page with a notice (see _redirect_view).
+ if @event.custom_url.present? && !@can_edit
+ redirect_to @event.custom_url, allow_other_host: true
+ return
+ end
+
+ @completed = @event.completed?
+ @registration_open = @event.registration_open?
+ @has_results = @event.has_results?
+ @sorted_results = @event.courses.index_by(&:id).transform_values(&:sorted_results)
+ @registered_course_ids = registered_course_ids(@event)
+
+ render json: event_json(@event) if request.format.json?
+ end
+
+ # Google map for a single event, always rendered bare for embedding
+ def map
+ @event = current_club.events.find(params[:id])
+ render layout: 'clubsite/embed', formats: [:html]
+ end
+
+ # Serves the manually uploaded results rendering for an event
+ def rendering
+ id = begin
+ Integer(params[:id])
+ rescue ArgumentError, TypeError
+ raise ActiveRecord::RecordNotFound
+ end
+
+ path = MediaStore.new(current_club.id, 'Event').file_path(id)
+ raise ActiveRecord::RecordNotFound if path.nil?
+
+ send_file path, disposition: :inline
+ end
+
+ # Add/edit form. Add when no id is given (legacy URL shape).
+ def edit
+ @event = find_event_or_new
+ authorize @event, @event.new_record? ? :create? : :update?
+ prepare_edit_form(@event)
+ end
+
+ # Handles the edit form POST for both add and edit.
+ def save
+ @event = find_event_or_new
+ authorize @event, @event.new_record? ? :create? : :update?
+
+ organizers = parse_json_blob(params.dig(:event, :organizers))
+ courses = parse_json_blob(params.dig(:event, :courses))
+ assign_event_fields(@event)
+
+ if persist_event(@event, organizers, courses)
+ flash[:success] = 'The event has been updated.'
+ redirect_to "/events/view/#{@event.id}"
+ else
+ flash.now[:danger] = 'The event could not be updated.'
+ prepare_edit_form(@event,
+ organizers_json: params.dig(:event, :organizers),
+ courses_json: params.dig(:event, :courses))
+ render :edit
+ end
+ end
+
+ def destroy
+ event = current_club.events.find(params[:id])
+ authorize event, :destroy?
+ # Destroys organizers, courses and the result list through the model
+ # associations; course results cascade at the database level.
+ event.destroy
+ flash[:success] = 'The event was deleted.'
+ redirect_to '/events/'
+ end
+
+ # Planning aids: maps that haven't been used recently and regular
+ # attendees who haven't volunteered as organizers.
+ def planner
+ authorize Event.new(club: current_club), :plan?
+ @date_threshold = PLANNER_DATE_THRESHOLD.ago
+ @attendance_threshold = PLANNER_ATTENDANCE_THRESHOLD
+ @maps = planner_maps
+ @volunteers = planner_volunteers
+ end
+
+ # Printable per-course entry list for use at the registration desk
+ def printable_entries
+ @event = current_club.events.includes(courses: { results: :user }).find(params[:id])
+ @sorted_results = @event.courses.index_by(&:id)
+ .transform_values { |course| course.results.sort_by { |result| result.user.name } }
+ render layout: 'clubsite/printable'
+ end
+
+ # Lists the event's courses with per-course map upload forms (the upload
+ # itself is handled by CoursesController#upload_map).
+ def upload_maps
+ @event = current_club.events.find(params[:id])
+ authorize @event, :update?
+ @courses = @event.courses
+ end
+
+ # Knockout-based results editor; the initial data is fetched client-side
+ # from the event JSON.
+ def edit_results
+ @event = current_club.events.find(params[:id])
+ authorize @event, :update?
+ end
+
+ # Saves the results editor form. Courses arrive as a JSON blob of
+ # {id, is_score_o, results: [...]} hashes.
+ def update_results
+ @event = current_club.events.find(params[:id])
+ authorize @event, :update?
+
+ allowed_course_ids = @event.courses.pluck(:id)
+ courses = parse_json_blob(params.dig(:event, :courses))
+
+ # Security check: every course being edited must belong to this event
+ unless (courses.map { |course| course['id'].to_i } - allowed_course_ids).empty?
+ return redirect_to '/', alert: 'You are not authorized to edit those courses.'
+ end
+
+ begin
+ ActiveRecord::Base.transaction do
+ courses.each do |course_data|
+ save_course_results(course_data, allowed_course_ids)
+ course = @event.courses.find(course_data['id'])
+ course.update!(is_score_o: boolean_param(course_data['is_score_o']))
+ end
+ @event.update!(results_posted: boolean_param(params.dig(:event, :results_posted))) if courses.any?
+ end
+ rescue ActiveRecord::RecordInvalid
+ flash[:danger] = 'The results could not be updated.'
+ return redirect_to "/events/editResults/#{@event.id}"
+ end
+
+ redirect_to "/events/view/#{@event.id}"
+ end
+
+ # Shows or hides the event's live result list
+ def toggle_live_results_visibility
+ @event = current_club.events.find(params[:id])
+ authorize @event, :update?
+ live_result = ResultList.find_by(event_id: @event.id, status: ResultList::LIVE_STATUS)
+ live_result&.update(visible: params[:visible] == 'true')
+ redirect_to "/events/view/#{@event.id}"
+ end
+
+ private
+
+ def find_event_or_new
+ params[:id].present? ? current_club.events.find(params[:id]) : current_club.events.new
+ end
+
+ def prepare_edit_form(event, organizers_json: nil, courses_json: nil)
+ @maps = current_club.maps.order(:name)
+ @series_options = series_options(event)
+ @event_classifications = EventClassification.all
+ @organizers_json = organizers_json || organizers_to_json(event)
+ @courses_json = courses_json || courses_to_json(event)
+ end
+
+ # Only current series are offered, unless the event is already assigned to
+ # a non-current series (in which case all are offered, current first).
+ def series_options(event)
+ if event.persisted? && event.series && !event.series.is_current
+ current_club.series.order(is_current: :desc)
+ else
+ current_club.series.where(is_current: true)
+ end
+ end
+
+ def organizers_to_json(event)
+ return '[]' if event.new_record?
+
+ event.organizers.includes(:user, :role).map do |organizer|
+ {
+ id: organizer.user_id,
+ name: organizer.user.name,
+ role: { id: organizer.role_id, name: organizer.role&.name }
+ }
+ end.to_json
+ end
+
+ def courses_to_json(event)
+ return '[]' if event.new_record?
+
+ event.courses.map do |course|
+ {
+ id: course.id,
+ name: course.name,
+ distance: course.distance,
+ climb: course.climb,
+ description: course.description,
+ isScoreO: course.is_score_o
+ }
+ end.to_json
+ end
+
+ def parse_json_blob(value)
+ JSON.parse(value.presence || '[]')
+ rescue JSON::ParserError
+ []
+ end
+
+ def assign_event_fields(event)
+ event.assign_attributes(event_params)
+ event.club = current_club
+
+ # Separate date and time inputs are combined in the club's time zone
+ # (the request runs inside Time.use_zone).
+ event.date = combine_date_time(params.dig(:event, :date), params.dig(:event, :time))
+ event.finish_date = combine_date_time(params.dig(:event, :finish_date), params.dig(:event, :finish_time))
+ event.registration_deadline = registration_deadline_param
+
+ # The map marker defaults to the club's location; don't store it
+ if event.lat.to_f == current_club.lat.to_f && event.lng.to_f == current_club.lng.to_f
+ event.lat = nil
+ event.lng = nil
+ end
+ end
+
+ def event_params
+ params.require(:event).permit(
+ :name, :event_classification_id, :series_id, :map_id, :description,
+ :custom_url, :registration_url, :results_url, :routegadget_url,
+ :facebook_url, :attackpoint_url, :number_of_participants, :is_ranked,
+ :lat, :lng
+ )
+ end
+
+ def combine_date_time(date, time)
+ return nil if date.blank?
+
+ Time.zone.parse("#{date} #{time}")
+ rescue ArgumentError
+ nil
+ end
+
+ # The deadline time defaults to the end of the chosen day
+ def registration_deadline_param
+ date = params.dig(:event, :deadline_date)
+ return nil if date.blank?
+
+ time = params.dig(:event, :deadline_time).presence || '23:59'
+ combine_date_time(date, time)
+ end
+
+ def persist_event(event, organizers, courses)
+ ActiveRecord::Base.transaction do
+ # Legacy behavior: organizers are deleted and recreated on every save
+ event.organizers.destroy_all if event.persisted?
+ raise ActiveRecord::Rollback unless event.save
+
+ organizers.each do |organizer_data|
+ event.organizers.create!(user_id: organizer_data['id'],
+ role_id: organizer_data.dig('role', 'id'))
+ end
+
+ # Courses are updated in place by id and added when new; removed
+ # courses are deleted separately by the course editor UI.
+ courses.each do |course_data|
+ course = course_data['id'].present? ? event.courses.find(course_data['id']) : event.courses.build
+ course.update!(name: course_data['name'],
+ distance: course_data['distance'],
+ climb: course_data['climb'],
+ description: course_data['description'],
+ is_score_o: boolean_param(course_data['isScoreO']))
+ end
+
+ true
+ rescue ActiveRecord::RecordInvalid
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ def boolean_param(value)
+ ActiveModel::Type::Boolean.new.cast(value) || false
+ end
+
+ # Club maps ordered by when they last hosted an event, least recently
+ # used first. Returns [map, last_event] pairs; last_event is nil for
+ # never-used maps.
+ def planner_maps
+ latest_events = current_club.events.where.not(map_id: nil).order(:date).index_by(&:map_id)
+ current_club.maps
+ .sort_by { |map| latest_events[map.id]&.date || Time.zone.at(0) }
+ .map { |map| [map, latest_events[map.id]] }
+ end
+
+ # Club members who attended enough recent events (anywhere) but haven't
+ # organized any. Returns [user, attended_count] pairs, most active first.
+ def planner_volunteers
+ threshold_time = @date_threshold
+ attendance = Result.joins(course: :event)
+ .where('events.date > ?', threshold_time)
+ .group(:user_id)
+ .order(Arel.sql('COUNT(results.user_id) DESC'))
+ .count
+ attendance.select! { |_user_id, count| count >= @attendance_threshold }
+
+ organized = Organizer.joins(:event)
+ .where('events.date > ?', threshold_time)
+ .group(:user_id)
+ .count
+
+ volunteer_ids = (attendance.keys - organized.keys) & current_club.users.pluck(:id)
+ users = User.where(id: volunteer_ids).index_by(&:id)
+ volunteer_ids.filter_map { |user_id| users[user_id] && [users[user_id], attendance[user_id]] }
+ end
+
+ def save_course_results(course_data, allowed_course_ids)
+ Array(course_data['results']).each do |result_data|
+ result = if result_data['id'].present?
+ Result.where(course_id: allowed_course_ids).find_by(id: result_data['id'])
+ end
+ result ||= Result.new
+
+ attributes = {
+ user_id: result_data.dig('user', 'id'),
+ course_id: course_data['id'],
+ time_seconds: time_from_parts(result_data),
+ status: result_data['status'].presence || 'ok',
+ registrant_comment: result_data['registrant_comment'].presence,
+ official_comment: result_data['official_comment'].presence
+ }
+ attributes[:score_points] = result_data['score_points'] if result_data['score_points'].present?
+
+ result.update!(attributes)
+ end
+ end
+
+ # An all-zero time means no time was recorded
+ def time_from_parts(result_data)
+ hours = result_data['hours'].to_i
+ minutes = result_data['minutes'].to_i
+ seconds = result_data['seconds'].to_i
+ milliseconds = result_data['milliseconds'].to_i
+ return nil if hours.zero? && minutes.zero? && seconds.zero? && milliseconds.zero?
+
+ (3600 * hours) + (60 * minutes) + seconds + (0.001 * milliseconds)
+ end
+
+ # .embed requests reuse the html templates; only the layout differs (see
+ # BaseController#club_layout).
+ def default_render
+ if request.format == :embed
+ render action_name, formats: [:html]
+ else
+ super
+ end
+ end
+
+ def registered_course_ids(event)
+ return [] unless user_signed_in?
+
+ event.courses.select { |course| course.results.any? { |result| result.user_id == current_user.id } }
+ .map(&:id)
+ end
+
+ def event_json(event)
+ event.as_json(
+ methods: [:url],
+ include: {
+ series: {},
+ map: {},
+ event_classification: {},
+ result_list: { except: [:data] },
+ organizers: { include: { user: { only: %i[id name] }, role: {} } },
+ courses: { include: { results: { include: { user: { only: %i[id name si_number] } } } } }
+ }
+ ).merge(
+ 'completed' => @completed,
+ 'registration_open' => @registration_open,
+ 'has_results' => @has_results
+ )
+ end
+ end
+end
diff --git a/app/controllers/clubsite/map_standards_controller.rb b/app/controllers/clubsite/map_standards_controller.rb
new file mode 100644
index 0000000..6824851
--- /dev/null
+++ b/app/controllers/clubsite/map_standards_controller.rb
@@ -0,0 +1,46 @@
+module Clubsite
+ # Admin pages for map standards, shared between all clubs.
+ class MapStandardsController < BaseController
+ def index
+ authorize current_club, :index?, policy_class: MapStandardPolicy
+ @map_standards = MapStandard.all
+ end
+
+ def edit
+ authorize current_club, :edit?, policy_class: MapStandardPolicy
+ @map_standard = find_or_build_map_standard
+ end
+
+ def update
+ authorize current_club, :update?, policy_class: MapStandardPolicy
+ @map_standard = find_or_build_map_standard
+ if @map_standard.update(map_standard_params)
+ flash[:success] = 'The map standard has been updated.'
+ redirect_to '/mapStandards/'
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ authorize current_club, :destroy?, policy_class: MapStandardPolicy
+ map_standard = MapStandard.find(params[:id])
+ if map_standard.destroy
+ flash[:success] = 'The map standard has been deleted.'
+ else
+ flash[:danger] = 'The map standard could not be deleted.'
+ end
+ redirect_to '/mapStandards/'
+ end
+
+ private
+
+ def find_or_build_map_standard
+ params[:id].present? ? MapStandard.find(params[:id]) : MapStandard.new
+ end
+
+ def map_standard_params
+ params.require(:map_standard).permit(:name, :color, :description)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/maps_controller.rb b/app/controllers/clubsite/maps_controller.rb
new file mode 100644
index 0000000..5d5522c
--- /dev/null
+++ b/app/controllers/clubsite/maps_controller.rb
@@ -0,0 +1,178 @@
+# MediaStore's image pipeline uses MiniMagick, which is not autoloaded
+require 'mini_magick'
+
+module Clubsite
+ # Map pages: listing, detail, usage report, the map editor, plus serving map
+ # file downloads and uploaded map renderings.
+ class MapsController < BaseController
+ # Serves uploaded media files through MediaStore. Shared with
+ # CoursesController, which serves uploaded course maps the same way.
+ module MediaServing
+ CONTENT_TYPES = {
+ '.png' => 'image/png',
+ '.jpg' => 'image/jpeg',
+ '.jpeg' => 'image/jpeg',
+ '.gif' => 'image/gif',
+ '.pdf' => 'application/pdf'
+ }.freeze
+
+ private
+
+ # Serves the uploaded file (or one of its generated thumbnails) for the
+ # given media type, falling back to the bundled default image when
+ # nothing has been uploaded (the legacy app served the default with a
+ # 200 as well).
+ def serve_media(type, id_param, thumbnail)
+ id = begin
+ Integer(id_param)
+ rescue ArgumentError, TypeError
+ not_found_404
+ end
+
+ store = MediaStore.new(current_club.id, type)
+ path = if thumbnail
+ not_found_404 unless store.valid_thumbnail?(thumbnail)
+ store.thumbnail_path(id, thumbnail)
+ else
+ store.file_path(id)
+ end
+ path ||= MediaStore.default_image_path(type).to_s
+
+ extension = File.extname(path).downcase
+ send_file path, disposition: 'inline',
+ type: CONTENT_TYPES.fetch(extension, 'application/octet-stream')
+ end
+ end
+
+ include MediaServing
+
+ DROPBOX_DIRECT_DOWNLOAD_HOST = 'dl.dropboxusercontent.com'.freeze
+
+ def index
+ # The map markers are fetched client-side from /api/maps.json
+ @edit = edit_maps?
+
+ respond_to do |format|
+ format.html
+ # Advertised iframe embed; reuses the html template with the embed
+ # layout picked by BaseController#club_layout.
+ format.embed { render :index, formats: :html }
+ end
+ end
+
+ def show
+ @map = current_club.maps.find(params[:id])
+ @events = current_club.events.where(map_id: @map.id).includes(:series).order(:date)
+ @edit = edit_maps?
+ end
+
+ def report
+ @maps = current_club.maps.order(:name)
+ end
+
+ def download
+ map = current_club.maps.find(params[:id])
+ # The legacy privilege for downloading map files (maps.viewOCAD) is level
+ # 0, which every visitor (including anonymous) satisfies, so there is no
+ # privilege check here.
+ not_found_404 if map.file_url.blank?
+
+ redirect_to direct_download_url(map.file_url), allow_other_host: true
+ end
+
+ # Displays a rendering of the map file (manually uploaded)
+ def rendering
+ serve_media('Map', params[:id], params[:thumbnail])
+ end
+
+ # Edit doubles as add when no id is given (legacy URL shape)
+ def edit
+ @map = find_or_build_map
+ authorize @map
+ @map_standards = MapStandard.all
+ end
+
+ def save
+ @map = find_or_build_map
+ authorize @map
+
+ if @map.update(map_params)
+ error = store_uploaded_image(@map)
+ if error
+ flash[:danger] = error
+ else
+ flash[:success] = 'The map has been updated.'
+ end
+ redirect_to "/maps/view/#{@map.id}"
+ else
+ @map_standards = MapStandard.all
+ render :edit
+ end
+ end
+
+ def destroy
+ map = current_club.maps.find(params[:id])
+ authorize map
+ map.destroy
+ map_media_store.delete(map.id)
+ flash[:success] = 'The map was deleted.'
+ redirect_to '/maps/'
+ end
+
+ # Posted by the draggable marker on the edit form when the marker is
+ # dropped. The legacy action had no privilege check; it now requires the
+ # same maps.edit privilege as the rest of the editor.
+ def update_location
+ map = current_club.maps.find(params[:id])
+ authorize map
+ map.update!(lat: params[:lat], lng: params[:lng])
+ head :ok
+ end
+
+ private
+
+ # Scoping the find through current_club 404s ids belonging to other clubs.
+ def find_or_build_map
+ if params[:id].present?
+ current_club.maps.find(params[:id])
+ else
+ current_club.maps.new
+ end
+ end
+
+ def map_params
+ params.require(:map).permit(:name, :map_standard_id, :scale, :file_url, :notes, :lat, :lng)
+ end
+
+ def map_media_store
+ MediaStore.new(current_club.id, 'Map')
+ end
+
+ # Stores an uploaded map image and regenerates the randomly cropped 60x60
+ # banner (the legacy generateBanner step, which ran after every upload).
+ # Returns an error message string on rejection, nil otherwise.
+ def store_uploaded_image(map)
+ upload = params.dig(:map, :image)
+ return nil if upload.blank?
+
+ store = map_media_store
+ error = store.store(map.id, upload)
+ store.create_cropped_thumbnail(map.id, '60x60') if error.nil?
+ error
+ end
+
+ def edit_maps?
+ user_signed_in? && MapPolicy.new(current_user, Map.new(club: current_club)).edit?
+ end
+
+ # Make direct downloads work for Dropbox (it's broken in some browsers
+ # without the dl=1 parameter)
+ def direct_download_url(url)
+ uri = URI.parse(url)
+ uri.query = 'dl=1' if uri.host == DROPBOX_DIRECT_DOWNLOAD_HOST && uri.query.blank?
+ uri.to_s
+ rescue URI::InvalidURIError
+ url
+ end
+ end
+end
diff --git a/app/controllers/clubsite/memberships_controller.rb b/app/controllers/clubsite/memberships_controller.rb
new file mode 100644
index 0000000..a0a6698
--- /dev/null
+++ b/app/controllers/clubsite/memberships_controller.rb
@@ -0,0 +1,60 @@
+module Clubsite
+ # Club membership tracking. The index doubles as the add form; edit doubles
+ # as add when no id is given (legacy URL shape).
+ class MembershipsController < BaseController
+ def index
+ authorize current_club, :index?, policy_class: MembershipPolicy
+ @memberships = current_club.memberships.includes(:user).order(year: :desc)
+ @membership = current_club.memberships.new(year: Time.zone.today.year, created: Time.zone.now)
+ @users = user_options
+ end
+
+ def edit
+ @membership = find_or_build_membership
+ authorize @membership
+ @users = user_options
+ end
+
+ def update
+ @membership = find_or_build_membership
+ authorize @membership
+ if @membership.update(membership_params)
+ flash[:success] = 'The membership has been updated.'
+ else
+ flash[:danger] = 'The membership could not be updated.'
+ end
+ redirect_to '/memberships/'
+ end
+
+ def destroy
+ membership = current_club.memberships.find(params[:id])
+ authorize membership
+ if membership.destroy
+ flash[:success] = 'The membership has been deleted.'
+ else
+ flash[:danger] = 'The membership could not be deleted.'
+ end
+ redirect_to '/memberships/'
+ end
+
+ private
+
+ # Scoping the find through current_club 404s ids belonging to other clubs.
+ def find_or_build_membership
+ if params[:id].present?
+ current_club.memberships.find(params[:id])
+ else
+ current_club.memberships.new
+ end
+ end
+
+ # The legacy app offered all users in the dropdown, not just club members.
+ def user_options
+ User.order(:name)
+ end
+
+ def membership_params
+ params.require(:membership).permit(:user_id, :year, :created)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/officials_controller.rb b/app/controllers/clubsite/officials_controller.rb
new file mode 100644
index 0000000..6981d8e
--- /dev/null
+++ b/app/controllers/clubsite/officials_controller.rb
@@ -0,0 +1,58 @@
+module Clubsite
+ # Officials certification tracking (user + classification + date). Officials
+ # are shared between all clubs; access is gated by the club privilege only.
+ # All forms live on the index page, so edit just returns there.
+ class OfficialsController < BaseController
+ def index
+ authorize current_club, :index?, policy_class: OfficialPolicy
+ @officials = Official.includes(:user, :official_classification)
+ .order(:official_classification_id)
+ @official_classifications = OfficialClassification.all
+ end
+
+ def create
+ authorize current_club, :create?, policy_class: OfficialPolicy
+ official = Official.new(official_params)
+ if official.save
+ flash[:success] = 'The official has been added.'
+ else
+ flash[:danger] = 'The official could not be added.'
+ end
+ redirect_to '/officials/'
+ end
+
+ # The legacy edit page rendered nothing; editing happens inline on index.
+ def edit
+ authorize current_club, :edit?, policy_class: OfficialPolicy
+ redirect_to '/officials/'
+ end
+
+ def update
+ authorize current_club, :update?, policy_class: OfficialPolicy
+ official = Official.find(params[:id])
+ if official.update(official_params)
+ flash[:success] = 'The official has been updated.'
+ else
+ flash[:danger] = 'The official could not be updated.'
+ end
+ redirect_to '/officials/'
+ end
+
+ def destroy
+ authorize current_club, :destroy?, policy_class: OfficialPolicy
+ official = Official.find(params[:id])
+ if official.destroy
+ flash[:success] = 'The official was deleted.'
+ else
+ flash[:danger] = 'The official could not be deleted.'
+ end
+ redirect_to '/officials/'
+ end
+
+ private
+
+ def official_params
+ params.require(:official).permit(:user_id, :official_classification_id, :date)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/pages_controller.rb b/app/controllers/clubsite/pages_controller.rb
new file mode 100644
index 0000000..a1454bc
--- /dev/null
+++ b/app/controllers/clubsite/pages_controller.rb
@@ -0,0 +1,97 @@
+module Clubsite
+ # The club home page, a fixed set of static pages, and dynamic Resources
+ # pages that are edited in place with jEditable.
+ class PagesController < BaseController
+ # The only static templates /pages/:page may render. Arbitrary params are
+ # never rendered as template paths.
+ STATIC_PAGES = %w[contact resources admin export].freeze
+
+ def home
+ render :other if current_club.layout == 'other'
+ end
+
+ def show
+ if params[:page].match?(/\A\d+\z/)
+ show_dynamic_page(params[:page])
+ elsif STATIC_PAGES.include?(params[:page])
+ show_static_page(params[:page])
+ else
+ render_not_found
+ end
+ end
+
+ def create
+ @page = current_club.pages.new(page_params)
+ # Section is hardcoded for now
+ @page.section = 'Resources'
+ authorize @page
+
+ if @page.save
+ flash[:success] = 'The page has been added.'
+ else
+ flash[:danger] = 'The page could not be added.'
+ end
+ redirect_to '/pages/resources'
+ end
+
+ # jEditable POST target for editing a page's content or title in place.
+ def update
+ page = current_club.pages.find_by(id: entity_id(params[:id].to_s))
+ return render_not_found if page.nil?
+
+ authorize page
+ if params[:value].present?
+ page.update(content: params[:value])
+ content = params[:value]
+ elsif params[:name].present?
+ page.update(name: params[:name])
+ content = params[:name]
+ else
+ # This should never happen, but somehow it does..
+ content = nil
+ end
+ render plain: content
+ end
+
+ def destroy
+ page = current_club.pages.find_by(id: params[:id])
+ return render_not_found if page.nil?
+
+ authorize page
+ page.destroy
+ redirect_to '/pages/resources'
+ end
+
+ private
+
+ def show_dynamic_page(id)
+ @page = current_club.pages.find_by(id: id)
+ return render_not_found if @page.nil?
+
+ render :display
+ end
+
+ def show_static_page(name)
+ case name
+ when 'resources'
+ @pages = current_club.pages.where(section: 'Resources').select(:id, :name)
+ when 'admin'
+ authorize current_club, :admin?, policy_class: PagePolicy
+ @allow_show_duplicates = current_user.has_privilege?(Settings.privileges.user.merge, current_club)
+ end
+ render name
+ end
+
+ def page_params
+ params.require(:page).permit(:name, :content)
+ end
+
+ def entity_id(id)
+ id.sub('page-resource-', '').sub('title-', '')
+ end
+
+ def render_not_found
+ render plain: 'Not Found', status: :not_found
+ end
+ end
+end
diff --git a/app/controllers/clubsite/privileges_controller.rb b/app/controllers/clubsite/privileges_controller.rb
new file mode 100644
index 0000000..65d28cb
--- /dev/null
+++ b/app/controllers/clubsite/privileges_controller.rb
@@ -0,0 +1,67 @@
+module Clubsite
+ # Grants and revokes club privileges (group memberships). Every action
+ # requires the privilege edit level, and users can only see and manage
+ # groups at or below their own privilege level for the club -- nobody can
+ # grant (or revoke) a level above their own. Only groups belonging to the
+ # current club are managed here; global groups are never offered.
+ class PrivilegesController < BaseController
+ def index
+ authorize current_club, policy_class: PrivilegePolicy
+ @groups = manageable_groups
+ @privileges = Privilege.joins(:user_group, :user)
+ .where(groups: { id: @groups.select(:id) })
+ .includes(:user, :user_group)
+ .order('groups.access_level DESC, users.name')
+ # A plain club-member select for now; the person-picker autocomplete
+ # arrives with the users JSON endpoint in the registration port.
+ @users = current_club.users.merge(User.find_all_real).order(:name)
+ end
+
+ def create
+ authorize current_club, policy_class: PrivilegePolicy
+
+ # Scoping to the current club 404s cross-club and global group ids
+ group = Group.where(club_id: current_club.id).find(privilege_params[:group_id])
+ raise Pundit::NotAuthorizedError if group.access_level > acting_level
+
+ user = User.find(privilege_params[:user_id])
+ if Privilege.exists?(user: user, user_group: group)
+ flash[:notice] = "#{user.name} already has the #{group.name} privilege."
+ elsif Privilege.create(user: user, user_group: group).persisted?
+ flash[:success] = 'The privilege has been added.'
+ else
+ flash[:danger] = 'The privilege could not be added.'
+ end
+ redirect_to '/privileges/'
+ end
+
+ def destroy
+ authorize current_club, policy_class: PrivilegePolicy
+
+ privilege = Privilege.joins(:user_group)
+ .where(groups: { club_id: current_club.id })
+ .find(params[:id])
+ raise Pundit::NotAuthorizedError if privilege.user_group.access_level > acting_level
+
+ privilege.destroy
+ flash[:success] = 'The privilege has been deleted.'
+ redirect_to '/privileges/'
+ end
+
+ private
+
+ def privilege_params
+ params.require(:privilege).permit(:user_id, :group_id)
+ end
+
+ def acting_level
+ @acting_level ||= current_user.privilege_level(current_club)
+ end
+
+ def manageable_groups
+ Group.where(club_id: current_club.id)
+ .up_to_level(acting_level)
+ .order(access_level: :desc)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/resources_controller.rb b/app/controllers/clubsite/resources_controller.rb
new file mode 100644
index 0000000..cc8493b
--- /dev/null
+++ b/app/controllers/clubsite/resources_controller.rb
@@ -0,0 +1,36 @@
+module Clubsite
+ # Club branding uploads (header image, logo, custom stylesheet). Uploads are
+ # stored on the shared data volume and thumbnailed; see Resource.
+ class ResourcesController < BaseController
+ def index
+ authorize current_club, policy_class: ResourcePolicy
+ @resources = Resource.where(club_id: current_club.id).index_by(&:key)
+ @can_delete = ResourcePolicy.new(current_user, current_club).destroy?
+ end
+
+ def create
+ authorize current_club, policy_class: ResourcePolicy
+
+ key = params.dig(:resource, :key).to_s
+ not_found_404 unless Resource::RESOURCE_KEYS.key?(key)
+
+ begin
+ Resource.save_for_club(current_club, key,
+ params.dig(:resource, :file), params.dig(:resource, :caption))
+ flash[:success] = 'The resource has been uploaded.'
+ rescue Resource::InvalidUpload => error
+ flash[:danger] = error.message
+ end
+ redirect_to '/resources/index'
+ end
+
+ def destroy
+ authorize current_club, policy_class: ResourcePolicy
+
+ resource = Resource.where(club_id: current_club.id).find(params[:id])
+ resource.destroy
+ flash[:success] = 'The resource has been deleted.'
+ redirect_to '/resources/index'
+ end
+ end
+end
diff --git a/app/controllers/clubsite/results_controller.rb b/app/controllers/clubsite/results_controller.rb
new file mode 100644
index 0000000..ad19515
--- /dev/null
+++ b/app/controllers/clubsite/results_controller.rb
@@ -0,0 +1,45 @@
+module Clubsite
+ # Paginated listing of results for the club's events, plus the registrant
+ # comment editor and result deletion used by the event pages.
+ class ResultsController < BaseController
+ PER_PAGE = 20
+
+ def index
+ scope = Result.joins(course: :event).where(events: { club_id: current_club.id })
+
+ @page = [params[:page].to_i, 1].max
+ @total_pages = [(scope.count / PER_PAGE.to_f).ceil, 1].max
+ @results = scope.includes(:user, course: :event)
+ .order('events.date DESC, results.id ASC')
+ .limit(PER_PAGE)
+ .offset((@page - 1) * PER_PAGE)
+ end
+
+ # POST target of the comment modal on the event page. Only the person who
+ # made the registration or the registered user may edit the comment.
+ def edit_registrant_comment
+ return redirect_to '/users/login' unless user_signed_in?
+
+ result = find_result(params.require(:result)[:id])
+ authorize result, :edit_registrant_comment?
+ result.update!(registrant_comment: params[:result][:registrant_comment])
+ redirect_to "/events/view/#{result.course.event_id}"
+ end
+
+ # Deletes a result row (called via AJAX from the result editor), for
+ # people who may edit the event
+ def destroy
+ result = find_result(params[:id])
+ authorize result, :destroy?
+ result.destroy!
+ head :ok
+ end
+
+ private
+
+ # Results are reachable only through the current club's events
+ def find_result(id)
+ Result.joins(course: :event).where(events: { club_id: current_club.id }).find(id)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/robots_controller.rb b/app/controllers/clubsite/robots_controller.rb
new file mode 100644
index 0000000..1f9791c
--- /dev/null
+++ b/app/controllers/clubsite/robots_controller.rb
@@ -0,0 +1,10 @@
+module Clubsite
+ # Dynamic robots.txt: club sites are hidden from crawlers outside production
+ # and for clubs marked not visible.
+ class RobotsController < BaseController
+ def show
+ hidden = Rails.configuration.x.clubsite_robots_hidden || !current_club.visible
+ render plain: hidden ? "User-agent: *\nDisallow: /\n" : "User-agent: *\nDisallow:\n"
+ end
+ end
+end
diff --git a/app/controllers/clubsite/roles_controller.rb b/app/controllers/clubsite/roles_controller.rb
new file mode 100644
index 0000000..aedabb8
--- /dev/null
+++ b/app/controllers/clubsite/roles_controller.rb
@@ -0,0 +1,45 @@
+module Clubsite
+ # Organizer roles, shared between all clubs. Any signed-in user may view the
+ # list (the .json variant feeds the event organizer editor); editing is
+ # restricted to administrators. There is intentionally no delete action:
+ # deleting a role would leave dangling references from organizers.
+ class RolesController < BaseController
+ def index
+ authorize current_club, :index?, policy_class: RolePolicy
+ @roles = Role.all
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: @roles.map { |role| { id: role.id, name: role.name } }
+ end
+ end
+ end
+
+ def edit
+ authorize current_club, :edit?, policy_class: RolePolicy
+ @role = find_or_build_role
+ end
+
+ def update
+ authorize current_club, :update?, policy_class: RolePolicy
+ @role = find_or_build_role
+ if @role.update(role_params)
+ flash[:success] = 'The role has been updated.'
+ redirect_to '/roles/'
+ else
+ render :edit
+ end
+ end
+
+ private
+
+ def find_or_build_role
+ params[:id].present? ? Role.find(params[:id]) : Role.new
+ end
+
+ def role_params
+ params.require(:role).permit(:name, :description)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/series_controller.rb b/app/controllers/clubsite/series_controller.rb
new file mode 100644
index 0000000..e491c06
--- /dev/null
+++ b/app/controllers/clubsite/series_controller.rb
@@ -0,0 +1,41 @@
+module Clubsite
+ # Admin pages for the club's event series. Edit doubles as add when no id is
+ # given (legacy URL shape).
+ class SeriesController < BaseController
+ def index
+ authorize current_club, :index?, policy_class: SeriesPolicy
+ @series = current_club.series
+ end
+
+ def edit
+ @series = find_or_build_series
+ authorize @series
+ end
+
+ def update
+ @series = find_or_build_series
+ authorize @series
+ if @series.update(series_params)
+ flash[:success] = 'The series has been updated.'
+ redirect_to '/series/index'
+ else
+ render :edit
+ end
+ end
+
+ private
+
+ # Scoping the find through current_club 404s ids belonging to other clubs.
+ def find_or_build_series
+ if params[:id].present?
+ current_club.series.find(params[:id])
+ else
+ current_club.series.new
+ end
+ end
+
+ def series_params
+ params.require(:series).permit(:acronym, :name, :color, :information, :is_current)
+ end
+ end
+end
diff --git a/app/controllers/clubsite/sessions_controller.rb b/app/controllers/clubsite/sessions_controller.rb
new file mode 100644
index 0000000..4f598c8
--- /dev/null
+++ b/app/controllers/clubsite/sessions_controller.rb
@@ -0,0 +1,44 @@
+module Clubsite
+ # Club-domain sign-in/out. There is no sign-in form on club domains: the
+ # visitor is sent to the apex site and comes back with a short-lived signed
+ # token (see SsoToken and SsoController).
+ class SessionsController < BaseController
+ rate_limit to: 10, within: 1.minute, only: :consume
+
+ def new
+ query = { return_host: request.host_with_port, return_path: params[:return_path] }.compact.to_query
+ redirect_to "#{Settings.coreURL.chomp('/')}/sso/authorize?#{query}",
+ allow_other_host: true
+ end
+
+ def consume
+ payload = SsoToken.verify(params[:token].to_s)
+ user = payload && payload['host'] == request.host_with_port && User.find_by(id: payload['user_id'])
+ if user
+ sign_in(user)
+ redirect_to safe_path
+ else
+ redirect_to '/', alert: 'Signing in failed. Please try again.'
+ end
+ end
+
+ def destroy
+ sign_out(current_user) if user_signed_in?
+ query = { return_host: request.host_with_port }.to_query
+ redirect_to "#{Settings.coreURL.chomp('/')}/sso/logout?#{query}",
+ allow_other_host: true
+ end
+
+ def logout_complete
+ flash[:notice] = 'You have been signed out.'
+ redirect_to '/'
+ end
+
+ private
+
+ def safe_path
+ path = params[:path].to_s
+ path.start_with?('/') && !path.start_with?('//') ? path : '/'
+ end
+ end
+end
diff --git a/app/controllers/clubsite/users_controller.rb b/app/controllers/clubsite/users_controller.rb
new file mode 100644
index 0000000..c35adc4
--- /dev/null
+++ b/app/controllers/clubsite/users_controller.rb
@@ -0,0 +1,135 @@
+module Clubsite
+ # Club-scoped user endpoints: the person-picker JSON autocomplete, creating
+ # name-only ("fake") users when registering others, and the duplicate
+ # account merge tools.
+ class UsersController < BaseController
+ before_action :require_sign_in
+
+ # JSON autocomplete for the person pickers. `term` is matched as a
+ # substring; `allowFake=false` restricts the results to accounts with an
+ # email address. Requiring sign-in is a deliberate hardening over the
+ # legacy app, which served this anonymously.
+ def index
+ users = []
+ if params[:term].present?
+ users = User.search_by_name(params[:term]).limit(8)
+ users = users.merge(User.find_all_real) if params[:allowFake] == 'false'
+ end
+ render json: users.map { |user| { name: user.name, identifiableName: user.name, id: user.id } }
+ end
+
+ # Creates a name-only fake user so that people without an account can be
+ # registered. Responds with the new user's id as a bare JSON value, the
+ # contract expected by register-others.js and result-editor.js. A name is
+ # required (and length-bounded) so the endpoint can't be used to flood the
+ # users table with empty/garbage rows.
+ def create
+ name = params[:userName].to_s.strip
+ if name.blank? || name.length > 255
+ return render json: { error: 'A valid name is required.' }, status: :unprocessable_entity
+ end
+
+ user = User.create_fake(name)
+ render json: user.id
+ end
+
+ def merge
+ authorize current_club, :merge?, policy_class: UserPolicy
+ perform_merge(params[:target_id], params[:source_id])
+ redirect_to '/users/showDuplicates'
+ end
+
+ # Lists detected duplicate accounts with merge buttons; POST performs a
+ # manual merge of two picked accounts
+ def show_duplicates
+ authorize current_club, :show_duplicates?, policy_class: UserPolicy
+ # Merge is a lower privilege level than edit. Merge-level users may only
+ # merge people associated with their club, whereas edit-level and global
+ # admins can merge anyone (see #can_merge_any_user?).
+ @can_merge_any_user = can_merge_any_user?
+
+ if request.post?
+ if perform_merge(params.dig(:user, '0', :user_id), params.dig(:user, '1', :user_id))
+ flash[:success] = 'Users merged'
+ else
+ flash[:notice] = 'Users not merged'
+ end
+ return redirect_to '/users/showDuplicates'
+ end
+
+ @users = User.order(:name)
+ @duplicates = User.duplicate_sets
+ unless @can_merge_any_user
+ @duplicates = @duplicates.select do |match|
+ related_to_club?(match[:primary]) || related_to_club?(match[:duplicate])
+ end
+ end
+ end
+
+ private
+
+ # Merges the source account into the target account. Missing users, merging
+ # an account into itself, and merges the current user isn't allowed to
+ # perform are all silently ignored (matching the legacy behavior). Returns
+ # whether a merge happened.
+ def perform_merge(target_id, source_id)
+ return false if target_id.blank? || target_id.to_s == source_id.to_s
+
+ target = User.find_by(id: target_id)
+ source = User.find_by(id: source_id)
+ return false if target.nil? || source.nil?
+ return false unless can_merge?(target) && can_merge?(source)
+
+ Tools::UserMerge.merge(target, source)
+ true
+ end
+
+ # Guards each account in a merge. A merge re-points the source's privileges
+ # onto the target, so it is a privilege-granting operation and must not let
+ # a club admin absorb rights they don't already hold:
+ # * only a global admin may merge an account that holds a global
+ # (cross-club) privilege -- this is what stops a club webmaster from
+ # merging a platform administrator into their own account;
+ # * mergers who can't merge arbitrary accounts are restricted to accounts
+ # associated with the current club.
+ def can_merge?(user)
+ return false if user.global_admin? && !current_user.global_admin?
+ return true if can_merge_any_user?
+
+ user_related_to_club?(user)
+ end
+
+ def can_merge_any_user?
+ @can_merge_any_user ||=
+ current_user.global_admin? ||
+ UserPolicy.new(current_user, current_club).edit_any?
+ end
+
+ # A duplicate entry concerns the current club when the account or its
+ # most recent event belongs to the club
+ def related_to_club?(entry)
+ entry[:user].club_id == current_club.id ||
+ entry[:most_recent_event]&.dig(:club_id) == current_club.id
+ end
+
+ # Whether an account is associated with the current club: it belongs to the
+ # club, holds a membership, or has competed at one of the club's events.
+ def user_related_to_club?(user)
+ user.club_id == current_club.id ||
+ Membership.exists?(user_id: user.id, club_id: current_club.id) ||
+ Result.joins(course: :event)
+ .where(events: { club_id: current_club.id }, results: { user_id: user.id })
+ .exists?
+ end
+
+ def require_sign_in
+ return if user_signed_in?
+
+ if request.format.json?
+ head :unauthorized
+ else
+ redirect_to '/users/login'
+ end
+ end
+ end
+end
diff --git a/app/controllers/content_blocks_controller.rb b/app/controllers/content_blocks_controller.rb
deleted file mode 100644
index 93790c9..0000000
--- a/app/controllers/content_blocks_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class ContentBlocksController < ApplicationController
-end
diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb
deleted file mode 100644
index b7a6f7e..0000000
--- a/app/controllers/courses_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class CoursesController < ApplicationController
-end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
deleted file mode 100644
index 2f973a6..0000000
--- a/app/controllers/groups_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class GroupsController < ApplicationController
-end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 9b329f4..b1524d9 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -7,4 +7,14 @@ def about_whyjustrun
def about_orienteering
@top_level_clubs = Club.all_top_level.where(:visible => true)
end
+
+ def robots
+ render plain: <<~ROBOTS
+ # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file
+ #
+ # To ban all spiders from the entire site uncomment the next two lines:
+ # User-Agent: *
+ # Disallow: /
+ ROBOTS
+ end
end
diff --git a/app/controllers/map_standards_controller.rb b/app/controllers/map_standards_controller.rb
deleted file mode 100644
index 2b44992..0000000
--- a/app/controllers/map_standards_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class MapStandardsController < ApplicationController
-end
diff --git a/app/controllers/memberships_controller.rb b/app/controllers/memberships_controller.rb
deleted file mode 100644
index 37101fc..0000000
--- a/app/controllers/memberships_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class MembershipsController < ApplicationController
-end
diff --git a/app/controllers/organizers_controller.rb b/app/controllers/organizers_controller.rb
deleted file mode 100644
index 48014a2..0000000
--- a/app/controllers/organizers_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class OrganizersController < ApplicationController
-end
diff --git a/app/controllers/privileges_controller.rb b/app/controllers/privileges_controller.rb
deleted file mode 100644
index 6a4a632..0000000
--- a/app/controllers/privileges_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class PrivilegesController < ApplicationController
-end
diff --git a/app/controllers/redactor_controller.rb b/app/controllers/redactor_controller.rb
index f9cb7aa..26e2b17 100644
--- a/app/controllers/redactor_controller.rb
+++ b/app/controllers/redactor_controller.rb
@@ -5,37 +5,51 @@ class RedactorController < ApplicationController
# since this request is coming via PHP, we don't have an authenticity token
skip_before_action :verify_authenticity_token
- def upload_image
- data = params[:file]
- image_types = ['image/png', 'image/jpg', 'image/gif',
- 'image/jpeg', 'image/pjpeg']
- @url = nil
- if image_types.include? data.content_type
- @url = store_file(data)
- end
+ # Extensions accepted by the rich-text "insert file" button. The stored file
+ # is served from the data host, and browsers pick how to render it from its
+ # extension -- so anything that can be interpreted as HTML/script (.html,
+ # .svg, .xml, .js, ...) is deliberately excluded to prevent stored XSS.
+ ALLOWED_FILE_EXTENSIONS = %w[
+ png jpg jpeg gif webp
+ pdf txt csv
+ doc docx xls xlsx ppt pptx odt ods odp
+ zip
+ ].freeze
+
+ # The "insert image" button is restricted further to raster image types.
+ ALLOWED_IMAGE_EXTENSIONS = %w[png jpg jpeg gif webp].freeze
+ def upload_image
+ @url = store_file(params[:file], ALLOWED_IMAGE_EXTENSIONS)
respond_to :json, :html
end
def upload_file
data = params[:file]
- @url = store_file(data)
- @filename = data.original_filename
+ @url = store_file(data, ALLOWED_FILE_EXTENSIONS)
+ @filename = data.original_filename if @url
respond_to :json, :html
end
protected
- def store_file(data)
+ # Stores an uploaded file under a random name, keeping only an allowlisted
+ # extension derived from the original filename. Returns the public URL, or
+ # nil when the upload is missing or its extension is not allowed.
+ def store_file(data, allowed_extensions)
raise "not authorized" unless RedactorPolicy.new(current_user).store_file?
- extension = File.extname(data.original_filename)
+ return nil if data.blank?
+
+ extension = File.extname(data.original_filename.to_s).delete_prefix('.').downcase
+ return nil unless allowed_extensions.include?(extension)
+
root_path = Settings.dataFolder
root_url = Settings.dataURL
random = SecureRandom.urlsafe_base64
random_folder_1 = SecureRandom.random_number(9).to_s
random_folder_2 = SecureRandom.random_number(9).to_s
folder = 'files/' + random_folder_1 + "/" + random_folder_2
- relative_path = "%s/%s%s" % [folder, random, extension]
+ relative_path = "%s/%s.%s" % [folder, random, extension]
# ensure the folder structure exists
FileUtils.mkpath(root_path + folder)
diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb
index 690e01e..b82e0ce 100644
--- a/app/controllers/results_controller.rb
+++ b/app/controllers/results_controller.rb
@@ -1,7 +1,10 @@
require 'nokogiri'
class ResultsController < ApplicationController
- before_action :authenticate_user!, :only => [:update_live, :process_result_list]
+ before_action :authenticate_user!, :only => [:update_live_result_list, :process_result_list]
+ # Result list uploads come from external tools authenticating over HTTP Basic,
+ # which cannot supply a CSRF token.
+ skip_forgery_protection only: [:process_result_list, :update_live_result_list]
def check_event_id id
unless Event.exists? id
@@ -30,39 +33,6 @@ def live_result_list
end
end
- # TODO: This is the WIP replacement to process_result_list...
- # Request body is the XML to store
- # URL parameters
- def update_result_list
- # event_id = params[:id]
- # resolutions = JSON.parse(param[:resolutions])
- # data = params[:file].read
- event_id = 1404
- resolutions = []
- data = File.read('/tmp.xml')
-
- check_event_id event_id
- data = ResultList.resolve_result_list_user_conflicts(resolutions, data)
-
- result_list = ResultList.find_or_initialize_by event_id: event_id, status: ResultList::FINAL_STATUS
- authorize result_list
- result_list.user_id = current_user.id
- result_list.data = data
- result_list.upload_time = Time.now
-
- # Check if there are any remaining unresolved users
- remaining_conflicts = ResultList.result_list_user_conflicts event_id, data
- if remaining_conflicts.length > 0
- # Reply with remaining conflicts.
- elsif result_list.save
- result_list.sync_result_list
- else
- # TODO handle validation error
- end
-
- render :plain => remaining_conflicts.to_yaml
- end
-
def result_list
supported = ['2.0.3', '3.0']
unless supported.include? params[:iof_version] then
@@ -82,7 +52,6 @@ def result_list
end
end
- # TODO: this should have been replaced by update_result_list a long time ago.
def process_result_list
user = current_user
event = Event.find(params[:id])
diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb
deleted file mode 100644
index 0cb1584..0000000
--- a/app/controllers/roles_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class RolesController < ApplicationController
-end
diff --git a/app/controllers/series_controller.rb b/app/controllers/series_controller.rb
deleted file mode 100644
index ea896a5..0000000
--- a/app/controllers/series_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class SeriesController < ApplicationController
-end
diff --git a/app/controllers/sso_controller.rb b/app/controllers/sso_controller.rb
new file mode 100644
index 0000000..8ab44f8
--- /dev/null
+++ b/app/controllers/sso_controller.rb
@@ -0,0 +1,60 @@
+# Apex endpoints for signing in and out of club websites. Sign-in happens only
+# on the apex domain; club domains receive the session via a short-lived
+# signed token (see SsoToken and Clubsite::SessionsController).
+class SsoController < ApplicationController
+ # Sends the visitor back to the club domain with a sign-in token, going
+ # through the apex sign-in form first when needed.
+ def authorize
+ return_host = params[:return_host].to_s
+ club = find_club(return_host)
+ if club.nil?
+ redirect_to root_path, alert: 'Unknown club website.'
+ return
+ end
+
+ unless user_signed_in?
+ session[:sso_return_host] = return_host
+ session[:sso_return_path] = safe_return_path
+ redirect_to new_user_session_path
+ return
+ end
+
+ token = SsoToken.generate(current_user, return_host)
+ query = { token: token, path: safe_return_path }.compact.to_query
+ redirect_to "#{club.domain_protocol}://#{return_host}/sso/consume?#{query}",
+ allow_other_host: true
+ end
+
+ # Ends the apex session as part of signing out of a club website
+ def logout
+ return_host = params[:return_host].to_s
+ club = find_club(return_host)
+ sign_out(current_user) if user_signed_in?
+
+ if club
+ redirect_to "#{club.domain_protocol}://#{return_host}/users/logoutComplete",
+ allow_other_host: true
+ else
+ redirect_to root_path
+ end
+ end
+
+ private
+
+ # Clubs are looked up by domain, tolerating a port in the return host: the
+ # club domains carry no port in production, but the visitor's Host header
+ # does whenever the server runs on a non-standard port (as the system-test
+ # server does). The strict format check keeps anything but a plain
+ # host[:port] out of the redirect target.
+ def find_club(return_host)
+ return nil unless return_host.match?(/\A[a-z0-9.-]+(:\d+)?\z/i)
+
+ Club.find_by(domain: return_host) || Club.find_by(domain: return_host.sub(/:\d+\z/, ''))
+ end
+
+ # Only relative paths may be forwarded to the club domain
+ def safe_return_path
+ path = params[:return_path].to_s
+ path if path.start_with?('/') && !path.start_with?('//')
+ end
+end
diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb
new file mode 100644
index 0000000..e4f73a2
--- /dev/null
+++ b/app/controllers/users/passwords_controller.rb
@@ -0,0 +1,5 @@
+module Users
+ class PasswordsController < Devise::PasswordsController
+ rate_limit to: 5, within: 5.minutes, only: :create
+ end
+end
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
new file mode 100644
index 0000000..5cbc696
--- /dev/null
+++ b/app/controllers/users/sessions_controller.rb
@@ -0,0 +1,5 @@
+module Users
+ class SessionsController < Devise::SessionsController
+ rate_limit to: 10, within: 1.minute, only: :create
+ end
+end
diff --git a/app/helpers/clubs_helper.rb b/app/helpers/clubs_helper.rb
index dca71f8..298a9bb 100644
--- a/app/helpers/clubs_helper.rb
+++ b/app/helpers/clubs_helper.rb
@@ -13,9 +13,20 @@ def participant_counts_helper
}
series = event.series
map = event.map
- csv << [event.id, event.date, event.name, (series != nil) ? series.name : nil, event.number_of_participants, event.club.name, organizers.join(", "), map ? map.name : nil]
+ csv << [event.id, event.date, csv_safe(event.name), csv_safe(series&.name), event.number_of_participants, csv_safe(event.club.name), csv_safe(organizers.join(", ")), csv_safe(map&.name)]
end
end
end
+ private
+
+ # Neutralizes spreadsheet formula injection: a cell whose first character
+ # is one of = + - @ (or a leading tab/CR) is treated as a formula by Excel
+ # and Sheets, so prefix those values with an apostrophe.
+ def csv_safe(value)
+ str = value.to_s
+ str = "'" + str if str.match?(/\A[=+\-@\t\r]/)
+ str
+ end
+
end
diff --git a/app/helpers/clubsite/content_blocks_helper.rb b/app/helpers/clubsite/content_blocks_helper.rb
new file mode 100644
index 0000000..74342be
--- /dev/null
+++ b/app/helpers/clubsite/content_blocks_helper.rb
@@ -0,0 +1,25 @@
+module Clubsite
+ module ContentBlocksHelper
+ # Renders the club's content blocks for a key, each wrapped in
+ # (plus wjr-editable
+ # for users who may edit, so editable.js attaches the in-place editor).
+ # Ported from the CakePHP ContentBlockHelper::render.
+ def render_content_blocks(key, start_wrapper = nil, end_wrapper = nil)
+ editable = user_signed_in? && current_user.has_privilege?(Settings.privileges.contentBlock.edit, current_club)
+ css_class = editable ? 'content-block wjr-editable' : 'content-block'
+
+ blocks = ContentBlock.for_key(key, current_club)
+ safe_join(blocks.map do |block|
+ parts = []
+ parts << start_wrapper.html_safe if start_wrapper
+ # Content is admin-authored HTML; sanitize it so a content-block editor
+ # can't plant script that runs in a visitor's (or higher-privileged
+ # admin's) browser. sanitize keeps ordinary rich-text formatting.
+ parts << content_tag(:div, sanitize(block.content.to_s),
+ id: "content-block-#{block.id}", class: css_class)
+ parts << end_wrapper.html_safe if end_wrapper
+ safe_join(parts)
+ end)
+ end
+ end
+end
diff --git a/app/helpers/clubsite/geocode_helper.rb b/app/helpers/clubsite/geocode_helper.rb
new file mode 100644
index 0000000..e0422b4
--- /dev/null
+++ b/app/helpers/clubsite/geocode_helper.rb
@@ -0,0 +1,17 @@
+module Clubsite
+ # Reverse geocodes a point to neighbourhood/city names, with caching.
+ module GeocodeHelper
+ def geocode_look_up(lat, lng)
+ return { 'neighbourhood' => '', 'city' => '' } if lat.blank?
+
+ Rails.cache.fetch("geocoded/#{lat} #{lng}", expires_in: 365.days) do
+ result = Geocoder.search([lat, lng]).first
+ address = result&.data&.fetch('address', {}) || {}
+ {
+ 'neighbourhood' => address['suburb'].presence || '',
+ 'city' => address['city'].presence || ''
+ }
+ end
+ end
+ end
+end
diff --git a/app/helpers/clubsite/layout_helper.rb b/app/helpers/clubsite/layout_helper.rb
new file mode 100644
index 0000000..c5f0065
--- /dev/null
+++ b/app/helpers/clubsite/layout_helper.rb
@@ -0,0 +1,34 @@
+module Clubsite
+ module LayoutHelper
+ # Absolute URL to the apex (whyjustrun.ca) site, for cross-domain links
+ # from club pages.
+ def core_url(path = '/')
+ Settings.coreURL.chomp('/') + path
+ end
+
+ def profile_url(user = current_user)
+ core_url("/users/#{user.id}")
+ end
+
+ def admin_access?
+ user_signed_in? && current_user.has_privilege?(Settings.privileges.admin.page, current_club)
+ end
+
+ def officials_access?
+ user_signed_in? && current_user.has_privilege?(Settings.privileges.official.edit, current_club)
+ end
+
+ # Redactor is licensed software mounted into public/redactor at deploy
+ # time; rich text editing degrades gracefully when it is absent.
+ def redactor_available?
+ File.exist?(Rails.public_path.join('redactor', 'redactor.js'))
+ end
+
+ def redactor_script_tags
+ return unless redactor_available?
+
+ javascript_include_tag('/redactor/redactor.js', skip_pipeline: true) +
+ stylesheet_link_tag('/redactor/redactor.css', skip_pipeline: true)
+ end
+ end
+end
diff --git a/app/helpers/clubsite/link_helper.rb b/app/helpers/clubsite/link_helper.rb
new file mode 100644
index 0000000..c90ca20
--- /dev/null
+++ b/app/helpers/clubsite/link_helper.rb
@@ -0,0 +1,20 @@
+module Clubsite
+ module LinkHelper
+ # URL to an event's page: relative when the event belongs to the current
+ # club, absolute on the owning club's domain otherwise.
+ def event_link_url(event)
+ path = "/events/view/#{event.id}"
+ return path if event.club_id == current_club.id
+
+ event.club.clubsite_url(path)
+ end
+
+ # URL to pin some content to Pinterest
+ def pinterest_pin_url(content_url, media_url, description)
+ '//www.pinterest.com/pin/create/button/' \
+ "?url=#{ERB::Util.url_encode(content_url)}" \
+ "&media=#{ERB::Util.url_encode(media_url)}" \
+ "&description=#{ERB::Util.url_encode(description)}"
+ end
+ end
+end
diff --git a/app/helpers/clubsite/media_helper.rb b/app/helpers/clubsite/media_helper.rb
new file mode 100644
index 0000000..b90d35c
--- /dev/null
+++ b/app/helpers/clubsite/media_helper.rb
@@ -0,0 +1,43 @@
+module Clubsite
+ # Renders images and links for uploaded media, served through the
+ # controller file-serving actions. The 'Result' type is event results files
+ # (stored under the Event media type), kept for parity with the old views.
+ module MediaHelper
+ MEDIA_ENDPOINTS = {
+ 'Course' => '/courses/map/',
+ 'Map' => '/maps/rendering/',
+ 'Result' => '/events/rendering/'
+ }.freeze
+
+ MEDIA_STORE_TYPES = {
+ 'Course' => 'Course',
+ 'Map' => 'Map',
+ 'Result' => 'Event'
+ }.freeze
+
+ def media_image(type, id, thumbnail = nil, options = {})
+ options = options.merge(srcset: "#{media_url(type, id, thumbnail)} 1x, #{media_url(type, id, thumbnail, hi_dpi: true)} 2x")
+ image_tag(media_url(type, id, thumbnail), options)
+ end
+
+ def media_linked_image(type, id, thumbnail = nil, options = {}, image_options = {})
+ link_to(media_image(type, id, thumbnail, image_options), media_url(type, id), options)
+ end
+
+ def media_linked_file(type, id, options = {})
+ link_to('Results', media_url(type, id), options)
+ end
+
+ def media_url(type, id, thumbnail = nil, hi_dpi: false)
+ raise ArgumentError, 'No media resource ID provided to build URL.' if id.blank?
+
+ endpoint = MEDIA_ENDPOINTS.fetch(type)
+ thumbnail = MediaStore.double_size(thumbnail) if hi_dpi && thumbnail
+ thumbnail ? "#{endpoint}#{id}/#{thumbnail}" : "#{endpoint}#{id}"
+ end
+
+ def media_exists?(type, id, thumbnail = nil)
+ MediaStore.new(current_club.id, MEDIA_STORE_TYPES.fetch(type)).exists?(id, thumbnail)
+ end
+ end
+end
diff --git a/app/helpers/clubsite/menu_helper.rb b/app/helpers/clubsite/menu_helper.rb
new file mode 100644
index 0000000..07a4b12
--- /dev/null
+++ b/app/helpers/clubsite/menu_helper.rb
@@ -0,0 +1,19 @@
+module Clubsite
+ module MenuHelper
+ def menu_item(name, url, css_class = '', home: false)
+ classes = [menu_active_class(url, home), css_class].reject(&:blank?).join(' ')
+ content_tag(:li, link_to(name, url), class: classes.presence)
+ end
+
+ private
+
+ def menu_active_class(path, home)
+ current = request.path
+ if home
+ ['', '/', '/pages/home'].include?(current) ? 'active' : nil
+ else
+ current == path ? 'active' : nil
+ end
+ end
+ end
+end
diff --git a/app/helpers/clubsite/open_graph_helper.rb b/app/helpers/clubsite/open_graph_helper.rb
new file mode 100644
index 0000000..48d005f
--- /dev/null
+++ b/app/helpers/clubsite/open_graph_helper.rb
@@ -0,0 +1,9 @@
+module Clubsite
+ # Collects Open Graph meta tags for the layout head, which yields
+ # :open_graph (see layouts/clubsite/_head.html.erb).
+ module OpenGraphHelper
+ def og_tag(property, value)
+ content_for :open_graph, tag.meta(property: property, content: value)
+ end
+ end
+end
diff --git a/app/helpers/clubsite/time_helper.rb b/app/helpers/clubsite/time_helper.rb
new file mode 100644
index 0000000..9bd78df
--- /dev/null
+++ b/app/helpers/clubsite/time_helper.rb
@@ -0,0 +1,77 @@
+module Clubsite
+ # Event/result time formatting. Times render in the request's time zone,
+ # which BaseController sets to the club's zone.
+ module TimeHelper
+ # Date range for the event page header, collapsing the finish onto the
+ # start when both fall on the same day. A NULL finish date renders the
+ # start only.
+ def formatted_event_date(event)
+ start_time = event.date
+ finish_time = event.read_attribute(:finish_date)
+ if finish_time.nil?
+ long_date_with_time(start_time)
+ elsif same_calendar_day?(start_time, finish_time)
+ "#{long_date_with_time(start_time)} - #{time_of_day(finish_time)}"
+ else
+ "#{long_date_with_time(start_time)} - #{long_date_with_time(finish_time)}"
+ end
+ end
+
+ # Compact date range for event boxes, e.g. "Sun June 15th 10:00am"
+ def event_box_date(event)
+ start_time = event.date
+ finish_time = event.read_attribute(:finish_date)
+ if finish_time.nil?
+ day_date_with_time(start_time)
+ elsif same_calendar_day?(start_time, finish_time)
+ "#{day_date_with_time(start_time)} - #{time_of_day(finish_time)}"
+ else
+ "#{day_date(start_time)} - #{day_date(finish_time)}"
+ end
+ end
+
+ # e.g. "Jun 15th 10:00am", used for registration deadlines
+ def short_date_with_time(time)
+ "#{time.strftime('%b')} #{time.day.ordinalize} #{time_of_day(time)}"
+ end
+
+ # H:MM:SS with unpadded hours, e.g. "1:02:03"
+ def formatted_result_time(seconds)
+ return nil if seconds.nil?
+
+ seconds = seconds.to_i
+ format('%d:%02d:%02d', seconds / 3600, (seconds % 3600) / 60, seconds % 60)
+ end
+
+ # A