From afbeb694d67f0a1c0f5222f40746bd638495eb80 Mon Sep 17 00:00:00 2001 From: Ben Carlsson Date: Fri, 18 Oct 2019 12:55:40 -0700 Subject: [PATCH] Update game page design (#632) * Update game page design Adds game box art, condenses export / race buttons, adds Twitch links, and adds some basic information about median run lengths to the game page. * Clean up info bubbles --- Gemfile | 1 + Gemfile.lock | 3 + app/controllers/search_controller.rb | 2 +- app/javascript/like.js | 4 +- app/models/category.rb | 14 ++ app/models/run.rb | 2 +- app/views/games/categories/_title.slim | 125 ++++++++++++------ .../leaderboards/sum_of_bests/index.slim | 4 +- app/views/games/categories/show.slim | 17 +-- app/views/games/edit.slim | 2 - app/views/races/_create.slim | 48 +++---- app/views/runs/_compare_button.slim | 2 +- app/views/runs/_export_button.slim | 3 +- app/views/runs/_title.slim | 8 +- app/views/runs/index.slim | 6 - app/views/search/index.slim | 17 ++- app/views/shared/_run_table.slim | 2 +- db/seeds.rb | 21 +-- public/500.html | 2 +- 19 files changed, 162 insertions(+), 121 deletions(-) diff --git a/Gemfile b/Gemfile index ee2d74460..601126906 100644 --- a/Gemfile +++ b/Gemfile @@ -69,6 +69,7 @@ gem 'cancancan' gem 'doorkeeper' # db +gem 'active_median' gem 'active_record_union' gem 'activerecord-import' gem 'aws-sdk-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 6bbafdf43..ae3ea9b25 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -52,6 +52,8 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) + active_median (0.2.3) + activesupport (>= 5) active_record_union (1.3.0) activerecord (>= 4.0) activejob (6.0.0) @@ -453,6 +455,7 @@ PLATFORMS ruby DEPENDENCIES + active_median active_record_union activerecord-import activerecord-nulldb-adapter diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 8798ef763..02dfdf22f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -5,7 +5,7 @@ def index @results = {} redirect_to(games_path) && return if @query.blank? - @results[:games] = Game.search(@query).order(:name).includes(:categories) + @results[:games] = Game.search(@query).includes(:categories) @results[:users] = User.search(@query).includes(:games) end diff --git a/app/javascript/like.js b/app/javascript/like.js index 3029d6c2b..08d7f8b3d 100644 --- a/app/javascript/like.js +++ b/app/javascript/like.js @@ -36,7 +36,7 @@ const setLiked = (likeButton) => { return } - likeButton.classList.add('btn-light') + likeButton.classList.add('btn-dark') likeButton.classList.remove('btn-outline-light') likesCount.textContent = ++likesCount.dataset.value } @@ -48,7 +48,7 @@ const setNotLiked = (likeButton) => { return } - likeButton.classList.remove('btn-light') + likeButton.classList.remove('btn-dark') likeButton.classList.add('btn-outline-light') likesCount.textContent = --likesCount.dataset.value } diff --git a/app/models/category.rb b/app/models/category.rb index 244d85ff7..861636e6b 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -5,6 +5,8 @@ class Category < ApplicationRecord has_many :rivalries, dependent: :destroy has_many :races, dependent: :nullify, class_name: 'Race' + has_many :run_histories, through: :runs, source: :histories + has_many :users, through: :runs has_one :srdc, class_name: 'SpeedrunDotComCategory', dependent: :destroy @@ -104,4 +106,16 @@ def route Run.find(result.id) end + + # median_duration returns the median duration of completed attempts for this category. If attempt_number is given, it + # returns the median duration of completed attempts which had that attempt number. + def median_duration(timing, attempt_number: nil) + relation = run_histories.joins(:run).where( + runs: {archived: false} + ).where.not(runs: {user: nil}).where.not(Run.duration_type(timing) => nil).where.not(Run.duration_type(timing) => 0) + + return Duration.new(relation.median("run_histories.#{Run.duration_type(timing)}")) if attempt_number.nil? + + Duration.new(relation.where(attempt_number: attempt_number).median("run_histories.#{Run.duration_type(timing)}")) + end end diff --git a/app/models/run.rb b/app/models/run.rb index 96cf5dc67..fc583809c 100644 --- a/app/models/run.rb +++ b/app/models/run.rb @@ -37,7 +37,7 @@ class Run < ApplicationRecord scope :by_game, ->(game_or_games) { joins(:category).where(categories: {game_id: game_or_games}) } scope :by_category, ->(category) { where(category: category) } - scope :nonempty, -> { where('realtime_duration_ms != 0') } + scope :nonempty, -> { where.not(realtime_duration_ms: 0) } scope :owned, -> { where.not(user: nil) } scope :unarchived, -> { where(archived: false).where.not(user: nil) } scope :categorized, lambda { diff --git a/app/views/games/categories/_title.slim b/app/views/games/categories/_title.slim index f0a4191c1..463249957 100644 --- a/app/views/games/categories/_title.slim +++ b/app/views/games/categories/_title.slim @@ -1,41 +1,84 @@ -h1 = @category.game -- if @category.game.aliases.where.not(name: @category.game.name).present? - h6 Also known as: - ul - - @category.game.aliases.where.not(name: @category.game.name).each do |game_alias| - li = game_alias -h6 - - if @on_game_page && @category.game.srdc.try(:url).present? - a.btn.btn-dark.mr-2.tip href=@category.game.srdc.url title='See on Speedrun.com' - = image_tag(asset_path('srdc.png'), style: 'height: 0.8em') - - elsif !@on_game_page && @category.srdc.try(:url).present? - a.btn.btn-dark.mr-2.tip href=@category.srdc.url title='See on Speedrun.com' - = image_tag(asset_path('srdc.png'), style: 'height: 0.8em') - - if @category.game.srl.present? - a.btn.btn-dark.mr-2.tip href=@category.game.srl.url title='See on SpeedRunsLive' - = image_tag(asset_path('srl.png')) - - if can?(:edit, @category.game) - a.btn.btn-outline-light href=edit_game_path(@category.game) - => icon('fas', 'edit') - span Edit -article data-turbolinks-temporary=true - .row - .col-sm-3.mb-3 - .statcard.p-3 - h3.statcard-number = @game.users.count - span.statcard-desc = 'Runner'.pluralize(@game.users.count) - .col-sm-3.mb-3 - .statcard.p-3 - h3.statcard-number = @game.runs.count - span.statcard-desc = 'Run'.pluralize(@game.runs.count) - .col-sm-3.mb-3 - .statcard.p-3 - h3.statcard-number = @game.categories.count - span.statcard-desc = 'Category'.pluralize(@game.categories.count) - - if @game.runs.any? - .col-sm-3.mb-3 - .statcard.p-3 - h3.statcard-number = time_ago_in_words(@game.runs.order(created_at: :desc).first.created_at) - span.statcard-desc - span> Time since - = link_to('last run', @game.runs.order(created_at: :desc).first, class: 'text-muted') +.card.mb-3 + .row.no-gutters + - if @game.srdc&.cover_url + .col-md-4 style='max-width: 200px' + img.card-img src=@game.srdc.cover_url + .col-md-8 + .card-body + h1.card-title = @category.game + h6 + .btn-group.mr-2 + - if @on_game_page && @category.game.srdc.try(:url).present? + a.btn.btn-dark.tip href=@category.game.srdc.url title='See on Speedrun.com' + = image_tag(asset_path('srdc.png'), style: 'height: 0.8em') + - elsif !@on_game_page && @category.srdc.try(:url).present? + a.btn.btn-dark.tip href=@category.srdc.url title='See on Speedrun.com' + = image_tag(asset_path('srdc.png'), style: 'height: 0.8em') + - if @category.game.srdc&.twitch_name + a.btn.btn-dark.tip title='See on Twitch' href="https://www.twitch.tv/directory/game/#{@category.game.srdc.twitch_name}" + .text-light = icon('fab', 'twitch') + - if @category.game.srl.present? + a.btn.btn-dark.tip href=@category.game.srl.url title='See on SpeedRunsLive' + = image_tag(asset_path('srl.png')) + - if @category.route + span.mr-2 + = render partial: 'runs/export_button', locals: { \ + run: @category.route, button_text: "#{@category} splits", force_route_only: true \ + } + span#vue-race.mr-2 = render partial: 'races/create', locals: {game: @game, category: @category} + - if can?(:edit, @category.game) + a.btn.btn-dark.mr-2 href=edit_game_path(@category.game) + => icon('fas', 'edit') + span Edit + .row + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @game.users.count + span.statcard-desc = 'Runner'.pluralize(@game.users.count) + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @game.runs.count + span.statcard-desc = 'Run'.pluralize(@game.runs.count) + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @game.categories.count + span.statcard-desc = 'Category'.pluralize(@game.categories.count) + - if @game.runs.any? + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = time_ago_in_words(@game.runs.order(created_at: :desc).first.created_at) + span.statcard-desc + span> Since + = link_to('last run', @game.runs.order(created_at: :desc).first, class: 'text-muted') + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @category.median_duration(timing).format + span.statcard-desc + ' Median run length + span.tip.text-secondary( + title='Half of all runs are slower, and half are faster, than this time.' + ) = icon('fas', 'info-circle') + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @category.median_duration(timing, attempt_number: 1).format + span.statcard-desc + ' Median blind run + span.tip.text-secondary( + title='Half of all first attempts are slower, and half are faster, than this time.' + ) = icon('fas', 'info-circle') + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @category.median_duration(timing, attempt_number: 10).format + span.statcard-desc + ' Median 10th attempt + span.tip.text-secondary( + title='Half of all 10th attempts are slower, and half are faster, than this time.' + ) = icon('fas', 'info-circle') + .col-sm-3.mb-3 + .statcard.p-3 + h3.statcard-number = @category.median_duration(timing, attempt_number: 100).format + span.statcard-desc + ' Median 100th attempt + span.tip.text-secondary( + title='Half of all 100th attempts are slower, and half are faster, than this time.' + ) = icon('fas', 'info-circle') diff --git a/app/views/games/categories/leaderboards/sum_of_bests/index.slim b/app/views/games/categories/leaderboards/sum_of_bests/index.slim index fdb1bdf2c..96e39ec40 100644 --- a/app/views/games/categories/leaderboards/sum_of_bests/index.slim +++ b/app/views/games/categories/leaderboards/sum_of_bests/index.slim @@ -7,7 +7,7 @@ li.breadcrumb-item = link_to(@category.game, game_path(@category.game)) li.breadcrumb-item = link_to(@category, game_category_path(@category.game, @category)) li.breadcrumb-item = link_to('Sum of Best Leaderboard', game_category_sum_of_bests_path(@category.game, @category)) - = render partial: 'games/categories/title', locals: {category: @category} +.row.mx-2: .col-md-12 = render partial: 'games/categories/title', locals: {timing: timing, category: @category} article data-turbolinks-temporary=true .card @@ -24,7 +24,7 @@ article data-turbolinks-temporary=true tr th th.align-left Runner - th.align-left Sum of Best + th.align-left Sum of best th.align-left PB th.align-left | Potential save diff --git a/app/views/games/categories/show.slim b/app/views/games/categories/show.slim index 60811cb85..5bddc946f 100644 --- a/app/views/games/categories/show.slim +++ b/app/views/games/categories/show.slim @@ -1,3 +1,4 @@ +- timing = params[:timing] || Run::REAL - content_for(:title, @game) - content_for(:header) do ol.breadcrumb.shadow @@ -6,21 +7,9 @@ li.breadcrumb-item = link_to(@category.game, game_path(@category.game)) - unless @on_game_page li.breadcrumb-item = link_to @category, game_category_path(@game, @category) - / Specify the full partial path because this view is used by different controllers - = render partial: 'games/categories/title', locals: {category: @category} - -#vue-race.row.mx-2 - - route = @category.route - - if @category.route.present? - .col-md-4: .card.mb-4 - h5.card-header #{@category.name} Route - .card-body - p.card-text Automatically determined based on popularity - = render partial: 'runs/export_button', locals: {run: route, button_text: 'Download splits', force_route_only: true} - - - if current_user.present? - = render partial: 'races/create', locals: {game: @game, category: @category} +/ Specify the full partial path because this view is used by different controllers +.row.mx-2: .col-md-12 = render partial: 'games/categories/title', locals: {timing: timing, category: @category} .row.mx-2 .col-md-6.mb-3: .card diff --git a/app/views/games/edit.slim b/app/views/games/edit.slim index 6c48ef26c..a0926125a 100644 --- a/app/views/games/edit.slim +++ b/app/views/games/edit.slim @@ -5,8 +5,6 @@ li.breadcrumb-item = link_to('Games', games_path) li.breadcrumb-item = link_to(@game, game_path(@game)) li.breadcrumb-item = link_to('Edit', edit_game_path(@game)) - h3 Edit game - h5 #{link_to @game, @game, class: 'stealth-link game-link'} article = form_for GameAlias.new, url: game_aliases_path(@game) do |f| h3 Merge games diff --git a/app/views/races/_create.slim b/app/views/races/_create.slim index 748826db9..b57ade68a 100644 --- a/app/views/races/_create.slim +++ b/app/views/races/_create.slim @@ -3,29 +3,25 @@ // Make sure to have #vue-race be defined before calling this partial race-create inline-template=true game-id=game.id category-id=category.id - .col-md-4 v-cloak=true - .card.mb-4 - h5.card-header Create race - .card-body - p.card-text Race #{game.to_s || ''} against others - .btn-group - button.btn.btn-primary( - type='button' - @click='createPublic' - :disabled='loading' - :title='error' - v-tippy=true - ) - template v-if="loading" = render partial: 'shared/spinner' - span.text-danger v-else-if='error' => icon('fas', 'exclamation-triangle') - template v-else=true => icon('fas', 'flag-checkered') - ' Create public race - button.btn.btn-primary.dropdown-toggle.dropdown-toggle-split( - type='button' - data={toggle: 'dropdown'} - aria={haspopup: true, expanded: false} - ) - span.sr-only Toggle dropdown - .dropdown-menu.bg-dark - button.dropdown-item.text-secondary @click='createInviteOnly' Create invite-only race - button.dropdown-item.text-secondary @click='createSecret' Create secret race + .btn-group v-cloak=true + button.btn.btn-dark( + type='button' + @click='createPublic' + :disabled='loading' + :title='error' + v-tippy=true + ) + template v-if="loading" = render partial: 'shared/spinner' + span.text-danger v-else-if='error' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'flag-checkered') + ' Create race + button.btn.btn-dark.dropdown-toggle.dropdown-toggle-split( + type='button' + data={toggle: 'dropdown'} + aria={haspopup: true, expanded: false} + ) + span.sr-only Toggle dropdown + .dropdown-menu.bg-dark + button.dropdown-item.text-secondary @click='createPublic' Create public race + button.dropdown-item.text-secondary @click='createInviteOnly' Create invite-only race + button.dropdown-item.text-secondary @click='createSecret' Create secret race diff --git a/app/views/runs/_compare_button.slim b/app/views/runs/_compare_button.slim index e3a87533b..90ea193ba 100644 --- a/app/views/runs/_compare_button.slim +++ b/app/views/runs/_compare_button.slim @@ -1,6 +1,6 @@ - if run.category.present? .btn-group - button#compare.btn.btn-outline-light.dropdown-toggle( + button#compare.btn.btn-dark.dropdown-toggle( aria={haspopup: true, expanded: false} data={toggle: 'dropdown'} type='button' diff --git a/app/views/runs/_export_button.slim b/app/views/runs/_export_button.slim index 666a98066..12c1a40ef 100644 --- a/app/views/runs/_export_button.slim +++ b/app/views/runs/_export_button.slim @@ -1,9 +1,10 @@ .btn-group - button#export-link.btn.btn-outline-light.dropdown-toggle( + button#export-link.btn.btn-dark.dropdown-toggle.tip( href='#' role='button' aria={haspopup: true, expanded: false} data={toggle: 'dropdown'} + title=local_assigns.fetch(:tooltip_text, nil) ) => icon('fas', 'download') = local_assigns.fetch(:button_text, 'Export') diff --git a/app/views/runs/_title.slim b/app/views/runs/_title.slim index 6de6aa88c..67cc1384b 100644 --- a/app/views/runs/_title.slim +++ b/app/views/runs/_title.slim @@ -25,16 +25,16 @@ h5 .btn-toolbar role='toolbar' aria={label: 'Run navigation'} .btn-group.m-2 role='group' aria={label: 'Run navigation'} .btn-group role='group' - = render partial: 'export_button', locals: {run: run} - if can?(:edit, run) - if request.path_info == edit_run_path(run) - a.btn.btn-outline-light href=run_path(run) + a.btn.btn-dark href=run_path(run) => icon('fas', 'hand-point-left') ' Back - else - a.btn.btn-outline-light href=edit_run_path(run) + a.btn.btn-dark href=edit_run_path(run) => icon('fas', 'edit') ' Edit + = render partial: 'export_button', locals: {run: run} = render partial: 'compare_button', locals: {timing: timing, run: run, compare_runs: compare_runs} - if run.user.nil? #claim-nav-link-container.btn-group hidden=true @@ -47,7 +47,7 @@ h5 - if can?(:create, RunLike) .btn-group.m-2: button#like-button.btn.tip( data={liked: current_user.likes?(run) ? '1' : '0'} - class=(current_user.likes?(run) ? 'btn-light' : 'btn-outline-light') + class=(current_user.likes?(run) ? 'btn-dark' : 'btn-outline-light') title=tooltip(run) ) ' 🎉 diff --git a/app/views/runs/index.slim b/app/views/runs/index.slim index a276c6003..adda43465 100644 --- a/app/views/runs/index.slim +++ b/app/views/runs/index.slim @@ -7,12 +7,6 @@ - if current_user.runs.any? article .row - .col-sm-3: .statcard.p-3 - h2.statcard-number - span> = s3_bytes_used(current_user) - small.delta-indicator.delta-positive.tip-top title='Since last year' - = s3_bytes_used(current_user, since: 1.year.ago) - span.statcard-desc Space used .col-sm-3: .statcard.p-3 h2.statcard-number span> = current_user.runs.count diff --git a/app/views/search/index.slim b/app/views/search/index.slim index 2169c84c3..3fbbbe8c8 100644 --- a/app/views/search/index.slim +++ b/app/views/search/index.slim @@ -24,15 +24,14 @@ article#search-results table.card-body.table.table-striped.mb-0 tbody - results.each do |obj| - tr - td - - case search_type - - when :games - = link_to obj, game_path(obj), class: 'game-link' - small.float-right = obj.categories.pluck(:name).join(', ') - - when :users - = link_to obj, user_path(obj), class: 'game-link' - small.float-right = obj.games.pluck(:shortname).compact.uniq.join(', ') + tr: td + - case search_type + - when :games + = link_to obj, game_path(obj), class: 'game-link' + small.float-right = obj.categories.pluck(:name).join(', ') + - when :users + = link_to obj, user_path(obj), class: 'game-link' + small.float-right = obj.games.pluck(:shortname).compact.uniq.join(', ') article#runs - if @results[:games].present? h4 Runs diff --git a/app/views/shared/_run_table.slim b/app/views/shared/_run_table.slim index 3eab00608..c5b001c7a 100644 --- a/app/views/shared/_run_table.slim +++ b/app/views/shared/_run_table.slim @@ -2,7 +2,7 @@ - cols ||= [:runner, :time, :name, :video, :rival, :uploaded] / This allows us to blindly check the keys for items instead of wraping in `try`s - col_options = cols.to_h { |col| [col, []] }.merge(local_assigns.fetch(:col_options, {})) -- runs = order_runs(runs).page(params[:page]).includes(:user, :game) +- runs = order_runs(runs).page(params[:page]).includes(:user, :game, :category, :video, :segments) - non_pb_runs = user.non_pbs(runs.map(&:category_id)).group_by(&:category_id) if col_options[:time].include?(:archived) - if runs.none? - if description.present? diff --git a/db/seeds.rb b/db/seeds.rb index 06c9a32dd..670374d44 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,12 +1,3 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). -# -# Examples: -# -# cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) -# Mayor.create(name: 'Emanuel', city: cities.first) - -# See https://github.com/rails/rails/issues/35812 ActiveJob::Base.queue_adapter = Rails.application.config.active_job.queue_adapter [ @@ -69,3 +60,15 @@ User.create( name: 'Alice' ) + +Doorkeeper::Application.create( + name: 'Splits.io', + uid: ENV['SPLITSIO_CLIENT_ID'], + secret: ENV['SPLITSIO_CLIENT_SECRET'], + redirect_uri: 'http://localhost:3000/auth/splitsio/callback', + scopes: '', + owner_id: 2, # First user made after fresh build + owner_type: 'User', + secret_generated_at: Time.now, + confidential: true, +) diff --git a/public/500.html b/public/500.html index eaebb1535..b1cec8269 100644 --- a/public/500.html +++ b/public/500.html @@ -1,6 +1,6 @@ - Error - Splits I/O + Error - Splits.io