the browser-facing portion of osu!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'master' into profile-customization-drop-cover

authored by

bakaneko and committed by
GitHub
190e5a77 2982f827

+1592 -809
+13 -10
.env.example
··· 265 265 # NOTIFICATION_CLEANUP_MAX_DELETE=50000 266 266 267 267 # The open source bounty info page/form url 268 - #OS_BOUNTY_URL=http://example.com/bounty_form 268 + # OS_BOUNTY_URL=http://example.com/bounty_form 269 269 270 270 # OAUTH_MAX_USER_CLIENTS=1 271 271 ··· 292 292 # PAGINATION_MAX_COUNT=10000 293 293 294 294 ## Limits for the allowed number of simultaneous beatmapset uploads (displayed on the support page: /home/support) 295 - #BEATMAPSET_UPLOAD_ALLOWED=4 296 - #BEATMAPSET_UPLOAD_BONUS_PER_RANKED=1 297 - #BEATMAPSET_UPLOAD_BONUS_PER_RANKED_MAX=2 298 - #BEATMAPSET_UPLOAD_ALLOWED_SUPPORTER=8 299 - #BEATMAPSET_UPLOAD_BONUS_PER_RANKED_SUPPORTER=1 300 - #BEATMAPSET_UPLOAD_BONUS_PER_RANKED_MAX_SUPPORTER=12 295 + # BEATMAPSET_UPLOAD_ALLOWED=4 296 + # BEATMAPSET_UPLOAD_BONUS_PER_RANKED=1 297 + # BEATMAPSET_UPLOAD_BONUS_PER_RANKED_MAX=2 298 + # BEATMAPSET_UPLOAD_ALLOWED_SUPPORTER=8 299 + # BEATMAPSET_UPLOAD_BONUS_PER_RANKED_SUPPORTER=1 300 + # BEATMAPSET_UPLOAD_BONUS_PER_RANKED_MAX_SUPPORTER=12 301 301 302 - #RECAPTCHA_SECRET= 303 - #RECAPTCHA_SITEKEY= 304 - #RECAPTCHA_THRESHOLD= 302 + # RECAPTCHA_SECRET= 303 + # RECAPTCHA_SITEKEY= 304 + # RECAPTCHA_THRESHOLD= 305 305 306 306 # TWITCH_CLIENT_ID= 307 307 # TWITCH_CLIENT_SECRET= ··· 336 336 337 337 # USER_COUNTRY_CHANGE_MAX_MIXED_MONTHS=2 338 338 # USER_COUNTRY_CHANGE_MIN_MONTHS=6 339 + 340 + # USER_INACTIVE_DAYS_VERIFICATION=180 341 + # USER_INACTIVE_FORCE_PASSWORD_RESET=false
+2 -1
app/Http/Controllers/Forum/TopicsController.php
··· 16 16 use App\Models\Forum\TopicCover; 17 17 use App\Models\Forum\TopicPoll; 18 18 use App\Models\Forum\TopicWatch; 19 + use App\Models\UserProfileCustomization; 19 20 use App\Transformers\Forum\TopicCoverTransformer; 20 21 use Auth; 21 22 use DB; ··· 625 626 $params['limit'] = clamp($params['limit'] ?? 20, 1, 50); 626 627 627 628 if ($userCanModerate) { 628 - $params['with_deleted'] = $params['with_deleted'] ?? $currentUser->profileCustomization()->forum_posts_show_deleted; 629 + $params['with_deleted'] ??= ($currentUser->userProfileCustomization ?? UserProfileCustomization::DEFAULTS)['forum_posts_show_deleted']; 629 630 } else { 630 631 $params['with_deleted'] = false; 631 632 }
+9 -2
app/Http/Controllers/ScorePinsController.php
··· 91 91 $rulesetId = Beatmap::MODES[$score->getMode()]; 92 92 $currentMinDisplayOrder = $user->scorePins()->where('ruleset_id', $rulesetId)->min('display_order') ?? 2500; 93 93 94 + $soloScore = $score instanceof Solo\Score 95 + ? $score 96 + : Solo\Score::firstWhere(['ruleset_id' => $rulesetId, 'legacy_score_id' => $score->getKey()]); 97 + 94 98 try { 95 - (new ScorePin(['display_order' => $currentMinDisplayOrder - 100, 'ruleset_id' => $rulesetId])) 96 - ->user()->associate($user) 99 + (new ScorePin([ 100 + 'display_order' => $currentMinDisplayOrder - 100, 101 + 'ruleset_id' => $rulesetId, 102 + 'new_score_id' => $soloScore?->getKey(), 103 + ]))->user()->associate($user) 97 104 ->score()->associate($score) 98 105 ->saveOrExplode(); 99 106 } catch (Exception $ex) {
+23 -13
app/Http/Controllers/ScoresController.php
··· 7 7 8 8 use App\Models\Score\Best\Model as ScoreBest; 9 9 use App\Models\Solo\Score as SoloScore; 10 - use App\Models\UserCountryHistory; 11 10 use App\Transformers\ScoreTransformer; 12 11 use App\Transformers\UserCompactTransformer; 13 - use Carbon\CarbonImmutable; 14 12 15 13 class ScoresController extends Controller 16 14 { 15 + const REPLAY_DOWNLOAD_COUNT_INTERVAL = 86400; // 1 day 16 + 17 17 public function __construct() 18 18 { 19 19 parent::__construct(); ··· 52 52 abort(404); 53 53 } 54 54 55 - if (\Auth::user()->getKey() !== $score->user_id) { 56 - $score->user->statistics($score->getMode(), true)->increment('replay_popularity'); 55 + $currentUser = \Auth::user(); 56 + if ( 57 + !$currentUser->isRestricted() 58 + && $currentUser->getKey() !== $score->user_id 59 + && ($currentUser->token()?->client->password_client ?? false) 60 + ) { 61 + $countLock = \Cache::lock( 62 + "view:score_replay:{$score->getKey()}:{$currentUser->getKey()}", 63 + static::REPLAY_DOWNLOAD_COUNT_INTERVAL, 64 + ); 57 65 58 - $month = CarbonImmutable::now(); 59 - $currentMonth = UserCountryHistory::formatDate($month); 66 + if ($countLock->get()) { 67 + $score->user->statistics($score->getMode(), true)->increment('replay_popularity'); 60 68 61 - $score->user->replaysWatchedCounts() 62 - ->firstOrCreate(['year_month' => $currentMonth], ['count' => 0]) 63 - ->incrementInstance('count'); 69 + $currentMonth = format_month_column(new \DateTime()); 70 + $score->user->replaysWatchedCounts() 71 + ->firstOrCreate(['year_month' => $currentMonth], ['count' => 0]) 72 + ->incrementInstance('count'); 64 73 65 - if ($score instanceof ScoreBest) { 66 - $score->replayViewCount() 67 - ->firstOrCreate([], ['play_count' => 0]) 68 - ->incrementInstance('play_count'); 74 + if ($score instanceof ScoreBest) { 75 + $score->replayViewCount() 76 + ->firstOrCreate([], ['play_count' => 0]) 77 + ->incrementInstance('play_count'); 78 + } 69 79 } 70 80 } 71 81
+2
app/Http/Controllers/SessionsController.php
··· 77 77 $forceReactivation = new ForceReactivation($user, $request); 78 78 79 79 if ($forceReactivation->isRequired()) { 80 + DatadogLoginAttempt::log('password_reset'); 80 81 $forceReactivation->run(); 81 82 82 83 \Session::flash('password_reset_start', [ ··· 87 88 return ujs_redirect(route('password-reset')); 88 89 } 89 90 91 + DatadogLoginAttempt::log(null); 90 92 $this->login($user, $remember); 91 93 92 94 return [
+87
app/Http/Controllers/UserCoverPresetsController.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + namespace App\Http\Controllers; 7 + 8 + use App\Models\UserCoverPreset; 9 + use Symfony\Component\HttpFoundation\Response; 10 + 11 + class UserCoverPresetsController extends Controller 12 + { 13 + public function __construct() 14 + { 15 + $this->middleware('auth'); 16 + 17 + parent::__construct(); 18 + } 19 + 20 + public function batchActivate(): Response 21 + { 22 + $params = get_params(\Request::all(), null, [ 23 + 'ids:int[]', 24 + 'active:bool', 25 + ]); 26 + if (!isset($params['active'])) { 27 + abort(422, 'parameter "active" is missing'); 28 + } 29 + UserCoverPreset::whereKey($params['ids'] ?? [])->update(['active' => $params['active']]); 30 + 31 + return response(null, 204); 32 + } 33 + 34 + public function index(): Response 35 + { 36 + priv_check('UserCoverPresetManage')->ensureCan(); 37 + 38 + return ext_view('user_cover_presets.index', [ 39 + 'items' => UserCoverPreset::orderBy('id', 'ASC')->get(), 40 + ]); 41 + } 42 + 43 + public function store(): Response 44 + { 45 + priv_check('UserCoverPresetManage')->ensureCan(); 46 + 47 + try { 48 + $files = \Request::file('files') ?? []; 49 + foreach ($files as $file) { 50 + $item = \DB::transaction(function () use ($file) { 51 + $item = UserCoverPreset::create(); 52 + $item->file()->store($file->getRealPath()); 53 + $item->saveOrExplode(); 54 + 55 + return $item; 56 + }); 57 + $hash ??= "#cover-{$item->getKey()}"; 58 + } 59 + \Session::flash('popup', osu_trans('user_cover_presets.store.ok')); 60 + } catch (\Throwable $e) { 61 + \Session::flash('popup', osu_trans('user_cover_presets.store.failed', ['error' => $e->getMessage()])); 62 + } 63 + 64 + return ujs_redirect(route('user-cover-presets.index').($hash ?? '')); 65 + } 66 + 67 + public function update(string $id): Response 68 + { 69 + priv_check('UserCoverPresetManage')->ensureCan(); 70 + 71 + $item = UserCoverPreset::findOrFail($id); 72 + $params = get_params(\Request::all(), null, [ 73 + 'file:file', 74 + 'active:bool', 75 + ], ['null_missing' => true]); 76 + 77 + if ($params['file'] !== null) { 78 + $item->file()->store($params['file']); 79 + $item->save(); 80 + } 81 + if ($params['active'] !== null) { 82 + $item->update(['active' => $params['active']]); 83 + } 84 + 85 + return ujs_redirect(route('user-cover-presets.index').'#cover-'.$item->getKey()); 86 + } 87 + }
-2
app/Http/Controllers/UsersController.php
··· 665 665 } else { 666 666 $achievements = json_collection(app('medals')->all(), 'Achievement'); 667 667 668 - $extras = []; 669 - 670 668 $initialData = [ 671 669 'achievements' => $achievements, 672 670 'current_mode' => $currentMode,
+8 -8
app/Jobs/Notifications/BeatmapsetDiscussionQualifiedProblem.php
··· 29 29 30 30 $ids = []; 31 31 32 - $notificationOptions = UserNotificationOption 32 + UserNotificationOption 33 33 ::where(['name' => Notification::BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM]) 34 34 ->whereNotNull('details') 35 - ->get(); 36 - 37 - foreach ($notificationOptions as $notificationOption) { 38 - if (count(array_intersect($notificationOption->details['modes'] ?? [], $modes)) > 0) { 39 - $ids[] = $notificationOption->user_id; 40 - } 41 - } 35 + ->chunkById(1000, function ($options) use (&$ids, $modes) { 36 + foreach ($options as $option) { 37 + if (count(array_intersect($option->details['modes'] ?? [], $modes)) > 0) { 38 + $ids[] = $option->user_id; 39 + } 40 + } 41 + }); 42 42 43 43 return $ids; 44 44 }
+8 -9
app/Jobs/Notifications/BeatmapsetDisqualify.php
··· 20 20 return Beatmap::modeStr($modeInt); 21 21 }, $modes); 22 22 23 - $notificationOptions = UserNotificationOption 24 - ::where(['name' => Notification::BEATMAPSET_DISQUALIFY]) 23 + UserNotificationOption::where(['name' => Notification::BEATMAPSET_DISQUALIFY]) 25 24 ->whereNotNull('details') 26 - ->get(); 27 - 28 - foreach ($notificationOptions as $notificationOption) { 29 - if (count(array_intersect($notificationOption->details['modes'] ?? [], $modes)) > 0) { 30 - $ids[] = $notificationOption->user_id; 31 - } 32 - } 25 + ->chunkById(1000, function ($options) use (&$ids, $modes) { 26 + foreach ($options as $option) { 27 + if (count(array_intersect($option->details['modes'] ?? [], $modes)) > 0) { 28 + $ids[] = $option->user_id; 29 + } 30 + } 31 + }); 33 32 34 33 return $ids; 35 34 }
+1 -1
app/Libraries/CommentBundleParams.php
··· 36 36 $this->cursor = null; 37 37 $this->limit = static::DEFAULT_LIMIT; 38 38 $this->page = static::DEFAULT_PAGE; 39 - $this->sort = optional($user)->profileCustomization()->comments_sort ?? null; 39 + $this->sort = $user->userProfileCustomization->comments_sort ?? null; 40 40 41 41 $this->setAll($params); 42 42 }
+10 -4
app/Libraries/Search/BeatmapsetSearch.php
··· 425 425 private function getPlayedBeatmapIds(?array $rank = null) 426 426 { 427 427 $query = Solo\Score 428 - ::where('user_id', $this->params->user->getKey()) 428 + ::indexable() 429 + ->where('user_id', $this->params->user->getKey()) 429 430 ->whereIn('ruleset_id', $this->getSelectedModes()); 430 431 431 432 if ($rank === null) { ··· 433 434 } 434 435 435 436 $topScores = []; 436 - $scoreField = ScoreSearchParams::showLegacyForUser($this->params->user) 437 - ? 'legacy_total_score' 438 - : 'total_score'; 437 + $showLegacyOnly = ScoreSearchParams::showLegacyForUser($this->params->user) ?? false; 438 + $scoreField = $showLegacyOnly ? 'legacy_total_score' : 'total_score'; 439 439 foreach ($query->get() as $score) { 440 440 $prevScore = $topScores[$score->beatmap_id] ?? null; 441 441 442 442 $scoreValue = $score->$scoreField; 443 443 if ($scoreValue !== null && ($prevScore === null || $prevScore->$scoreField < $scoreValue)) { 444 444 $topScores[$score->beatmap_id] = $score; 445 + } 446 + } 447 + 448 + if ($showLegacyOnly) { 449 + foreach ($topScores as $beatmapId => $score) { 450 + $topScores[$beatmapId] = $score->makeLegacyEntry(); 445 451 } 446 452 } 447 453
+3 -8
app/Libraries/Search/BeatmapsetSearchRequestParams.php
··· 107 107 $this->showRecommended = in_array('recommended', $generals, true); 108 108 $this->showSpotlights = in_array('spotlights', $generals, true); 109 109 110 - $includeNsfw = $params['nsfw']; 111 - if (!isset($includeNsfw) && $user !== null && $user->userProfileCustomization !== null) { 112 - $includeNsfw = $user->userProfileCustomization->beatmapset_show_nsfw; 113 - } 114 - 115 - if (isset($includeNsfw)) { 116 - $this->includeNsfw = $includeNsfw; 117 - } 110 + $this->includeNsfw = $params['nsfw'] 111 + ?? $user->userProfileCustomization->beatmapset_show_nsfw 112 + ?? $this->includeNsfw; 118 113 } else { 119 114 $sort = null; 120 115 }
+3
app/Libraries/Search/ScoreSearch.php
··· 56 56 $query->mustNot(['term' => ['mods' => $excludedMod]]); 57 57 } 58 58 } 59 + if ($this->params->excludeWithoutPp === true) { 60 + $query->filter(['exists' => ['field' => 'pp']]); 61 + } 59 62 60 63 $this->addModsFilter($query); 61 64
+3 -1
app/Libraries/Search/ScoreSearchParams.php
··· 24 24 public bool $excludeConverts = false; 25 25 public ?array $excludeMods = null; 26 26 public ?bool $isLegacy = null; 27 + public bool $excludeWithoutPp = false; 27 28 public ?array $mods = null; 28 29 public ?int $rulesetId = null; 29 30 public $size = 50; ··· 39 40 $params->beatmapIds = $rawParams['beatmap_ids'] ?? null; 40 41 $params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts; 41 42 $params->excludeMods = $rawParams['exclude_mods'] ?? null; 43 + $params->excludeWithoutPp = $rawParams['exclude_without_pp'] ?? $params->excludeWithoutPp; 42 44 $params->isLegacy = $rawParams['is_legacy'] ?? null; 43 45 $params->mods = $rawParams['mods'] ?? null; 44 46 $params->rulesetId = $rawParams['ruleset_id'] ?? null; ··· 84 86 return null; 85 87 } 86 88 87 - return $user?->userProfileCustomization?->legacy_score_only ?? UserProfileCustomization::DEFAULT_LEGACY_ONLY_ATTRIBUTE 89 + return ($user->userProfileCustomization ?? UserProfileCustomization::DEFAULTS)['legacy_score_only'] 88 90 ? true 89 91 : null; 90 92 }
+2 -3
app/Libraries/User/CountryChangeTarget.php
··· 10 10 use App\Models\Tournament; 11 11 use App\Models\TournamentRegistration; 12 12 use App\Models\User; 13 - use App\Models\UserCountryHistory; 14 13 use Carbon\CarbonImmutable; 15 14 16 15 class CountryChangeTarget ··· 38 37 ->userCountryHistory() 39 38 ->whereBetween('year_month', [ 40 39 // one year maximum range. Offset by 1 because the range is inclusive 41 - UserCountryHistory::formatDate($until->subMonths(11)), 42 - UserCountryHistory::formatDate($until), 40 + format_month_column($until->subMonths(11)), 41 + format_month_column($until), 43 42 ])->distinct() 44 43 ->orderBy('year_month', 'DESC') 45 44 ->limit($minMonths)
+9 -16
app/Libraries/User/Cover.php
··· 15 15 16 16 private const AVAILABLE_PRESET_IDS = ['1', '2', '3', '4', '5', '6', '7', '8']; 17 17 18 - private ?array $json; 19 - 20 18 public function __construct(private User $user) 21 19 { 22 20 } ··· 32 30 return $this->user->customCover()->url(); 33 31 } 34 32 35 - public function presetId(): ?string 33 + public function defaultPresetId(): string 36 34 { 37 - if ($this->hasCustomCover()) { 38 - return null; 39 - } 35 + $id = max(0, $this->user->getKey() ?? 0); 40 36 41 - $id = $this->user->getKey(); 37 + return static::AVAILABLE_PRESET_IDS[$id % count(static::AVAILABLE_PRESET_IDS)]; 38 + } 42 39 43 - if ($id === null || $id < 1) { 44 - return null; 45 - } 46 - 47 - $presetId = (string) $this->user->cover_preset_id; 48 - 49 - return static::isValidPresetId($presetId) 50 - ? $presetId 51 - : static::AVAILABLE_PRESET_IDS[$id % count(static::AVAILABLE_PRESET_IDS)]; 40 + public function presetId(): ?string 41 + { 42 + return $this->hasCustomCover() 43 + ? null 44 + : (string) ($this->user->cover_preset_id ?? $this->defaultPresetId()); 52 45 } 53 46 54 47 public function set(?string $presetId, ?string $filePath): void
+12 -5
app/Libraries/User/ForceReactivation.php
··· 13 13 14 14 class ForceReactivation 15 15 { 16 + const INACTIVE = 'inactive'; 16 17 const INACTIVE_DIFFERENT_COUNTRY = 'inactive_different_country'; 17 18 18 19 private $country; ··· 27 28 28 29 $this->country = request_country($this->request); 29 30 30 - if ($this->user->isInactive() && $this->user->country_acronym !== $this->country) { 31 - $this->reason = static::INACTIVE_DIFFERENT_COUNTRY; 31 + if ($this->user->isInactive()) { 32 + if ($this->user->country_acronym !== $this->country) { 33 + $this->reason = static::INACTIVE_DIFFERENT_COUNTRY; 34 + } elseif ($GLOBALS['cfg']['osu']['user']['inactive_force_password_reset']) { 35 + $this->reason = static::INACTIVE; 36 + } 32 37 } 33 38 } 34 39 ··· 62 67 63 68 private function addHistoryNote() 64 69 { 65 - if ($this->reason === static::INACTIVE_DIFFERENT_COUNTRY) { 66 - $message = "First login after {$this->user->user_lastvisit->diffInDays()} days from {$this->country}. Forcing password reset."; 67 - } 70 + $message = match ($this->reason) { 71 + static::INACTIVE => "First login after {$this->user->user_lastvisit->diffInDays()} days. Forcing password reset.", 72 + static::INACTIVE_DIFFERENT_COUNTRY => "First login after {$this->user->user_lastvisit->diffInDays()} days from {$this->country}. Forcing password reset.", 73 + default => null, 74 + }; 68 75 69 76 if ($message !== null) { 70 77 UserAccountHistory::addNote($this->user, $message);
+7
app/Models/BeatmapPack.php
··· 117 117 ]; 118 118 if ($this->no_diff_reduction) { 119 119 $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray(); 120 + if ($isLegacy !== true) { 121 + // the intended meaning of this check is that the scores should not include mods 122 + // that disqualify them from granting pp. 123 + // mods are not the only reason why pp might be missing, but it's the best that we have for now. 124 + // see also: https://github.com/ppy/osu-queue-score-statistics/pull/234 125 + $params['exclude_without_pp'] = true; 126 + } 120 127 } 121 128 122 129 static $aggName = 'by_beatmap';
+19 -13
app/Models/Beatmapset.php
··· 501 501 } 502 502 503 503 $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); 504 + // archive file is gone, nothing to do for now 505 + if ($statusCode === 302) { 506 + return false; 507 + } 504 508 if ($statusCode !== 200) { 505 509 throw new BeatmapProcessorException('Failed downloading osz: HTTP Error '.$statusCode); 506 510 } 507 511 508 - return new BeatmapsetArchive(get_stream_filename($oszFile)); 512 + try { 513 + return new BeatmapsetArchive(get_stream_filename($oszFile)); 514 + } catch (BeatmapProcessorException $e) { 515 + // zip file is broken, nothing to do for now 516 + return false; 517 + } 509 518 } 510 519 511 520 public function regenerateCovers(array $sizesToRegenerate = null) ··· 531 540 532 541 if ($backgroundFilename !== false) { 533 542 $tmpFile = tmpfile(); 534 - $bytesWritten = fwrite($tmpFile, $osz->readFile($backgroundFilename)); 535 - fseek($tmpFile, 0); // reset file position cursor, required for storeCover below 543 + fwrite($tmpFile, $osz->readFile($backgroundFilename)); 536 544 $backgroundImage = get_stream_filename($tmpFile); 537 545 if (!static::isValidBackgroundImage($backgroundImage)) { 538 546 return false; ··· 1481 1489 1482 1490 public function getDisplayArtist(?User $user) 1483 1491 { 1484 - $profileCustomization = $user->userProfileCustomization ?? new UserProfileCustomization(); 1485 - if ($profileCustomization->beatmapset_title_show_original) { 1486 - return $this->artist_unicode; 1487 - } 1492 + $profileCustomization = $user->userProfileCustomization ?? UserProfileCustomization::DEFAULTS; 1488 1493 1489 - return $this->artist; 1494 + return $profileCustomization['beatmapset_title_show_original'] 1495 + ? $this->artist_unicode 1496 + : $this->artist; 1490 1497 } 1491 1498 1492 1499 public function getDisplayTitle(?User $user) 1493 1500 { 1494 - $profileCustomization = $user->userProfileCustomization ?? new UserProfileCustomization(); 1495 - if ($profileCustomization->beatmapset_title_show_original) { 1496 - return $this->title_unicode; 1497 - } 1501 + $profileCustomization = $user->userProfileCustomization ?? UserProfileCustomization::DEFAULTS; 1498 1502 1499 - return $this->title; 1503 + return $profileCustomization['beatmapset_title_show_original'] 1504 + ? $this->title_unicode 1505 + : $this->title; 1500 1506 } 1501 1507 1502 1508 public function freshHype()
+1 -1
app/Models/Comment.php
··· 302 302 { 303 303 return [ 304 304 'reason' => 'Spam', 305 - 'user_id' => $this->user_id, 305 + 'user_id' => $this->user_id ?? 0, 306 306 ]; 307 307 } 308 308
+2 -1
app/Models/Multiplayer/ScoreLink.php
··· 28 28 public static function complete(ScoreToken $token, array $params): static 29 29 { 30 30 return \DB::transaction(function () use ($params, $token) { 31 - $score = Score::createFromJsonOrExplode($params); 31 + // multiplayer scores are always preserved. 32 + $score = Score::createFromJsonOrExplode([...$params, 'preserve' => true]); 32 33 33 34 $playlistItem = $token->playlistItem; 34 35 $requiredMods = array_column($playlistItem->required_mods, 'acronym');
+1
app/Models/Multiplayer/UserScoreAggregate.php
··· 203 203 { 204 204 $agg = PlaylistItemUserHighScore 205 205 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 206 + ->whereNotNull('score_id') 206 207 ->selectRaw(' 207 208 SUM(accuracy) AS accuracy_sum, 208 209 SUM(total_score) AS total_score_sum,
+16 -6
app/Models/Solo/Score.php
··· 19 19 use App\Models\ScoreToken; 20 20 use App\Models\Traits; 21 21 use App\Models\User; 22 + use Illuminate\Contracts\Filesystem\Filesystem; 22 23 use Illuminate\Database\Eloquent\Builder; 23 24 use LaravelRedis; 24 - use Storage; 25 25 26 26 /** 27 27 * @property float $accuracy ··· 118 118 $params['started_at'] = $scoreToken->created_at; 119 119 $params['user_id'] = $scoreToken->user_id; 120 120 121 + $params['passed'] ??= false; 122 + $params['preserve'] = $params['passed']; 123 + 121 124 $beatmap = $scoreToken->beatmap; 122 125 // anything that have leaderboard 123 - $params['ranked'] = $beatmap !== null && $beatmap->approved > 0; 126 + $params['ranked'] = $params['passed'] && $beatmap !== null && $beatmap->approved > 0; 127 + 128 + return $params; 129 + } 124 130 125 - $params['preserve'] = $params['passed'] ?? false; 131 + public static function replayFileDiskName(): string 132 + { 133 + return "{$GLOBALS['cfg']['osu']['score_replays']['storage']}-solo-replay"; 134 + } 126 135 127 - return $params; 136 + public static function replayFileStorage(): Filesystem 137 + { 138 + return \Storage::disk(static::replayFileDiskName()); 128 139 } 129 140 130 141 public function beatmap() ··· 253 264 254 265 public function getReplayFile(): ?string 255 266 { 256 - return Storage::disk($GLOBALS['cfg']['osu']['score_replays']['storage'].'-solo-replay') 257 - ->get($this->getKey()); 267 + return static::replayFileStorage()->get($this->getKey()); 258 268 } 259 269 260 270 public function isLegacy(): bool
+5 -1
app/Models/User.php
··· 2078 2078 } 2079 2079 } 2080 2080 2081 - DatadogLoginAttempt::log($authError); 2082 2081 2083 2082 if ($authError !== null) { 2083 + DatadogLoginAttempt::log($authError); 2084 2084 LoginAttempt::logAttempt($ip, $user, 'fail', $password); 2085 2085 2086 2086 return osu_trans('users.login.failed'); ··· 2398 2398 2399 2399 public function save(array $options = []) 2400 2400 { 2401 + if (!$this->exists) { 2402 + $this->cover_preset_id ??= $this->cover()->defaultPresetId(); 2403 + } 2404 + 2401 2405 if ($options['skipValidations'] ?? false) { 2402 2406 return parent::save($options); 2403 2407 }
+1 -6
app/Models/UserCountryHistory.php
··· 28 28 protected $primaryKeys = ['user_id', 'year_month', 'country_acronym']; 29 29 protected $table = 'user_country_history'; 30 30 31 - public static function formatDate(\DateTimeInterface $date): string 32 - { 33 - return $date->format('ym'); 34 - } 35 - 36 31 public function country(): BelongsTo 37 32 { 38 33 return $this->belongsTo(Country::class, 'country_acronym'); ··· 46 41 public function setYearMonthAttribute(\DateTimeInterface|string $value): void 47 42 { 48 43 $this->attributes['year_month'] = $value instanceof \DateTimeInterface 49 - ? static::formatDate($value) 44 + ? format_month_column($value) 50 45 : $value; 51 46 } 52 47 }
+31
app/Models/UserCoverPreset.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + namespace App\Models; 7 + 8 + use App\Libraries\Uploader; 9 + use App\Libraries\User\Cover; 10 + 11 + /** 12 + * @property bool $active 13 + * @property \Carbon\Carbon|null $created_at 14 + * @property string|null $filename 15 + * @property int $id 16 + * @property \Carbon\Carbon|null $updated_at 17 + */ 18 + class UserCoverPreset extends Model 19 + { 20 + private Uploader $file; 21 + 22 + public function file(): Uploader 23 + { 24 + return $this->file ??= new Uploader( 25 + 'user-cover-presets', 26 + $this, 27 + 'filename', 28 + ['image' => ['maxDimensions' => Cover::CUSTOM_COVER_MAX_DIMENSIONS]], 29 + ); 30 + } 31 + }
+35 -18
app/Models/UserProfileCustomization.php
··· 16 16 */ 17 17 class UserProfileCustomization extends Model 18 18 { 19 + const DEFAULTS = [ 20 + 'audio_autoplay' => false, 21 + 'audio_muted' => false, 22 + 'audio_volume' => 0.45, 23 + 'beatmapset_card_size' => self::BEATMAPSET_CARD_SIZES[0], 24 + 'beatmapset_download' => self::BEATMAPSET_DOWNLOAD[0], 25 + 'beatmapset_show_nsfw' => false, 26 + 'beatmapset_title_show_original' => false, 27 + 'comments_show_deleted' => false, 28 + 'comments_sort' => Comment::DEFAULT_SORT, 29 + 'extras_order' => self::SECTIONS, 30 + 'forum_posts_show_deleted' => true, 31 + 'legacy_score_only' => true, 32 + 'profile_cover_expanded' => true, 33 + 'user_list_filter' => self::USER_LIST['filters']['default'], 34 + 'user_list_sort' => self::USER_LIST['sorts']['default'], 35 + 'user_list_view' => self::USER_LIST['views']['default'], 36 + ]; 37 + 19 38 /** 20 39 * An array of all possible profile sections, also in their default order. 21 40 */ ··· 32 51 const BEATMAPSET_CARD_SIZES = ['normal', 'extra']; 33 52 34 53 const BEATMAPSET_DOWNLOAD = ['all', 'no_video', 'direct']; 35 - 36 - const DEFAULT_LEGACY_ONLY_ATTRIBUTE = true; 37 54 38 55 const USER_LIST = [ 39 56 'filters' => ['all' => ['all', 'online', 'offline'], 'default' => 'all'], ··· 66 83 67 84 public function getAudioAutoplayAttribute() 68 85 { 69 - return $this->options['audio_autoplay'] ?? false; 86 + return $this->options['audio_autoplay'] ?? static::DEFAULTS['audio_autoplay']; 70 87 } 71 88 72 89 public function setAudioAutoplayAttribute($value) ··· 76 93 77 94 public function getAudioMutedAttribute() 78 95 { 79 - return $this->options['audio_muted'] ?? false; 96 + return $this->options['audio_muted'] ?? static::DEFAULTS['audio_muted']; 80 97 } 81 98 82 99 public function setAudioMutedAttribute($value) ··· 86 103 87 104 public function getAudioVolumeAttribute() 88 105 { 89 - return $this->options['audio_volume'] ?? 0.45; 106 + return $this->options['audio_volume'] ?? static::DEFAULTS['audio_volume']; 90 107 } 91 108 92 109 public function setAudioVolumeAttribute($value) ··· 96 113 97 114 public function getBeatmapsetCardSizeAttribute() 98 115 { 99 - return $this->options['beatmapset_card_size'] ?? static::BEATMAPSET_CARD_SIZES[0]; 116 + return $this->options['beatmapset_card_size'] ?? static::DEFAULTS['beatmapset_card_size']; 100 117 } 101 118 102 119 public function setBeatmapsetCardSizeAttribute($value) ··· 110 127 111 128 public function getBeatmapsetDownloadAttribute() 112 129 { 113 - return $this->options['beatmapset_download'] ?? static::BEATMAPSET_DOWNLOAD[0]; 130 + return $this->options['beatmapset_download'] ?? static::DEFAULTS['beatmapset_download']; 114 131 } 115 132 116 133 public function setBeatmapsetDownloadAttribute($value) ··· 124 141 125 142 public function getBeatmapsetShowNsfwAttribute() 126 143 { 127 - return $this->options['beatmapset_show_nsfw'] ?? false; 144 + return $this->options['beatmapset_show_nsfw'] ?? static::DEFAULTS['beatmapset_show_nsfw']; 128 145 } 129 146 130 147 public function setBeatmapsetShowNsfwAttribute($value) ··· 134 151 135 152 public function getBeatmapsetTitleShowOriginalAttribute() 136 153 { 137 - return $this->options['beatmapset_title_show_original'] ?? false; 154 + return $this->options['beatmapset_title_show_original'] ?? static::DEFAULTS['beatmapset_title_show_original']; 138 155 } 139 156 140 157 public function setBeatmapsetTitleShowOriginalAttribute($value) ··· 144 161 145 162 public function getCommentsShowDeletedAttribute() 146 163 { 147 - return $this->options['comments_show_deleted'] ?? false; 164 + return $this->options['comments_show_deleted'] ?? static::DEFAULTS['comments_show_deleted']; 148 165 } 149 166 150 167 public function setCommentsShowDeletedAttribute($value) ··· 154 171 155 172 public function getCommentsSortAttribute() 156 173 { 157 - return $this->options['comments_sort'] ?? Comment::DEFAULT_SORT; 174 + return $this->options['comments_sort'] ?? static::DEFAULTS['comments_sort']; 158 175 } 159 176 160 177 public function setCommentsSortAttribute($value) ··· 168 185 169 186 public function getForumPostsShowDeletedAttribute() 170 187 { 171 - return $this->options['forum_posts_show_deleted'] ?? true; 188 + return $this->options['forum_posts_show_deleted'] ?? static::DEFAULTS['forum_posts_show_deleted']; 172 189 } 173 190 174 191 public function setForumPostsShowDeletedAttribute($value) ··· 178 195 179 196 public function getLegacyScoreOnlyAttribute(): bool 180 197 { 181 - return $this->options['legacy_score_only'] ?? static::DEFAULT_LEGACY_ONLY_ATTRIBUTE; 198 + return $this->options['legacy_score_only'] ?? static::DEFAULTS['legacy_score_only']; 182 199 } 183 200 184 201 public function setLegacyScoreOnlyAttribute($value): void ··· 188 205 189 206 public function getUserListFilterAttribute() 190 207 { 191 - return $this->options['user_list_filter'] ?? static::USER_LIST['filters']['default']; 208 + return $this->options['user_list_filter'] ?? static::DEFAULTS['user_list_filter']; 192 209 } 193 210 194 211 public function setUserListFilterAttribute($value) ··· 202 219 203 220 public function getUserListSortAttribute() 204 221 { 205 - return $this->options['user_list_sort'] ?? static::USER_LIST['sorts']['default']; 222 + return $this->options['user_list_sort'] ?? static::DEFAULTS['user_list_sort']; 206 223 } 207 224 208 225 public function setUserListSortAttribute($value) ··· 216 233 217 234 public function getUserListViewAttribute() 218 235 { 219 - return $this->options['user_list_view'] ?? static::USER_LIST['views']['default']; 236 + return $this->options['user_list_view'] ?? static::DEFAULTS['user_list_view']; 220 237 } 221 238 222 239 public function setUserListViewAttribute($value) ··· 237 254 } 238 255 239 256 if ($newValue === null) { 240 - return static::SECTIONS; 257 + return static::DEFAULTS['extras_order']; 241 258 } 242 259 243 260 return static::repairExtrasOrder($newValue); ··· 251 268 252 269 public function getProfileCoverExpandedAttribute() 253 270 { 254 - return $this->options['profile_cover_expanded'] ?? true; 271 + return $this->options['profile_cover_expanded'] ?? static::DEFAULTS['profile_cover_expanded']; 255 272 } 256 273 257 274 public function setProfileCoverExpandedAttribute($value)
+5
app/Singletons/OsuAuthorize.php
··· 2087 2087 return 'unauthorized'; 2088 2088 } 2089 2089 2090 + public function checkUserCoverPresetManage(?User $user): ?string 2091 + { 2092 + return null; 2093 + } 2094 + 2090 2095 /** 2091 2096 * @param User|null $user 2092 2097 * @return string
+9 -4
app/Transformers/UserCompactTransformer.php
··· 10 10 use App\Models\Beatmap; 11 11 use App\Models\User; 12 12 use App\Models\UserProfileCustomization; 13 + use Illuminate\Support\Arr; 13 14 use League\Fractal\Resource\ResourceInterface; 14 15 15 16 class UserCompactTransformer extends TransformerAbstract ··· 445 446 446 447 public function includeUserPreferences(User $user) 447 448 { 448 - $customization = $user->userProfileCustomization ?? new UserProfileCustomization(); 449 - 450 - return $this->primitive($customization->only([ 449 + static $fields = [ 451 450 'audio_autoplay', 452 451 'audio_muted', 453 452 'audio_volume', ··· 462 461 'user_list_filter', 463 462 'user_list_sort', 464 463 'user_list_view', 465 - ])); 464 + ]; 465 + 466 + $customization = $user->userProfileCustomization; 467 + 468 + return $this->primitive($customization === null 469 + ? Arr::only(UserProfileCustomization::DEFAULTS, $fields) 470 + : $customization->only($fields)); 466 471 } 467 472 468 473 public function setMode(string $mode)
+4 -4
app/Transformers/UserTransformer.php
··· 29 29 { 30 30 $result = parent::transform($user); 31 31 32 - $profileOrder = $user->userProfileCustomization->extras_order 33 - ?? UserProfileCustomization::SECTIONS; 32 + $profileOrder = ($user->userProfileCustomization ?? UserProfileCustomization::DEFAULTS)['extras_order']; 34 33 35 - return array_merge($result, [ 34 + return [ 35 + ...$result, 36 36 'cover_url' => $user->cover()->url(), // TODO: deprecated. 37 37 'discord' => $user->user_discord, 38 38 'has_supported' => $user->hasSupported(), ··· 50 50 'title_url' => $user->titleUrl(), 51 51 'twitter' => $user->user_twitter, 52 52 'website' => $user->user_website, 53 - ]); 53 + ]; 54 54 } 55 55 }
+13 -5
app/helpers.php
··· 395 395 return "/assets/images/flags/{$baseFileName}.svg"; 396 396 } 397 397 398 + function format_month_column(\DateTimeInterface $date): string 399 + { 400 + return $date->format('ym'); 401 + } 402 + 398 403 function format_rank(?int $rank): string 399 404 { 400 405 return $rank !== null ? '#'.i18n_number_format($rank) : '-'; ··· 1354 1359 ]); 1355 1360 $data = curl_exec($curl); 1356 1361 1357 - $errorCode = curl_errno($curl); 1358 - if ($errorCode !== 0 && $logErrorId !== null) { 1362 + $ret = read_image_properties_from_string($data); 1363 + 1364 + if ($ret === null && $logErrorId !== null) { 1359 1365 log_error(new FastImagesizeFetchException(), [ 1360 - 'curl_error_code' => $errorCode, 1361 - 'curl_error_message' => curl_error($curl), 1366 + 'curl_error_code' => curl_errno($curl), 1367 + 'curl_error_message' => presence(curl_error($curl)) ?? 'ok', 1368 + 'curl_status_code' => curl_getinfo($curl, CURLINFO_HTTP_CODE), 1362 1369 'error_id' => $logErrorId, 1370 + 'url' => $url, 1363 1371 ]); 1364 1372 } 1365 1373 1366 1374 // null isn't cached 1367 - return read_image_properties_from_string($data) ?? false; 1375 + return $ret ?? false; 1368 1376 }, 1369 1377 )); 1370 1378 }
+1
config/osu.php
··· 247 247 'allowed_rename_groups' => explode(' ', env('USER_ALLOWED_RENAME_GROUPS', 'default')), 248 248 'bypass_verification' => get_bool(env('USER_BYPASS_VERIFICATION')) ?? false, 249 249 'hide_pinned_solo_scores' => get_bool(env('USER_HIDE_PINNED_SOLO_SCORES')) ?? true, 250 + 'inactive_force_password_reset' => get_bool(env('USER_INACTIVE_FORCE_PASSWORD_RESET') ?? false), 250 251 'inactive_seconds_verification' => (get_int(env('USER_INACTIVE_DAYS_VERIFICATION')) ?? 180) * 86400, 251 252 'min_plays_for_posting' => get_int(env('USER_MIN_PLAYS_FOR_POSTING')) ?? 10, 252 253 'min_plays_allow_verified_bypass' => get_bool(env('USER_MIN_PLAYS_ALLOW_VERIFIED_BYPASS')) ?? true,
+9
database/factories/Solo/ScoreFactory.php
··· 45 45 ]); 46 46 } 47 47 48 + public function withReplay(): static 49 + { 50 + return $this 51 + ->state(['has_replay' => true]) 52 + ->afterCreating(function ($score) { 53 + Score::replayFileStorage()->put($score->getKey(), 'placeholder replay file'); 54 + }); 55 + } 56 + 48 57 private function makeData(?array $overrides = null): callable 49 58 { 50 59 return fn (array $attr): array => array_map(
+27
database/migrations/2024_02_15_115214_add_new_score_id_to_score_pins.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + declare(strict_types=1); 7 + 8 + use Illuminate\Database\Migrations\Migration; 9 + use Illuminate\Database\Schema\Blueprint; 10 + use Illuminate\Support\Facades\Schema; 11 + 12 + return new class extends Migration 13 + { 14 + public function up(): void 15 + { 16 + Schema::table('score_pins', function (Blueprint $table) { 17 + $table->unsignedBigInteger('new_score_id')->after('score_id')->nullable(true); 18 + }); 19 + } 20 + 21 + public function down(): void 22 + { 23 + Schema::table('score_pins', function (Blueprint $table) { 24 + $table->dropColumn('new_score_id'); 25 + }); 26 + } 27 + };
+29
database/migrations/2024_03_12_110401_create_user_cover_presets.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + declare(strict_types=1); 7 + 8 + use Illuminate\Database\Migrations\Migration; 9 + use Illuminate\Database\Schema\Blueprint; 10 + use Illuminate\Support\Facades\Schema; 11 + 12 + return new class extends Migration 13 + { 14 + public function up(): void 15 + { 16 + Schema::create('user_cover_presets', function (Blueprint $table) { 17 + $table->mediumIncrements('id'); 18 + $table->string('filename')->nullable(true); 19 + $table->boolean('active')->default(false)->nullable(false); 20 + $table->timestamps(); 21 + $table->index('active'); 22 + }); 23 + } 24 + 25 + public function down(): void 26 + { 27 + Schema::dropIfExists('user_cover_presets'); 28 + } 29 + };
+6 -6
docker-compose.yml
··· 107 107 # important to use 127.0.0.1 instead of localhost as mysql starts twice. 108 108 # the first time it listens on sockets but isn't actually ready 109 109 # see https://github.com/docker-library/mysql/issues/663 110 + start_interval: 1s 111 + start_period: 60s 110 112 test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] 111 - interval: 1s 112 113 timeout: 60s 113 - start_period: 60s 114 114 115 115 redis: 116 116 command: ['redis-server', '--save', '60', '1', '--loglevel', 'warning'] ··· 118 118 ports: 119 119 - "${REDIS_EXTERNAL_PORT:-127.0.0.1:6379}:6379" 120 120 healthcheck: 121 + start_interval: 1s 122 + start_period: 60s 121 123 test: ["CMD", "redis-cli", "--raw", "incr", "ping"] 122 - interval: 1s 123 124 timeout: 60s 124 - start_period: 60s 125 125 volumes: 126 126 - redis:/data 127 127 ··· 138 138 ES_JAVA_OPTS: "-Xms512m -Xmx512m" # less OOM on default settings. 139 139 ingest.geoip.downloader.enabled: false 140 140 healthcheck: 141 + start_interval: 1s 142 + start_period: 60s 141 143 test: curl -s http://localhost:9200/_cluster/health?wait_for_status=yellow >/dev/null || exit 1 142 - interval: 1s 143 144 timeout: 60s 144 - start_period: 60s 145 145 146 146 nginx: 147 147 image: nginx:latest
+2
resources/css/bem-index.less
··· 381 381 @import "bem/user-card"; 382 382 @import "bem/user-card-brick"; 383 383 @import "bem/user-cards"; 384 + @import "bem/user-cover-preset-replace"; 385 + @import "bem/user-cover-preset-table"; 384 386 @import "bem/user-group-badge"; 385 387 @import "bem/user-home"; 386 388 @import "bem/user-home-beatmapset";
+1 -1
resources/css/bem/blackout.less
··· 7 7 z-index: @z-index--blackout; 8 8 backface-visibility: hidden; 9 9 background-color: #000; 10 - opacity: 0.75; 10 + opacity: 0.5; 11 11 12 12 &--overlay { 13 13 z-index: @z-index--overlay;
+5
resources/css/bem/btn-osu-big.less
··· 291 291 border-radius: 100000px; 292 292 } 293 293 294 + &--rounded-small { 295 + border-radius: 100000px; 296 + padding: 4px 10px; 297 + } 298 + 294 299 &--rounded-thin { 295 300 padding: 8px 15px; 296 301 border-radius: 100000px;
+3
resources/css/bem/osu-switch-v2.less
··· 59 59 } 60 60 61 61 .@{_top}__input[type=checkbox][data-indeterminate='true'] + & { 62 + color: hsl(var(--hsl-h1)); 63 + 62 64 &::after { 63 65 .fas(); 64 66 content: '\f068'; 67 + opacity: 1; 65 68 } 66 69 } 67 70
+13 -14
resources/css/bem/store-slider.less
··· 11 11 12 12 // inset slider to prevent overflows making the page wider. 13 13 // the 2px is to add a gap between the callout and the page edge. 14 - padding: 0 ((@callout-extension / 2) - (@callout-padding * 2) + 2px); 15 14 padding: 0 (@slider-callout--width / 2 - @gutter-v2 + 2px); 16 15 17 16 @media @desktop { ··· 76 75 text-align: center; 77 76 border-radius: 10px; 78 77 79 - .js-store--disabled & { 78 + &--active { 79 + background-color: @osu-colour-pink-1; 80 + } 81 + 82 + &--disabled { 80 83 background-color: @osu-colour-b1; 81 84 pointer-events: none; 82 85 cursor: default; 83 - } 84 - 85 - &.js-slider-preset--active { 86 - background-color: @osu-colour-pink-1; 87 - .js-store--disabled & { 88 - background-color: @osu-colour-b1; 89 - } 90 86 } 91 87 } 92 88 ··· 108 104 109 105 .ui-slider { 110 106 background-color: @osu-colour-b3; 111 - .js-store--disabled & { 112 - .ui-slider-handle, .ui-slider-range { 113 - background-color: @osu-colour-b1; 114 - } 115 - } 107 + 116 108 .ui-slider-handle { 117 109 background-color: @osu-colour-pink-1; 118 110 } 111 + 119 112 .ui-slider-range { 120 113 background-color: @osu-colour-pink-1; 114 + } 115 + 116 + &--disabled { 117 + .ui-slider-handle, .ui-slider-range { 118 + background-color: @osu-colour-b1; 119 + } 121 120 } 122 121 } 123 122 }
+13
resources/css/bem/user-cover-preset-replace.less
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + .user-cover-preset-replace { 5 + display: grid; 6 + gap: 10px; 7 + padding-top: 10px; 8 + border-top: 1px solid hsl(var(--hsl-b1)); 9 + 10 + &__input { 11 + width: 100%; 12 + } 13 + }
+38
resources/css/bem/user-cover-preset-table.less
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + .user-cover-preset-table { 5 + display: grid; 6 + grid-template-columns: auto 150px 1fr; 7 + 8 + &__image { 9 + width: 100%; 10 + } 11 + 12 + &__toolbar { 13 + grid-column-start: 2; 14 + grid-column-end: 4; 15 + } 16 + 17 + &__row { 18 + padding: 10px; 19 + gap: 10px; 20 + 21 + display: grid; 22 + grid-column: ~"1 / 4"; 23 + grid-template-columns: subgrid; 24 + 25 + &--item { 26 + background: var(--row-bg); 27 + --row-bg: hsl(var(--hsl-b4)); 28 + 29 + &:hover { 30 + background: hsl(var(--hsl-b2)); 31 + } 32 + 33 + &:nth-child(even) { 34 + --row-bg: hsl(var(--hsl-b3)); 35 + } 36 + } 37 + } 38 + }
-62
resources/js/_classes/store-supporter-tag-price.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { trans, transChoice } from 'utils/lang' 5 - 6 - class window.StoreSupporterTagPrice 7 - @durationToPrice: (duration) -> 8 - duration = +duration 9 - switch 10 - when duration >= 12 then Math.ceil(duration / 12.0 * 26) 11 - when duration == 10 then 24 12 - when duration == 9 then 22 13 - when duration == 8 then 20 14 - when duration == 6 then 16 15 - when duration == 4 then 12 16 - when duration == 2 then 8 17 - when duration == 1 then 4 18 - 19 - constructor: (price) -> 20 - @_price = price 21 - 22 - price: -> 23 - @_price 24 - 25 - duration: -> 26 - @_duration ?= switch 27 - when @_price >= 26 then Math.floor(@_price / 26.0 * 12) 28 - when @_price >= 24 then 10 29 - when @_price >= 22 then 9 30 - when @_price >= 20 then 8 31 - when @_price >= 16 then 6 32 - when @_price >= 12 then 4 33 - when @_price >= 8 then 2 34 - when @_price >= 4 then 1 35 - else 0 36 - 37 - discount: -> 38 - if @duration() >= 12 39 - 46 40 - else 41 - raw = ((1 - (@_price / @duration()) / 4) * 100) 42 - Math.max(0, Math.round(raw, 0)) 43 - 44 - discountText: -> 45 - trans('store.discount', percent: @discount()) 46 - 47 - durationInYears: -> 48 - years: Math.floor(@duration() / 12) 49 - months: Math.floor(@duration() % 12) 50 - 51 - durationText: -> 52 - # don't forget to update SupporterTag::getDurationText() in php 53 - duration = @durationInYears() 54 - texts = [] 55 - 56 - if duration.years > 0 57 - texts.push transChoice('common.count.years', duration.years) 58 - 59 - if duration.months > 0 60 - texts.push transChoice('common.count.months', duration.months) 61 - 62 - texts.join(', ')
+6 -4
resources/js/beatmap-discussions/beatmap-list.tsx
··· 4 4 import BeatmapListItem from 'components/beatmap-list-item'; 5 5 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 6 import UserJson from 'interfaces/user-json'; 7 - import { action, computed, makeObservable, observable } from 'mobx'; 8 - import { observer } from 'mobx-react'; 7 + import { action, autorun, computed, makeObservable, observable } from 'mobx'; 8 + import { disposeOnUnmount, observer } from 'mobx-react'; 9 9 import { deletedUserJson } from 'models/user'; 10 10 import * as React from 'react'; 11 11 import { makeUrl } from 'utils/beatmapset-discussion-helper'; ··· 34 34 super(props); 35 35 36 36 makeObservable(this); 37 + disposeOnUnmount(this, autorun(() => { 38 + blackoutToggle(this, this.showingSelector); 39 + })); 37 40 } 38 41 39 42 componentDidMount() { 40 43 $(document).on(`click.${this.eventId}`, this.onDocumentClick); 41 44 $(document).on(`turbolinks:before-cache.${this.eventId}`, this.handleBeforeCache); 42 - blackoutToggle(this.showingSelector, 0.5); 43 45 } 44 46 45 47 componentWillUnmount() { 46 48 $(document).off(`.${this.eventId}`); 49 + blackoutToggle(this, false); 47 50 } 48 51 49 52 render() { ··· 127 130 @action 128 131 private setShowingSelector(state: boolean) { 129 132 this.showingSelector = state; 130 - blackoutToggle(state, 0.5); 131 133 } 132 134 133 135 @action
+2 -4
resources/js/components/modal.tsx
··· 49 49 50 50 private readonly close = () => { 51 51 modals.delete(this); 52 - if (modals.size === 0) { 53 - blackoutToggle(false); 54 - } 52 + blackoutToggle(this, false); 55 53 }; 56 54 57 55 private readonly handleBeforeCache = () => { ··· 91 89 92 90 private readonly open = () => { 93 91 modals.add(this); 94 - blackoutToggle(true, 0.5); 92 + blackoutToggle(this, true); 95 93 }; 96 94 }
+2 -1
resources/js/components/select-options.tsx
··· 48 48 super(props); 49 49 makeObservable(this); 50 50 disposeOnUnmount(this, autorun(() => { 51 - blackoutToggle(this.props.blackout && this.showingSelector, 0.5); 51 + blackoutToggle(this, this.props.blackout && this.showingSelector); 52 52 })); 53 53 } 54 54 ··· 58 58 59 59 componentWillUnmount() { 60 60 document.removeEventListener('click', this.hideSelector); 61 + blackoutToggle(this, false); 61 62 } 62 63 63 64 render() {
-37
resources/js/components/user-card-store.tsx
··· 1 - // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - // See the LICENCE file in the repository root for full licence text. 3 - 4 - import UserJson from 'interfaces/user-json'; 5 - import * as React from 'react'; 6 - import { UserCard } from './user-card'; 7 - 8 - interface Props { 9 - user: UserJson | null; 10 - } 11 - 12 - interface State { 13 - user?: UserJson | null; 14 - } 15 - 16 - /** 17 - * This component's job shims UserCard for store-supporter-tag to update UserCard's props. 18 - */ 19 - export class UserCardStore extends React.PureComponent<Props, State> { 20 - state: Readonly<State> = { user: this.props.user }; 21 - 22 - componentDidMount() { 23 - $.subscribe('store-supporter-tag:update-user', this.setUser); 24 - } 25 - 26 - componentWillUnmount() { 27 - $.unsubscribe('store-supporter-tag:update-user', this.setUser); 28 - } 29 - 30 - render() { 31 - return <UserCard user={this.state.user} />; 32 - } 33 - 34 - setUser = (event: JQuery.Event, user?: UserJson) => { 35 - this.setState({ user }); 36 - }; 37 - }
+3 -3
resources/js/core-legacy/nav2.coffee
··· 1 1 # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 # See the LICENCE file in the repository root for full licence text. 3 3 4 - import { blackoutHide, blackoutShow } from 'utils/blackout' 4 + import { blackoutToggle } from 'utils/blackout' 5 5 import { fadeToggle } from 'utils/fade' 6 6 7 7 export default class Nav2 ··· 50 50 51 51 if @showingMobileNav 52 52 document.body.classList.add('js-nav2--active') 53 - blackoutShow() 53 + blackoutToggle(this, true) 54 54 else if previousTree.indexOf('mobile-menu') != -1 55 - blackoutHide() 55 + blackoutToggle(this, false) 56 56 Timeout.set 0, => 57 57 $(@clickMenu.menu('mobile-menu')).finish().slideUp 150, => 58 58 # use actual state instead of always removing the class in case
-155
resources/js/core-legacy/store-supporter-tag.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { route } from 'laroute' 5 - import { fadeToggle } from 'utils/fade' 6 - import { toggleCart } from 'utils/store-cart' 7 - 8 - export default class StoreSupporterTag 9 - RESOLUTION: 8 10 - MIN_VALUE: 4 11 - MAX_VALUE: 52 12 - 13 - @initialize: -> 14 - new StoreSupporterTag(elem) for elem in document.getElementsByClassName('js-store-supporter-tag') 15 - 16 - constructor: (rootElement) -> 17 - @debouncedGetUser = _.debounce @getUser, 300 18 - @el = rootElement 19 - @searching = false 20 - 21 - # Everything should be scoped under the root @el 22 - @priceElement = @el.querySelector('.js-price') 23 - @durationElement = @el.querySelector('.js-duration') 24 - @discountElement = @el.querySelector('.js-discount') 25 - @messageInput = @el.querySelector('.js-store-supporter-tag-message') 26 - @slider = @el.querySelector('.js-slider') 27 - @sliderPresets = @el.querySelectorAll('.js-slider-preset') 28 - @targetIdElement = @el.querySelector('input[name="item[extra_data][target_id]"]') 29 - @usernameInput = @el.querySelector('.js-username-input') 30 - 31 - @reactElement = @el.querySelector('.js-react--user-card-store') 32 - @user = JSON.parse(@reactElement.dataset.user) 33 - if !@user? 34 - @user = currentUser 35 - @reactElement.dataset.user = JSON.stringify(@user) 36 - 37 - $(document).one 'turbolinks:before-cache', => 38 - @reactElement.dataset.user = JSON.stringify(@user) 39 - 40 - @cost = @calculate(@initializeSlider().slider('value')) 41 - @initializeSliderPresets() 42 - @initializeUsernameInput() 43 - @updateCostDisplay() 44 - 45 - # force initial values for consistency. 46 - @updateSearchResult() 47 - 48 - $(@usernameInput).trigger('input') if @usernameInput.value != '' 49 - 50 - 51 - initializeSlider: => 52 - # remove leftover from previous initialization 53 - $(@slider).find('.ui-slider-range').remove() 54 - 55 - $(@slider).slider 56 - range: 'min' 57 - value: @slider.dataset.lastValue ? @sliderValue(@MIN_VALUE) 58 - min: @sliderValue(@MIN_VALUE) 59 - max: @sliderValue(@MAX_VALUE) 60 - step: 1 61 - animate: true 62 - slide: @onSliderValueChanged 63 - change: @onSliderValueChanged 64 - 65 - 66 - initializeSliderPresets: => 67 - $(@sliderPresets).on 'click', (event) => 68 - target = event.currentTarget 69 - price = StoreSupporterTagPrice.durationToPrice(target.dataset.months) 70 - $(@slider).slider('value', @sliderValue(price)) if price 71 - 72 - 73 - initializeUsernameInput: => 74 - $(@usernameInput).on 'input', @onInput 75 - 76 - 77 - getUser: (username) => 78 - if !username # reset to current user on empty 79 - @user = window.currentUser 80 - @searching = false 81 - @updateSearchResult() 82 - return 83 - 84 - $.ajax 85 - data: 86 - username: username 87 - dataType: 'json', 88 - type: 'POST' 89 - url: route('users.check-username-exists') 90 - .done (data) => 91 - @user = data 92 - 93 - .fail (xhr, status) => 94 - $(@usernameInput) 95 - .trigger 'ajax:error', [xhr, status] 96 - .one 'click', @onInput 97 - 98 - .always => 99 - @searching = false 100 - @updateSearchResult() 101 - 102 - 103 - calculate: (position) => 104 - new StoreSupporterTagPrice(Math.floor(position / @RESOLUTION)) 105 - 106 - 107 - onSliderValueChanged: (event, ui) => 108 - @slider.dataset.lastValue = ui.value 109 - @cost = @calculate(ui.value) 110 - @updateCostDisplay() 111 - 112 - 113 - onInput: (event) => 114 - if !@searching 115 - @searching = true 116 - @user = null 117 - @updateSearchResult() 118 - @debouncedGetUser(event.currentTarget.value) 119 - 120 - 121 - sliderValue: (price) -> 122 - price * @RESOLUTION 123 - 124 - 125 - updateCostDisplay: => 126 - @el.querySelector('input[name="item[cost]"]').value = @cost.price() 127 - @priceElement.textContent = "USD #{@cost.price()}" 128 - @durationElement.textContent = @cost.durationText() 129 - @discountElement.textContent = @cost.discountText() 130 - @updateSliderPreset(elem, @cost) for elem in @sliderPresets 131 - 132 - 133 - updateSearchResult: => 134 - $.publish 'store-supporter-tag:update-user', @user 135 - @updateTargetId() 136 - @updateUserInteraction() 137 - 138 - 139 - updateSliderPreset: (elem, cost) -> 140 - $(elem).toggleClass('js-slider-preset--active', cost.duration() >= +elem.dataset.months) 141 - 142 - 143 - updateTargetId: => 144 - @targetIdElement.value = @user?.id 145 - 146 - 147 - updateUserInteraction: => 148 - enabled = @user?.id? && Number.isFinite(@user.id) && @user.id > 0 149 - messageInputVisible = enabled && @user?.id != window.currentUser.id 150 - fadeToggle(@messageInput, messageInputVisible) 151 - 152 - toggleCart(enabled) 153 - # TODO: need to elevate this element when switching over to new store design. 154 - $(@el).toggleClass('js-store--disabled', !enabled) 155 - $('.js-slider').slider('disabled': !enabled)
-49
resources/js/core-legacy/twitch-player.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { fadeOut } from 'utils/fade' 5 - 6 - export default class TwitchPlayer 7 - constructor: (@turbolinksReload) -> 8 - @playerDivs = document.getElementsByClassName('js-twitch-player') 9 - 10 - addEventListener 'turbolinks:load', @startAll 11 - 12 - 13 - initializeEmbed: => 14 - @turbolinksReload 15 - .load 'https://player.twitch.tv/js/embed/v1.js' 16 - ?.then @startAll 17 - 18 - 19 - startAll: => 20 - return if @playerDivs.length == 0 21 - 22 - if !Twitch? 23 - @initializeEmbed() 24 - else 25 - @start(div) for div in @playerDivs 26 - 27 - 28 - start: (div) => 29 - return if div.dataset.twitchPlayerStarted 30 - 31 - div.dataset.twitchPlayerStarted = true 32 - options = 33 - width: '100%' 34 - height: '100%' 35 - channel: div.dataset.channel 36 - 37 - player = new Twitch.Player(div.id, options) 38 - player.addEventListener Twitch.Player.PLAY, => @openPlayer(div) 39 - 40 - 41 - noCookieDiv: (playerDivId) => 42 - document.querySelector(".js-twitch-player--no-cookie[data-player-id='#{playerDivId}']") 43 - 44 - 45 - openPlayer: (div) => 46 - return unless div.classList.contains 'hidden' 47 - 48 - div.classList.remove 'hidden' 49 - fadeOut @noCookieDiv(div.id)
+12
resources/js/core/twitch-embed-player.d.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + declare module 'twitch-embed-player' { 5 + // (2024-03-26) see https://dev.twitch.tv/docs/embed/video-and-clips/ for all options. 6 + export default class TwitchEmbedPlayer { 7 + static PLAY: string; 8 + 9 + constructor (id: string, options: Record<string, unknown>); 10 + addEventListener(action: string, callback: () => void): void; 11 + } 12 + }
+69
resources/js/core/twitch-player.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import type TwitchEmbedPlayer from 'twitch-embed-player'; 5 + import { fadeOut } from 'utils/fade'; 6 + import TurbolinksReload from './turbolinks-reload'; 7 + 8 + declare global { 9 + interface Window { 10 + Twitch?: { 11 + Embed: unknown; // unused 12 + Player: typeof TwitchEmbedPlayer; 13 + }; 14 + } 15 + } 16 + 17 + export default class TwitchPlayer { 18 + private readonly playerDivs = document.getElementsByClassName('js-twitch-player'); 19 + 20 + constructor(private readonly turbolinksReload: TurbolinksReload) { 21 + document.addEventListener('turbolinks:load', this.startAll); 22 + } 23 + 24 + initializeEmbed() { 25 + this.turbolinksReload 26 + .load('https://player.twitch.tv/js/embed/v1.js') 27 + ?.then(this.startAll); 28 + } 29 + 30 + noCookieDiv(playerDivId: string) { 31 + return document.querySelector<HTMLElement>(`.js-twitch-player--no-cookie[data-player-id='${playerDivId}']`); 32 + } 33 + 34 + openPlayer(div: HTMLElement) { 35 + if (!div.classList.contains('hidden')) return; 36 + 37 + div.classList.remove('hidden'); 38 + fadeOut(this.noCookieDiv(div.id)); 39 + } 40 + 41 + start(div: HTMLElement) { 42 + if (window.Twitch == null 43 + || div.dataset.twitchPlayerStarted === 'true') return; 44 + 45 + div.dataset.twitchPlayerStarted = 'true'; 46 + const options = { 47 + channel: div.dataset.channel, 48 + height: '100%', 49 + width: '100%', 50 + }; 51 + 52 + const player = new window.Twitch.Player(div.id, options); 53 + player.addEventListener(window.Twitch.Player.PLAY, () => this.openPlayer(div)); 54 + } 55 + 56 + startAll = () => { 57 + if (this.playerDivs.length === 0) return; 58 + 59 + if (window.Twitch == null) { 60 + this.initializeEmbed(); 61 + } else { 62 + for (const div of this.playerDivs) { 63 + if (div instanceof HTMLElement) { 64 + this.start(div); 65 + } 66 + } 67 + } 68 + }; 69 + }
-1
resources/js/entrypoints/app.ts
··· 5 5 6 6 import 'jquery-pubsub.coffee'; 7 7 8 - import '_classes/store-supporter-tag-price.coffee'; 9 8 import '_classes/timeout.coffee'; 10 9 11 10 import 'spoilerbox.coffee';
+6
resources/js/entrypoints/user-cover-presets.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import UserCoverPresetBatchActivate from 'user-cover-preset-batch-activate'; 5 + 6 + new UserCoverPresetBatchActivate();
-4
resources/js/main.coffee
··· 32 32 import Search from 'core-legacy/search' 33 33 import StickyFooter from 'core-legacy/sticky-footer' 34 34 import { StoreCheckout } from 'core-legacy/store-checkout' 35 - import StoreSupporterTag from 'core-legacy/store-supporter-tag' 36 35 import SyncHeight from 'core-legacy/sync-height' 37 36 import TooltipDefault from 'core-legacy/tooltip-default' 38 - import TwitchPlayer from 'core-legacy/twitch-player' 39 37 import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay' 40 38 import { navigate } from 'utils/turbolinks' 41 39 ··· 61 59 62 60 $(document).on 'turbolinks:load', -> 63 61 BeatmapPack.initialize() 64 - StoreSupporterTag.initialize() 65 62 StoreCheckout.initialize() 66 63 67 64 # ensure currentUser is updated early enough. ··· 98 95 window.forumTopicPostJump ?= new ForumTopicPostJump(window.forum) 99 96 window.forumTopicReply ?= new ForumTopicReply(bbcodePreview: window.bbcodePreview, forum: window.forum, stickyFooter: window.stickyFooter) 100 97 window.nav2 ?= new Nav2(osuCore.clickMenu) 101 - window.twitchPlayer ?= new TwitchPlayer(osuCore.turbolinksReload) 102 98 103 99 104 100 $(document).on 'change', '.js-url-selector', (e) ->
+4
resources/js/osu-core.ts
··· 19 19 import StickyHeader from 'core/sticky-header'; 20 20 import Timeago from 'core/timeago'; 21 21 import TurbolinksReload from 'core/turbolinks-reload'; 22 + import TwitchPlayer from 'core/twitch-player'; 22 23 import ScorePins from 'core/user/score-pins'; 23 24 import UserLogin from 'core/user/user-login'; 24 25 import UserLoginObserver from 'core/user/user-login-observer'; ··· 62 63 readonly stickyHeader; 63 64 readonly timeago; 64 65 readonly turbolinksReload; 66 + readonly twitchPlayer; 65 67 readonly userLogin; 66 68 readonly userLoginObserver; 67 69 readonly userPreferences; ··· 112 114 this.enchant = new Enchant(this.turbolinksReload); 113 115 this.osuAudio = new OsuAudio(this.userPreferences); 114 116 this.reactTurbolinks = new ReactTurbolinks(this, this.turbolinksReload); 117 + this.twitchPlayer = new TwitchPlayer(this.turbolinksReload); 118 + 115 119 this.userLogin = new UserLogin(this.captcha); 116 120 // should probably figure how to conditionally or lazy initialize these so they don't all init when not needed. 117 121 // TODO: requires dynamic imports to lazy load modules.
+11 -5
resources/js/register-components.tsx
··· 15 15 import RankingVariantFilter from 'components/ranking-variant-filter'; 16 16 import SpotlightSelectOptions from 'components/spotlight-select-options'; 17 17 import { UserCard } from 'components/user-card'; 18 - import { UserCardStore } from 'components/user-card-store'; 19 18 import { startListening, UserCardTooltip } from 'components/user-card-tooltip'; 20 19 import { UserCards } from 'components/user-cards'; 21 20 import { WikiSearch } from 'components/wiki-search'; ··· 25 24 import QuickSearch from 'quick-search/main'; 26 25 import QuickSearchWorker from 'quick-search/worker'; 27 26 import * as React from 'react'; 27 + import StoreSupporterTag from 'store/store-supporter-tag'; 28 28 import { parseJson } from 'utils/json'; 29 29 import { mapBy } from 'utils/map'; 30 + import { getInt } from 'utils/math'; 30 31 31 32 function reqJson<T>(input: string|undefined): T { 32 33 // This will throw when input is missing and thus parsing empty string. ··· 106 107 <RankingVariantFilter {...parseJson('json-variant-filter')} /> 107 108 )); 108 109 110 + core.reactTurbolinks.register('store-supporter-tag', (container) => { 111 + const maxMessageLength = getInt(container.dataset.maxMessageLength); 112 + if (maxMessageLength == null) { 113 + throw new Error('missing maxMessageLength'); 114 + } 115 + 116 + return <StoreSupporterTag maxMessageLength={maxMessageLength} />; 117 + }); 118 + 109 119 core.reactTurbolinks.register('user-card', (container) => ( 110 120 <UserCard 111 121 modifiers={reqJson(container.dataset.modifiers ?? 'null')} 112 122 user={container.dataset.isCurrentUser === '1' ? core.currentUser : reqJson(container.dataset.user ?? 'null')} 113 123 /> 114 - )); 115 - 116 - core.reactTurbolinks.register('user-card-store', (container) => ( 117 - <UserCardStore user={reqJson(container.dataset.user)} /> 118 124 )); 119 125 120 126 core.reactTurbolinks.register('user-card-tooltip', (container) => (
+301
resources/js/store/store-supporter-tag.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { UserCard } from 'components/user-card'; 5 + import UserJson from 'interfaces/user-json'; 6 + import { route } from 'laroute'; 7 + import { debounce } from 'lodash'; 8 + import { action, autorun, computed, makeObservable, observable, runInAction } from 'mobx'; 9 + import { disposeOnUnmount, observer } from 'mobx-react'; 10 + import core from 'osu-core-singleton'; 11 + import React from 'react'; 12 + import { onError } from 'utils/ajax'; 13 + import { classWithModifiers } from 'utils/css'; 14 + import { parseJsonNullable, storeJson } from 'utils/json'; 15 + import { trans, transChoice } from 'utils/lang'; 16 + import { toggleCart } from 'utils/store-cart'; 17 + import { currentUrlParams } from 'utils/turbolinks'; 18 + 19 + const jsonId = 'json-store-supporter-tag'; 20 + 21 + const maxValue = 52; 22 + const minValue = 4; 23 + 24 + interface Props { 25 + maxMessageLength: number; 26 + } 27 + 28 + interface SavedState { 29 + savedGiftMessage: string; 30 + sliderValue: number; 31 + username: string; 32 + } 33 + 34 + interface SliderUIParams { 35 + handle?: JQuery | undefined; 36 + value?: number | undefined; 37 + values?: number[] | undefined; 38 + } 39 + 40 + const monthPresets = [1, 2, 4, 6, 12, 18, 24] as const; 41 + 42 + function durationToPrice(duration: number) { 43 + switch (true) { 44 + case duration >= 12: return Math.ceil(duration / 12.0 * 26); 45 + case duration === 10: return 24; 46 + case duration === 9: return 22; 47 + case duration === 8: return 20; 48 + case duration === 6: return 16; 49 + case duration === 4: return 12; 50 + case duration === 2: return 8; 51 + case duration === 1: return 4; 52 + } 53 + } 54 + 55 + @observer 56 + export default class StoreSupporterTag extends React.Component<Props> { 57 + private readonly debouncedGetUser; 58 + private readonly giftMessageRef = React.createRef<HTMLTextAreaElement>(); 59 + private readonly savedGiftMessage: string = ''; 60 + private readonly sliderRef = React.createRef<HTMLDivElement>(); 61 + @observable private sliderValue = minValue; 62 + @observable private user: UserJson | null; 63 + @observable private username = currentUrlParams().get('target') ?? ''; 64 + private xhr: JQuery.jqXHR<UserJson> | null = null; 65 + 66 + @computed 67 + get cost() { 68 + return Math.floor(this.sliderValue); 69 + } 70 + 71 + get discount() { 72 + if (this.duration >= 12) { 73 + return 46; 74 + } 75 + 76 + const raw = ((1 - (this.cost / this.duration) / 4) * 100); 77 + return Math.max(0, Math.round(raw)); 78 + } 79 + 80 + @computed 81 + get duration() { 82 + switch (true) { 83 + case this.cost >= 26: return Math.floor(this.cost / 26.0 * 12); 84 + case this.cost >= 24: return 10; 85 + case this.cost >= 22: return 9; 86 + case this.cost >= 20: return 8; 87 + case this.cost >= 16: return 6; 88 + case this.cost >= 12: return 4; 89 + case this.cost >= 8: return 2; 90 + case this.cost >= 4: return 1; 91 + default: return 0; 92 + } 93 + } 94 + 95 + get durationInYears() { 96 + return { 97 + months: Math.floor(this.duration % 12), 98 + years: Math.floor(this.duration / 12), 99 + }; 100 + } 101 + 102 + get durationText() { 103 + // don't forget to update SupporterTag::getDurationText() in php 104 + const duration = this.durationInYears; 105 + const texts: string[] = []; 106 + 107 + if (duration.years > 0) { 108 + texts.push(transChoice('common.count.years', duration.years)); 109 + } 110 + 111 + if (duration.months > 0) { 112 + texts.push(transChoice('common.count.months', duration.months)); 113 + } 114 + 115 + return texts.join(', '); 116 + } 117 + 118 + get isGiftingSelf() { 119 + return this.user != null && this.user.id === core.currentUser?.id; 120 + } 121 + 122 + get isValidUser() { 123 + return this.user != null && Number.isFinite(this.user.id) && this.user.id > 0; 124 + } 125 + 126 + constructor(props: Props) { 127 + super(props); 128 + 129 + this.debouncedGetUser = debounce(this.getUser, 300); 130 + document.addEventListener('turbolinks:before-cache', this.handleBeforeCache); 131 + 132 + makeObservable(this); 133 + 134 + const json = parseJsonNullable<SavedState>(jsonId, true); 135 + if (json != null) { 136 + this.savedGiftMessage = json.savedGiftMessage; 137 + this.sliderValue = json.sliderValue; 138 + this.username = json.username; 139 + } 140 + 141 + if (this.username !== '') { 142 + this.user = null; 143 + this.debouncedGetUser(this.username); 144 + } else { 145 + this.user = core.currentUserOrFail; 146 + } 147 + 148 + disposeOnUnmount( 149 + this, 150 + autorun(() => { 151 + toggleCart(this.isValidUser); 152 + if (this.sliderRef.current != null) { 153 + $(this.sliderRef.current).slider({ disabled: !this.isValidUser }); 154 + } 155 + }), 156 + ); 157 + } 158 + 159 + componentDidMount() { 160 + this.initializeSlider(); 161 + } 162 + 163 + componentWillUnmount() { 164 + document.removeEventListener('turbolinks:before-cache', this.handleBeforeCache); 165 + this.xhr?.abort(); 166 + } 167 + 168 + render() { 169 + return ( 170 + <div className='store-supporter-tag'> 171 + <input defaultValue={this.cost} id='supporter-tag-form-price' name='item[cost]' type='hidden' /> 172 + <input defaultValue={this.user?.id} name='item[extra_data][target_id]' type='hidden' /> 173 + <div className='store-supporter-tag__user-search'> 174 + <UserCard user={this.user} /> 175 + <input 176 + autoComplete='off' 177 + className='store-supporter-tag__input' 178 + id='username' 179 + name='item[extra_data][username]' 180 + onChange={this.handleUsernameChange} 181 + placeholder={trans('store.supporter_tag.gift')} 182 + value={this.username} 183 + /> 184 + <div data-visibility={!this.isValidUser || this.isGiftingSelf ? 'hidden' : 'visible'}> 185 + <textarea 186 + ref={this.giftMessageRef} 187 + className='store-supporter-tag__input store-supporter-tag__input--message' 188 + defaultValue={this.savedGiftMessage} 189 + maxLength={this.props.maxMessageLength} 190 + name='item[extra_data][message]' 191 + placeholder={trans('store.supporter_tag.gift_message', { length: this.props.maxMessageLength })} 192 + rows={3} 193 + /> 194 + </div> 195 + </div> 196 + <div className='store-slider'> 197 + <div ref={this.sliderRef} className={`${classWithModifiers('ui-slider', { disabled: !this.isValidUser })} ui-slider-horizontal`}> 198 + <div className='ui-slider-handle'> 199 + <div className='store-slider__fake-callout'> 200 + <div className='store-slider__callout'> 201 + <div className='store-slider__bigtext'>USD {this.cost}</div> 202 + <div>{this.durationText}</div> 203 + <div className='store-slider__subtext'>{trans('store.discount', { percent: this.discount })}</div> 204 + </div> 205 + </div> 206 + </div> 207 + </div> 208 + <div className='store-slider__presets'> 209 + <span className='store-slider__presets-blurb'>{trans('supporter_tag.months')}</span> 210 + {monthPresets.map((preset) => ( 211 + // TODO: button 212 + <div 213 + key={preset} 214 + className={classWithModifiers('store-slider__preset', { 215 + active: this.duration >= preset, 216 + disabled: !this.isValidUser, 217 + })} 218 + data-months={preset} 219 + onClick={this.handlePresetClick} 220 + > 221 + {preset} 222 + </div> 223 + ))} 224 + </div> 225 + </div> 226 + </div> 227 + ); 228 + } 229 + 230 + @action 231 + private readonly getUser = (username: string) => { 232 + this.xhr = $.ajax({ 233 + data: { username }, 234 + dataType: 'json', 235 + type: 'POST', 236 + url: route('users.check-username-exists'), 237 + }); 238 + 239 + this.xhr 240 + .done((data) => runInAction(() => { 241 + this.user = data; 242 + })) 243 + .fail(onError) 244 + .always(() => { 245 + this.xhr = null; 246 + }); 247 + }; 248 + 249 + private readonly handleBeforeCache = () => { 250 + storeJson<SavedState>(jsonId, { 251 + savedGiftMessage: this.giftMessageRef.current?.value ?? '', 252 + sliderValue: this.sliderValue, 253 + username: this.username, 254 + }); 255 + }; 256 + 257 + private readonly handlePresetClick = (event: React.SyntheticEvent<HTMLElement>) => { 258 + const price = durationToPrice(+(event.currentTarget.dataset?.months ?? 0)); 259 + if (price != null && this.sliderRef.current != null) { 260 + $(this.sliderRef.current).slider('value', price); 261 + } 262 + }; 263 + 264 + @action 265 + private readonly handleSliderValueChanged = (_event: JQueryEventObject, ui: SliderUIParams) => { 266 + if (ui.value == null) return; 267 + this.sliderValue = ui.value; 268 + }; 269 + 270 + @action 271 + private readonly handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => { 272 + this.username = event.currentTarget.value; 273 + 274 + this.debouncedGetUser.cancel(); 275 + this.xhr?.abort(); 276 + 277 + // reset to current user on empty 278 + if (this.username === '') { 279 + this.user = core.currentUserOrFail; 280 + } else { 281 + this.user = null; 282 + this.debouncedGetUser(this.username); 283 + } 284 + }; 285 + 286 + private initializeSlider() { 287 + const slider = this.sliderRef.current; 288 + if (slider == null) return; 289 + 290 + return $(slider).slider({ 291 + animate: true, 292 + change: this.handleSliderValueChanged, 293 + max: maxValue, 294 + min: minValue, 295 + range: 'min', 296 + slide: this.handleSliderValueChanged, 297 + step: 0.125, 298 + value: this.sliderValue, 299 + }); 300 + } 301 + }
+132
resources/js/user-cover-preset-batch-activate/index.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { route } from 'laroute'; 5 + import { trans, transChoice } from 'utils/lang'; 6 + import { popup } from 'utils/popup'; 7 + import { reloadPage } from 'utils/turbolinks'; 8 + 9 + const checkboxSelector = '.js-user-cover-preset-batch-enable--checkbox'; 10 + 11 + export default class UserCoverPresetBatchActivate { 12 + private lastSelected: HTMLInputElement | null = null; 13 + private xhr: JQuery.jqXHR<void> | null = null; 14 + 15 + constructor() { 16 + $(document) 17 + .on('click', '.js-user-cover-preset-batch-enable', this.handleEvent) 18 + .on('turbolinks:before-cache', this.cleanup); 19 + } 20 + 21 + private applySelected(active: boolean) { 22 + const ids = [...this.checkboxes()] 23 + .filter((el) => el.checked) 24 + .map((el) => el.dataset.id); 25 + const count = ids.length; 26 + 27 + if (count === 0) { 28 + popup('no covers selected'); 29 + return; 30 + } 31 + 32 + if (!confirm(trans('user_cover_presets.index.batch_confirm._', { 33 + action: trans(`user_cover_presets.index.batch_confirm.${active ? 'enable' : 'disable'}`), 34 + items: transChoice('user_cover_presets.index.batch_confirm.items', count), 35 + }))) { 36 + return; 37 + } 38 + 39 + this.xhr = $.post(route('user-cover-presets.batch-activate'), { active, ids }); 40 + this.xhr 41 + .done(() => { 42 + reloadPage(); 43 + }) 44 + .fail((xhr, status) => { 45 + if (status !== 'abort') { 46 + popup('Update failed', 'danger'); 47 + } 48 + }); 49 + } 50 + 51 + private checkboxes() { 52 + return document.querySelectorAll<HTMLInputElement>(checkboxSelector); 53 + } 54 + 55 + private readonly cleanup = () => { 56 + this.lastSelected = null; 57 + this.xhr?.abort(); 58 + this.xhr = null; 59 + }; 60 + 61 + private readonly handleEvent = (e: JQuery.ClickEvent<Document, unknown, HTMLElement, HTMLElement>) => { 62 + const target = e.currentTarget; 63 + 64 + switch (target.dataset.action) { 65 + case 'disable-selected': return this.applySelected(false); 66 + case 'enable-selected': return this.applySelected(true); 67 + case 'select': return this.select(target, e); 68 + case 'select-all': return this.toggleAll(target as HTMLInputElement); 69 + } 70 + }; 71 + 72 + private readonly select = (target: HTMLElement, e: JQuery.ClickEvent) => { 73 + const checkbox = target.querySelector(checkboxSelector); 74 + if (checkbox instanceof HTMLInputElement) { 75 + if ((e.originalEvent?.shiftKey ?? false) && this.lastSelected != null) { 76 + const checked = this.lastSelected.checked; 77 + let started = false; 78 + for (const el of this.checkboxes()) { 79 + if (el === checkbox) { 80 + el.checked = checked; 81 + } 82 + if (el === this.lastSelected || el === checkbox) { 83 + if (started) { 84 + break; 85 + } else { 86 + started = true; 87 + } 88 + continue; 89 + } 90 + if (started) { 91 + el.checked = checked; 92 + } 93 + } 94 + } 95 + this.lastSelected = checkbox; 96 + } 97 + this.syncToggleState(); 98 + }; 99 + 100 + private selectAllCheckbox() { 101 + const ret = document.querySelector('.js-user-cover-preset-batch-enable--select-all'); 102 + if (!(ret instanceof HTMLInputElement)) { 103 + throw new Error('select all checkbox element is not HTMLInputElement'); 104 + } 105 + 106 + return ret; 107 + } 108 + 109 + private syncToggleState() { 110 + const selectAllCheckbox = this.selectAllCheckbox(); 111 + let state: boolean | null = null; 112 + for (const el of this.checkboxes()) { 113 + if (state == null) { 114 + selectAllCheckbox.checked = state = el.checked; 115 + selectAllCheckbox.dataset.indeterminate = 'false'; 116 + } else { 117 + if (state !== el.checked) { 118 + selectAllCheckbox.dataset.indeterminate = 'true'; 119 + break; 120 + } 121 + } 122 + } 123 + } 124 + 125 + private readonly toggleAll = (target: HTMLInputElement) => { 126 + const checked = target.checked; 127 + for (const el of this.checkboxes()) { 128 + el.checked = checked; 129 + } 130 + target.dataset.indeterminate = 'false'; 131 + }; 132 + }
+12 -16
resources/js/utils/blackout.ts
··· 3 3 4 4 import { fadeToggle } from './fade'; 5 5 6 - export function blackoutHide() { 7 - blackoutToggle(false); 8 - } 9 - 10 - export function blackoutShow() { 11 - blackoutToggle(true); 12 - } 13 - 14 - export function blackoutToggle(state: boolean, opacity?: number) { 15 - const el = window.newBody?.querySelector('.js-blackout'); 6 + const elements = new Set<unknown>(); 16 7 17 - if (el instanceof HTMLElement) { 18 - el.style.opacity = !state || opacity == null ? '' : String(opacity); 19 - fadeToggle(el, state); 8 + export function blackoutToggle(element: unknown, state: boolean) { 9 + if (state) { 10 + elements.add(element); 11 + } else { 12 + elements.delete(element); 20 13 } 14 + 15 + fadeToggle( 16 + window.newBody?.querySelector('.js-blackout'), 17 + blackoutVisible(), 18 + ); 21 19 } 22 20 23 21 export function blackoutVisible() { 24 - const el = document.querySelector('.js-blackout'); 25 - 26 - return el instanceof HTMLElement && el.style.opacity !== ''; 22 + return elements.size > 0; 27 23 }
+1 -1
resources/js/utils/json.ts
··· 89 89 * @param id id of the element to store to. Contents of an existing HTMLScriptElement will be overriden. 90 90 * @param object state to store. 91 91 */ 92 - export function storeJson(id: string, object: unknown) { 92 + export function storeJson<T = unknown>(id: string, object: T) { 93 93 const json = JSON.stringify(object); 94 94 const maybeElement = document.getElementById(id); 95 95
+12 -2
resources/js/utils/store-cart.ts
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 export function toggleCart(flag: boolean) { 5 - $('.js-store-add-to-cart').prop('disabled', !flag); 6 - $('#product-form').data('disabled', !flag); 5 + const body = window.newBody; 6 + if (body == null) return; 7 + 8 + const button = body.querySelector<HTMLButtonElement>('.js-store-add-to-cart'); 9 + if (button != null) { 10 + button.disabled = !flag; 11 + } 12 + 13 + const form = body.querySelector<HTMLFormElement>('#product-form'); 14 + if (form != null) { 15 + $(form).data('disabled', !flag); 16 + } 7 17 }
+3 -3
resources/lang/be/contest.php
··· 17 17 'hide_judged' => '', 18 18 'nav_title' => '', 19 19 'no_current_vote' => '', 20 - 'update' => '', 20 + 'update' => 'абнавіць', 21 21 'validation' => [ 22 - 'missing_score' => '', 22 + 'missing_score' => 'адсутны вынік', 23 23 'contest_vote_judged' => '', 24 24 ], 25 25 'voted' => '', ··· 28 28 'judge_results' => [ 29 29 '_' => '', 30 30 'creator' => '', 31 - 'score' => '', 31 + 'score' => 'Вынік', 32 32 'total_score' => '', 33 33 ], 34 34
+2 -2
resources/lang/be/score_tokens.php
··· 5 5 6 6 return [ 7 7 'create' => [ 8 - 'beatmap_hash_invalid' => '', 9 - 'submission_disabled' => '', 8 + 'beatmap_hash_invalid' => 'няслушны ці адсутны beatmap_hash', 9 + 'submission_disabled' => 'адпраўка рэкордаў на сервер адключана', 10 10 ], 11 11 ];
+2 -2
resources/lang/cs/page_title.php
··· 64 64 '_' => 'komentáře', 65 65 ], 66 66 'contest_entries_controller' => [ 67 - 'judge_results' => '', 67 + 'judge_results' => 'výsledky hodnocení soutěže', 68 68 ], 69 69 'contests_controller' => [ 70 70 '_' => 'soutěže', 71 - 'judge' => '', 71 + 'judge' => 'hodnocení soutěže', 72 72 ], 73 73 'groups_controller' => [ 74 74 'show' => 'skupiny',
+1 -1
resources/lang/cs/wiki.php
··· 10 10 'missing' => 'Požadovaná stránka ":keyword" nebyla nalezena.', 11 11 'missing_title' => 'Nenalezeno', 12 12 'missing_translation' => 'Požadovaná stránka nebyla nalezena pro zvolený jazyk.', 13 - 'needs_cleanup_or_rewrite' => 'Tato stránka nesplňuje standardy osu! wiki a potřebuje být vylepšena nebo přepsáno. Pokud chcete, můžete pomoci s aktualizací článku!', 13 + 'needs_cleanup_or_rewrite' => 'Tato stránka nesplňuje standardy osu! wiki a potřebuje být vylepšena nebo přepsána. Pokud chcete, můžete pomoci s aktualizací článku!', 14 14 'search' => 'Prohledat existující stránky pro :link.', 15 15 'stub' => 'Tento článek je neúplný a čeká na někoho, kdo ho rozšíří.', 16 16 'toc' => 'Obsah',
+1 -1
resources/lang/de/authorization.php
··· 81 81 ], 82 82 83 83 'contest' => [ 84 - 'judging_not_active' => '', 84 + 'judging_not_active' => 'Bewertung für diesen Wettbewerb ist nicht aktiv.', 85 85 'voting_over' => 'Stimmen können nach dem Abstimmungsende nicht mehr geändert werden.', 86 86 87 87 'entry' => [
+10 -10
resources/lang/de/contest.php
··· 14 14 ], 15 15 16 16 'judge' => [ 17 - 'hide_judged' => '', 18 - 'nav_title' => '', 19 - 'no_current_vote' => '', 17 + 'hide_judged' => 'bewertete Einträge ausblenden', 18 + 'nav_title' => 'Bewerten', 19 + 'no_current_vote' => 'Du hast noch nicht abgestimmt.', 20 20 'update' => 'aktualisieren', 21 21 'validation' => [ 22 - 'missing_score' => '', 23 - 'contest_vote_judged' => '', 22 + 'missing_score' => 'fehlende Punktzahl', 23 + 'contest_vote_judged' => 'Abstimmen bei bewerteten Wettbewerben nicht möglich', 24 24 ], 25 - 'voted' => '', 25 + 'voted' => 'Du hast für diesen Eintrag bereits abgestimmt.', 26 26 ], 27 27 28 28 'judge_results' => [ 29 - '_' => '', 30 - 'creator' => '', 29 + '_' => 'Jury-Ergebnisse', 30 + 'creator' => 'Ersteller', 31 31 'score' => 'Ergebnis', 32 32 'total_score' => 'Gesamtergebnis', 33 33 ], 34 34 35 35 'voting' => [ 36 - 'judge_link' => '', 37 - 'judged_notice' => '', 36 + 'judge_link' => 'Du bist ein Juror bei diesem Wettbewerb. Bewerte die Beiträge hier!', 37 + 'judged_notice' => 'Dieser Wettbewerb läuft über das Bewertungssystem, die Jury bearbeitet derzeit die Beiträge.', 38 38 'login_required' => 'Bitte einloggen, um abzustimmen', 39 39 'over' => 'Die Abstimmung für diesen Wettbewerb ist beendet', 40 40 'show_voted_only' => 'Stimmen anzeigen',
+2 -2
resources/lang/de/page_title.php
··· 64 64 '_' => 'kommentare', 65 65 ], 66 66 'contest_entries_controller' => [ 67 - 'judge_results' => '', 67 + 'judge_results' => 'Ergebnisse der Wettbewerbsbewertung', 68 68 ], 69 69 'contests_controller' => [ 70 70 '_' => 'Wettbewerbe', 71 - 'judge' => '', 71 + 'judge' => 'Wettbewerbsbewertung', 72 72 ], 73 73 'groups_controller' => [ 74 74 'show' => 'gruppen',
+1 -1
resources/lang/de/rankings.php
··· 24 24 ], 25 25 26 26 'type' => [ 27 - 'charts' => 'Spotlights', 27 + 'charts' => 'Spotlights (alt)', 28 28 'country' => 'Länder', 29 29 'kudosu' => 'Kudosu', 30 30 'multiplayer' => 'Mehrspieler',
+1 -1
resources/lang/de/scores.php
··· 28 28 'non_passing' => 'Nur erfolgreiche Scores geben pp', 29 29 'no_pp' => 'Für diesen Score werden keine pp vergeben', 30 30 'processing' => 'Dieser Score wird noch berechnet und in Kürze angezeigt', 31 - 'no_rank' => '', 31 + 'no_rank' => 'Diese Punktzahl hat keinen Rang, da sie unranked oder zum Löschen markiert ist', 32 32 ], 33 33 ];
+3
resources/lang/en/page_title.php
··· 110 110 'tournaments_controller' => [ 111 111 '_' => 'tournaments', 112 112 ], 113 + 'user_cover_presets_controller' => [ 114 + '_' => 'user cover presets', 115 + ], 113 116 'users_controller' => [ 114 117 '_' => 'player info', 115 118 'create' => 'create account',
+37
resources/lang/en/user_cover_presets.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + return [ 7 + 'index' => [ 8 + 'batch_disable' => 'Disable Selected', 9 + 'batch_enable' => 'Enable Selected', 10 + 11 + 'batch_confirm' => [ 12 + '_' => ':action :items?', 13 + 'disable' => 'Disable', 14 + 'enable' => 'Enable', 15 + 'items' => ':count_delimited cover|:count_delimited covers', 16 + ], 17 + 18 + 'create_form' => [ 19 + 'files' => 'Files', 20 + 'submit' => 'Save', 21 + 'title' => 'Add New', 22 + ], 23 + 24 + 'item' => [ 25 + 'click_to_disable' => 'Click to disable', 26 + 'click_to_enable' => 'Click to enable', 27 + 'enabled' => 'Enabled', 28 + 'disabled' => 'Disabled', 29 + 'image_store' => 'Set Image', 30 + 'image_update' => 'Replace Image', 31 + ], 32 + ], 33 + 'store' => [ 34 + 'failed' => 'Error occurred when creating cover: :error', 35 + 'ok' => 'Covers created', 36 + ], 37 + ];
+1
resources/lang/en/users.php
··· 97 97 98 98 'force_reactivation' => [ 99 99 'reason' => [ 100 + 'inactive' => "Your account hasn't been used in a long time.", 100 101 'inactive_different_country' => "Your account hasn't been used in a long time.", 101 102 ], 102 103 ],
+2 -2
resources/lang/fi/accounts.php
··· 102 102 ], 103 103 104 104 'options' => [ 105 - 'beatmapset_show_nsfw' => 'piilota varoitukset sopimattomasta sisällöstä beatmapeissa', 105 + 'beatmapset_show_nsfw' => 'piilota varoitukset sopimattomasta sisällöstä rytmikartoissa', 106 106 'beatmapset_title_show_original' => 'näytä rytmikarttojen kuvailutiedot alkuperäisellä kielellä', 107 107 'title' => 'Asetukset', 108 108 109 109 'beatmapset_download' => [ 110 110 '_' => 'rytmikarttojen oletuslataustyyppi', 111 111 'all' => 'videon kanssa jos saatavilla', 112 - 'direct' => 'avaa osu!-directissä', 112 + 'direct' => 'avaa osu!directissä', 113 113 'no_video' => 'ilman videota', 114 114 ], 115 115 ],
+3 -3
resources/lang/fi/client_verifications.php
··· 8 8 'home' => 'Siirry yleiskatsaukseen', 9 9 'logout' => 'Kirjaudu ulos', 10 10 'text' => 'Voit nyt sulkea tämän välilehden/ikkunan', 11 - 'title' => 'osu!-klientin vahvistaminen on valmis', 11 + 'title' => 'osu!-asiakasohjelman vahvistaminen on valmis', 12 12 ], 13 13 14 14 'create' => [ 15 - 'confirm' => 'Klikkaa allaolevaa valtuutuspainiketta viimeistelläksesi klientin vahvistamisen.', 16 - 'title' => 'osu!-klientin vahvistaminen', 15 + 'confirm' => 'Napsauta allaolevaa valtuutuspainiketta viimeistelläksesi asiakasohjelman vahvistamisen.', 16 + 'title' => 'osu!-asiakasohjelman vahvistaminen', 17 17 ], 18 18 ];
+1 -1
resources/lang/fi/common.php
··· 79 79 'post' => ':count_delimited viesti|:count_delimited viestiä', 80 80 'second_short_unit' => 's|s', 81 81 'star_priority' => ':count_delimited tähtitaso|:count_delimited tähtitasoja', 82 - 'update' => ':count_delimited päivitys|:count_delimited päivityksiä', 82 + 'update' => ':count_delimited päivitys|:count_delimited päivitystä', 83 83 'view' => ':count_delimited katselukerta|:count_delimited katselukertaa', 84 84 'years' => ':count vuosi|:count vuotta', 85 85 ],
+1 -1
resources/lang/fi/events.php
··· 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 6 return [ 7 - 'achievement' => '<strong><em>:user</em></strong> on saanut "<strong>:achievement</strong>" mitalin!', 7 + 'achievement' => '<strong><em>:user</em></strong> on saanut "<strong>:achievement</strong>" -mitalin!', 8 8 'beatmap_playcount' => 'Karttaa :beatmap on pelattu :count kertaa!', 9 9 'beatmapset_approve' => ':beatmapset käyttäjältä <strong>:user</strong> on nyt :approval', 10 10 'beatmapset_delete' => ':beatmapset on poistettu.',
+1 -1
resources/lang/fi/follows.php
··· 33 33 ], 34 34 35 35 'modding' => [ 36 - 'title' => 'rytmikartan keskustelu', 36 + 'title' => 'rytmikartan keskustelut', 37 37 ], 38 38 ];
+2 -2
resources/lang/fi/forum.php
··· 331 331 332 332 'feature_vote' => [ 333 333 'current' => 'Tärkeys tällä hetkellä: +:count', 334 - 'do' => 'Ehdota tätä', 334 + 'do' => 'Ehdota tätä pyyntöä', 335 335 336 336 'info' => [ 337 337 '_' => 'Tämä on :feature_request. :supporters voivat äänestää ominaisuuspyyntöjä.', 338 338 'feature_request' => 'ominaisuuspyyntö', 339 - 'supporters' => 'tukijat', 339 + 'supporters' => 'Tukijat', 340 340 ], 341 341 342 342 'user' => [
+3 -3
resources/lang/fi/store.php
··· 106 106 'cancel_not_allowed' => 'Tätä tilausta ei voi peruuttaa tällä hetkellä.', 107 107 'invoice' => 'Näytä lasku', 108 108 'no_orders' => 'Ei tilauksia katsottavissa.', 109 - 'paid_on' => 'Tilaus laitettu :date', 109 + 'paid_on' => 'Tilaus tehty :date', 110 110 'resume' => 'Jatka kassalle', 111 111 'shipping_and_handling' => 'Toimitus & käsittely', 112 112 'shopify_expired' => 'Tämän tilauksen kassalinkki on vanhentunut.', ··· 136 136 137 137 'not_modifiable_exception' => [ 138 138 'cancelled' => 'Et voi muokata tilaustasi, sillä se on peruuntunut.', 139 - 'checkout' => 'Et voi muokata tilaustasi, koska sitä käsitellään vielä.', // checkout and processing should have the same message. 139 + 'checkout' => 'Et voi muokata tilaustasi silloin kun sitä käsitellään.', // checkout and processing should have the same message. 140 140 'default' => 'Tilausta ei voi muokata', 141 141 'delivered' => 'Et voi muokata tilaustasi, sillä se on jo toimitettu.', 142 142 'paid' => 'Et voi muokata tilaustasi, sillä se on jo maksettu.', 143 - 'processing' => 'Et voi muokata tilaustasi, koska sitä käsitellään vielä.', 143 + 'processing' => 'Et voi muokata tilaustasi silloin kun sitä käsitellään.', 144 144 'shipped' => 'Et voi muokata tilaustasi, sillä se on jo matkalla.', 145 145 ], 146 146
+11 -11
resources/lang/fi/users.php
··· 259 259 'discussions' => [ 260 260 'title' => 'Keskustelut', 261 261 'title_longer' => 'Viimeaikaiset keskustelut', 262 - 'show_more' => 'nää lisää keskusteluja', 262 + 'show_more' => 'katso lisää keskusteluja', 263 263 ], 264 264 'events' => [ 265 265 'title' => 'Tapahtumat', 266 - 'title_longer' => 'Viimeisimmät tapahtumat', 267 - 'show_more' => 'nää lisää tapahtumia', 266 + 'title_longer' => 'Viimeaikaiset tapahtumat', 267 + 'show_more' => 'katso lisää tapahtumia', 268 268 ], 269 269 'historical' => [ 270 270 'title' => 'Historialliset', ··· 279 279 ], 280 280 'recent_plays' => [ 281 281 'accuracy' => 'tarkkuus: :percentage', 282 - 'title' => 'Viimeisimmät pelaukset (24t)', 282 + 'title' => 'Viimeaikaiset pelaukset (24t)', 283 283 ], 284 284 'replays_watched_counts' => [ 285 285 'title' => 'Uusintojen katsomishistoria', ··· 348 348 ], 349 349 'posts' => [ 350 350 'title' => 'Julkaisut', 351 - 'title_longer' => 'Viimeisimmät julkaisut', 352 - 'show_more' => 'Katso lisää julkaisuja', 351 + 'title_longer' => 'Viimeaikaiset julkaisut', 352 + 'show_more' => 'katso lisää julkaisuja', 353 353 ], 354 354 'recent_activity' => [ 355 355 'title' => 'Viimeisimmät', ··· 384 384 'given' => 'Annetut äänet (viimeiset 3 kuukautta)', 385 385 'received' => 'Saadut äänet (viimeiset 3 kuukautta)', 386 386 'title' => 'Äänet', 387 - 'title_longer' => 'Viimeisimmät Äänet', 387 + 'title_longer' => 'Viimeaikaiset äänet', 388 388 'vote_count' => ':count_delimited ääni|:count_delimited ääntä', 389 389 ], 390 390 'account_standing' => [ ··· 393 393 'remaining_silence' => '<strong>:username</strong> pystyy puhumaan seuraavan kerran :duration.', 394 394 395 395 'recent_infringements' => [ 396 - 'title' => 'Viimeisimmät rikkomukset', 396 + 'title' => 'Viimeaikaiset rikkomukset', 397 397 'date' => 'päivä', 398 398 'action' => 'toiminto', 399 399 'length' => 'pituus', ··· 421 421 ], 422 422 'not_found' => [ 423 423 'reason_1' => 'Käyttäjänimi saattaa olla vaihtunut.', 424 - 'reason_2' => 'Käyttäjä voi olla tilapaisesti poissa käytöstä tietoturvasyistä tai väärinkäytön seurauksena.', 424 + 'reason_2' => 'Käyttäjätunnus voi olla tilapäisesti pois käytöstä tietoturvasyistä tai väärinkäytön seurauksena.', 425 425 'reason_3' => 'Teit mahdollisesti kirjoitusvirheen!', 426 - 'reason_header' => 'Tähän on lukuisia mahdollisia syitä:', 426 + 'reason_header' => 'Tähän on muutama mahdollinen syy:', 427 427 'title' => 'Käyttäjää ei löytynyt! ;_;', 428 428 ], 429 429 'page' => [ 430 - 'button' => 'Muokkaa profiilisivua', 430 + 'button' => 'muokkaa profiilisivua', 431 431 'description' => '<strong>minä!</strong> on henkilökohtainen alue profiilisivullasi, jota voit muokata.', 432 432 'edit_big' => 'Muokkaa minua!', 433 433 'placeholder' => 'Kirjoita sivun sisältö tähän',
+1 -1
resources/lang/fr/rankings.php
··· 24 24 ], 25 25 26 26 'type' => [ 27 - 'charts' => 'spotlights', 27 + 'charts' => 'spotlights (ancien)', 28 28 'country' => 'pays', 29 29 'kudosu' => 'kudosu', 30 30 'multiplayer' => 'multijoueur',
+3 -3
resources/lang/hr-HR/accounts.php
··· 43 43 44 44 'country_change' => [ 45 45 '_' => "", 46 - 'update_link' => '', 46 + 'update_link' => 'Promjeni na :country', 47 47 ], 48 48 49 49 'user' => [ ··· 65 65 'github_user' => [ 66 66 'info' => "", 67 67 'link' => '', 68 - 'title' => '', 68 + 'title' => 'GitHub', 69 69 'unlink' => '', 70 70 71 71 'error' => [ 72 - 'already_linked' => '', 72 + 'already_linked' => 'Ovaj GitHub nalog je vec povezan drugom korisniku.', 73 73 'no_contribution' => '', 74 74 'unverified_email' => '', 75 75 ],
+1 -1
resources/lang/hr-HR/authorization.php
··· 53 53 ], 54 54 55 55 'beatmapset' => [ 56 - 'discussion_locked' => '', 56 + 'discussion_locked' => 'Ovaj beatmap je zakljucan za diskusiju.', 57 57 58 58 'metadata' => [ 59 59 'nominated' => 'Ne možeš promijeniti metapodatke nominirane mape. Obratite se BN ili NAT članu ako mislite da su pogrešno postavljeni.',
+1 -1
resources/lang/hr-HR/bbcode.php
··· 6 6 return [ 7 7 'bold' => 'Podebljano', 8 8 'heading' => 'Zaglavlje', 9 - 'help' => '', 9 + 'help' => 'Pomoc', 10 10 'image' => 'Fotografija', 11 11 'imagemap' => '', 12 12 'italic' => 'Ukošen',
+2 -2
resources/lang/hr-HR/beatmappacks.php
··· 6 6 return [ 7 7 'index' => [ 8 8 'description' => 'Unaprijed zapakirane zbirke beatmapa temeljenih na zajedničkoj temi.', 9 - 'empty' => '', 9 + 'empty' => 'Dolazi uskoro!', 10 10 'nav_title' => 'popis', 11 11 'title' => 'Paketi beatmapa', 12 12 ··· 39 39 'loved' => '', 40 40 'standard' => 'Standardni', 41 41 'theme' => 'Tema', 42 - 'tournament' => '', 42 + 'tournament' => 'Turnir', 43 43 ], 44 44 45 45 'require_login' => [
+4 -4
resources/lang/hr-HR/beatmaps.php
··· 79 79 ], 80 80 81 81 'message_type_title' => [ 82 - 'disqualify' => '', 82 + 'disqualify' => 'Poslije diskvalifikacije', 83 83 'hype' => '', 84 84 'mapper_note' => '', 85 - 'nomination_reset' => '', 85 + 'nomination_reset' => 'Ukloni sve nominacije', 86 86 'praise' => '', 87 87 'problem' => '', 88 - 'problem_warning' => '', 88 + 'problem_warning' => 'Postavi problem', 89 89 'review' => '', 90 90 'suggestion' => '', 91 91 ], ··· 215 215 '_' => 'Procjenjuje se da će ova beatmapa biti rangirana :date ako nema problema. To je :position. u :queue.', 216 216 'unresolved_problems' => '', 217 217 'problems' => '', 218 - 'on' => '', 218 + 'on' => 'na :date', 219 219 'queue' => 'red čekanja na rangiranje', 220 220 'soon' => 'uskoro', 221 221 ],
+1 -1
resources/lang/hr-HR/beatmapset_events.php
··· 34 34 'qualify' => 'Ova beatmapa je dosegla potreban broj nominacija i kvalificirana je.', 35 35 'rank' => 'Rangirano.', 36 36 'remove_from_loved' => 'Uklonjeno iz Voljeno od :user. (:text)', 37 - 'tags_edit' => '', 37 + 'tags_edit' => 'Oznake promjenjene sa ":old" na ":new".', 38 38 39 39 'nsfw_toggle' => [ 40 40 'to_0' => 'Uklonjena eksplicitna oznaka',
+3 -3
resources/lang/hr-HR/beatmapsets.php
··· 65 65 ], 66 66 67 67 'deleted_banner' => [ 68 - 'title' => '', 69 - 'message' => '', 68 + 'title' => 'Ovaj beatmap je obrisan.', 69 + 'message' => '(samo moderatori mogu ovo da vide)', 70 70 ], 71 71 72 72 'details' => [ ··· 134 134 'genre' => 'Žanr', 135 135 'language' => 'Jezik', 136 136 'no_scores' => 'Podaci se još kalkuliraju...', 137 - 'nominators' => '', 137 + 'nominators' => 'Nominatori', 138 138 'nsfw' => 'Eksplicitni sadržaj', 139 139 'offset' => 'Online razmak', 140 140 'points-of-failure' => 'Točke neuspjeha',
+6 -6
resources/lang/hr-HR/community.php
··· 6 6 return [ 7 7 'support' => [ 8 8 'convinced' => [ 9 - 'title' => '', 9 + 'title' => 'Ubjedjen/a sam! :D', 10 10 'support' => '', 11 11 'gift' => '', 12 12 'instructions' => '', ··· 45 45 'perks' => [ 46 46 'title' => '', 47 47 'osu_direct' => [ 48 - 'title' => '', 48 + 'title' => 'osu!direct', 49 49 'description' => '', 50 50 ], 51 51 ··· 60 60 ], 61 61 62 62 'mod_filtering' => [ 63 - 'title' => '', 63 + 'title' => 'Filtriraj prema Modovima', 64 64 'description' => '', 65 65 ], 66 66 ··· 95 95 ], 96 96 97 97 'speedy_downloads' => [ 98 - 'title' => '', 98 + 'title' => 'Brza preuzimanja', 99 99 'description' => '', 100 100 ], 101 101 102 102 'change_username' => [ 103 - 'title' => '', 103 + 'title' => 'Promjeni korisnicko ime', 104 104 'description' => '', 105 105 ], 106 106 ··· 124 124 'description' => '', 125 125 ], 126 126 'more_friends' => [ 127 - 'title' => '', 127 + 'title' => 'Vise prijatelja', 128 128 'description' => '', 129 129 ], 130 130 'more_beatmaps' => [
+1 -1
resources/lang/hr-HR/contest.php
··· 17 17 'hide_judged' => '', 18 18 'nav_title' => '', 19 19 'no_current_vote' => '', 20 - 'update' => '', 20 + 'update' => 'azuriraj', 21 21 'validation' => [ 22 22 'missing_score' => '', 23 23 'contest_vote_judged' => '',
+3 -3
resources/lang/hr-HR/events.php
··· 20 20 'username_change' => '', 21 21 22 22 'beatmapset_status' => [ 23 - 'approved' => '', 23 + 'approved' => 'odobreno', 24 24 'loved' => 'voljeno', 25 - 'qualified' => '', 26 - 'ranked' => '', 25 + 'qualified' => 'kvalificiran/a', 26 + 'ranked' => 'rangirano', 27 27 ], 28 28 29 29 'value' => [
+16 -16
resources/lang/hr-HR/forum.php
··· 5 5 6 6 return [ 7 7 'pinned_topics' => '', 8 - 'slogan' => "", 8 + 'slogan' => "opasno je igrati sam.", 9 9 'subforums' => '', 10 10 'title' => 'Forumi', 11 11 ··· 51 51 'posted_by_in' => '', 52 52 53 53 'actions' => [ 54 - 'destroy' => '', 55 - 'edit' => '', 56 - 'report' => '', 57 - 'restore' => '', 54 + 'destroy' => 'Obrisi objavu', 55 + 'edit' => 'Izmeni objavu', 56 + 'report' => 'Prijavi objavu', 57 + 'restore' => 'Povrati objavu', 58 58 ], 59 59 60 60 'create' => [ 61 61 'title' => [ 62 - 'reply' => '', 62 + 'reply' => 'Novi odgovor', 63 63 ], 64 64 ], 65 65 ··· 86 86 'latest_reply_by' => '', 87 87 'new_topic' => '', 88 88 'new_topic_login' => '', 89 - 'post_reply' => '', 89 + 'post_reply' => 'Objava', 90 90 'reply_box_placeholder' => '', 91 91 'reply_title_prefix' => 'Re', 92 92 'started_by' => 'od :user', ··· 102 102 'preview' => 'Pretpregled', 103 103 // TL note: this is used in the topic reply preview, when 104 104 // the user goes back from previewing to editing the reply 105 - 'preview_hide' => '', 105 + 'preview_hide' => 'Izmjeni', 106 106 'submit' => '', 107 107 108 108 'necropost' => [ ··· 167 167 'restore_post' => '', 168 168 'restore_topic' => '', 169 169 'split_destination' => '', 170 - 'split_source' => '', 170 + 'split_source' => 'Podijeli objave', 171 171 'topic_type' => '', 172 172 'topic_type_changed' => '', 173 173 'unlock' => '', ··· 210 210 '_' => '', 211 211 212 212 'actions' => [ 213 - 'login_reply' => '', 214 - 'reply' => '', 213 + 'login_reply' => 'Prijavi se da odgovoriš', 214 + 'reply' => 'Odgovori', 215 215 'reply_with_quote' => '', 216 216 'search' => 'Pretraži', 217 217 ], ··· 222 222 'preview' => '', 223 223 224 224 'create_poll_button' => [ 225 - 'add' => '', 225 + 'add' => 'Napravi anketu', 226 226 'remove' => '', 227 227 ], 228 228 ··· 236 236 'max_options_info' => '', 237 237 'options' => 'Opcije', 238 238 'options_info' => '', 239 - 'title' => '', 239 + 'title' => 'Pitanje', 240 240 'vote_change' => '', 241 241 'vote_change_info' => '', 242 242 ], 243 243 ], 244 244 245 245 'edit_title' => [ 246 - 'start' => '', 246 + 'start' => 'Uredi naslov', 247 247 ], 248 248 249 249 'index' => [ 250 - 'feature_votes' => '', 251 - 'replies' => '', 250 + 'feature_votes' => 'prioritet zvijezda', 251 + 'replies' => 'odgovori', 252 252 'views' => '', 253 253 ], 254 254
+1 -1
resources/lang/id/authorization.php
··· 92 92 93 93 'forum' => [ 94 94 'moderate' => [ 95 - 'no_permission' => 'Kamu tdak memiliki izin untuk memoderasi forum ini.', 95 + 'no_permission' => 'Kamu tidak memiliki izin untuk memoderasi forum ini.', 96 96 ], 97 97 98 98 'post' => [
+2 -2
resources/lang/it/artist.php
··· 13 13 14 14 'beatmaps' => [ 15 15 '_' => 'Beatmap', 16 - 'download' => 'Scarica il Template della Beatmap', 17 - 'download-na' => 'Template della Beatmap non ancora disponibile', 16 + 'download' => 'scarica il template della beatmap', 17 + 'download-na' => 'template della beatmap non ancora disponibile', 18 18 ], 19 19 20 20 'index' => [
+2 -2
resources/lang/it/authorization.php
··· 9 9 'require_verification' => 'Esegui la verifica per poter continuare.', 10 10 'restricted' => "Non puoi farlo mentre sei limitato.", 11 11 'silenced' => "Non puoi farlo mentre sei silenziato.", 12 - 'unauthorized' => 'Accesso Negato.', 12 + 'unauthorized' => 'Accesso negato.', 13 13 14 14 'beatmap_discussion' => [ 15 15 'destroy' => [ ··· 34 34 'bot' => "Non puoi votare in una discussione creata da un bot", 35 35 'limit_exceeded' => 'Attendi un po\' prima di aggiungere più voti', 36 36 'owner' => "Non puoi votare la tua discussione.", 37 - 'wrong_beatmapset_state' => 'Puoi votare solo su discussioni di beatmap in attesa.', 37 + 'wrong_beatmapset_state' => 'Puoi votare solo sulle discussioni di beatmap in attesa.', 38 38 ], 39 39 ], 40 40
+1 -1
resources/lang/it/bbcode.php
··· 8 8 'heading' => 'Intestazione', 9 9 'help' => 'Aiuto', 10 10 'image' => 'Immagine', 11 - 'imagemap' => 'Mappa Immagine', 11 + 'imagemap' => 'Immagine Mappata', 12 12 'italic' => 'Corsivo', 13 13 'link' => 'Link', 14 14 'list' => 'Lista',
+1 -1
resources/lang/it/rankings.php
··· 24 24 ], 25 25 26 26 'type' => [ 27 - 'charts' => 'spotlight', 27 + 'charts' => 'spotlight (archivio)', 28 28 'country' => 'paese', 29 29 'kudosu' => 'kudosu', 30 30 'multiplayer' => 'multigiocatore',
+1 -1
resources/lang/ja/bbcode.php
··· 6 6 return [ 7 7 'bold' => '太字', 8 8 'heading' => 'ヘッダー', 9 - 'help' => '', 9 + 'help' => 'ヘルプ', 10 10 'image' => '画像', 11 11 'imagemap' => '', 12 12 'italic' => '斜体',
+1 -1
resources/lang/ja/beatmappacks.php
··· 6 6 return [ 7 7 'index' => [ 8 8 'description' => '共通のテーマを有するビートマップを集めたパックです。', 9 - 'empty' => '', 9 + 'empty' => '近日公開!', 10 10 'nav_title' => '一覧', 11 11 'title' => 'ビートマップパック', 12 12
+3 -3
resources/lang/ja/contest.php
··· 27 27 28 28 'judge_results' => [ 29 29 '_' => '', 30 - 'creator' => '', 31 - 'score' => '', 32 - 'total_score' => '', 30 + 'creator' => '作成者', 31 + 'score' => 'スコア', 32 + 'total_score' => '合計スコア', 33 33 ], 34 34 35 35 'voting' => [
+2 -2
resources/lang/ja/store.php
··· 53 53 54 54 'invoice' => [ 55 55 'contact' => '', 56 - 'date' => '', 56 + 'date' => '日付:', 57 57 'echeck_delay' => '決済方法がeCheckのため、PayPalを介した支払いが完了するまで、さらに最大10日を要します。予めご了承ください。', 58 58 'hide_from_activity' => 'osu!サポータータグは最近のアクティビティには表示されません。', 59 59 'sent_via' => '', ··· 114 114 'total' => '', 115 115 116 116 'details' => [ 117 - 'order_number' => '', 117 + 'order_number' => '注文 #', 118 118 'payment_terms' => '', 119 119 'salesperson' => '', 120 120 'shipping_method' => '',
+5 -5
resources/lang/ja/users.php
··· 124 124 ], 125 125 126 126 'ogp' => [ 127 - 'modding_description' => '', 128 - 'modding_description_empty' => '', 127 + 'modding_description' => 'ビートマップ: :counts', 128 + 'modding_description_empty' => 'このユーザーにはビートマップがありません...', 129 129 130 130 'description' => [ 131 - '_' => '', 132 - 'country' => '', 133 - 'global' => '', 131 + '_' => 'ランク (:ruleset): :global | :country', 132 + 'country' => '国 :rank', 133 + 'global' => '世界 :rank', 134 134 ], 135 135 ], 136 136
+1 -1
resources/lang/ko/events.php
··· 12 12 'beatmapset_update' => '<strong><em>:user</em></strong>님이 "<em>:beatmapset</em>" 맵셋을 업데이트했습니다.', 13 13 'beatmapset_upload' => '<strong><em>:user</em></strong>님이 새 비트맵 ":beatmapset"을 제출했습니다.', 14 14 'empty' => "최근에 눈에 띄는 활동이 없네요!", 15 - 'rank' => '<strong><em>:user</em></strong>님이 <em>:beatmap</em> (:mode)맵에서 #:rank등을 기록했습니다', 15 + 'rank' => ':user님이 :beatmap (:mode) 맵에서 :rank등을 기록했습니다.', 16 16 'rank_lost' => '<strong><em>:user</em></strong>님이 <em>:beatmap</em> (:mode)맵에서 1등 자리를 빼앗겼습니다.', 17 17 'user_support_again' => '<strong>:user</strong>님이 다시 한번 osu!를 지원하기로 결정하셨습니다. - 감사합니다!', 18 18 'user_support_first' => '<strong>:user</strong>님이 osu! 서포터가 되셨습니다. - 감사합니다!',
+8 -8
resources/lang/lv-LV/news.php
··· 5 5 6 6 return [ 7 7 'index' => [ 8 - 'title_page' => '', 8 + 'title_page' => 'osu!jaunumi', 9 9 10 10 'nav' => [ 11 - 'newer' => '', 12 - 'older' => '', 11 + 'newer' => 'Jaunākie raksti', 12 + 'older' => 'Vecāki raksti', 13 13 ], 14 14 15 15 'title' => [ 16 - '_' => '', 17 - 'info' => '', 16 + '_' => 'jaunumi', 17 + 'info' => 'sākumlapa', 18 18 ], 19 19 ], 20 20 21 21 'show' => [ 22 - 'by' => '', 22 + 'by' => 'no :user', 23 23 24 24 'nav' => [ 25 - 'newer' => '', 26 - 'older' => '', 25 + 'newer' => 'Jaunāks raksts', 26 + 'older' => 'Vecāks raksts', 27 27 ], 28 28 29 29 'title' => [
+1 -1
resources/lang/nl/authorization.php
··· 81 81 ], 82 82 83 83 'contest' => [ 84 - 'judging_not_active' => '', 84 + 'judging_not_active' => 'Beoordeling is niet actief voor deze wedstrijd.', 85 85 'voting_over' => 'Je kan je stem niet meer veranderen nadat de stemperiode van deze wedstrijd is afgelopen.', 86 86 87 87 'entry' => [
+2 -2
resources/lang/nl/beatmaps.php
··· 213 213 214 214 'rank_estimate' => [ 215 215 '_' => 'Deze map staat gepland om ranked te worden op :date als er geen problemen worden gevonden. Het is #:position in de :queue.', 216 - 'unresolved_problems' => '', 217 - 'problems' => '', 216 + 'unresolved_problems' => 'Deze map is momenteel geblokkeerd om de Gekwalificeerde sectie te verlaten totdat :problems zijn opgelost.', 217 + 'problems' => 'deze problemen', 218 218 'on' => 'op :date', 219 219 'queue' => 'ranking wachtlijst', 220 220 'soon' => 'binnenkort',
+13 -13
resources/lang/nl/contest.php
··· 14 14 ], 15 15 16 16 'judge' => [ 17 - 'hide_judged' => '', 18 - 'nav_title' => '', 19 - 'no_current_vote' => '', 20 - 'update' => '', 17 + 'hide_judged' => 'verberg beoordeelde items', 18 + 'nav_title' => 'beoordeel', 19 + 'no_current_vote' => 'je hebt nog niet gestemd.', 20 + 'update' => 'werk bij', 21 21 'validation' => [ 22 - 'missing_score' => '', 23 - 'contest_vote_judged' => '', 22 + 'missing_score' => 'ontbrekende score', 23 + 'contest_vote_judged' => 'kan niet stemmen in beoordeelde wedstrijden', 24 24 ], 25 - 'voted' => '', 25 + 'voted' => 'Je hebt al een stem ingediend voor dit item.', 26 26 ], 27 27 28 28 'judge_results' => [ 29 - '_' => '', 30 - 'creator' => '', 31 - 'score' => '', 32 - 'total_score' => '', 29 + '_' => 'Beoordelingsresultaten', 30 + 'creator' => 'maker', 31 + 'score' => 'Score', 32 + 'total_score' => 'totale score', 33 33 ], 34 34 35 35 'voting' => [ 36 - 'judge_link' => '', 37 - 'judged_notice' => '', 36 + 'judge_link' => 'Jij bent een jurylid voor deze wedstrijd. Beoordeel de inzendingen hier!', 37 + 'judged_notice' => 'Deze wedstrijd maakt gebruik van het jurysysteem, de jury verwerkt momenteel de inzendingen.', 38 38 'login_required' => 'Log in om te kunnen stemmen.', 39 39 'over' => 'Je kan niet meer stemmen in deze wedstrijd', 40 40 'show_voted_only' => 'Toon gestemde stemmen',
+2 -2
resources/lang/nl/layout.php
··· 195 195 'account-edit' => 'Instellingen', 196 196 'follows' => 'Volglijsten', 197 197 'friends' => 'Vrienden', 198 - 'legacy_score_only_toggle' => '', 199 - 'legacy_score_only_toggle_tooltip' => '', 198 + 'legacy_score_only_toggle' => 'Lazermodus', 199 + 'legacy_score_only_toggle_tooltip' => 'Lazermodus toont scores gezet op lazer met een nieuw scoringsalgoritme', 200 200 'logout' => 'Log Uit', 201 201 'profile' => 'Mijn Profiel', 202 202 ],
+1 -1
resources/lang/nl/notifications.php
··· 57 57 'beatmapset_discussion_unlock_compact' => 'Discussie is ontgrendeld', 58 58 59 59 'review_count' => [ 60 - 'praises' => '', 60 + 'praises' => ':count_delimited lof|:count_delimited lof', 61 61 'problems' => ':count_delimited probleem|:count_delimited problemen', 62 62 'suggestions' => ':count_delimited suggestie|:count_delimited suggesties', 63 63 ],
+2 -2
resources/lang/nl/page_title.php
··· 64 64 '_' => 'opmerkingen', 65 65 ], 66 66 'contest_entries_controller' => [ 67 - 'judge_results' => '', 67 + 'judge_results' => 'wedstrijd beoordelingsresultaten', 68 68 ], 69 69 'contests_controller' => [ 70 70 '_' => 'wedstrijden', 71 - 'judge' => '', 71 + 'judge' => 'wedstrijd beoordeling', 72 72 ], 73 73 'groups_controller' => [ 74 74 'show' => 'groepen',
+1 -1
resources/lang/nl/password_reset.php
··· 37 37 'username' => 'Vul e-mail adres of gebruikersnaam in', 38 38 39 39 'reason' => [ 40 - 'inactive_different_country' => "", 40 + 'inactive_different_country' => "Uw account is een lange tijd niet gebruikt. Om uw accountbeveiliging te verzekeren, reset uw wachtwoord.", 41 41 ], 42 42 'support' => [ 43 43 '_' => 'Meer hulp nodig? Neem contact met ons op via onze :button.',
+1 -1
resources/lang/nl/score_tokens.php
··· 6 6 return [ 7 7 'create' => [ 8 8 'beatmap_hash_invalid' => '', 9 - 'submission_disabled' => '', 9 + 'submission_disabled' => 'score inzending is uitgeschakeld', 10 10 ], 11 11 ];
+2 -2
resources/lang/nl/scores.php
··· 26 26 'status' => [ 27 27 'non_best' => 'Enkel je beste score op een beatmap levert pp op', 28 28 'non_passing' => 'Alleen geslaagde scores leveren pp op', 29 - 'no_pp' => '', 29 + 'no_pp' => 'pp word niet opgeleverd voor deze score', 30 30 'processing' => 'Deze score wordt nog berekend en zal zo dadelijk getoond worden', 31 - 'no_rank' => '', 31 + 'no_rank' => 'Deze score heeft geen rang omdat deze ongerangschikt is of gemarkeerd is voor verwijdering', 32 32 ], 33 33 ];
+5 -5
resources/lang/nl/store.php
··· 78 78 ], 79 79 'prepared' => [ 80 80 'title' => 'Je bestelling wordt voorbereid!', 81 - 'line_1' => '', 82 - 'line_2' => '', 81 + 'line_1' => 'Wacht alsjeblieft iets langer voor de verzending. Tracking-informatie zal hier verschijnen zodra de bestelling is verwerkt en verzonden. Dit kan tot 5 dagen duren (maar vaak minder!) afhankelijk van hoe druk we zijn.', 82 + 'line_2' => 'We verzenden alle bestellingen vanuit Japan d.m.v. een aantal bezorgdiensten afhankelijk van het gewicht en de waarde. Dit gebied zal worden bijgewerkt met details zodra we de bestelling hebben verzonden.', 83 83 ], 84 84 'processing' => [ 85 85 'title' => 'Uw betaling is nog niet bevestigd!', ··· 91 91 ], 92 92 'shipped' => [ 93 93 'title' => 'Je bestelling is verzonden!', 94 - 'tracking_details' => '', 94 + 'tracking_details' => 'Tracking-details volgen:', 95 95 'no_tracking_details' => [ 96 - '_' => "", 96 + '_' => "We hebben geen tracking-details omdat we jouw pakket via Air Mail verzonden hebben, maar je kunt deze verwachten binnen 1-3 weken. In Europa kan de douane soms vertraging buiten onze controle veroorzaken. Als je vragen hebt, antwoord op de bestelbevestigings-e-mail die je hebt ontvangen (of :link).", 97 97 'link_text' => 'stuur ons een email', 98 98 ], 99 99 ], ··· 157 157 'thanks' => [ 158 158 'title' => 'Bedankt voor je bestelling!', 159 159 'line_1' => [ 160 - '_' => '', 160 + '_' => 'Je zal binnenkort een bevestigings-e-mail ontvangen. Als je vragen hebt, :link!', 161 161 'link_text' => 'contacteer ons', 162 162 ], 163 163 ],
+1 -1
resources/lang/nl/users.php
··· 124 124 ], 125 125 126 126 'ogp' => [ 127 - 'modding_description' => '', 127 + 'modding_description' => 'Beatmaps: :counts', 128 128 'modding_description_empty' => 'Gebruiker heeft geen beatmaps...', 129 129 130 130 'description' => [
+1 -1
resources/lang/ru/rankings.php
··· 24 24 ], 25 25 26 26 'type' => [ 27 - 'charts' => 'по чартам', 27 + 'charts' => 'по чартам (устар.)', 28 28 'country' => 'по странам', 29 29 'kudosu' => 'по кудосу', 30 30 'multiplayer' => 'в мультиплеере',
+14 -14
resources/lang/sk/accounts.php
··· 19 19 'new_confirmation' => 'potvrdenie emailu', 20 20 'title' => 'Email', 21 21 'locked' => [ 22 - '_' => '', 23 - 'accounts' => '', 22 + '_' => 'Prosím kontaktuje :accounts ak potrebujete aktualizovať svoj email.', 23 + 'accounts' => 'tím podpory účtu', 24 24 ], 25 25 ], 26 26 27 27 'legacy_api' => [ 28 - 'api' => '', 29 - 'irc' => '', 28 + 'api' => 'api', 29 + 'irc' => 'irc', 30 30 'title' => '', 31 31 ], 32 32 ··· 38 38 ], 39 39 40 40 'profile' => [ 41 - 'country' => '', 41 + 'country' => 'krajina', 42 42 'title' => 'Profil', 43 43 44 44 'country_change' => [ 45 - '_' => "", 46 - 'update_link' => '', 45 + '_' => "Krajina vášho účtu nezodpovedá krajine vášho bydliska. :update_link.", 46 + 'update_link' => 'Aktualizovať na :country', 47 47 ], 48 48 49 49 'user' => [ ··· 63 63 ], 64 64 65 65 'github_user' => [ 66 - 'info' => "", 67 - 'link' => '', 68 - 'title' => '', 69 - 'unlink' => '', 66 + 'info' => "Ak ste prispievateľom do úložisiek s otvoreným zdrojovým kódom osu!, prepojenie účtu GitHub tu priradí vaše záznamy z denníku zmien k vášmu osu! profilu. Účty GitHub bez histórie príspevkov do osu! nemožno prepojiť.", 67 + 'link' => 'Prepoj GitHub účet', 68 + 'title' => 'GitHub', 69 + 'unlink' => 'Preruš prepojenie GitHub účtu', 70 70 71 71 'error' => [ 72 - 'already_linked' => '', 73 - 'no_contribution' => '', 74 - 'unverified_email' => '', 72 + 'already_linked' => 'Tento GitHub účet je už prepojený na iného užívateľa.', 73 + 'no_contribution' => 'GitHub účet bez histórie príspevkov do úložisiek osu! nemožno prepojiť.', 74 + 'unverified_email' => 'Prosím skontrolujte svoj primárny email v GitHub, potom skúste znova prepojiť svoj účet.', 75 75 ], 76 76 ], 77 77
+3 -2
resources/lang/sk/api.php
··· 17 17 'identify' => 'Identifikovať vás a prezerať váš verejný profil.', 18 18 19 19 'chat' => [ 20 - 'read' => '', 20 + 'read' => 'Prečítajte si správy vo vašom mene. 21 + ', 21 22 'write' => 'Posielajte správy vo vašom mene.', 22 - 'write_manage' => '', 23 + 'write_manage' => 'Pripojte sa a opustite kanály vo vašom mene.', 23 24 ], 24 25 25 26 'forum' => [
+2 -2
resources/lang/sk/authorization.php
··· 81 81 ], 82 82 83 83 'contest' => [ 84 - 'judging_not_active' => '', 84 + 'judging_not_active' => 'Hodnotenie tejto súťaže nie je aktívne.', 85 85 'voting_over' => 'Po tom, čo sa hlasovacie obdobie pre túto súťaž ukončilo, svoj hlas nemôžete zmeniť.', 86 86 87 87 'entry' => [ ··· 172 172 173 173 'score' => [ 174 174 'pin' => [ 175 - 'disabled_type' => "", 175 + 'disabled_type' => "Tento typ skóre sa nedá pripnúť", 176 176 'failed' => "", 177 177 'not_owner' => 'Skóre môže pripnúť iba pôvodný hráč.', 178 178 'too_many' => 'Už bolo pripnuté maximum skóre.',
+1 -1
resources/lang/zh-tw/beatmapsets.php
··· 35 35 'all' => '下載', 36 36 'video' => '下載並包含影片', 37 37 'no_video' => '下載並不包含影片', 38 - 'direct' => '在osu!direct中查看', 38 + 'direct' => '在 osu!direct 中查看', 39 39 ], 40 40 ], 41 41
+5 -5
resources/lang/zh-tw/mail.php
··· 14 14 'common' => [ 15 15 'closing' => '祝順,', 16 16 'hello' => '嗨 :user,', 17 - 'report' => '如果您沒有進行此項操作,請「立刻」回覆此信件!', 17 + 'report' => '如果您沒有進行此項操作,請「立刻」回覆此信件!', 18 18 'ignore' => '若您未請求,則可以安全忽略這封信。', 19 19 ], 20 20 ··· 92 92 ], 93 93 94 94 'user_password_updated' => [ 95 - 'confirmation' => '提醒您,您的osu!密碼已被修改', 95 + 'confirmation' => '提醒您,您的 osu! 密碼已被修改。', 96 96 'subject' => 'osu! 帳號密碼變更', 97 97 ], 98 98 99 99 'user_verification' => [ 100 100 'code' => '您的驗證碼是:', 101 - 'code_hint' => '你可以帶或不帶空格地輸入該驗證碼', 102 - 'link' => '或者,你也可以點擊下列連結以完成認證:', 101 + 'code_hint' => '你可以帶或不帶空格地輸入該驗證碼。', 102 + 'link' => '或者,你也可以點擊下列連結以完成認證:', 103 103 'report' => '如果您並沒有進行此項操作,請「立刻」回覆此信件,您的帳戶可能有危險。', 104 104 'subject' => 'osu! 帳號驗證', 105 105 106 106 'action_from' => [ 107 - '_' => '有一項來自 :country 對您的帳戶所執行的操作需要認證', 107 + '_' => '有一項來自 :country 對您的帳戶所執行的操作需要認證。', 108 108 'unknown_country' => '未知國家', 109 109 ], 110 110 ],
+3 -3
resources/lang/zh-tw/oauth.php
··· 13 13 ], 14 14 15 15 'authorized_clients' => [ 16 - 'confirm_revoke' => '您確定要撤消此客戶端的權限嗎?', 16 + 'confirm_revoke' => '您確定要撤銷此客戶端的權限嗎?', 17 17 'scopes_title' => '這個應用程式可以:', 18 18 'owned_by' => '擁有者 :user', 19 19 'none' => '沒有客戶端', ··· 42 42 'header' => '註冊一個新的 OAuth 應用程式', 43 43 'register' => '註冊應用程式', 44 44 'terms_of_use' => [ 45 - '_' => '在使用API之前您必須同意 :link.', 45 + '_' => '在使用 API 之前您必須同意 :link 。', 46 46 'link' => '使用條款', 47 47 ], 48 48 ], 49 49 50 50 'own_clients' => [ 51 51 'confirm_delete' => '您確定要刪除此客戶端嗎?', 52 - 'confirm_reset' => '您確定鑰重置客戶端密鑰嗎? 這將撤銷現有的所有token。', 52 + 'confirm_reset' => '您確定要重置客戶端密鑰嗎?這將撤銷現有的所有 token。', 53 53 'new' => '新增 OAuth 應用程式', 54 54 'none' => '沒有客戶端', 55 55
+1 -1
resources/views/accounts/_edit_options.blade.php
··· 3 3 See the LICENCE file in the repository root for full licence text. 4 4 --}} 5 5 @php 6 - $customization = auth()->user()->profileCustomization(); 6 + $customization = Auth::user()->profileCustomization(); 7 7 @endphp 8 8 <div class="account-edit"> 9 9 <div class="account-edit__section">
+2 -5
resources/views/objects/_beatmapset_cover.blade.php
··· 7 7 8 8 $isNsfw = $beatmapset->nsfw; 9 9 if ($isNsfw) { 10 - $attributesBag = request()->attributes; 10 + $attributesBag = Request::instance()->attributes; 11 11 $userShowNsfw = $attributesBag->get('user_beatmapset_show_nsfw'); 12 12 if ($userShowNsfw === null) { 13 - $user = auth()->user(); 14 - $userShowNsfw = $user !== null 15 - ? ($user->userProfileCustomization ?? $user->userProfileCustomization()->make())->beatmapset_show_nsfw 16 - : false; 13 + $userShowNsfw = (Auth::user()->userProfileCustomization ?? App\Models\UserProfileCustomization::DEFAULTS)['beatmapset_show_nsfw']; 17 14 $attributesBag->set('user_beatmapset_show_nsfw', $userShowNsfw); 18 15 } 19 16 }
+7 -4
resources/views/store/products/show.blade.php
··· 124 124 </div> 125 125 126 126 <div class="store-page store-page--footer" id="add-to-cart"> 127 - @if($product->inStock()) 128 - <button type="submit" class="btn-osu-big btn-osu-big--store-action js-store-add-to-cart js-login-required--click"> 127 + @if ($product->inStock()) 128 + <button 129 + class="btn-osu-big btn-osu-big--store-action js-login-required--click js-store-add-to-cart" 130 + type="submit" 131 + {{ $product->custom_class === App\Models\Store\Product::SUPPORTER_TAG_NAME ? 'disabled' : '' }} 132 + > 129 133 {{ osu_trans('store.product.add_to_cart') }} 130 134 </button> 131 - 132 - @elseif(!$requestedNotification) 135 + @elseif (!$requestedNotification) 133 136 <a 134 137 class="btn-osu-big btn-osu-big--store-action js-login-required--click" 135 138 href="{{ route('store.notification-request', ['product' => $product->product_id]) }}"
+6 -47
resources/views/store/products/supporter-tag.blade.php
··· 9 9 @if(!Auth::user()) 10 10 {!! require_login('store.supporter_tag.require_login._', 'store.supporter_tag.require_login.link_text') !!} 11 11 @else 12 - <div class="js-store js-store-supporter-tag store-supporter-tag"> 13 - <input type="hidden" name="item[product_id]" value="{{ $product->product_id }}" /> 14 - <input type="hidden" name="item[quantity]" class="js-store-item-quantity" value="1" /> 15 - <input type="hidden" id="supporter-tag-form-price" name="item[cost]" value="4" /> 16 - <input type="hidden" name="item[extra_data][target_id]" value="{{ Auth::user()->user_id }}" /> 17 - <div class="store-supporter-tag__user-search"> 18 - <div class="js-react--user-card-store" data-user="null"></div> 19 - <input 20 - autocomplete="off" 21 - class="js-username-input store-supporter-tag__input" 22 - id="username" 23 - name="item[extra_data][username]" 24 - placeholder="{{ osu_trans('store.supporter_tag.gift') }}" 25 - value="{{ get_string(request('target')) }}" 26 - /> 27 - <div class="js-store-supporter-tag-message"> 28 - <textarea 29 - class="store-supporter-tag__input store-supporter-tag__input--message" 30 - maxlength="{{ ExtraDataSupporterTag::MAX_MESSAGE_LENGTH }}" 31 - name="item[extra_data][message]" 32 - placeholder="{{ osu_trans('store.supporter_tag.gift_message', [ 33 - 'length' => ExtraDataSupporterTag::MAX_MESSAGE_LENGTH, 34 - ]) }}" 35 - rows="3" 36 - ></textarea> 37 - </div> 38 - </div> 39 - <div class="store-slider"> 40 - <div class="js-slider ui-slider ui-slider-horizontal"> 41 - <div class="ui-slider-handle"> 42 - <div class="store-slider__fake-callout"> 43 - <div class="store-slider__callout"> 44 - <div class="js-price store-slider__bigtext"></div> 45 - <div class="js-duration"></div> 46 - <div class="js-discount store-slider__subtext"></div> 47 - </div> 48 - </div> 49 - </div> 50 - </div> 51 - <div class="store-slider__presets"> 52 - <span class="store-slider__presets-blurb">{{ osu_trans('supporter_tag.months') }}</span> 53 - @foreach([1, 2, 4, 6, 12, 18, 24] as $months) 54 - <div class="js-slider-preset store-slider__preset" data-months="{{$months}}">{{$months}}</div> 55 - @endforeach 56 - </div> 57 - </div> 58 - </div> 12 + <div 13 + class="js-react--store-supporter-tag" 14 + data-max-message-length={{ ExtraDataSupporterTag::MAX_MESSAGE_LENGTH }} 15 + ></div> 16 + <input name="item[product_id]" type="hidden" value={{ $product->product_id }} /> 17 + <input class="js-store-item-quantity" name="item[quantity]" type="hidden" value="1" /> 59 18 @endif
+144
resources/views/user_cover_presets/index.blade.php
··· 1 + {{-- 2 + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 3 + See the LICENCE file in the repository root for full licence text. 4 + --}} 5 + @extends('master') 6 + 7 + @section('content') 8 + @include('layout._page_header_v4') 9 + <div class="osu-page osu-page--generic"> 10 + <form 11 + class="simple-form simple-form--search-box" 12 + action="{{ route('user-cover-presets.store') }}" 13 + method="POST" 14 + enctype="multipart/form-data" 15 + > 16 + @csrf 17 + <h2 class="simple-form__row simple-form__row--title"> 18 + {{ osu_trans('user_cover_presets.index.create_form.title') }} 19 + </h2> 20 + <div class="simple-form__row"> 21 + <div class="simple-form__label"> 22 + {{ osu_trans('user_cover_presets.index.create_form.files') }} 23 + </div> 24 + <input class="simple-form__input" type="file" multiple="1" name="files[]" accept="image/*" /> 25 + </div> 26 + 27 + <div class="simple-form__row simple-form__row--no-label"> 28 + <button class="btn-osu-big btn-osu-big--rounded-thin"> 29 + {{ osu_trans('user_cover_presets.index.create_form.submit') }} 30 + </button> 31 + </div> 32 + </form> 33 + 34 + <div class="user-cover-preset-table"> 35 + <div class="user-cover-preset-table__row"> 36 + @include('objects._switch', ['locals' => [ 37 + 'additionalClass' => ' 38 + js-user-cover-preset-batch-enable 39 + js-user-cover-preset-batch-enable--select-all 40 + ', 41 + 'attributes' => ['data-action' => 'select-all'], 42 + 'modifiers' => 'grid', 43 + 'name' => 'select-all', 44 + ]]) 45 + 46 + <div class="user-cover-preset-table__toolbar"> 47 + <button 48 + class="js-user-cover-preset-batch-enable btn-osu-big btn-osu-big--rounded-small" 49 + data-action="enable-selected" 50 + type="button" 51 + > 52 + {{ osu_trans('user_cover_presets.index.batch_enable') }} 53 + </button> 54 + 55 + <button 56 + class="js-user-cover-preset-batch-enable btn-osu-big btn-osu-big--rounded-small" 57 + data-action="disable-selected" 58 + type="button" 59 + > 60 + {{ osu_trans('user_cover_presets.index.batch_disable') }} 61 + </button> 62 + </div> 63 + </div> 64 + 65 + @foreach ($items as $item) 66 + @php 67 + $id = $item->getKey(); 68 + $imageUrl = $item->file()->url(); 69 + $isActive = $item->active; 70 + @endphp 71 + <div 72 + class="user-cover-preset-table__row user-cover-preset-table__row--item" 73 + id="cover-{{ $id }}" 74 + > 75 + {{-- wrap in u-contents because shift-click on label doesn't trigger click on the checkbox --}} 76 + <div 77 + class="u-contents js-user-cover-preset-batch-enable" 78 + data-action="select" 79 + > 80 + @include('objects._switch', ['locals' => [ 81 + 'additionalClass' => 'js-user-cover-preset-batch-enable--checkbox', 82 + 'attributes' => [ 83 + 'data-id' => $id, 84 + ], 85 + 'modifiers' => 'grid', 86 + 'name' => 'ids[]', 87 + ]]) 88 + </div> 89 + 90 + <div> 91 + <p> 92 + <button 93 + class="btn-osu-big btn-osu-big--rounded-small" 94 + data-url="{{ route('user-cover-presets.update', [ 95 + 'user_cover_preset' => $item->getKey(), 96 + 'active' => $item->active ? '0' : '1', 97 + ]) }}" 98 + data-method="PUT" 99 + data-reload-on-success="1" 100 + data-remote="1" 101 + title="{{ osu_trans('user_cover_presets.index.item.'.( 102 + $isActive ? 'click_to_disable' : 'click_to_enable' 103 + )) }}" 104 + > 105 + @if ($isActive) 106 + <span class="fas fa-circle"></span> 107 + {{ osu_trans('user_cover_presets.index.item.enabled') }} 108 + @else 109 + <span class="far fa-circle"></span> 110 + {{ osu_trans('user_cover_presets.index.item.disabled') }} 111 + @endif 112 + </button> 113 + </p> 114 + <p> 115 + <form 116 + action="{{ route('user-cover-presets.update', $item) }}" 117 + enctype="multipart/form-data" 118 + method="POST" 119 + class="user-cover-preset-replace" 120 + > 121 + @csrf 122 + <input type="hidden" name="_method" value="PUT" /> 123 + <input class="user-cover-preset-replace__input" type="file" name="file" accept="image/*" required /> 124 + <button class="btn-osu-big btn-osu-big--rounded-small"> 125 + {{ osu_trans('user_cover_presets.index.item.'.( 126 + $imageUrl === null ? 'image_store' : 'image_update' 127 + )) }} 128 + </button> 129 + </form> 130 + </p> 131 + </div> 132 + 133 + @if ($imageUrl === null) 134 + <p>{{ osu_trans('user_cover_presets.index.item.no_image') }}</p> 135 + @else 136 + <img class="user-cover-preset-table__image" src="{{ $imageUrl }}" alt="" /> 137 + @endif 138 + </div> 139 + @endforeach 140 + </table> 141 + </div> 142 + 143 + @include('layout._extra_js', ['src' => 'js/user-cover-presets.js']) 144 + @endsection
+3
routes/web.php
··· 290 290 Route::post('session', 'SessionsController@store')->name('login'); 291 291 Route::delete('session', 'SessionsController@destroy')->name('logout'); 292 292 293 + Route::post('user-cover-presets/batch-activate', 'UserCoverPresetsController@batchActivate')->name('user-cover-presets.batch-activate'); 294 + Route::resource('user-cover-presets', 'UserCoverPresetsController', ['only' => ['index', 'store', 'update']]); 295 + 293 296 Route::post('users/check-username-availability', 'UsersController@checkUsernameAvailability')->name('users.check-username-availability'); 294 297 Route::post('users/check-username-exists', 'UsersController@checkUsernameExists')->name('users.check-username-exists'); 295 298 Route::get('users/disabled', 'UsersController@disabled')->name('users.disabled');
+1 -1
tests/Browser/SanityTest.php
··· 508 508 509 509 private function checkAdminPermission(Browser $browser, LaravelRoute $route) 510 510 { 511 - $adminRestricted = ['chat.users.index', 'forum.topics.logs.index']; 511 + $adminRestricted = ['chat.users.index', 'forum.topics.logs.index', 'user-cover-presets.index']; 512 512 513 513 if (starts_with($route->uri, 'admin') || in_array($route->getName(), $adminRestricted, true)) { 514 514 // TODO: retry and check page as admin? (will affect subsequent tests though, so figure out how to deal with that..)
+94 -54
tests/Controllers/ScoresControllerTest.php
··· 5 5 6 6 namespace Tests\Controllers; 7 7 8 - use App\Models\Beatmap; 8 + use App\Models\OAuth\Client; 9 9 use App\Models\Score\Best\Osu; 10 10 use App\Models\Solo\Score as SoloScore; 11 11 use App\Models\User; 12 - use App\Models\UserCountryHistory; 13 12 use App\Models\UserStatistics; 14 - use Carbon\CarbonImmutable; 15 13 use Illuminate\Filesystem\Filesystem; 16 14 use Storage; 17 15 use Tests\TestCase; ··· 22 20 private User $user; 23 21 private User $otherUser; 24 22 25 - public function testDownloadSameUser() 23 + private static function getLegacyScoreReplayViewCount(Osu $score): int 24 + { 25 + return $score->replayViewCount()->first()?->play_count ?? 0; 26 + } 27 + 28 + private static function getUserReplaysWatchedCount(Osu|SoloScore $score): int 29 + { 30 + $month = format_month_column(new \DateTime()); 31 + 32 + return $score->user->replaysWatchedCounts()->firstWhere('year_month', $month)?->count ?? 0; 33 + } 34 + 35 + private static function getUserReplayPopularity(Osu|SoloScore $score): int 36 + { 37 + return $score->user->statistics($score->getMode(), true)->first()?->replay_popularity ?? 0; 38 + } 39 + 40 + public function testDownloadApiSameUser() 26 41 { 27 - $this->expectCountChange(fn () => $this->score->user->statistics($this->score->getMode())->replay_popularity, 0); 42 + $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 0); 43 + $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 0); 44 + $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 0); 28 45 29 46 $this 30 - ->actingAs($this->user) 31 - ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/']) 47 + ->actAsPasswordClientUser($this->user) 32 48 ->json( 33 49 'GET', 34 - route('scores.download-legacy', $this->params()) 50 + route('api.scores.download-legacy', $this->params()) 35 51 ) 36 52 ->assertSuccessful(); 37 - 38 - $month = CarbonImmutable::now(); 39 - $currentMonth = UserCountryHistory::formatDate($month); 40 - $this->assertNull($this->score->user->replaysWatchedCounts()->where('year_month', $currentMonth)->first()); 41 - 42 - $this->assertNull($this->score->replayViewCount()->first()); 43 53 } 44 54 45 - public function testDownloadSoloScoreSameUser() 55 + public function testDownloadApiSoloScoreSameUser() 46 56 { 47 57 $soloScore = SoloScore::factory() 48 - ->create([ 49 - 'legacy_score_id' => $this->score->getKey(), 50 - 'ruleset_id' => Beatmap::MODES[$this->score->getMode()], 51 - 'user_id' => $this->score->user_id, 52 - 'has_replay' => true, 53 - ]); 58 + ->withReplay() 59 + ->create(['user_id' => $this->user->getKey()]); 54 60 55 - $this->expectCountChange(fn () => $this->score->user->statistics($this->score->getMode())->replay_popularity, 0); 61 + $this->expectCountChange(fn () => static::getUserReplayPopularity($soloScore), 0); 62 + $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($soloScore), 0); 56 63 57 64 $this 58 - ->actingAs($this->user) 59 - ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/']) 65 + ->actAsPasswordClientUser($this->user) 60 66 ->json( 61 67 'GET', 62 - route('scores.download', $soloScore) 68 + route('api.scores.download', $soloScore) 63 69 ) 64 70 ->assertSuccessful(); 65 - 66 - $month = CarbonImmutable::now(); 67 - $currentMonth = UserCountryHistory::formatDate($month); 68 - $this->assertNull($this->score->user->replaysWatchedCounts()->where('year_month', $currentMonth)->first()); 69 71 } 70 72 71 73 public function testDownload() 72 74 { 75 + $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 0); 76 + $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 0); 77 + $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 0); 78 + 73 79 $this 74 80 ->actingAs($this->otherUser) 75 81 ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/']) ··· 78 84 route('scores.download-legacy', $this->params()) 79 85 ) 80 86 ->assertSuccessful(); 87 + } 81 88 82 - $this->assertEquals($this->score->user->statistics($this->score->getMode())->replay_popularity, 1); 89 + public function testDownloadApi(): void 90 + { 91 + $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 1); 92 + $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 1); 93 + $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 1); 83 94 84 - $month = CarbonImmutable::now(); 85 - $currentMonth = UserCountryHistory::formatDate($month); 86 - $this->assertEquals($this->score->user->replaysWatchedCounts()->where('year_month', $currentMonth)->first()->count, 1); 95 + $this 96 + ->actAsPasswordClientUser($this->otherUser) 97 + ->json( 98 + 'GET', 99 + route('api.scores.download-legacy', $this->params()) 100 + ) 101 + ->assertSuccessful(); 102 + } 87 103 88 - $this->assertEquals($this->score->replayViewCount()->first()->play_count, 1); 104 + public function testDownloadApiTwiceNoCountChange(): void 105 + { 106 + $this 107 + ->actAsPasswordClientUser($this->otherUser) 108 + ->json( 109 + 'GET', 110 + route('api.scores.download-legacy', $this->params()) 111 + ) 112 + ->assertSuccessful(); 113 + 114 + $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 0); 115 + $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 0); 116 + $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 0); 117 + 118 + $this 119 + ->actAsPasswordClientUser($this->otherUser) 120 + ->json( 121 + 'GET', 122 + route('api.scores.download-legacy', $this->params()) 123 + ) 124 + ->assertSuccessful(); 89 125 } 90 126 91 127 public function testDownloadSoloScore() 92 128 { 93 129 $soloScore = SoloScore::factory() 94 - ->create([ 95 - 'legacy_score_id' => $this->score->getKey(), 96 - 'ruleset_id' => Beatmap::MODES[$this->score->getMode()], 97 - 'user_id' => $this->score->user_id, 98 - 'has_replay' => true, 99 - ]); 130 + ->withReplay() 131 + ->create(['user_id' => $this->user->getKey()]); 132 + 133 + $this->expectCountChange(fn () => static::getUserReplayPopularity($soloScore), 0); 134 + $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($soloScore), 0); 100 135 101 136 $this 102 137 ->actingAs($this->otherUser) ··· 106 141 route('scores.download', $soloScore) 107 142 ) 108 143 ->assertSuccessful(); 109 - 110 - $this->assertEquals($soloScore->user->statistics($soloScore->getMode())->replay_popularity, 1); 111 - 112 - $month = CarbonImmutable::now(); 113 - $currentMonth = UserCountryHistory::formatDate($month); 114 - $this->assertEquals($soloScore->user->replaysWatchedCounts()->where('year_month', $currentMonth)->first()->count, 1); 115 144 } 116 145 117 146 public function testDownloadDeletedBeatmap() ··· 151 180 { 152 181 $this 153 182 ->actingAs($this->user) 183 + ->withHeaders(['HTTP_REFERER' => rtrim($GLOBALS['cfg']['app']['url'], '/').'.example.com']) 154 184 ->json( 155 185 'GET', 156 186 route('scores.download-legacy', $this->params()) 157 187 ) 158 188 ->assertRedirect(route('scores.show', $this->params())); 189 + } 159 190 191 + public function testDownloadNoReferer() 192 + { 160 193 $this 161 194 ->actingAs($this->user) 162 - ->withHeaders(['HTTP_REFERER' => rtrim($GLOBALS['cfg']['app']['url'], '/').'.example.com']) 163 195 ->json( 164 196 'GET', 165 197 route('scores.download-legacy', $this->params()) ··· 167 199 ->assertRedirect(route('scores.show', $this->params())); 168 200 } 169 201 170 - public function testDownloadInvalidMode() 202 + public function testDownloadInvalidRuleset() 171 203 { 172 204 $this 173 205 ->actingAs($this->user) ··· 183 215 parent::setUp(); 184 216 185 217 // fake all the replay disks 186 - $disks = []; 187 - foreach (array_keys($GLOBALS['cfg']['filesystems']['disks']['replays']) as $key) { 188 - foreach (array_keys($GLOBALS['cfg']['filesystems']['disks']['replays'][$key]) as $type) { 189 - $disk = "replays.{$key}.{$type}"; 190 - $disks[] = $disk; 191 - Storage::fake($disk); 218 + $disks = [SoloScore::replayFileDiskName()]; 219 + foreach ($GLOBALS['cfg']['filesystems']['disks']['replays'] as $ruleset => $types) { 220 + foreach ($types as $type => $_config) { 221 + $disks[] = "replays.{$ruleset}.{$type}"; 192 222 } 223 + } 224 + foreach ($disks as $disk) { 225 + Storage::fake($disk); 193 226 } 194 227 195 228 // Laravel doesn't remove the directory created for fakes and ··· 206 239 207 240 UserStatistics\Osu::factory()->create(['user_id' => $this->user->user_id]); 208 241 $this->score = Osu::factory()->withReplay()->create(['user_id' => $this->user->user_id]); 242 + } 243 + 244 + private function actAsPasswordClientUser(User $user): static 245 + { 246 + $this->actAsScopedUser($user, ['*'], Client::factory()->create(['password_client' => true])); 247 + 248 + return $this; 209 249 } 210 250 211 251 private function params()
+19
tests/Controllers/SessionsControllerTest.php
··· 45 45 $this->get(route('home'))->assertStatus(401); 46 46 } 47 47 48 + public function testLoginInactiveUserForceReset(): void 49 + { 50 + config_set('osu.user.inactive_force_password_reset', true); 51 + 52 + $password = 'password1'; 53 + $countryAcronym = (Country::first() ?? Country::factory()->create())->getKey(); 54 + $user = User::factory()->create(['password' => $password, 'country_acronym' => $countryAcronym]); 55 + $user->update(['user_lastvisit' => time() - $GLOBALS['cfg']['osu']['user']['inactive_seconds_verification'] - 1]); 56 + 57 + $this->post(route('login'), [ 58 + 'username' => $user->username, 59 + 'password' => $password, 60 + ], [ 61 + 'CF_IPCOUNTRY' => $countryAcronym, 62 + ])->assertStatus(302); 63 + 64 + $this->assertGuest(); 65 + } 66 + 48 67 public function testLoginInactiveUserDifferentCountry() 49 68 { 50 69 $password = 'password1';
+2 -3
tests/Libraries/User/CountryChangeTargetTest.php
··· 11 11 use App\Models\Country; 12 12 use App\Models\Tournament; 13 13 use App\Models\User; 14 - use App\Models\UserCountryHistory; 15 14 use Carbon\CarbonImmutable; 16 15 use Database\Factories\UserFactory; 17 16 use Tests\TestCase; ··· 114 113 115 114 $user 116 115 ->userCountryHistory() 117 - ->where('year_month', '>', UserCountryHistory::formatDate(CountryChangeTarget::currentMonth()->subMonths($minMonths))) 116 + ->where('year_month', '>', format_month_column(CountryChangeTarget::currentMonth()->subMonths($minMonths))) 118 117 ->inRandomOrder() 119 118 ->limit(1) 120 119 ->delete(); ··· 131 130 132 131 $user 133 132 ->userCountryHistory() 134 - ->where('year_month', '>', UserCountryHistory::formatDate(CountryChangeTarget::currentMonth()->subMonths($minMonths))) 133 + ->where('year_month', '>', format_month_column(CountryChangeTarget::currentMonth()->subMonths($minMonths))) 135 134 ->inRandomOrder() 136 135 ->limit(2) 137 136 ->delete();
+7 -7
tests/Libraries/UserRegistrationTest.php
··· 17 17 { 18 18 $attrs = $this->basicAttributes(); 19 19 20 - $origCount = User::count(); 21 - $origCountCache = Count::totalUsers()->count; 20 + $this->expectCountChange(fn () => User::count(), 1); 21 + $this->expectCountChange(fn () => Count::totalUsers()->count, 1); 22 22 $reg = new UserRegistration($attrs); 23 23 $thrown = $this->runSubject($reg); 24 24 25 25 $this->assertFalse($thrown); 26 - $this->assertSame($origCount + 1, User::count()); 27 - $this->assertTrue($reg->user()->userGroups->every(function ($userGroup) { 28 - return $userGroup->user_pending === false; 29 - })); 30 - $this->assertSame($origCountCache + 1, Count::totalUsers()->count); 26 + 27 + $user = $reg->user()->fresh(); 28 + $this->assertNotNull($user->cover_preset_id); 29 + $this->assertTrue($user->userGroups->every(fn ($userGroup) => 30 + $userGroup->user_pending === false)); 31 31 } 32 32 33 33 public function testRequiresUsername()
+18
tests/Models/Multiplayer/ScoreLinkTest.php
··· 176 176 'user_id' => $scoreToken->user_id, 177 177 ]); 178 178 } 179 + 180 + public function testFailedMultiplayerScoresArePreserved() 181 + { 182 + $playlistItem = PlaylistItem::factory()->create(); 183 + $scoreToken = ScoreToken::factory()->create([ 184 + 'beatmap_id' => $playlistItem->beatmap_id, 185 + 'playlist_item_id' => $playlistItem, 186 + ]); 187 + 188 + $scoreLink = ScoreLink::complete($scoreToken, [ 189 + ...static::$commonScoreParams, 190 + 'beatmap_id' => $playlistItem->beatmap_id, 191 + 'ruleset_id' => $playlistItem->ruleset_id, 192 + 'user_id' => $scoreToken->user_id, 193 + 'passed' => false, 194 + ]); 195 + $this->assertTrue($scoreLink->score->preserve); 196 + } 179 197 }
+3 -1
tests/Models/Multiplayer/UserScoreAggregateTest.php
··· 140 140 'total_score' => 10, 141 141 ]); 142 142 143 - $this->addPlay($user, $playlistItem, [ 143 + $playlistItem2 = $this->createPlaylistItem(); 144 + $this->addPlay($user, $playlistItem2, [ 144 145 'accuracy' => 1, 145 146 'passed' => true, 146 147 'total_score' => 1, ··· 150 151 151 152 $this->assertSame(2, $agg->attempts); 152 153 $this->assertSame(1, $agg->completed); 154 + $this->assertSame(1.0, $agg->averageAccuracy()); 153 155 $this->assertSame(1, $agg->total_score); 154 156 } 155 157
+3 -3
yarn.lock
··· 3526 3526 integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== 3527 3527 3528 3528 follow-redirects@^1.0.0: 3529 - version "1.15.4" 3530 - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" 3531 - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== 3529 + version "1.15.6" 3530 + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" 3531 + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== 3532 3532 3533 3533 for-in@^1.0.2: 3534 3534 version "1.0.2"