From d8acced27df78ba51dad1c0850ee22efbde17c64 Mon Sep 17 00:00:00 2001 From: Venix <30481900+venix12@users.noreply.github.com> Date: Sun, 6 Aug 2023 23:15:32 +0200 Subject: [PATCH 01/87] contest judge system --- .../Controllers/Admin/ContestsController.php | 7 +- .../Controllers/ContestEntriesController.php | 122 +++++++++++++ app/Http/Controllers/ContestsController.php | 23 +++ app/Libraries/OsuAuthorize.php | 17 ++ app/Models/Contest.php | 28 ++- app/Models/ContestEntry.php | 14 ++ app/Models/ContestJudge.php | 27 +++ app/Models/ContestJudgeCategory.php | 20 +++ app/Models/ContestJudgeCategoryVote.php | 33 ++++ app/Models/ContestJudgeVote.php | 45 +++++ app/Models/User.php | 13 ++ app/Transformers/ContestEntryTransformer.php | 34 +++- .../ContestJudgeCategoryTransformer.php | 27 +++ .../ContestJudgeCategoryVoteTransformer.php | 32 ++++ .../ContestJudgeVoteTransformer.php | 45 +++++ app/Transformers/ContestTransformer.php | 15 ++ ..._20_151049_create_contest_judges_table.php | 38 ++++ ...53356_create_contest_judge_votes_table.php | 39 +++++ ..._create_contest_judge_categories_table.php | 38 ++++ ...ate_contest_judge_category_votes_table.php | 39 +++++ ...026_add_judge_score_on_contest_entries.php | 36 ++++ resources/css/bem-index.less | 5 + .../css/bem/contest-judge-categories.less | 24 +++ resources/css/bem/contest-judge-entry.less | 102 +++++++++++ .../css/bem/contest-judge-results-header.less | 16 ++ resources/css/bem/contest-judge-results.less | 37 ++++ resources/css/bem/contest-judge.less | 21 +++ resources/css/bem/contest-voting-list.less | 3 + resources/css/bem/select-options.less | 5 + resources/css/bem/sort.less | 5 + resources/css/bem/value-display.less | 11 ++ resources/js/contest-judge-results/header.tsx | 107 ++++++++++++ resources/js/contest-judge-results/main.tsx | 36 ++++ resources/js/contest-judge-results/vote.tsx | 54 ++++++ resources/js/contest-judge/entry.tsx | 162 ++++++++++++++++++ resources/js/contest-judge/main.tsx | 82 +++++++++ resources/js/contest-voting/entry-list.coffee | 11 +- resources/js/contest-voting/entry.coffee | 17 +- .../js/entrypoints/contest-judge-results.tsx | 15 ++ resources/js/entrypoints/contest-judge.tsx | 18 ++ resources/js/interfaces/contest-entry-json.ts | 20 +++ resources/js/interfaces/contest-json.ts | 13 ++ .../interfaces/contest-judge-category-json.ts | 8 + .../contest-judge-category-vote-json.ts | 11 ++ .../js/interfaces/contest-judge-vote-json.ts | 13 ++ resources/js/models/contest-entry.ts | 30 ++++ resources/js/stores/contest-entry-store.ts | 28 +++ resources/lang/en/contest.php | 25 +++ resources/views/admin/contests/show.blade.php | 16 ++ .../contest_entries/judge-results.blade.php | 40 +++++ resources/views/contests/judge.blade.php | 36 ++++ resources/views/contests/voting.blade.php | 6 + routes/web.php | 4 + 53 files changed, 1660 insertions(+), 13 deletions(-) create mode 100644 app/Models/ContestJudge.php create mode 100644 app/Models/ContestJudgeCategory.php create mode 100644 app/Models/ContestJudgeCategoryVote.php create mode 100644 app/Models/ContestJudgeVote.php create mode 100644 app/Transformers/ContestJudgeCategoryTransformer.php create mode 100644 app/Transformers/ContestJudgeCategoryVoteTransformer.php create mode 100644 app/Transformers/ContestJudgeVoteTransformer.php create mode 100644 database/migrations/2023_07_20_151049_create_contest_judges_table.php create mode 100644 database/migrations/2023_07_20_153356_create_contest_judge_votes_table.php create mode 100644 database/migrations/2023_07_20_153406_create_contest_judge_categories_table.php create mode 100644 database/migrations/2023_07_20_153416_create_contest_judge_category_votes_table.php create mode 100644 database/migrations/2023_08_01_161026_add_judge_score_on_contest_entries.php create mode 100644 resources/css/bem/contest-judge-categories.less create mode 100644 resources/css/bem/contest-judge-entry.less create mode 100644 resources/css/bem/contest-judge-results-header.less create mode 100644 resources/css/bem/contest-judge-results.less create mode 100644 resources/css/bem/contest-judge.less create mode 100644 resources/js/contest-judge-results/header.tsx create mode 100644 resources/js/contest-judge-results/main.tsx create mode 100644 resources/js/contest-judge-results/vote.tsx create mode 100644 resources/js/contest-judge/entry.tsx create mode 100644 resources/js/contest-judge/main.tsx create mode 100644 resources/js/entrypoints/contest-judge-results.tsx create mode 100644 resources/js/entrypoints/contest-judge.tsx create mode 100644 resources/js/interfaces/contest-entry-json.ts create mode 100644 resources/js/interfaces/contest-json.ts create mode 100644 resources/js/interfaces/contest-judge-category-json.ts create mode 100644 resources/js/interfaces/contest-judge-category-vote-json.ts create mode 100644 resources/js/interfaces/contest-judge-vote-json.ts create mode 100644 resources/js/models/contest-entry.ts create mode 100644 resources/js/stores/contest-entry-store.ts create mode 100644 resources/views/contest_entries/judge-results.blade.php create mode 100644 resources/views/contests/judge.blade.php diff --git a/app/Http/Controllers/Admin/ContestsController.php b/app/Http/Controllers/Admin/ContestsController.php index a7029a214c4..154d7814f2d 100644 --- a/app/Http/Controllers/Admin/ContestsController.php +++ b/app/Http/Controllers/Admin/ContestsController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Admin; use App\Models\Contest; +use App\Models\ContestJudgeVote; use App\Models\DeletedUser; use App\Models\UserContestEntry; use GuzzleHttp; @@ -22,7 +23,11 @@ public function index() public function show($id) { - $contest = Contest::findOrFail($id); + $contest = Contest::with('judges') + ->with('judges.contestJudgeVotes') + ->withCount('entries') + ->findOrFail($id); + $entries = UserContestEntry::withTrashed() ->where('contest_id', $id) ->with('user') diff --git a/app/Http/Controllers/ContestEntriesController.php b/app/Http/Controllers/ContestEntriesController.php index df6975ab4d9..b7fa37d3a25 100644 --- a/app/Http/Controllers/ContestEntriesController.php +++ b/app/Http/Controllers/ContestEntriesController.php @@ -5,20 +5,142 @@ namespace App\Http\Controllers; +use App\Exceptions\InvariantException; use App\Models\Contest; use App\Models\ContestEntry; +use App\Models\ContestJudgeCategoryVote; +use App\Models\ContestJudgeVote; use App\Models\UserContestEntry; use Auth; +use Illuminate\Support\Facades\DB; use Request; class ContestEntriesController extends Controller { + public function judgeResults($id) + { + $entry = ContestEntry::with('contest') + ->with('contest.entries') + ->with('contest.judgeCategories') + ->with('judgeVotes') + ->with('judgeVotes.categoryVotes') + ->with('judgeVotes.user') + ->with('judgeVotes.categoryVotes.category') + ->with('user') + ->withSum('categoryVotes', 'value') + ->findOrFail($id); + + abort_if(!$entry->contest->isJudged() || !$entry->contest->show_votes, 404); + + $contestJson = json_item( + $entry->contest->loadSum('judgeCategories', 'max_value'), + 'Contest', + ['max_judging_score'] + ); + + $entryJson = json_item($entry, 'ContestEntry', [ + 'judge_votes.user', + 'judge_votes.score', + 'judge_votes.category_votes.category', + 'results', + 'user', + ]); + + $entriesJson = json_collection($entry->contest->entries, 'ContestEntry'); + + return ext_view('contest_entries.judge-results', [ + 'contestJson' => $contestJson, + 'entryJson' => $entryJson, + 'entriesJson' => $entriesJson, + ]); + } + + public function judgeVote($id) + { + $entry = ContestEntry::with('contest') + ->with('contest.judgeCategories') + ->with('judgeVotes') + ->findOrFail($id); + + if (!$entry->contest->isJudgingActive()) { + throw new InvariantException(osu_trans('contest.judge.validation.judging_not_active')); + } + + priv_check('ContestJudge', $entry->contest)->ensureCan(); + + $params = get_params(request()->all(), null, [ + 'category_votes:array', + 'comment', + ]); + + $categoryVotes = collect($params['category_votes']); + $comment = $params['comment']; + + DB::osu_transaction(function () use ($categoryVotes, $comment, $entry) { + $vote = $entry->judgeVotes->where('user_id', auth()->user()->getKey())->first(); + + if ($vote !== null) { + if ($comment !== $vote->comment) { + $vote->update(['comment' => $comment]); + } + } else { + $vote = ContestJudgeVote::create([ + 'comment' => $comment, + 'contest_entry_id' => $entry->getKey(), + 'user_id' => auth()->user()->getKey(), + ]); + } + + foreach ($entry->contest->judgeCategories as $category) { + $categoryVote = $categoryVotes + ->where('contest_judge_category_id', $category->getKey()) + ->first(); + + if ($categoryVote == null) { + throw new InvariantException(osu_trans('contest.judge.validation.missing_category_vote')); + } + + $currentCategoryVote = ContestJudgeCategoryVote::where('contest_judge_vote_id', $vote->getKey()) + ->where('contest_judge_category_id', $category->getKey()) + ->first(); + + $value = clamp($categoryVote['value'], 0, $category->max_value); + + if ($currentCategoryVote !== null) { + $currentValue = $currentCategoryVote->value; + + if ($currentValue !== $value) { + $currentCategoryVote->update(['value' => $value]); + } + } else { + ContestJudgeCategoryVote::create([ + 'contest_judge_category_id' => $category->getKey(), + 'contest_judge_vote_id' => $vote->getKey(), + 'value' => $value, + ]); + } + } + }); + + $updatedEntry = ContestEntry::with('judgeVotes') + ->with('judgeVotes.categoryVotes') + ->findOrFail($entry->getKey()); + + $updatedEntryJson = json_item($updatedEntry, 'ContestEntry', ['current_user_judge_vote.category_votes']); + + return $updatedEntryJson; + } + public function vote($id) { $user = Auth::user(); $entry = ContestEntry::findOrFail($id); $contest = Contest::with('entries')->with('entries.contest')->findOrFail($entry->contest_id); + if ($contest->isJudged()) { + throw new InvariantException(osu_trans('contest.judge.validation.contest_vote_judged')); + } + priv_check('ContestVote', $contest)->ensureCan(); $contest->vote($user, $entry); diff --git a/app/Http/Controllers/ContestsController.php b/app/Http/Controllers/ContestsController.php index 3ed5d131fd4..427b7ee4f7e 100644 --- a/app/Http/Controllers/ContestsController.php +++ b/app/Http/Controllers/ContestsController.php @@ -24,6 +24,29 @@ public function index() ]); } + public function judge($id) + { + $contest = Contest::with('entries') + ->with('entries.judgeVotes') + ->with('entries.judgeVotes.categoryVotes') + ->with('judgeCategories') + ->findOrFail($id); + + abort_if(!$contest->isJudgingActive(), 404); + + priv_check('ContestJudge', $contest)->ensureCan(); + + $contestJson = json_item($contest, 'Contest', ['judge_categories']); + $entriesJson = json_collection($contest->entries, 'ContestEntry', [ + 'current_user_judge_vote.category_votes', + ]); + + return ext_view('contests.judge', [ + 'contestJson' => $contestJson, + 'entriesJson' => $entriesJson, + ]); + } + public function show($id) { $contest = Contest::findOrFail($id); diff --git a/app/Libraries/OsuAuthorize.php b/app/Libraries/OsuAuthorize.php index 8f562da0e4d..7a4b229e581 100644 --- a/app/Libraries/OsuAuthorize.php +++ b/app/Libraries/OsuAuthorize.php @@ -1328,6 +1328,23 @@ public function checkContestEntryDestroy(?User $user, UserContestEntry $contestE return 'ok'; } + /** + * @param User|null $user + * @param Contest $contest + * @return string + * @throws AuthorizationCheckException + */ + public function checkContestJudge(?User $user, Contest $contest): string + { + $this->ensureLoggedIn($user); + + if ($contest->judges->find($user->getKey()) == null) { + return 'unauthorized'; + } + + return 'ok'; + } + /** * @param User|null $user * @param Contest $contest diff --git a/app/Models/Contest.php b/app/Models/Contest.php index aa6c8797c2a..1f57a514f54 100644 --- a/app/Models/Contest.php +++ b/app/Models/Contest.php @@ -12,6 +12,8 @@ use App\Transformers\UserContestEntryTransformer; use Cache; use Exception; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property \Carbon\Carbon|null $created_at @@ -56,6 +58,16 @@ public function entries() return $this->hasMany(ContestEntry::class); } + public function judges(): BelongsToMany + { + return $this->belongsToMany(User::class, ContestJudge::class); + } + + public function judgeCategories(): HasMany + { + return $this->hasMany(ContestJudgeCategory::class); + } + public function userContestEntries() { return $this->hasMany(UserContestEntry::class); @@ -109,6 +121,16 @@ public function isBestOf(): bool return isset($this->getExtraOptions()['best_of']); } + public function isJudged(): bool + { + return $this->getExtraOptions()['judged'] ?? false; + } + + public function isJudgingActive(): bool + { + return $this->isJudged() && $this->isVotingStarted() && !$this->show_votes; + } + public function isSubmittedBeatmaps(): bool { return $this->isBestOf() || ($this->getExtraOptions()['submitted_beatmaps'] ?? false); @@ -245,17 +267,21 @@ public function entriesByType($user = null) if ($this->show_votes) { return Cache::remember("contest_entries_with_votes_{$this->id}", 300, function () use ($entries) { $entries = $entries->with('user'); + $orderValue = 'votes_count'; if ($this->isBestOf()) { $entries = $entries ->selectRaw('*') ->selectRaw('(SELECT FLOOR(SUM(`weight`)) FROM `contest_votes` WHERE `contest_entries`.`id` = `contest_votes`.`contest_entry_id`) AS votes_count') ->limit(50); // best of contests tend to have a _lot_ of entries... + } else if ($this->isJudged()) { + $entries = $entries->withSum('categoryVotes', 'value'); + $orderValue = 'category_votes_sum_value'; } else { $entries = $entries->withCount('votes'); } - return $entries->orderBy('votes_count', 'desc')->get(); + return $entries->orderBy($orderValue, 'desc')->get(); }); } else { if ($this->isBestOf()) { diff --git a/app/Models/ContestEntry.php b/app/Models/ContestEntry.php index 82e7ba580a8..48f3aff805b 100644 --- a/app/Models/ContestEntry.php +++ b/app/Models/ContestEntry.php @@ -5,11 +5,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; + /** * @property Contest $contest * @property int $contest_id * @property \Carbon\Carbon|null $created_at * @property string|null $entry_url + * @property string|null $judge_score * @property string|null $thumbnail_url * @property int $id * @property string $masked_name @@ -21,11 +25,21 @@ */ class ContestEntry extends Model { + public function categoryVotes(): HasManyThrough + { + return $this->hasManyThrough(ContestJudgeCategoryVote::class, ContestJudgeVote::class); + } + public function contest() { return $this->belongsTo(Contest::class); } + public function judgeVotes(): HasMany + { + return $this->hasMany(ContestJudgeVote::class); + } + public function user() { return $this->belongsTo(User::class, 'user_id'); diff --git a/app/Models/ContestJudge.php b/app/Models/ContestJudge.php new file mode 100644 index 00000000000..7af659c27e9 --- /dev/null +++ b/app/Models/ContestJudge.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property Contest $contest + * @property int $contest_id + * @property \Carbon\Carbon|null $created_at + * @property int $id + * @property \Carbon\Carbon|null $updated_at + * @property User $user + * @property int $user_id + */ +class ContestJudge extends Model +{ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/ContestJudgeCategory.php b/app/Models/ContestJudgeCategory.php new file mode 100644 index 00000000000..05061d540ef --- /dev/null +++ b/app/Models/ContestJudgeCategory.php @@ -0,0 +1,20 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +/** + * @property int $contest_id + * @property \Carbon\Carbon|null $created_at + * @property int $id + * @property int $max_value + * @property string $name + * @property \Carbon\Carbon|null $updated_at + */ +class ContestJudgeCategory extends Model +{ +} diff --git a/app/Models/ContestJudgeCategoryVote.php b/app/Models/ContestJudgeCategoryVote.php new file mode 100644 index 00000000000..dfa17556c16 --- /dev/null +++ b/app/Models/ContestJudgeCategoryVote.php @@ -0,0 +1,33 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property ContestJudgeCategory $category + * @property int $contest_judge_category_id + * @property int $contest_judge_vote_id + * @property \Carbon\Carbon|null $created_at + * @property int $id + * @property \Carbon\Carbon|null $updated_at + * @property int $value + * @property ContestJudgeVote $vote + */ +class ContestJudgeCategoryVote extends Model +{ + public function category(): BelongsTo + { + return $this->belongsTo(ContestJudgeCategory::class, 'contest_judge_category_id'); + } + + public function vote(): BelongsTo + { + return $this->belongsTo(ContestJudgeVote::class, 'contest_judge_vote_id'); + } +} diff --git a/app/Models/ContestJudgeVote.php b/app/Models/ContestJudgeVote.php new file mode 100644 index 00000000000..d6ef44a7880 --- /dev/null +++ b/app/Models/ContestJudgeVote.php @@ -0,0 +1,45 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; + +/** + * @property \Illuminate\Database\Eloquent\Collection $categoryVotes + * @property string|null $comment + * @property int $contest_entry_id + * @property \Carbon\Carbon|null $created_at + * @property ContestEntry $entry + * @property int $id + * @property \Carbon\Carbon|null $updated_at + * @property User $user + * @property int $user_id + */ +class ContestJudgeVote extends Model +{ + public function categoryVotes(): HasMany + { + return $this->hasMany(ContestJudgeCategoryVote::class); + } + + public function entry(): BelongsTo + { + return $this->belongsTo(ContestEntry::class, 'contest_entry_id'); + } + + public function score(): int + { + return intval($this->categoryVotes()->sum('value')); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 74ae71d4f41..b8866350d5f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -870,6 +870,7 @@ public function getAttribute($key) 'channels', 'clients', 'comments', + 'contestJudgeVotes', 'country', 'events', 'favourites', @@ -1502,6 +1503,11 @@ public function comments() return $this->hasMany(Comment::class); } + public function contestJudgeVotes(): HasMany + { + return $this->hasMany(ContestJudgeVote::class); + } + public function follows() { return $this->hasMany(Follow::class); @@ -1645,6 +1651,13 @@ public function blockedUserIds() return $this->blocks->pluck('user_id'); } + public function contestJudgeParticipation(Contest $contest): int + { + return $this->contestJudgeVotes() + ->whereIn('contest_entry_id', $contest->entries->pluck('id')) + ->count(); + } + public function userGroupsForBadges() { return $this->memoize(__FUNCTION__, function () { diff --git a/app/Transformers/ContestEntryTransformer.php b/app/Transformers/ContestEntryTransformer.php index aeb25e51ce4..bcaf86a730b 100644 --- a/app/Transformers/ContestEntryTransformer.php +++ b/app/Transformers/ContestEntryTransformer.php @@ -7,12 +7,15 @@ use App\Models\ContestEntry; use App\Models\DeletedUser; +use League\Fractal\Resource\Item; use Sentry\State\Scope; class ContestEntryTransformer extends TransformerAbstract { protected array $availableIncludes = [ 'artMeta', + 'current_user_judge_vote', + 'judge_votes', 'results', 'user', ]; @@ -20,7 +23,8 @@ class ContestEntryTransformer extends TransformerAbstract public function transform(ContestEntry $entry) { $return = [ - 'id' => $entry->id, + 'contest_id' => $entry->contest_id, + 'id' => $entry->getKey(), 'title' => $entry->contest->unmasked ? $entry->name : $entry->masked_name, 'preview' => $entry->entry_url, ]; @@ -32,11 +36,37 @@ public function transform(ContestEntry $entry) return $return; } + public function includeCurrentUserJudgeVote(ContestEntry $entry): ?Item + { + $currentUser = auth()->user(); + + if ($currentUser === null) { + return null; + } + + $judgeVote = $entry->judgeVotes->where('user_id', $currentUser->getKey())->first(); + + if ($judgeVote === null) { + return null; + } + + return $this->item($judgeVote, new ContestJudgeVoteTransformer()); + } + + public function includeJudgeVotes(ContestEntry $entry) + { + return $this->collection($entry->judgeVotes, new ContestJudgeVoteTransformer); + } + public function includeResults(ContestEntry $entry) { + $votes = $entry->contest->isJudged() + ? $entry->category_votes_sum_value + : $entry->votes_count; + return $this->primitive([ 'actual_name' => $entry->name, - 'votes' => (int) $entry->votes_count, + 'votes' => (int) $votes, ]); } diff --git a/app/Transformers/ContestJudgeCategoryTransformer.php b/app/Transformers/ContestJudgeCategoryTransformer.php new file mode 100644 index 00000000000..7e022b1105f --- /dev/null +++ b/app/Transformers/ContestJudgeCategoryTransformer.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Transformers; + +use App\Models\ContestJudgeCategory; + +class ContestJudgeCategoryTransformer extends TransformerAbstract +{ + protected array $availableIncludes = [ + 'entries', + 'users_voted_count', + ]; + + public function transform(ContestJudgeCategory $judgeCategory): array + { + return [ + 'id' => $judgeCategory->getKey(), + 'max_value' => $judgeCategory->max_value, + 'name' => $judgeCategory->name, + ]; + } +} diff --git a/app/Transformers/ContestJudgeCategoryVoteTransformer.php b/app/Transformers/ContestJudgeCategoryVoteTransformer.php new file mode 100644 index 00000000000..52ee3582f32 --- /dev/null +++ b/app/Transformers/ContestJudgeCategoryVoteTransformer.php @@ -0,0 +1,32 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Transformers; + +use App\Models\ContestJudgeCategoryVote; +use League\Fractal\Resource\Item; + +class ContestJudgeCategoryVoteTransformer extends TransformerAbstract +{ + protected array $availableIncludes = [ + 'category', + ]; + + public function transform(ContestJudgeCategoryVote $categoryVote): array + { + return [ + 'contest_judge_category_id' => $categoryVote->contest_judge_category_id, + 'id' => $categoryVote->getKey(), + 'value' => $categoryVote->value, + ]; + } + + public function includeCategory(ContestJudgeCategoryVote $categoryVote): Item + { + return $this->item($categoryVote->category, new ContestJudgeCategoryTransformer()); + } +} diff --git a/app/Transformers/ContestJudgeVoteTransformer.php b/app/Transformers/ContestJudgeVoteTransformer.php new file mode 100644 index 00000000000..14adbd0916a --- /dev/null +++ b/app/Transformers/ContestJudgeVoteTransformer.php @@ -0,0 +1,45 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Transformers; + +use App\Models\ContestJudgeVote; +use League\Fractal\Resource\Collection; +use League\Fractal\Resource\Item; +use League\Fractal\Resource\Primitive; + +class ContestJudgeVoteTransformer extends TransformerAbstract +{ + protected array $availableIncludes = [ + 'category_votes', + 'score', + 'user', + ]; + + public function transform(ContestJudgeVote $judgeVote): array + { + return [ + 'comment' => $judgeVote->comment, + 'id' => $judgeVote->getKey(), + ]; + } + + public function includeCategoryVotes(ContestJudgeVote $judgeVote): Collection + { + return $this->collection($judgeVote->categoryVotes, new ContestJudgeCategoryVoteTransformer()); + } + + public function includeScore(ContestJudgeVote $judgeVote): Primitive + { + return $this->primitive($judgeVote->score()); + } + + public function includeUser(ContestJudgeVote $judgeVote): Item + { + return $this->item($judgeVote->user, new UserCompactTransformer()); + } +} diff --git a/app/Transformers/ContestTransformer.php b/app/Transformers/ContestTransformer.php index d97e50154dd..a863ee093f7 100644 --- a/app/Transformers/ContestTransformer.php +++ b/app/Transformers/ContestTransformer.php @@ -7,12 +7,16 @@ use App\Models\Contest; use Auth; +use League\Fractal\Resource\Collection; +use League\Fractal\Resource\Primitive; use League\Fractal\Resource\ResourceInterface; class ContestTransformer extends TransformerAbstract { protected array $availableIncludes = [ 'entries', + 'judge_categories', + 'max_judging_score', 'users_voted_count', ]; @@ -25,6 +29,7 @@ public function transform(Contest $contest) 'entry_starts_at' => json_time($contest->entry_starts_at), 'header_url' => $contest->header_url, 'id' => $contest->id, + 'judged' => $contest->isJudged(), 'link_icon' => $contest->link_icon, 'max_entries' => $contest->max_entries, 'max_votes' => $contest->max_votes, @@ -45,6 +50,16 @@ public function includeEntries(Contest $contest) return $this->collection($contest->entriesByType(Auth::user()), new ContestEntryTransformer()); } + public function includeJudgeCategories(Contest $contest): Collection + { + return $this->Collection($contest->judgeCategories, new ContestJudgeCategoryTransformer()); + } + + public function includeMaxJudgingScore(Contest $contest): Primitive + { + return $this->primitive((int) $contest->judge_categories_sum_max_value); + } + public function includeUsersVotedCount(Contest $contest): ResourceInterface { return $this->primitive($contest->usersVotedCount()); diff --git a/database/migrations/2023_07_20_151049_create_contest_judges_table.php b/database/migrations/2023_07_20_151049_create_contest_judges_table.php new file mode 100644 index 00000000000..afc3674e202 --- /dev/null +++ b/database/migrations/2023_07_20_151049_create_contest_judges_table.php @@ -0,0 +1,38 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('contest_judges', function (Blueprint $table) { + $table->id(); + $table->integer('user_id')->unsigned(); + $table->integer('contest_id')->unsigned(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('contest_id'); + $table->unique(['user_id', 'contest_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('contest_judges'); + } +}; diff --git a/database/migrations/2023_07_20_153356_create_contest_judge_votes_table.php b/database/migrations/2023_07_20_153356_create_contest_judge_votes_table.php new file mode 100644 index 00000000000..2ea4c78346e --- /dev/null +++ b/database/migrations/2023_07_20_153356_create_contest_judge_votes_table.php @@ -0,0 +1,39 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('contest_judge_votes', function (Blueprint $table) { + $table->id(); + $table->integer('user_id')->unsigned(); + $table->integer('contest_entry_id')->unsigned(); + $table->text('comment')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('contest_entry_id'); + $table->unique(['user_id', 'contest_entry_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('contest_judge_votes'); + } +}; diff --git a/database/migrations/2023_07_20_153406_create_contest_judge_categories_table.php b/database/migrations/2023_07_20_153406_create_contest_judge_categories_table.php new file mode 100644 index 00000000000..ca38e2716c0 --- /dev/null +++ b/database/migrations/2023_07_20_153406_create_contest_judge_categories_table.php @@ -0,0 +1,38 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('contest_judge_categories', function (Blueprint $table) { + $table->id(); + $table->integer('contest_id')->unsigned(); + $table->string('name'); + $table->tinyInteger('max_value')->default(10); + $table->timestamps(); + + $table->index('contest_id'); + $table->unique(['contest_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('contest_judge_categories'); + } +}; diff --git a/database/migrations/2023_07_20_153416_create_contest_judge_category_votes_table.php b/database/migrations/2023_07_20_153416_create_contest_judge_category_votes_table.php new file mode 100644 index 00000000000..8c328f2a060 --- /dev/null +++ b/database/migrations/2023_07_20_153416_create_contest_judge_category_votes_table.php @@ -0,0 +1,39 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('contest_judge_category_votes', function (Blueprint $table) { + $table->id(); + $table->integer('contest_judge_vote_id')->unsigned(); + $table->integer('contest_judge_category_id')->unsigned(); + $table->tinyInteger('value'); + $table->timestamps(); + + $table->index('contest_judge_vote_id'); + $table->index('contest_judge_category_id'); + $table->unique(['contest_judge_vote_id', 'contest_judge_category_id'], 'vote_category'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('contest_judge_category_votes'); + } +}; diff --git a/database/migrations/2023_08_01_161026_add_judge_score_on_contest_entries.php b/database/migrations/2023_08_01_161026_add_judge_score_on_contest_entries.php new file mode 100644 index 00000000000..7503b1b73e1 --- /dev/null +++ b/database/migrations/2023_08_01_161026_add_judge_score_on_contest_entries.php @@ -0,0 +1,36 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('contest_entries', function (Blueprint $table) { + $table->integer('judge_score')->nullable(); + $table->index('judge_score'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('contest_entries', function (Blueprint $table) { + $table->dropColumn('judge_score'); + $table->dropIndex(['judge_score']); + }); + } +}; diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index 71b4044667c..5c0811b9c77 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -120,6 +120,11 @@ @import "bem/contest"; @import "bem/contest-art-entry"; @import "bem/contest-art-list"; +@import "bem/contest-judge"; +@import "bem/contest-judge-categories"; +@import "bem/contest-judge-entry"; +@import "bem/contest-judge-results"; +@import "bem/contest-judge-results-header"; @import "bem/contest-list"; @import "bem/contest-list-item"; @import "bem/contest-list-legend"; diff --git a/resources/css/bem/contest-judge-categories.less b/resources/css/bem/contest-judge-categories.less new file mode 100644 index 00000000000..d2bbc600ce0 --- /dev/null +++ b/resources/css/bem/contest-judge-categories.less @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.contest-judge-categories { + display: grid; + align-items: center; + grid-gap: 5px; + // category-name value + grid-template-columns: minmax(auto, 120px) auto; + margin-top: 10px; + + &__col { + display: flex; + align-items: center; + + &--score { + color: hsl(var(--hsl-c2)); + } + } + + &__row { + display: contents; + } +} diff --git a/resources/css/bem/contest-judge-entry.less b/resources/css/bem/contest-judge-entry.less new file mode 100644 index 00000000000..c6d4df65f54 --- /dev/null +++ b/resources/css/bem/contest-judge-entry.less @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +@max-width: 500px; +@regular-margin: 10px; + +.contest-judge-entry { + margin-bottom: 20px; + + &__button { + display: flex; + max-width: @max-width; + justify-content: flex-end; + text-transform: lowercase; + } + + &__label { + font-size: @font-size--normal; + margin-bottom: @regular-margin; + margin-top: @regular-margin; + font-weight: bold; + } + + &__title { + margin-bottom: @regular-margin; + font-size: @font-size--large; + font-weight: bold; + color: hsl(var(--hsl-l1)); + } + + &__textarea { + .reset-input(); + .default-border-radius(); + + flex: 1; + background-color: hsl(var(--hsl-b3)); + border: 2px solid transparent; + color: #fff; + padding: 5px; + + &:focus { + border-color: hsl(var(--hsl-l1)); + } + } + + &__textarea-wrapper { + display: flex; + max-width: @max-width; + margin: 20px 0; + } + + &__range { + margin-top: @regular-margin; + max-width: @max-width; + + input[type='range'] { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + + &::-webkit-slider-runnable-track { + background: hsla(var(--hsl-pink-1), 50%); + height: 8px; + border-radius: @border-radius-base; + } + + &::-moz-range-track { + background: hsla(var(--hsl-pink-1), 50%); + height: 8px; + border-radius: @border-radius-base; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + margin-top: -4px; // initially slightly misaligned + background-color: hsl(var(--hsl-pink-1)); + width: 16px; + height: 16px; + border-radius: 9999px; + } + + &::-moz-range-thumb { + border: none; + border-radius: 9999px; + background-color: hsl(var(--hsl-pink-1)); + width: 16px; + height: 16px; + } + } + } + + &__range-value { + margin-top: @regular-margin; + max-width: @max-width; + display: flex; + justify-content: flex-end; + color: hsl(var(--hsl-c2)); + font-size: @font-size--title-small; + } +} diff --git a/resources/css/bem/contest-judge-results-header.less b/resources/css/bem/contest-judge-results-header.less new file mode 100644 index 00000000000..37f53b7f72c --- /dev/null +++ b/resources/css/bem/contest-judge-results-header.less @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.contest-judge-results-header { + .default-gutter-v2(); + background-color: hsl(var(--hsl-d3)); + padding-top: 20px; + padding-bottom: $padding-top; + display: grid; + gap: 20px; + + &__values { + display: flex; + gap: 20px; + } +} diff --git a/resources/css/bem/contest-judge-results.less b/resources/css/bem/contest-judge-results.less new file mode 100644 index 00000000000..16ab132a7e1 --- /dev/null +++ b/resources/css/bem/contest-judge-results.less @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.contest-judge-results { + .default-gutter-v2(); + background-color: hsl(var(--hsl-b5)); + color: #fff; + font-size: @font-size--normal; + padding-top: 20px; + padding-bottom: $padding-top; + display: grid; + gap: 30px; + + &__avatar { + width: 30px; + height: $width; + } + + &__user { + display: flex; + align-items: center; + font-size: @font-size--title-small-3; + } + + &__username { + margin-left: 10px; + } + + &__comment { + margin-top: 15px; + white-space: break-spaces; + } + + &__total-score { + margin-top: 10px; + } +} diff --git a/resources/css/bem/contest-judge.less b/resources/css/bem/contest-judge.less new file mode 100644 index 00000000000..e3bb3a159b9 --- /dev/null +++ b/resources/css/bem/contest-judge.less @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.contest-judge { + &__items { + .default-gutter-v2(); + background-color: hsl(var(--hsl-b5)); + color: white; + font-size: 14px; + padding-top: 20px; + padding-bottom: $padding-top; + } + + &__toolbar { + .default-gutter-v2(); + background-color: hsl(var(--hsl-d3)); + font-size: 14px; + padding-top: 15px; + padding-bottom: $padding-top; + } +} diff --git a/resources/css/bem/contest-voting-list.less b/resources/css/bem/contest-voting-list.less index fd30ef25204..6f35862853b 100644 --- a/resources/css/bem/contest-voting-list.less +++ b/resources/css/bem/contest-voting-list.less @@ -83,6 +83,9 @@ &--bg { background: rgba(0, 0, 0, 0.75); } + &--judge-results { + background-color: @osu-colour-b6; + } &--submitted-beatmaps { background-size: cover; flex: 0 0 50px; diff --git a/resources/css/bem/select-options.less b/resources/css/bem/select-options.less index 93f7f59f224..c3f39ce7ef9 100644 --- a/resources/css/bem/select-options.less +++ b/resources/css/bem/select-options.less @@ -20,6 +20,11 @@ } } + &--judge-results { + --selector-max-height: 400px; // arbitrary + --selector-overflow-y: scroll; + } + &--ranking { --selector-max-height: 400px; // arbitrary --selector-overflow-y: scroll; diff --git a/resources/css/bem/sort.less b/resources/css/bem/sort.less index 26624862de7..2b17fdb57dc 100644 --- a/resources/css/bem/sort.less +++ b/resources/css/bem/sort.less @@ -50,6 +50,11 @@ }; } + &--contest-judge { + margin: 0; + padding: 0; + } + &--title { padding: 0; } diff --git a/resources/css/bem/value-display.less b/resources/css/bem/value-display.less index ba460310872..169b017f750 100644 --- a/resources/css/bem/value-display.less +++ b/resources/css/bem/value-display.less @@ -14,6 +14,10 @@ min-width: 60px; flex: 1; + &--judge-results { + flex: none; + } + &--kudosu { --value-font-size-desktop: @font-size--large-3; --value-font-size: @font-size--large-3; @@ -46,6 +50,13 @@ } } + &--score { + --label-font-size: @font-size--normal; + --value-color: hsl(var(--hsl-c2)); + --value-font-size-desktop: @font-size--new-header-title; + --value-font-size: @font-size--new-header-title; + } + @media @desktop { --value-font-size: var(--value-font-size-desktop); --label-font-size: var(--label-font-size-desktop); diff --git a/resources/js/contest-judge-results/header.tsx b/resources/js/contest-judge-results/header.tsx new file mode 100644 index 00000000000..6aa3696dd39 --- /dev/null +++ b/resources/js/contest-judge-results/header.tsx @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import SelectOptions, { OptionRenderProps } from 'components/select-options'; +import UserLink from 'components/user-link'; +import ValueDisplay from 'components/value-display'; +import ContestEntryJson from 'interfaces/contest-entry-json'; +import ContestJson from 'interfaces/contest-json'; +import SelectOptionJson from 'interfaces/select-option-json'; +import { route } from 'laroute'; +import * as React from 'react'; +import { navigate } from 'utils/turbolinks'; + +interface Props { + contest: ContestJson; + entries: ContestEntryJson[]; + entry: ContestEntryJson; +} + +export default class Header extends React.PureComponent { + get selected() { + return { + id: this.props.entry.id, + text: this.props.entry.title, + }; + } + + get selectOptions() { + const ret = []; + + for (const x of this.props.entries) { + ret.push({ id: x.id, text: x.title }); + } + + return ret; + } + + render() { + const { contest, entry } = this.props; + + const score = entry.results?.votes; + const maxScore = (contest.max_judging_score ?? 0) * (entry.judge_votes?.length ?? 0); + const totalScore = `${score}/${maxScore}`; + + const userLink = this.renderUserLink(); + + return ( +
+ {this.renderSelectOptions()} + +
+ {score != null && } + + {userLink && } +
+
+ ); + } + + renderSelectOptions() { + return ( + + ); + } + + renderUserLink() { + const { user } = this.props.entry; + if (user == null) return; + + return ( + + ); + } + + private handleChange = (option: SelectOptionJson) => { + navigate(this.href(option.id)); + }; + + private href(id: number) { + return route('contest-entries.judge-results', { contest_entry: id }); + } + + private renderOption = (props: OptionRenderProps) => ( + + {props.children} + + ); +} diff --git a/resources/js/contest-judge-results/main.tsx b/resources/js/contest-judge-results/main.tsx new file mode 100644 index 00000000000..e7a113e386f --- /dev/null +++ b/resources/js/contest-judge-results/main.tsx @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import ContestEntryJson from 'interfaces/contest-entry-json'; +import ContestJson from 'interfaces/contest-json'; +import * as React from 'react'; +import Header from './header'; +import Vote from './vote'; + +interface Props { + contest: ContestJson; + entries: ContestEntryJson[]; + entry: ContestEntryJson; +} + +export default function Main(props: Props) { + return ( + <> +
+ +
+ {props.entry.judge_votes?.map((vote) => ( + + ))} +
+ + ); +} diff --git a/resources/js/contest-judge-results/vote.tsx b/resources/js/contest-judge-results/vote.tsx new file mode 100644 index 00000000000..91b99289c9c --- /dev/null +++ b/resources/js/contest-judge-results/vote.tsx @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import UserAvatar from 'components/user-avatar'; +import UserLink from 'components/user-link'; +import ValueDisplay from 'components/value-display'; +import ContestJson from 'interfaces/contest-json'; +import ContestJudgeVoteJson from 'interfaces/contest-judge-vote-json'; +import * as React from 'react'; +import { trans } from 'utils/lang'; + +interface Props { + contest: ContestJson; + vote: ContestJudgeVoteJson; +} + +export default function Vote(props: Props) { + return ( +
+ {props.vote.user != null && ( +
+ + + + + +
+ )} + +
+ +
+ +
+ {props.vote.category_votes?.map((categoryVote) => ( +
+
+ {categoryVote.category?.name} +
+
+ {categoryVote.value}/{categoryVote.category?.max_value} +
+
+ ))} +
+ + {props.vote.comment != null &&
{props.vote.comment}
} +
+ ); +} diff --git a/resources/js/contest-judge/entry.tsx b/resources/js/contest-judge/entry.tsx new file mode 100644 index 00000000000..ad790f39f74 --- /dev/null +++ b/resources/js/contest-judge/entry.tsx @@ -0,0 +1,162 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import BigButton from 'components/big-button'; +import ContestEntryJson from 'interfaces/contest-entry-json'; +import ContestJudgeCategory from 'interfaces/contest-judge-category-json'; +import ContestJudgeCategoryVoteJson from 'interfaces/contest-judge-category-vote-json'; +import { route } from 'laroute'; +import { action, makeObservable, observable, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import { ContestEntry } from 'models/contest-entry'; +import * as React from 'react'; +import ContestEntryStore from 'stores/contest-entry-store'; +import { onError } from 'utils/ajax'; +import { trans } from 'utils/lang'; + +interface Props { + entry: ContestEntry; + judgeCategories: ContestJudgeCategory[]; + store: ContestEntryStore; +} + +@observer +export default class Entry extends React.Component { + @observable private categoryVotes: ContestJudgeCategoryVoteJson[]; + @observable private comment: string; + @observable private posting = false; + @observable private xhr?: JQuery.jqXHR; + + constructor(props: Props) { + super(props); + + this.categoryVotes = props.entry.current_user_judge_vote?.category_votes ?? []; + this.comment = props.entry.current_user_judge_vote?.comment ?? ''; + + makeObservable(this); + } + + categoryVote(categoryId: number) { + return this.categoryVotes.find((x) => x.contest_judge_category_id === categoryId); + } + + disabled() { + for (const x of this.props.judgeCategories) { + if (this.categoryVote(x.id) == null) return true; + } + + return false; + } + + render() { + return ( +
+

+ {this.props.entry.title} +

+ + {this.props.judgeCategories.map((category) => { + const currentVote = this.categoryVote(category.id); + + return ( +
+
+ {category.name} +
+ + {this.renderRangeInput(category)} + +
+ { + currentVote != null + ? `${currentVote.value}/${category.max_value}` + : trans('contest.judge.no_current_vote') + } +
+
+ ); + })} + +
+