diff --git a/app/Community/Actions/DropGameClaimAction.php b/app/Community/Actions/DropGameClaimAction.php index d2bec60142..4efb04f37a 100644 --- a/app/Community/Actions/DropGameClaimAction.php +++ b/app/Community/Actions/DropGameClaimAction.php @@ -43,9 +43,9 @@ public function execute(Game $game): ?AchievementSetClaim Cache::forget(CacheKey::buildUserExpiringClaimsCacheKey($firstCollabClaim->user->User)); - addArticleComment("Server", ArticleType::SetClaim, $game->ID, "Primary claim dropped by {$currentUser->User}, transferred to {$firstCollabClaim->user->User}"); + addArticleComment("Server", ArticleType::SetClaim, $game->ID, "Primary claim dropped by {$currentUser->display_name}, transferred to {$firstCollabClaim->user->display_name}"); } else { - addArticleComment("Server", ArticleType::SetClaim, $game->ID, ClaimType::toString($claim->ClaimType) . " claim dropped by {$currentUser->User}"); + addArticleComment("Server", ArticleType::SetClaim, $game->ID, ClaimType::toString($claim->ClaimType) . " claim dropped by {$currentUser->display_name}"); } $webhookUrl = config('services.discord.webhook.claims'); diff --git a/app/Community/Actions/FetchDynamicShortcodeContentAction.php b/app/Community/Actions/FetchDynamicShortcodeContentAction.php new file mode 100644 index 0000000000..16bedf7a2c --- /dev/null +++ b/app/Community/Actions/FetchDynamicShortcodeContentAction.php @@ -0,0 +1,100 @@ + $this->fetchUsers($usernames), + 'tickets' => $this->fetchTickets($ticketIds), + 'achievements' => $this->fetchAchievements($achievementIds), + 'games' => $this->fetchGames($gameIds), + ]); + + return $results->toArray(); + } + + /** + * @return Collection + */ + private function fetchUsers(array $usernames): Collection + { + if (empty($usernames)) { + return collect(); + } + + $users = User::query() + ->where(function ($query) use ($usernames) { + $query->whereIn('User', $usernames) + ->orWhereIn('display_name', $usernames); + }) + ->get(); + + return $users->map(fn (User $user) => UserData::fromUser($user)); + } + + /** + * @return Collection + */ + private function fetchTickets(array $ticketIds): Collection + { + if (empty($ticketIds)) { + return collect(); + } + + return Ticket::with('achievement') + ->whereIn('ID', $ticketIds) + ->get() + ->map(fn (Ticket $ticket) => TicketData::fromTicket($ticket)->include('state', 'ticketable')); + } + + /** + * @return Collection + */ + private function fetchAchievements(array $achievementIds): Collection + { + if (empty($achievementIds)) { + return collect(); + } + + return Achievement::whereIn('ID', $achievementIds) + ->get() + ->map(fn (Achievement $achievement) => AchievementData::fromAchievement($achievement)->include( + 'badgeUnlockedUrl', + 'points' + )); + } + + /** + * @return Collection + */ + private function fetchGames(array $gameIds): Collection + { + if (empty($gameIds)) { + return collect(); + } + + return Game::with('system') + ->whereIn('ID', $gameIds) + ->get() + ->map(fn (Game $game) => GameData::fromGame($game)->include('badgeUrl', 'system.name')); + } +} diff --git a/app/Community/Actions/ReplaceUserShortcodesWithUsernamesAction.php b/app/Community/Actions/ReplaceUserShortcodesWithUsernamesAction.php index bae7154b39..c9a5225708 100644 --- a/app/Community/Actions/ReplaceUserShortcodesWithUsernamesAction.php +++ b/app/Community/Actions/ReplaceUserShortcodesWithUsernamesAction.php @@ -19,14 +19,18 @@ public function execute(string $messageBody): string return $messageBody; } - $users = User::whereIn('ID', $userIds)->get()->keyBy('ID'); + $users = User::whereIn('ID', $userIds) + ->get(['ID', 'User', 'display_name']) + ->keyBy('ID'); // Replace each shortcode with the corresponding username. return preg_replace_callback('/\[user=(\d+)\]/', function ($matches) use ($users) { $userId = $matches[1]; $user = $users->get($userId); - return '[user=' . ($user ? $user->username : $userId) . ']'; + $username = $user ? ($user->display_name ?? $user->username) : $userId; + + return "[user={$username}]"; }, $messageBody); } } diff --git a/app/Community/Commands/GenerateAnnualRecap.php b/app/Community/Commands/GenerateAnnualRecap.php index af0445db0f..69cd474e38 100644 --- a/app/Community/Commands/GenerateAnnualRecap.php +++ b/app/Community/Commands/GenerateAnnualRecap.php @@ -29,7 +29,7 @@ public function handle(): void if ($userId) { $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $this->generateAnnualRecapAction->execute($user); } else { diff --git a/app/Community/Components/UserCard.php b/app/Community/Components/UserCard.php index 1c0be2c679..240686c8a8 100644 --- a/app/Community/Components/UserCard.php +++ b/app/Community/Components/UserCard.php @@ -58,7 +58,7 @@ private function getUserData(string $username): ?array return Cache::store('array')->rememberForever( CacheKey::buildUserCardDataCacheKey($username), function () use ($username): ?array { - $foundUser = User::firstWhere('User', $username); + $foundUser = User::whereName($username)->first(); return $foundUser ? [ ...$foundUser->toArray(), @@ -79,7 +79,7 @@ private function buildAllCardViewValues(string $username, array $rawUserData): a private function buildCardBioData(array $rawUserData): array { - $username = $rawUserData['User'] ?? ""; + $username = $rawUserData['display_name'] ?? $rawUserData['User'] ?? ""; $motto = $rawUserData['Motto'] && !$rawUserData['isMuted'] ? $rawUserData['Motto'] : null; $avatarUrl = $rawUserData['avatarUrl'] ?? null; $hardcorePoints = $rawUserData['RAPoints'] ?? 0; diff --git a/app/Community/Components/UserProfileMeta.php b/app/Community/Components/UserProfileMeta.php index 15f3754367..9392f477fb 100644 --- a/app/Community/Components/UserProfileMeta.php +++ b/app/Community/Components/UserProfileMeta.php @@ -75,7 +75,7 @@ private function buildDeveloperStats(User $user, array $userMassData): array $setsWorkedOnStat = [ 'label' => 'Achievement sets worked on', 'value' => localized_number($gameAuthoredAchievementsCount), - 'href' => $gameAuthoredAchievementsCount ? route('developer.sets', ['user' => $user]) : null, + 'href' => $gameAuthoredAchievementsCount ? route('developer.sets', ['user' => $user->display_name]) : null, 'isMuted' => !$gameAuthoredAchievementsCount, ]; @@ -83,7 +83,7 @@ private function buildDeveloperStats(User $user, array $userMassData): array $achievementsUnlockedByPlayersStat = [ 'label' => 'Achievements unlocked by players', 'value' => localized_number($userMassData['ContribCount']), - 'href' => $userMassData['ContribCount'] > 0 ? route('user.achievement-author.feed', ['user' => $user]) : null, + 'href' => $userMassData['ContribCount'] > 0 ? route('user.achievement-author.feed', ['user' => $user->display_name]) : null, 'isMuted' => !$userMassData['ContribCount'], ]; @@ -91,7 +91,7 @@ private function buildDeveloperStats(User $user, array $userMassData): array $pointsAwardedToPlayersStat = [ 'label' => 'Points awarded to players', 'value' => localized_number($userMassData['ContribYield']), - 'href' => $userMassData['ContribYield'] > 0 ? route('user.achievement-author.feed', ['user' => $user]) : null, + 'href' => $userMassData['ContribYield'] > 0 ? route('user.achievement-author.feed', ['user' => $user->display_name]) : null, 'isMuted' => !$userMassData['ContribYield'], ]; @@ -100,7 +100,7 @@ private function buildDeveloperStats(User $user, array $userMassData): array $codeNotesCreatedStat = [ 'label' => 'Code notes created', 'value' => localized_number($totalAuthoredCodeNotes), - 'href' => $totalAuthoredCodeNotes ? '/individualdevstats.php?u=' . $user->User . '#code-notes' : null, + 'href' => $totalAuthoredCodeNotes ? '/individualdevstats.php?u=' . $user->display_name . '#code-notes' : null, 'isMuted' => !$totalAuthoredCodeNotes, ]; @@ -111,7 +111,7 @@ private function buildDeveloperStats(User $user, array $userMassData): array $leaderboardsCreatedStat = [ 'label' => 'Leaderboards created', 'value' => localized_number($totalAuthoredLeaderboards), - 'href' => $totalAuthoredLeaderboards ? '/individualdevstats.php?u=' . $user->User : null, + 'href' => $totalAuthoredLeaderboards ? '/individualdevstats.php?u=' . $user->display_name : null, 'isMuted' => !$totalAuthoredLeaderboards, ]; @@ -122,8 +122,8 @@ private function buildDeveloperStats(User $user, array $userMassData): array } $openTicketsStat = [ 'label' => 'Open tickets', - 'value' => $openTickets === null ? "Tickets can't be assigned to {$user->User}." : localized_number($openTickets), - 'href' => $openTickets ? route('developer.tickets', ['user' => $user]) : null, + 'value' => $openTickets === null ? "Tickets can't be assigned to {$user->display_name}." : localized_number($openTickets), + 'href' => $openTickets ? route('developer.tickets', ['user' => $user->display_name]) : null, 'isMuted' => !$openTickets, ]; @@ -242,7 +242,7 @@ private function buildPlayerStats( 'value' => localized_number($totalGamesBeaten), 'isHrefLabelBeforeLabel' => false, 'isMuted' => !$totalGamesBeaten, - 'href' => $retailGamesBeaten ? route('ranking.beaten-games', ['filter[user]' => $user->username]) : null, + 'href' => $retailGamesBeaten ? route('ranking.beaten-games', ['filter[user]' => $user->display_name]) : null, ]; // Started games beaten @@ -329,7 +329,7 @@ private function buildSocialStats(User $user): array $forumPostsStat = [ 'label' => 'Forum posts', 'value' => localized_number($numForumPosts), - 'href' => $numForumPosts ? route('user.posts.index', ['user' => $user]) : null, + 'href' => $numForumPosts ? route('user.posts.index', ['user' => $user->display_name]) : null, 'isMuted' => $numForumPosts === 0, ]; diff --git a/app/Community/Controllers/Api/ForumTopicCommentApiController.php b/app/Community/Controllers/Api/ForumTopicCommentApiController.php new file mode 100644 index 0000000000..e25c7fcd32 --- /dev/null +++ b/app/Community/Controllers/Api/ForumTopicCommentApiController.php @@ -0,0 +1,57 @@ +authorize('update', $comment); + + // Take any RA links and convert them to relevant shortcodes. + // eg: "https://retroachievements.org/game/1" --> "[game=1]" + $newPayload = normalize_shortcodes($request->input('body')); + + // Convert [user=$user->username] to [user=$user->id]. + $newPayload = Shortcode::convertUserShortcodesToUseIds($newPayload); + + $comment->body = $newPayload; + $comment->save(); + + return response()->json(['success' => true]); + } + + public function destroy(): void + { + } + + public function preview( + PreviewForumPostRequest $request, + FetchDynamicShortcodeContentAction $action + ): JsonResponse { + $entities = $action->execute( + usernames: $request->input('usernames'), + ticketIds: $request->input('ticketIds'), + achievementIds: $request->input('achievementIds'), + gameIds: $request->input('gameIds'), + ); + + return response()->json($entities); + } +} diff --git a/app/Community/Controllers/ForumTopicCommentController.php b/app/Community/Controllers/ForumTopicCommentController.php index 7033da7f9c..3f35838643 100644 --- a/app/Community/Controllers/ForumTopicCommentController.php +++ b/app/Community/Controllers/ForumTopicCommentController.php @@ -6,11 +6,15 @@ use App\Community\Actions\AddCommentAction; use App\Community\Actions\GetUrlToCommentDestinationAction; +use App\Community\Actions\ReplaceUserShortcodesWithUsernamesAction; use App\Community\Requests\ForumTopicCommentRequest; +use App\Data\EditForumTopicCommentPagePropsData; +use App\Data\ForumTopicCommentData; use App\Models\ForumTopic; use App\Models\ForumTopicComment; -use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; class ForumTopicCommentController extends CommentController { @@ -44,12 +48,22 @@ public function store( // ->with('success', $this->resourceActionSuccessMessage('comment', 'create')); } - public function edit(ForumTopicComment $comment): View + public function edit(ForumTopicComment $comment): InertiaResponse { $this->authorize('update', $comment); - return view('forum-topic-comment.edit') - ->with('comment', $comment); + // "[user=1]" -> "[user=Scott]" + $comment->body = (new ReplaceUserShortcodesWithUsernamesAction())->execute($comment->body); + + $props = new EditForumTopicCommentPagePropsData( + forumTopicComment: ForumTopicCommentData::from($comment)->include( + 'forumTopic', + 'forumTopic.forum', + 'forumTopic.forum.category', + ), + ); + + return Inertia::render('forums/post/[comment]/edit', $props); } protected function update( diff --git a/app/Community/Controllers/MessageController.php b/app/Community/Controllers/MessageController.php index 72a504c303..3828c3c555 100644 --- a/app/Community/Controllers/MessageController.php +++ b/app/Community/Controllers/MessageController.php @@ -53,7 +53,7 @@ public function store(MessageRequest $request): RedirectResponse (new AddToMessageThreadAction())->execute($thread, $user, $body); } else { - $recipient = User::firstWhere('User', $input['recipient']); + $recipient = User::whereName($input['recipient'])->first(); if (!$user->can('sendToRecipient', [Message::class, $recipient])) { return back()->withErrors($user->isMuted() ? diff --git a/app/Community/Controllers/UserSettingsController.php b/app/Community/Controllers/UserSettingsController.php index 550dc9cd63..f6a396cf35 100644 --- a/app/Community/Controllers/UserSettingsController.php +++ b/app/Community/Controllers/UserSettingsController.php @@ -95,7 +95,7 @@ public function updateEmail(UpdateEmailRequest $request): JsonResponse 'Server', ArticleType::UserModeration, $user->id, - "{$user->username} changed their email address" + "{$user->display_name} changed their email address" ); return response()->json(['success' => true]); diff --git a/app/Community/Enums/TicketState.php b/app/Community/Enums/TicketState.php index 07f16246c5..c13910ba2e 100644 --- a/app/Community/Enums/TicketState.php +++ b/app/Community/Enums/TicketState.php @@ -4,6 +4,9 @@ namespace App\Community\Enums; +use Spatie\TypeScriptTransformer\Attributes\TypeScript; + +#[TypeScript] abstract class TicketState { public const Closed = 0; diff --git a/app/Community/Listeners/NotifyMessageThreadParticipants.php b/app/Community/Listeners/NotifyMessageThreadParticipants.php index da50b91093..65506776ea 100644 --- a/app/Community/Listeners/NotifyMessageThreadParticipants.php +++ b/app/Community/Listeners/NotifyMessageThreadParticipants.php @@ -61,7 +61,7 @@ public function handle(MessageCreated $event): void // send email? if (BitSet($userTo->websitePrefs, UserPreference::EmailOn_PrivateMessage)) { - sendPrivateMessageEmail($userTo->User, $userTo->EmailAddress, $thread->title, $message->body, $userFrom->User); + sendPrivateMessageEmail($userTo->display_name, $userTo->EmailAddress, $thread->title, $message->body, $userFrom->display_name); } $this->forwardToDiscord($userFrom, $userTo, $thread, $message); @@ -156,9 +156,9 @@ private function forwardToDiscord( 'embeds' => [ [ 'author' => [ - 'name' => $userFrom->username, + 'name' => $userFrom->display_name, // TODO 'url' => route('user.show', $userFrom), - 'url' => url('user/' . $userFrom->username), + 'url' => url('user/' . $userFrom->display_name), 'icon_url' => $userFrom->avatar_url, ], 'title' => mb_substr($messageThread->title, 0, 100), diff --git a/app/Community/Requests/PreviewForumPostRequest.php b/app/Community/Requests/PreviewForumPostRequest.php new file mode 100644 index 0000000000..5b56475114 --- /dev/null +++ b/app/Community/Requests/PreviewForumPostRequest.php @@ -0,0 +1,24 @@ + 'present|array', + 'usernames.*' => 'string', + 'ticketIds' => 'present|array', + 'ticketIds.*' => 'integer', + 'achievementIds' => 'present|array', + 'achievementIds.*' => 'integer', + 'gameIds' => 'present|array', + 'gameIds.*' => 'integer', + ]; + } +} diff --git a/app/Community/Requests/UpdateForumTopicCommentRequest.php b/app/Community/Requests/UpdateForumTopicCommentRequest.php new file mode 100644 index 0000000000..b5e4b97a5b --- /dev/null +++ b/app/Community/Requests/UpdateForumTopicCommentRequest.php @@ -0,0 +1,23 @@ + [ + 'required', + 'string', + 'max:60000', + new ContainsRegularCharacter(), + ], + ]; + } +} diff --git a/app/Community/RouteServiceProvider.php b/app/Community/RouteServiceProvider.php index 4e883cc49d..9a35ceeb54 100755 --- a/app/Community/RouteServiceProvider.php +++ b/app/Community/RouteServiceProvider.php @@ -9,6 +9,7 @@ use App\Community\Controllers\AchievementSetClaimController; use App\Community\Controllers\Api\AchievementCommentApiController; use App\Community\Controllers\Api\ActivePlayersApiController; +use App\Community\Controllers\Api\ForumTopicCommentApiController; use App\Community\Controllers\Api\GameClaimsCommentApiController; use App\Community\Controllers\Api\GameCommentApiController; use App\Community\Controllers\Api\GameHashesCommentApiController; @@ -68,6 +69,9 @@ protected function mapWebRoutes(): void Route::group(['prefix' => 'internal-api'], function () { Route::post('achievement/{achievement}/comment', [AchievementCommentApiController::class, 'store'])->name('api.achievement.comment.store'); + Route::post('forums/post/preview', [ForumTopicCommentApiController::class, 'preview'])->name('api.forum-topic-comment.preview'); + Route::patch('forums/post/{comment}', [ForumTopicCommentApiController::class, 'update'])->name('api.forum-topic-comment.update'); + Route::post('game/{game}/claims/comment', [GameClaimsCommentApiController::class, 'store'])->name('api.game.claims.comment.store'); Route::post('game/{game}/comment', [GameCommentApiController::class, 'store'])->name('api.game.comment.store'); Route::post('game/{game}/hashes/comment', [GameHashesCommentApiController::class, 'store'])->name('api.game.hashes.comment.store'); @@ -114,6 +118,7 @@ protected function mapWebRoutes(): void Route::get('user/{user}/moderation-comments', [UserModerationCommentController::class, 'index'])->name('user.moderation-comment.index'); Route::get('forums/recent-posts', [ForumTopicController::class, 'recentPosts'])->name('forum.recent-posts'); + Route::get('forums/post/{comment}/edit2', [ForumTopicCommentController::class, 'edit'])->name('forum-topic-comment.edit'); Route::get('user/{user}/posts', [UserForumTopicCommentController::class, 'index'])->name('user.posts.index'); Route::get('user/{user}/achievement-checklist', [UserAchievementChecklistController::class, 'index'])->name('user.achievement-checklist'); diff --git a/app/Components/GeneralNotificationsIcon.php b/app/Components/GeneralNotificationsIcon.php index 4f71f1b3ca..ecac8c4663 100644 --- a/app/Components/GeneralNotificationsIcon.php +++ b/app/Components/GeneralNotificationsIcon.php @@ -33,7 +33,7 @@ public function render(): View $ticketFeedback = countRequestTicketsByUser($user); if ($ticketFeedback) { $notifications->push([ - 'link' => route('reporter.tickets', ['user' => $user]), + 'link' => route('reporter.tickets', ['user' => $user->display_name]), 'title' => $ticketFeedback . ' ' . __res('ticket', $ticketFeedback) . ' awaiting your feedback', ]); } @@ -44,13 +44,13 @@ public function render(): View $expiringClaims = getExpiringClaim($user); if ($expiringClaims['Expired'] ?? 0) { $notifications->push([ - 'link' => route('developer.claims', ['user' => $user]), + 'link' => route('developer.claims', ['user' => $user->display_name]), 'title' => 'Claim Expired', 'class' => 'text-danger', ]); } elseif ($expiringClaims['Expiring'] ?? 0) { $notifications->push([ - 'link' => route('developer.claims', ['user' => $user]), + 'link' => route('developer.claims', ['user' => $user->display_name]), 'title' => 'Claim Expiring Soon', 'class' => 'text-danger', ]); diff --git a/app/Components/TicketNotificationsIcon.php b/app/Components/TicketNotificationsIcon.php index 569bcdd295..2095fc9f1a 100644 --- a/app/Components/TicketNotificationsIcon.php +++ b/app/Components/TicketNotificationsIcon.php @@ -24,14 +24,14 @@ public function render(): View $openTicketsData = countOpenTicketsByDev($user); if ($openTicketsData[TicketState::Open]) { $notifications->push([ - 'link' => route('developer.tickets', ['user' => $user]), + 'link' => route('developer.tickets', ['user' => $user->display_name]), 'title' => $openTicketsData[TicketState::Open] . ' ' . __res('ticket', (int) $openTicketsData[TicketState::Open]) . ' for you to resolve', 'class' => 'text-danger', ]); } if ($openTicketsData[TicketState::Request]) { $notifications->push([ - 'link' => route('developer.tickets', ['user' => $user]), + 'link' => route('developer.tickets', ['user' => $user->display_name]), 'title' => $openTicketsData[TicketState::Request] . ' ' . __res('ticket', (int) $openTicketsData[TicketState::Request]) . ' pending feedback', 'read' => true, ]); @@ -40,7 +40,7 @@ public function render(): View $ticketFeedback = countRequestTicketsByUser($user); if ($ticketFeedback) { $notifications->push([ - 'link' => route('reporter.tickets', ['user' => $user]), + 'link' => route('reporter.tickets', ['user' => $user->display_name]), 'title' => $ticketFeedback . ' ' . __res('ticket', $ticketFeedback) . ' awaiting your feedback', ]); } diff --git a/app/Data/EditForumTopicCommentPagePropsData.php b/app/Data/EditForumTopicCommentPagePropsData.php new file mode 100644 index 0000000000..68a0a5cb16 --- /dev/null +++ b/app/Data/EditForumTopicCommentPagePropsData.php @@ -0,0 +1,17 @@ +id, + title: $category->title, + description: Lazy::create(fn () => $category->description), + orderColumn: Lazy::create(fn () => $category->order_column), + ); + } +} diff --git a/app/Data/ForumData.php b/app/Data/ForumData.php new file mode 100644 index 0000000000..e4fa09a893 --- /dev/null +++ b/app/Data/ForumData.php @@ -0,0 +1,34 @@ +id, + title: $forum->title, + description: Lazy::create(fn () => $forum->description), + orderColumn: Lazy::create(fn () => $forum->order_column), + category: Lazy::create(fn () => ForumCategoryData::fromForumCategory($forum->category)), + ); + } +} diff --git a/app/Data/ForumTopicCommentData.php b/app/Data/ForumTopicCommentData.php index 4def7b0662..72a35534dd 100644 --- a/app/Data/ForumTopicCommentData.php +++ b/app/Data/ForumTopicCommentData.php @@ -4,8 +4,10 @@ namespace App\Data; -use Illuminate\Support\Carbon; +use App\Models\ForumTopicComment; +use Carbon\Carbon; use Spatie\LaravelData\Data; +use Spatie\LaravelData\Lazy; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript('ForumTopicComment')] @@ -18,7 +20,21 @@ public function __construct( public ?Carbon $updatedAt, public ?UserData $user, public bool $isAuthorized, // TODO migrate to $authorizedAt - public ?int $forumTopicId = null, + public ?int $forumTopicId = null, // TODO remove and use $forumTopic instead + public Lazy|ForumTopicData|null $forumTopic = null, ) { } + + public static function fromForumTopicComment(ForumTopicComment $comment): self + { + return new self( + id: $comment->id, + body: $comment->body, + createdAt: $comment->created_at, + updatedAt: $comment->updated_at, + user: UserData::from($comment->user), + isAuthorized: $comment->is_authorized, + forumTopic: Lazy::create(fn () => ForumTopicData::from($comment->forumTopic)), + ); + } } diff --git a/app/Data/ForumTopicData.php b/app/Data/ForumTopicData.php index b6992170fa..5fad8ee75e 100644 --- a/app/Data/ForumTopicData.php +++ b/app/Data/ForumTopicData.php @@ -4,8 +4,9 @@ namespace App\Data; +use App\Models\ForumTopic; use App\Support\Shortcode\Shortcode; -use Illuminate\Support\Carbon; +use Carbon\Carbon; use Spatie\LaravelData\Data; use Spatie\LaravelData\Lazy; use Spatie\TypeScriptTransformer\Attributes\TypeScript; @@ -17,15 +18,33 @@ public function __construct( public int $id, public string $title, public Carbon $createdAt, - public Lazy|ForumTopicCommentData $latestComment, - public Lazy|int|null $commentCount24h, - public Lazy|int|null $oldestComment24hId, - public Lazy|int|null $commentCount7d, - public Lazy|int|null $oldestComment7dId, + public Lazy|ForumData|null $forum, + public Lazy|ForumTopicCommentData|null $latestComment, // TODO move to separate DTO + public Lazy|int|null $commentCount24h, // TODO move to separate DTO + public Lazy|int|null $oldestComment24hId, // TODO move to separate DTO + public Lazy|int|null $commentCount7d, // TODO move to separate DTO + public Lazy|int|null $oldestComment7dId, // TODO move to separate DTO public ?UserData $user = null, ) { } + public static function fromForumTopic(ForumTopic $topic): self + { + return new self( + id: $topic->id, + title: $topic->title, + createdAt: $topic->created_at, + forum: Lazy::create(fn () => ForumData::fromForum($topic->forum)), + user: UserData::from($topic->user), + + latestComment: null, + commentCount24h: null, + oldestComment24hId: null, + commentCount7d: null, + oldestComment7dId: null, + ); + } + public static function fromHomePageQuery(array $comment): self { return new self( @@ -33,6 +52,7 @@ public static function fromHomePageQuery(array $comment): self title: $comment['ForumTopicTitle'], createdAt: Carbon::parse($comment['PostedAt']), + forum: null, user: null, commentCount24h: null, @@ -58,6 +78,7 @@ public static function fromRecentlyActiveTopic(array $topic): self title: $topic['ForumTopicTitle'], createdAt: Carbon::parse($topic['PostedAt']), + forum: null, user: null, commentCount24h: Lazy::create(fn () => $topic['Count_1d']), @@ -87,6 +108,7 @@ public static function fromUserPost(array $userPost): self title: $userPost['ForumTopicTitle'], createdAt: Carbon::parse($userPost['PostedAt']), + forum: null, user: null, commentCount24h: null, diff --git a/app/Filament/Actions/ProcessUploadedImageAction.php b/app/Filament/Actions/ProcessUploadedImageAction.php index bac344b922..743bad825e 100644 --- a/app/Filament/Actions/ProcessUploadedImageAction.php +++ b/app/Filament/Actions/ProcessUploadedImageAction.php @@ -41,6 +41,7 @@ public function execute(string $tempImagePath, ImageUploadType $imageUploadType) ImageUploadType::GameBoxArt => ImageType::GameBoxArt, ImageUploadType::GameTitle => ImageType::GameTitle, ImageUploadType::GameInGame => ImageType::GameInGame, + ImageUploadType::EventAward => ImageType::GameIcon, }; $file = createFileArrayFromDataUrl($dataUrl); diff --git a/app/Filament/Enums/ImageUploadType.php b/app/Filament/Enums/ImageUploadType.php index 51900cd3fc..61b4c8f32f 100644 --- a/app/Filament/Enums/ImageUploadType.php +++ b/app/Filament/Enums/ImageUploadType.php @@ -12,4 +12,5 @@ enum ImageUploadType case GameBoxArt; case GameTitle; case GameInGame; + case EventAward; } diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 3a80cd7fa6..5279449bb6 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -9,6 +9,7 @@ use App\Filament\Extensions\Resources\Resource; use App\Filament\Resources\EventResource\Pages; use App\Filament\Resources\EventResource\RelationManagers\AchievementsRelationManager; +use App\Filament\Resources\EventResource\RelationManagers\EventAwardsRelationManager; use App\Filament\Resources\EventResource\RelationManagers\HubsRelationManager; use App\Filament\Rules\ExistsInForumTopics; use App\Models\Event; @@ -54,10 +55,10 @@ public static function infolist(Infolist $infolist): Infolist ->icon('heroicon-m-key') ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) ->schema([ - Infolists\Components\TextEntry::make('game.title') + Infolists\Components\TextEntry::make('legacyGame.title') ->label('Title'), - Infolists\Components\TextEntry::make('game.sort_title') + Infolists\Components\TextEntry::make('legacyGame.sort_title') ->label('Sort Title'), Infolists\Components\TextEntry::make('slug') @@ -71,7 +72,7 @@ public static function infolist(Infolist $infolist): Infolist ->extraAttributes(['class' => 'underline']) ->openUrlInNewTab(), - Infolists\Components\TextEntry::make('game.forumTopic.id') + Infolists\Components\TextEntry::make('legacyGame.forumTopic.id') ->label('Forum Topic ID') ->url(fn (?int $state) => url("viewtopic.php?t={$state}")) ->extraAttributes(['class' => 'underline']), @@ -92,11 +93,11 @@ public static function infolist(Infolist $infolist): Infolist ") ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) ->schema([ - Infolists\Components\TextEntry::make('game.players_total') + Infolists\Components\TextEntry::make('legacyGame.players_total') ->label('Players') ->numeric(), - Infolists\Components\TextEntry::make('game.achievements_published') + Infolists\Components\TextEntry::make('legacyGame.achievements_published') ->label('Achievements') ->numeric(), ]), @@ -108,7 +109,7 @@ public static function form(Form $form): Form return $form ->schema([ Forms\Components\Section::make() - ->relationship('game') + ->relationship('legacyGame') ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) ->schema([ Forms\Components\TextInput::make('Title') @@ -179,7 +180,7 @@ public static function form(Form $form): Form // https://discord.com/channels/310192285306454017/758865736072167474/1326712584623099927 Forms\Components\Section::make('Media from Game Record') ->icon('heroicon-s-photo') - ->relationship('game') + ->relationship('legacyGame') ->schema([ // Store a temporary file on disk until the user submits. // When the user submits, put in storage. @@ -249,7 +250,7 @@ public static function table(Table $table): Table ->sortable() ->searchable(), - Tables\Columns\TextColumn::make('game.title') + Tables\Columns\TextColumn::make('legacyGame.title') ->sortable() ->searchable(), @@ -263,19 +264,19 @@ public static function table(Table $table): Table ->sortable(['active_until']) ->toggleable(), - Tables\Columns\TextColumn::make('game.forumTopic.id') + Tables\Columns\TextColumn::make('legacyGame.forumTopic.id') ->label('Forum Topic') ->url(fn (?int $state) => url("viewtopic.php?t={$state}")) ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('game.players_hardcore') + Tables\Columns\TextColumn::make('legacyGame.players_hardcore') ->label('Players') ->numeric() ->sortable() ->alignEnd() ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('game.achievements_published') + Tables\Columns\TextColumn::make('legacyGame.achievements_published') ->label('Achievements') ->numeric() ->sortable() @@ -301,6 +302,7 @@ public static function getRelations(): array { return [ AchievementsRelationManager::class, + EventAwardsRelationManager::class, HubsRelationManager::class, ]; } @@ -330,6 +332,6 @@ public static function getPages(): array public static function getEloquentQuery(): Builder { return parent::getEloquentQuery() - ->with(['game']); + ->with(['legacyGame']); } } diff --git a/app/Filament/Resources/EventResource/RelationManagers/EventAwardsRelationManager.php b/app/Filament/Resources/EventResource/RelationManagers/EventAwardsRelationManager.php new file mode 100644 index 0000000000..5678af908e --- /dev/null +++ b/app/Filament/Resources/EventResource/RelationManagers/EventAwardsRelationManager.php @@ -0,0 +1,159 @@ +can('manage', EventAward::class); + } + + public function form(Form $form): Form + { + /** @var Event $event */ + $event = $this->getOwnerRecord(); + $minAchievements = 1; + $maxAchievements = $event->achievements()->count(); + $isNew = true; + + if (!$event->awards()->exists()) { + $tierIndex = 1; + } else { + /** @var EventAward $award */ + $award = $form->model; + if (is_a($award, EventAward::class)) { + $tierIndex = $award->tier_index; + $isNew = false; + } else { // new record just passes the class name as $form->model + $maxTier = $event->awards()->max('tier_index'); + $tierIndex = $maxTier + 1; + } + + $previousTier = $event->awards()->where('tier_index', $tierIndex - 1)->first(); + if ($previousTier) { + $minAchievements = $previousTier->achievements_required + 1; + } + + $nextTier = $event->awards()->where('tier_index', $tierIndex + 1)->first(); + if ($nextTier) { + $maxAchievements = $nextTier->achievements_required - 1; + } + } + + return $form + ->schema([ + Forms\Components\TextInput::make('label') + ->minLength(2) + ->maxLength(40) + ->required(), + + Forms\Components\TextInput::make('achievements_required') + ->default($maxAchievements) + ->numeric() + ->minValue($minAchievements) + ->maxValue($maxAchievements) + ->required(), + + Forms\Components\TextInput::make('tier_index') + ->default($tierIndex) + ->numeric() + ->readOnly() + ->required(), + + Forms\Components\Section::make('Media') + ->icon('heroicon-s-photo') + ->schema([ + // Store a temporary file on disk until the user submits. + // When the user submits, put in storage. + Forms\Components\FileUpload::make('image_asset_path') + ->label('Badge') + ->disk('livewire-tmp') // Use Livewire's self-cleaning temporary disk + ->image() + ->rules([ + 'dimensions:width=96,height=96', + ]) + ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif']) + ->maxSize(1024) + ->maxFiles(1) + ->required($isNew) + ->previewable(true), + ]) + ->columns(2), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('label') + ->columns([ + Tables\Columns\TextColumn::make('tier_index'), + + Tables\Columns\ImageColumn::make('badgeUrl') + ->label('Badge') + ->size(config('media.icon.md.width')), + + Tables\Columns\TextColumn::make('label') + ->label('Label'), + + Tables\Columns\TextColumn::make('achievements_required'), + ]) + ->filters([ + + ]) + ->headerActions([ + Tables\Actions\CreateAction::make() + ->mutateFormDataUsing(function (array $data): array { + $this->processUploadedImage($data, null); + + return $data; + }) + ->createAnother(false), // Create Another doesn't update tier_index, which causes a unique constraint error + ]) + ->actions([ + Tables\Actions\ActionGroup::make([ + Tables\Actions\EditAction::make() + ->mutateFormDataUsing(function (Model $record, array $data): array { + /** @var EventAward $record */ + $this->processUploadedImage($data, $record); + + return $data; + }), + ]), + ]); + } + + protected function processUploadedImage(array &$data, ?EventAward $record): void + { + if (isset($data['image_asset_path'])) { + $data['image_asset_path'] = (new ProcessUploadedImageAction())->execute( + $data['image_asset_path'], + ImageUploadType::EventAward, + ); + } else { + // If no new image was uploaded, retain the existing image. + unset($data['image_asset_path']); + } + } +} diff --git a/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php b/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php index 9b78235cd1..aa7d4055c8 100644 --- a/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php +++ b/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php @@ -100,7 +100,7 @@ public function table(Table $table): Table $event = $this->getOwnerRecord(); foreach ($data['hub_ids'] as $hubId) { $gameSet = GameSet::find($hubId); - (new AttachGamesToGameSetAction())->execute($gameSet, [$event->game->id]); + (new AttachGamesToGameSetAction())->execute($gameSet, [$event->legacyGame->id]); } }), ]) @@ -116,7 +116,7 @@ public function table(Table $table): Table /** @var Event $event */ $event = $this->getOwnerRecord(); - (new DetachGamesFromGameSetAction())->execute($gameSetToDetach, [$event->game->id]); + (new DetachGamesFromGameSetAction())->execute($gameSetToDetach, [$event->legacyGame->id]); }), Tables\Actions\Action::make('visit') @@ -138,7 +138,7 @@ public function table(Table $table): Table $event = $this->getOwnerRecord(); foreach ($gameSets as $gameSet) { - (new DetachGamesFromGameSetAction())->execute($gameSet, [$event->game->id]); + (new DetachGamesFromGameSetAction())->execute($gameSet, [$event->legacyGame->id]); } $this->deselectAllTableRecords(); diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php index ca62b13d4c..f643dd68eb 100644 --- a/app/Filament/Resources/GameResource.php +++ b/app/Filament/Resources/GameResource.php @@ -530,6 +530,8 @@ public static function getRecordSubNavigation(Page $page): array { return $page->generateNavigationItems([ Pages\Details::class, + Pages\Hubs::class, + Pages\SimilarGames::class, Pages\Hashes::class, Pages\AuditLog::class, ]); @@ -542,6 +544,8 @@ public static function getPages(): array 'create' => Pages\Create::route('/create'), 'view' => Pages\Details::route('/{record}'), 'edit' => Pages\Edit::route('/{record}/edit'), + 'hubs' => Pages\Hubs::route('/{record}/hubs'), + 'similar-games' => Pages\SimilarGames::route('/{record}/similar-games'), 'hashes' => Pages\Hashes::route('/{record}/hashes'), 'audit-log' => Pages\AuditLog::route('/{record}/audit-log'), ]; diff --git a/app/Filament/Resources/GameResource/Pages/Hubs.php b/app/Filament/Resources/GameResource/Pages/Hubs.php new file mode 100644 index 0000000000..4afc2f0795 --- /dev/null +++ b/app/Filament/Resources/GameResource/Pages/Hubs.php @@ -0,0 +1,166 @@ +can('manage', GameSet::class); + } + + public static function getNavigationBadge(): ?string + { + return (string) Livewire::current()->getRecord()->hubs->count(); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('badge_url') + ->label('') + ->size(config('media.icon.sm.width')), + + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable(), + + Tables\Columns\TextColumn::make('title') + ->label('Title') + ->searchable(), + ]) + ->filters([ + + ]) + ->headerActions([ + Tables\Actions\Action::make('add') + ->label('Add hubs') + ->form([ + Forms\Components\Select::make('hub_ids') + ->label('Hubs') + ->multiple() + ->options(function () { + return GameSet::whereType(GameSetType::Hub) + ->whereNotIn('id', $this->getOwnerRecord()->hubs->pluck('id')) + ->limit(50) + ->get() + ->mapWithKeys(fn ($hub) => [$hub->id => "[{$hub->id}] {$hub->title}"]); + }) + ->getOptionLabelsUsing(function (array $values): array { + return GameSet::whereType(GameSetType::Hub) + ->whereIn('id', $values) + ->get() + ->mapWithKeys(fn ($hub) => [$hub->id => "[{$hub->id}] {$hub->title}"]) + ->toArray(); + }) + ->searchable() + ->getSearchResultsUsing(function (string $search) { + return GameSet::whereType(GameSetType::Hub) + ->whereNotIn('id', $this->getOwnerRecord()->hubs->pluck('id')) + ->where(function ($query) use ($search) { + $query->where('id', 'LIKE', "%{$search}%") + ->orWhere('title', 'LIKE', "%{$search}%"); + }) + ->limit(50) + ->get() + ->mapWithKeys(fn ($hub) => [$hub->id => "[{$hub->id}] {$hub->title}"]); + }), + ]) + ->modalHeading('Add hubs to game') + ->modalAutofocus(false) + ->action(function (array $data): void { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + + $gameSets = GameSet::whereType(GameSetType::Hub) + ->whereIn('id', $data['hub_ids']) + ->get(); + + foreach ($gameSets as $gameSet) { + (new AttachGamesToGameSetAction())->execute($gameSet, [$game->id]); + } + }), + ]) + ->actions([ + Tables\Actions\Action::make('remove') + ->tooltip('Remove') + ->icon('heroicon-o-trash') + ->iconButton() + ->requiresConfirmation() + ->color('danger') + ->modalHeading('Remove hub from game') + ->action(function (GameSet $gameSet): void { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + + (new DetachGamesFromGameSetAction())->execute($gameSet, [$game->id]); + + Notification::make() + ->success() + ->title('Success') + ->body('Removed hub from the game.') + ->send(); + }), + + Tables\Actions\Action::make('visit') + ->tooltip('View on Site') + ->icon('heroicon-m-arrow-top-right-on-square') + ->iconButton() + ->url(fn (GameSet $record): string => route('hub.show', $record)) + ->openUrlInNewTab(), + ]) + ->bulkActions([ + Tables\Actions\BulkAction::make('remove') + ->label('Remove selected') + ->modalHeading('Remove selected hubs from game') + ->modalDescription('Are you sure you would like to do this?') + ->requiresConfirmation() + ->color('danger') + ->action(function (Collection $gameSets): void { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + + foreach ($gameSets as $gameSet) { + (new DetachGamesFromGameSetAction())->execute($gameSet, [$game->id]); + } + + $this->deselectAllTableRecords(); + + Notification::make() + ->success() + ->title('Success') + ->body('Removed selected hubs from the game.') + ->send(); + }), + ]); + } +} diff --git a/app/Filament/Resources/GameResource/Pages/SimilarGames.php b/app/Filament/Resources/GameResource/Pages/SimilarGames.php new file mode 100644 index 0000000000..74ba408030 --- /dev/null +++ b/app/Filament/Resources/GameResource/Pages/SimilarGames.php @@ -0,0 +1,197 @@ +can('manage', GameSet::class); + } + + public static function getNavigationBadge(): ?string + { + return (string) Livewire::current()->getRecord()->similarGamesList->count(); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('badge_url') + ->label('') + ->size(config('media.icon.sm.width')), + + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable(), + + Tables\Columns\TextColumn::make('Title') + ->sortable() + ->searchable(), + + Tables\Columns\TextColumn::make('system') + ->label('System') + ->formatStateUsing(fn (System $state) => "[{$state->id}] {$state->name}") + ->url(function (System $state) { + if (request()->user()->can('manage', System::class)) { + return SystemResource::getUrl('view', ['record' => $state->id]); + } + + return null; + }), + + Tables\Columns\TextColumn::make('achievements_published') + ->label('Achievements (Published)') + ->numeric() + ->sortable() + ->alignEnd(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('achievements_published') + ->label('Has core set') + ->placeholder('Any') + ->trueLabel('Yes') + ->falseLabel('No') + ->queries( + true: fn (Builder $query): Builder => $query->where('achievements_published', '>=', 6), + false: fn (Builder $query): Builder => $query->where('achievements_published', '<', 6), + blank: fn (Builder $query): Builder => $query, + ), + ]) + ->headerActions([ + Tables\Actions\Action::make('add') + ->label('Add similar games') + ->form([ + Forms\Components\Select::make('game_ids') + ->label('Games') + ->multiple() + ->options(function () { + return Game::whereNot('ID', $this->getOwnerRecord()->id) + ->limit(50) + ->with('system') + ->get() + ->mapWithKeys(fn ($game) => [$game->id => "[{$game->id}] {$game->title} ({$game->system->name})"]); + }) + ->getOptionLabelsUsing(function (array $values): array { + return Game::whereIn('ID', $values) + ->with('system') + ->get() + ->mapWithKeys(fn ($game) => [$game->id => "[{$game->id}] {$game->title} ({$game->system->name})"]) + ->toArray(); + }) + ->searchable() + ->getSearchResultsUsing(function (string $search) { + return Game::whereNot('ID', $this->getOwnerRecord()->id) + ->where(function ($query) use ($search) { + $query->where('ID', 'LIKE', "%{$search}%") + ->orWhere('Title', 'LIKE', "%{$search}%"); + }) + ->limit(50) + ->with('system') + ->get() + ->mapWithKeys(fn ($game) => [$game->id => "[{$game->id}] {$game->title} ({$game->system->name})"]); + }), + ]) + ->modalHeading('Add similar games') + ->modalAutofocus(false) + ->action(function (array $data): void { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + + (new LinkSimilarGamesAction())->execute($game, $data['game_ids']); + + Notification::make() + ->success() + ->title('Success') + ->body('Added similar game.') + ->send(); + }), + ]) + ->actions([ + Tables\Actions\Action::make('remove') + ->tooltip('Remove') + ->icon('heroicon-o-trash') + ->iconButton() + ->requiresConfirmation() + ->color('danger') + ->modalHeading('Remove similar game') + ->action(function (Game $similarGame): void { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + + (new UnlinkSimilarGamesAction())->execute($game, [$similarGame->id]); + + Notification::make() + ->success() + ->title('Success') + ->body('Removed similar game.') + ->send(); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkAction::make('remove') + ->label('Remove selected') + ->modalHeading('Remove selected similar games') + ->modalDescription('Are you sure you would like to do this?') + ->requiresConfirmation() + ->color('danger') + ->action(function (Collection $similarGames): void { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + + (new UnlinkSimilarGamesAction())->execute( + $game, + $similarGames->pluck('id')->toArray() + ); + + $this->deselectAllTableRecords(); + + Notification::make() + ->success() + ->title('Success') + ->body('Removed selected similar games.') + ->send(); + }), + ]); + } + + /** + * @param Builder $query + * @return Builder + */ + protected function modifyQueryWithActiveTab(Builder $query): Builder + { + return $query->with(['system']); + } +} diff --git a/app/Filament/Resources/HubResource/RelationManagers/GamesRelationManager.php b/app/Filament/Resources/HubResource/RelationManagers/GamesRelationManager.php index 04e43ab95e..e0690f3353 100644 --- a/app/Filament/Resources/HubResource/RelationManagers/GamesRelationManager.php +++ b/app/Filament/Resources/HubResource/RelationManagers/GamesRelationManager.php @@ -12,6 +12,7 @@ use App\Platform\Actions\AttachGamesToGameSetAction; use App\Platform\Actions\DetachGamesFromGameSetAction; use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -189,6 +190,12 @@ public function table(Table $table): Table $gameSet = $this->getOwnerRecord(); (new DetachGamesFromGameSetAction())->execute($gameSet, [$game->id]); + + Notification::make() + ->success() + ->title('Success') + ->body('Removed game from the hub.') + ->send(); }), Tables\Actions\Action::make('visit') @@ -213,6 +220,12 @@ public function table(Table $table): Table (new DetachGamesFromGameSetAction())->execute($gameSet, $games->pluck('id')->toArray()); $this->deselectAllTableRecords(); + + Notification::make() + ->success() + ->title('Success') + ->body('Removed selected games from the hub.') + ->send(); }), ]) ->paginated([50, 100, 150]); diff --git a/app/Filament/Resources/RoleResource/RelationManager/Users.php b/app/Filament/Resources/RoleResource/RelationManager/Users.php index 67ac05b3b8..c515f78724 100644 --- a/app/Filament/Resources/RoleResource/RelationManager/Users.php +++ b/app/Filament/Resources/RoleResource/RelationManager/Users.php @@ -25,9 +25,12 @@ public function table(Table $table): Table ->label('') ->size(config('media.icon.sm.width')) ->url(fn (User $record) => UserResource::getUrl('view', ['record' => $record])), - Tables\Columns\TextColumn::make('User') + + Tables\Columns\TextColumn::make('display_name') + ->label('User') ->url(fn (User $record) => UserResource::getUrl('view', ['record' => $record])) ->grow(true), + ]) ->defaultSort('User', 'asc') ->headerActions([ diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index c9b2714f1b..296e3d9639 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -32,18 +32,21 @@ class UserResource extends Resource protected static ?int $navigationSort = 1; - protected static ?string $recordRouteKeyName = 'User'; - - protected static ?string $recordTitleAttribute = 'username'; + protected static ?string $recordTitleAttribute = 'display_name'; protected static int $globalSearchResultsLimit = 5; + public static function resolveRecordRouteBinding(int|string $key): ?Model + { + return User::whereName($key)->first(); + } + /** * @param User $record */ public static function getGlobalSearchResultTitle(Model $record): string|Htmlable { - return $record->User; + return $record->display_name; } public static function getGlobalSearchResultDetails(Model $record): array @@ -102,6 +105,10 @@ public static function infolist(Infolist $infolist): Infolist ]), Infolists\Components\Group::make() ->schema([ + Infolists\Components\TextEntry::make('username') + ->label('Original Username') + ->hidden(fn (User $record) => $record->display_name === $record->username), + Infolists\Components\TextEntry::make('canonical_url') ->label('Canonical URL') ->url(fn (User $record): string => $record->getCanonicalUrlAttribute()) @@ -222,15 +229,11 @@ public static function table(Table $table): Table ->searchable() ->sortable(), - Tables\Columns\TextColumn::make('User') - ->description(fn (User $record): string => $record->display_name) + Tables\Columns\TextColumn::make('display_name') + ->description(fn (User $record): string => $record->username !== $record->display_name ? $record->username : '') ->label('Username') ->searchable(), - Tables\Columns\TextColumn::make('display_name') - ->searchable() - ->toggleable(isToggledHiddenByDefault: true), - // Tables\Columns\TextColumn::make('email_verified_at') // ->dateTime() // ->sortable() diff --git a/app/Helpers/database/achievement.php b/app/Helpers/database/achievement.php index 8ad8c6fd06..c936edab65 100644 --- a/app/Helpers/database/achievement.php +++ b/app/Helpers/database/achievement.php @@ -5,6 +5,7 @@ use App\Models\Achievement; use App\Models\User; use App\Platform\Actions\SyncAchievementSetOrderColumnsFromDisplayOrdersAction; +use App\Platform\Actions\UpsertTriggerVersionAction; use App\Platform\Enums\AchievementAuthorTask; use App\Platform\Enums\AchievementFlag; use App\Platform\Enums\AchievementPoints; @@ -184,7 +185,7 @@ function GetAchievementData(int $achievementId): ?array 'TrueRatio' => $achievement->points_weighted, 'Flags' => $achievement->Flags, 'type' => $achievement->type, - 'Author' => $achievement->developer?->User, + 'Author' => $achievement->developer?->display_name, 'DateCreated' => $achievement->DateCreated->format('Y-m-d H:i:s'), 'DateModified' => $achievement->DateModified->format('Y-m-d H:i:s'), 'BadgeName' => $achievement->badge_name, @@ -216,7 +217,7 @@ function UploadNewAchievement( $consoleName = $gameData['ConsoleName']; $isEventGame = $consoleName == 'Events'; - $author = User::firstWhere('User', $authorUsername); + $author = User::whereName($authorUsername)->first(); $authorPermissions = (int) $author?->getAttribute('Permissions'); // Prevent <= registered users from uploading or modifying achievements @@ -299,6 +300,14 @@ function UploadNewAchievement( $achievement->save(); $idInOut = $achievement->ID; + // It's a new achievement, so create the initial trigger version. + (new UpsertTriggerVersionAction())->execute( + $achievement, + $mem, + versioned: $flag === AchievementFlag::OfficialCore->value, + user: $author + ); + $achievement->ensureAuthorshipCredit($author, AchievementAuthorTask::Logic); static_addnewachievement($idInOut); @@ -306,8 +315,8 @@ function UploadNewAchievement( "Server", ArticleType::Achievement, $idInOut, - "$authorUsername uploaded this achievement.", - $authorUsername + "{$author->display_name} uploaded this achievement.", + $author->display_name ); return true; @@ -390,6 +399,21 @@ function UploadNewAchievement( if ($changingLogic) { $achievement->ensureAuthorshipCredit($author, AchievementAuthorTask::Logic); + + (new UpsertTriggerVersionAction())->execute( + $achievement, + $achievement->MemAddr, + versioned: $achievement->Flags === AchievementFlag::OfficialCore->value, + user: $author + ); + } elseif ($changingAchSet && $achievement->trigger && $achievement->Flags === AchievementFlag::OfficialCore->value) { + // If only flags changed, re-version the existing trigger (if it exists). + (new UpsertTriggerVersionAction())->execute( + $achievement, + $achievement->trigger->conditions, + versioned: true, + user: $author + ); } if ($changingAchSet) { @@ -398,16 +422,16 @@ function UploadNewAchievement( "Server", ArticleType::Achievement, $idInOut, - "$authorUsername promoted this achievement to the Core set.", - $authorUsername + "{$author->display_name} promoted this achievement to the Core set.", + $author->display_name ); } elseif ($flag === AchievementFlag::Unofficial->value) { addArticleComment( "Server", ArticleType::Achievement, $idInOut, - "$authorUsername demoted this achievement to Unofficial.", - $authorUsername + "{$author->display_name} demoted this achievement to Unofficial.", + $author->display_name ); } expireGameTopAchievers($gameID); @@ -419,8 +443,8 @@ function UploadNewAchievement( "Server", ArticleType::Achievement, $idInOut, - "$authorUsername edited this achievement's $editString.", - $authorUsername + "{$author->display_name} edited this achievement's $editString.", + $author->display_name ); } } diff --git a/app/Helpers/database/code-note.php b/app/Helpers/database/code-note.php index 30f334dd23..9721f6eb78 100644 --- a/app/Helpers/database/code-note.php +++ b/app/Helpers/database/code-note.php @@ -6,7 +6,7 @@ function loadCodeNotes(int $gameId): ?array { - $query = "SELECT ua.User, mn.address AS Address, mn.body AS Note + $query = "SELECT ua.display_name AS User, mn.address AS Address, mn.body AS Note FROM memory_notes AS mn LEFT JOIN UserAccounts AS ua ON ua.ID = mn.user_id WHERE mn.game_id = '$gameId' @@ -47,7 +47,7 @@ function getCodeNotes(int $gameId, ?array &$codeNotesOut): bool function submitCodeNote2(string $username, int $gameID, int $address, string $note): bool { /** @var ?User $user */ - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user?->can('create', MemoryNote::class)) { return false; @@ -87,10 +87,8 @@ function submitCodeNote2(string $username, int $gameID, int $address, string $no /** * Gets the number of code notes created for each game the user has created any notes for. */ -function getCodeNoteCounts(string $username): array +function getCodeNoteCounts(User $user): array { - /** @var ?User $user */ - $user = User::firstWhere('User', $username); $userId = $user->ID; $retVal = []; diff --git a/app/Helpers/database/forum.php b/app/Helpers/database/forum.php index 5140061995..8874c60203 100644 --- a/app/Helpers/database/forum.php +++ b/app/Helpers/database/forum.php @@ -236,7 +236,7 @@ function notifyUsersAboutForumActivity(int $topicID, string $topicTitle, User $a $urlTarget = "viewtopic.php?t=$topicID&c=$commentID#$commentID"; foreach ($subscribers as $sub) { - sendActivityEmail($sub['User'], $sub['EmailAddress'], $topicID, $author->User, ArticleType::Forum, $topicTitle, $urlTarget, payload: $payload); + sendActivityEmail($sub['User'], $sub['EmailAddress'], $topicID, $author->display_name, ArticleType::Forum, $topicTitle, $urlTarget, payload: $payload); } } diff --git a/app/Helpers/database/game.php b/app/Helpers/database/game.php index b019769e40..1b1c11d057 100644 --- a/app/Helpers/database/game.php +++ b/app/Helpers/database/game.php @@ -6,7 +6,7 @@ use App\Models\Game; use App\Models\User; use App\Platform\Actions\TrimGameMetadataAction; -use App\Platform\Actions\UpdateGameSetFromGameAlternativesModificationAction; +use App\Platform\Actions\UpsertTriggerVersionAction; use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction; use App\Platform\Enums\AchievementFlag; use Illuminate\Support\Facades\Log; @@ -118,7 +118,7 @@ function getGameMetadata( ach.Description, ach.Points, ach.TrueRatio, - ua.User AS Author, + COALESCE(ua.display_name, ua.User) AS Author, ach.DateModified, ach.DateCreated, ach.BadgeName, @@ -585,7 +585,7 @@ function getGameIDFromTitle(string $gameTitle, int $consoleID): int } function modifyGameData( - string $username, + User $user, int $gameId, ?string $developer, ?string $publisher, @@ -630,7 +630,7 @@ function modifyGameData( "Server", ArticleType::GameModification, $gameId, - "{$username} changed the " . + "{$user->display_name} changed the " . implode(", ", $modifications) . (count($modifications) == 1 ? " field" : " fields"), ); @@ -644,6 +644,7 @@ function modifyGameTitle(string $username, int $gameId, string $value): bool return false; } + $user = User::whereName($username)->first(); $game = Game::find($gameId); if (!$game) { return false; @@ -664,86 +665,12 @@ function modifyGameTitle(string $username, int $gameId, string $value): bool if ($game->isDirty()) { $game->save(); - addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the game name"); + addArticleComment('Server', ArticleType::GameModification, $gameId, "{$user->display_name} changed the game name"); } return true; } -function modifyGameAlternatives(string $user, int $gameID, int|string|null $toAdd = null, int|string|array|null $toRemove = null): void -{ - $arrayFromParameter = function ($parameter): array { - $ids = []; - if (is_int($parameter)) { - $ids[] = $parameter; - } elseif (is_string($parameter)) { - // Replace all non-numeric characters with comma so the string has a common delimiter. - $toAdd = preg_replace("/[^0-9]+/", ",", $parameter); - $tok = strtok($toAdd, ","); - while ($tok !== false && $tok > 0) { - $ids[] = (int) $tok; - $tok = strtok(","); - } - } elseif (is_array($parameter)) { - foreach ($parameter as $id) { - $ids[] = (int) $id; - } - } - - return $ids; - }; - - $createAuditLogEntries = function (string $action, array $ids) use ($user, $gameID) { - $message = (count($ids) == 1) ? "$user $action related game id " . $ids[0] : - "$user $action related game ids: " . implode(', ', $ids); - - addArticleComment('Server', ArticleType::GameModification, $gameID, $message); - - $message = "$user $action related game id $gameID"; - foreach ($ids as $id) { - addArticleComment('Server', ArticleType::GameModification, $id, $message); - } - }; - - if (!empty($toAdd)) { - $ids = $arrayFromParameter($toAdd); - if (!empty($ids)) { - $valuesArray = []; - foreach ($ids as $id) { - $valuesArray[] = "({$gameID}, {$id}), ({$id}, {$gameID})"; - } - $values = implode(", ", $valuesArray); - - $query = "INSERT INTO GameAlternatives (gameID, gameIDAlt) VALUES $values ON DUPLICATE KEY UPDATE Updated = CURRENT_TIMESTAMP"; - s_mysql_query($query); - - $createAuditLogEntries('added', $ids); - - // Double writes to game_sets. - foreach ($ids as $childId) { - (new UpdateGameSetFromGameAlternativesModificationAction())->execute($gameID, $childId); - } - } - } - - if (!empty($toRemove)) { - $ids = $arrayFromParameter($toRemove); - if (!empty($ids)) { - $values = implode(',', $ids); - $query = "DELETE FROM GameAlternatives - WHERE ( gameID = $gameID AND gameIDAlt IN ($values) ) || ( gameIDAlt = $gameID AND gameID IN ($values) )"; - s_mysql_query($query); - - $createAuditLogEntries('removed', $ids); - - // Double writes to game_sets. - foreach ($ids as $childId) { - (new UpdateGameSetFromGameAlternativesModificationAction())->execute($gameID, $childId, isAttaching: false); - } - } - } -} - function modifyGameForumTopic(string $username, int $gameId, int $newForumTopicId): bool { if ($gameId == 0 || $newForumTopicId == 0) { @@ -754,6 +681,7 @@ function modifyGameForumTopic(string $username, int $gameId, int $newForumTopicI return false; } + $user = User::whereName($username)->first(); $game = Game::find($gameId); if (!$game) { return false; @@ -762,7 +690,7 @@ function modifyGameForumTopic(string $username, int $gameId, int $newForumTopicI $game->ForumTopicID = $newForumTopicId; $game->save(); - addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the forum topic"); + addArticleComment('Server', ArticleType::GameModification, $gameId, "{$user->display_name} changed the forum topic"); return true; } @@ -850,7 +778,7 @@ function submitNewGameTitleJSON( $retVal['GameTitle'] = $titleIn; $retVal['Success'] = true; - $userModel = User::where('User', $username)->first(); + $userModel = User::whereName($username)->first(); $permissions = (int) $userModel->getAttribute('Permissions'); $userId = $userModel->id; CauserResolver::setCauser($userModel); @@ -920,9 +848,9 @@ function submitNewGameTitleJSON( // Log hash linked if (!empty($unsanitizedDescription)) { - addArticleComment("Server", ArticleType::GameHash, $gameID, $md5 . " linked by " . $username . ". Description: \"" . $unsanitizedDescription . "\""); + addArticleComment("Server", ArticleType::GameHash, $gameID, $md5 . " linked by " . $userModel->display_name . ". Description: \"" . $unsanitizedDescription . "\""); } else { - addArticleComment("Server", ArticleType::GameHash, $gameID, $md5 . " linked by " . $username); + addArticleComment("Server", ArticleType::GameHash, $gameID, $md5 . " linked by " . $userModel->display_name); } } else { /* @@ -937,7 +865,7 @@ function submitNewGameTitleJSON( return $retVal; } -function modifyGameRichPresence(string $username, int $gameId, string $dataIn): bool +function modifyGameRichPresence(User $user, int $gameId, string $dataIn): bool { getRichPresencePatch($gameId, $existingData); if ($existingData == $dataIn) { @@ -952,7 +880,14 @@ function modifyGameRichPresence(string $username, int $gameId, string $dataIn): $game->RichPresencePatch = $dataIn; $game->save(); - addArticleComment('Server', ArticleType::GameModification, $gameId, "{$username} changed the rich presence script"); + (new UpsertTriggerVersionAction())->execute( + $game, + $dataIn, + versioned: true, // rich presence is always published + user: $user + ); + + addArticleComment('Server', ArticleType::GameModification, $gameId, "{$user->display_name} changed the rich presence script"); return true; } diff --git a/app/Helpers/database/leaderboard.php b/app/Helpers/database/leaderboard.php index 30f003ae29..384beb17fa 100644 --- a/app/Helpers/database/leaderboard.php +++ b/app/Helpers/database/leaderboard.php @@ -7,6 +7,7 @@ use App\Models\LeaderboardEntry; use App\Models\User; use App\Platform\Actions\ResumePlayerSessionAction; +use App\Platform\Actions\UpsertTriggerVersionAction; use App\Platform\Enums\ValueFormat; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -190,6 +191,7 @@ function GetLeaderboardData( $retVal['Entries'][] = [ 'User' => $entry->user->display_name, + 'AvatarUrl' => $entry->user->avatar_url, 'DateSubmitted' => $entry->updated_at->unix(), 'Score' => $entry->score, 'Rank' => $rank, @@ -309,14 +311,14 @@ function UploadNewLeaderboard( $displayOrder = 0; $originalAuthor = null; + /** @var ?Leaderboard $foundLeaderboard */ + $foundLeaderboard = null; + if ($idInOut > 0) { $foundLeaderboard = Leaderboard::find($idInOut); if ($foundLeaderboard) { $displayOrder = $foundLeaderboard->order_column; $originalAuthor = $foundLeaderboard->developer; - - $data['DisplayOrder'] = $displayOrder; - $data['Author'] = $originalAuthor?->display_name ?? "Unknown"; } else { $errorOut = "Unknown leaderboard"; @@ -324,7 +326,7 @@ function UploadNewLeaderboard( } } - $authorModel = User::firstWhere('User', $authorUsername); + $authorModel = User::whereName($authorUsername)->first(); // Prevent non-developers from uploading or modifying leaderboards $userPermissions = (int) $authorModel->getAttribute('Permissions'); @@ -361,7 +363,6 @@ function UploadNewLeaderboard( $foundLeaderboard = Leaderboard::find($idInOut); if ($foundLeaderboard) { $displayOrder = $foundLeaderboard->order_column; - $data['DisplayOrder'] = $displayOrder; } } @@ -377,5 +378,12 @@ function UploadNewLeaderboard( ); } + (new UpsertTriggerVersionAction())->execute( + $foundLeaderboard, + $mem, + versioned: true, // we don't currently support unpublished leaderboards + user: $authorModel, + ); + return true; } diff --git a/app/Helpers/database/player-achievement.php b/app/Helpers/database/player-achievement.php index b9ad53b403..3285fd83c1 100644 --- a/app/Helpers/database/player-achievement.php +++ b/app/Helpers/database/player-achievement.php @@ -147,13 +147,13 @@ function getAchievementUnlocksData( return PlayerAchievement::where('achievement_id', $achievementId) ->join('UserAccounts', 'UserAccounts.ID', '=', 'user_id') ->orderByRaw('COALESCE(unlocked_hardcore_at, unlocked_at) DESC') - ->select(['UserAccounts.User', 'UserAccounts.RAPoints', 'UserAccounts.RASoftcorePoints', 'unlocked_at', 'unlocked_hardcore_at']) + ->select(['UserAccounts.User', 'UserAccounts.display_name', 'UserAccounts.RAPoints', 'UserAccounts.RASoftcorePoints', 'unlocked_at', 'unlocked_hardcore_at']) ->offset($offset) ->limit($limit) ->get() ->map(function ($row) { return [ - 'User' => $row->User, + 'User' => !empty($row->display_name) ? $row->display_name : $row->User, 'RAPoints' => $row->RAPoints, 'RASoftcorePoints' => $row->RASoftcorePoints, 'DateAwarded' => $row->unlocked_hardcore_at ?? $row->unlocked_at, @@ -197,7 +197,7 @@ function getRecentUnlocksPlayersData( } // Get recent winners, and their most recent activity: - $query = "SELECT u.User, u.RAPoints, " . unixTimestampStatement('pa.unlocked_at', 'DateAwarded') . " + $query = "SELECT u.User, u.display_name AS DisplayName, u.RAPoints, " . unixTimestampStatement('pa.unlocked_at', 'DateAwarded') . " FROM player_achievements AS pa LEFT JOIN UserAccounts AS u ON u.ID = pa.user_id WHERE pa.achievement_id = $achID $extraWhere @@ -205,6 +205,10 @@ function getRecentUnlocksPlayersData( LIMIT $offset, $count"; foreach (legacyDbFetchAll($query) as $db_entry) { + $db_entry['AvatarUrl'] = media_asset('UserPic/' . $db_entry['User'] . '.png'); + $db_entry['User'] = $db_entry['DisplayName']; + unset($db_entry['DisplayName']); + $db_entry['RAPoints'] = (int) $db_entry['RAPoints']; $db_entry['DateAwarded'] = (int) $db_entry['DateAwarded']; $retVal['RecentWinner'][] = $db_entry; diff --git a/app/Helpers/database/player-game.php b/app/Helpers/database/player-game.php index c0775c965f..d656743ae5 100644 --- a/app/Helpers/database/player-game.php +++ b/app/Helpers/database/player-game.php @@ -206,7 +206,7 @@ function getUserProgress(User $user, array $gameIDs, int $numRecentAchievements function getUserAchievementUnlocksForGame(User|string $user, int $gameID, AchievementFlag $flag = AchievementFlag::OfficialCore): array { - $user = is_string($user) ? User::firstWhere('User', $user) : $user; + $user = is_string($user) ? User::whereName($user)->first() : $user; $playerAchievements = $user ->playerAchievements() @@ -327,11 +327,11 @@ function getUsersCompletedGamesAndMax(string $user): array LEFT JOIN GameData AS gd ON gd.ID = pg.game_id LEFT JOIN Console AS c ON c.ID = gd.ConsoleID LEFT JOIN UserAccounts ua ON ua.ID = pg.user_id - WHERE ua.User = :user + WHERE (ua.User = :user OR ua.display_name = :user2) AND gd.achievements_published > $minAchievementsForCompletion ORDER BY PctWon DESC, PctWonHC DESC, MaxPossible DESC, gd.Title"; - return legacyDbFetchAll($query, ['user' => $user])->toArray(); + return legacyDbFetchAll($query, ['user' => $user, 'user2' => $user])->toArray(); } function getGameRecentPlayers(int $gameID, int $maximum_results = 10): array @@ -353,7 +353,7 @@ function getGameRecentPlayers(int $gameID, int $maximum_results = 10): array ->join('UserAccounts', 'UserAccounts.ID', '=', 'player_sessions.user_id') ->where('UserAccounts.Permissions', '>=', Permissions::Unregistered) ->orderBy('rich_presence_updated_at', 'DESC') - ->select(['player_sessions.user_id', 'User', 'player_sessions.rich_presence', 'player_sessions.rich_presence_updated_at']); + ->select(['player_sessions.user_id', 'display_name', 'player_sessions.rich_presence', 'player_sessions.rich_presence_updated_at']); if ($maximum_results) { $sessions = $sessions->limit($maximum_results); @@ -362,7 +362,7 @@ function getGameRecentPlayers(int $gameID, int $maximum_results = 10): array foreach ($sessions->get() as $session) { $retval[] = [ 'UserID' => $session->user_id, - 'User' => $session->User, + 'User' => $session->display_name, 'Date' => $session->rich_presence_updated_at->__toString(), 'Activity' => $session->rich_presence, 'NumAwarded' => 0, diff --git a/app/Helpers/database/player-history.php b/app/Helpers/database/player-history.php index e3d9cbdf46..cc7baece77 100644 --- a/app/Helpers/database/player-history.php +++ b/app/Helpers/database/player-history.php @@ -61,7 +61,8 @@ function getAchievementsEarnedBetween(string $dateStart, string $dateEnd, User $ $query = "SELECT COALESCE(pa.unlocked_hardcore_at, pa.unlocked_at) AS Date, CASE WHEN pa.unlocked_hardcore_at IS NOT NULL THEN 1 ELSE 0 END AS HardcoreMode, ach.ID AS AchievementID, ach.Title, ach.Description, - ach.BadgeName, ach.Points, ach.TrueRatio, ach.type as Type, ua.User AS Author, + ach.BadgeName, ach.Points, ach.TrueRatio, ach.type as Type, + COALESCE(ua.display_name, ua.User) AS Author, gd.Title AS GameTitle, gd.ImageIcon AS GameIcon, ach.GameID, c.Name AS ConsoleName FROM player_achievements pa diff --git a/app/Helpers/database/player-rank.php b/app/Helpers/database/player-rank.php index e35857db82..bf9be831ea 100644 --- a/app/Helpers/database/player-rank.php +++ b/app/Helpers/database/player-rank.php @@ -12,35 +12,11 @@ function SetUserUntrackedStatus(string $usernameIn, int $isUntracked): void { legacyDbStatement("UPDATE UserAccounts SET Untracked = $isUntracked, Updated=NOW() WHERE User = '$usernameIn'"); - PlayerRankedStatusChanged::dispatch(User::firstWhere('User', $usernameIn), (bool) $isUntracked); + PlayerRankedStatusChanged::dispatch(User::whereName($usernameIn)->first(), (bool) $isUntracked); // TODO update games that are affected by this user's library } -/** - * @deprecated take from authenticated user directly - */ -function getPlayerPoints(?string $user, ?array &$dataOut): bool -{ - if (empty($user) || !isValidUsername($user)) { - return false; - } - - $query = "SELECT ua.RAPoints, ua.RASoftcorePoints - FROM UserAccounts AS ua - WHERE ua.User=:username"; - - $dataOut = legacyDbFetch($query, ['username' => $user]); - if ($dataOut) { - $dataOut['RAPoints'] = (int) $dataOut['RAPoints']; - $dataOut['RASoftcorePoints'] = (int) $dataOut['RASoftcorePoints']; - - return true; - } - - return false; -} - function countRankedUsers(int $type = RankType::Hardcore): int { return Cache::remember("rankedUserCount:$type", @@ -69,21 +45,18 @@ function () use ($type) { function getTopUsersByScore(int $count): array { - if ($count > 10) { - $count = 10; - } - - $query = "SELECT User, RAPoints, TrueRAPoints - FROM UserAccounts AS ua - WHERE NOT ua.Untracked - ORDER BY RAPoints DESC, TrueRAPoints DESC - LIMIT 0, $count "; - - return legacyDbFetchAll($query)->map(fn ($row) => [ - 1 => $row['User'], - 2 => $row['RAPoints'], - 3 => $row['TrueRAPoints'], - ])->toArray(); + return User::select(['display_name', 'User', 'RAPoints', 'TrueRAPoints']) + ->where('Untracked', false) + ->orderBy('RAPoints', 'desc') + ->orderBy('TrueRAPoints', 'desc') + ->take(min($count, 10)) + ->get() + ->map(fn ($user) => [ + 1 => $user->display_name ?? $user->User, + 2 => $user->RAPoints, + 3 => $user->TrueRAPoints, + ]) + ->toArray(); } /** @@ -94,7 +67,7 @@ function getUserRank(string $username, int $type = RankType::Hardcore): ?int $key = CacheKey::buildUserRankCacheKey($username, $type); return Cache::remember($key, Carbon::now()->addMinutes(15), function () use ($username, $type) { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user || $user->Untracked) { return null; } diff --git a/app/Helpers/database/search.php b/app/Helpers/database/search.php index f195deffea..fd447a7501 100644 --- a/app/Helpers/database/search.php +++ b/app/Helpers/database/search.php @@ -69,14 +69,14 @@ function performSearch( } if (in_array(SearchType::User, $searchType)) { - $counts[] = "SELECT COUNT(*) AS Count FROM UserAccounts WHERE User LIKE '%$searchQuery%'"; + $counts[] = "SELECT COUNT(*) AS Count FROM UserAccounts WHERE display_name LIKE '%$searchQuery%'"; $parts[] = " - SELECT " . SearchType::User . " AS Type, ua.User AS ID, - CONCAT( '/user/', ua.User ) AS Target, ua.User AS Title, - CASE WHEN ua.User LIKE '$searchQuery%' THEN 0 ELSE 1 END AS SecondarySort - FROM UserAccounts AS ua - WHERE ua.User LIKE '%$searchQuery%' AND ua.Permissions >= 0 AND ua.Deleted IS NULL - ORDER BY SecondarySort, ua.User"; + SELECT " . SearchType::User . " AS Type, ua.display_name AS ID, + CONCAT( '/user/', ua.display_name ) AS Target, ua.display_name AS Title, + CASE WHEN ua.display_name LIKE '$searchQuery%' THEN 0 ELSE 1 END AS SecondarySort + FROM UserAccounts AS ua + WHERE ua.display_name LIKE '%$searchQuery%' AND ua.Permissions >= 0 AND ua.Deleted IS NULL + ORDER BY SecondarySort, ua.display_name"; } if (in_array(SearchType::Forum, $searchType)) { diff --git a/app/Helpers/database/set-claim.php b/app/Helpers/database/set-claim.php index 01847280a4..eec070bfed 100644 --- a/app/Helpers/database/set-claim.php +++ b/app/Helpers/database/set-claim.php @@ -250,7 +250,8 @@ function getFilteredClaims( $userCondition = ''; if (isset($username)) { $bindings['username'] = $username; - $userCondition = "AND ua.User = :username"; + $bindings['display_name'] = $username; + $userCondition = "AND (ua.User = :username OR ua.display_name = :display_name)"; } $gameCondition = ''; @@ -268,7 +269,7 @@ function getFilteredClaims( // Get either the filtered count or the filtered data $selectCondition = " sc.ID AS ID, - ua.User AS User, + COALESCE(ua.display_name, ua.User) AS User, sc.game_id AS GameID, gd.Title AS GameTitle, gd.ImageIcon AS GameIcon, diff --git a/app/Helpers/database/static.php b/app/Helpers/database/static.php index 012ca819f2..13bf5acb8e 100644 --- a/app/Helpers/database/static.php +++ b/app/Helpers/database/static.php @@ -33,7 +33,7 @@ function static_addnewregistereduser(string $user): void */ function static_addnewhardcoremastery(int $gameId, string $username): void { - $foundUser = User::firstWhere('User', $username); + $foundUser = User::whereName($username)->first(); if ($foundUser->Untracked) { return; } @@ -54,7 +54,7 @@ function static_addnewhardcoremastery(int $gameId, string $username): void */ function static_addnewhardcoregamebeaten(int $gameId, string $username): void { - $foundUser = User::firstWhere('User', $username); + $foundUser = User::whereName($username)->first(); if ($foundUser->Untracked) { return; } diff --git a/app/Helpers/database/subscription.php b/app/Helpers/database/subscription.php index e050804d19..fe60d05539 100644 --- a/app/Helpers/database/subscription.php +++ b/app/Helpers/database/subscription.php @@ -52,7 +52,11 @@ function isUserSubscribedTo(string $subjectType, int $topicID, int $userID): boo */ function getSubscribersOf(string $subjectType, int $subjectID, ?int $reqWebsitePrefs = null, ?string $implicitSubscriptionQry = null): array { - $explicitSubscribers = User::select('User', 'EmailAddress') + $explicitSubscribers = User::query() + ->select( + DB::raw('COALESCE(display_name, User) as User'), + 'EmailAddress' + ) ->whereHas('subscriptions', fn ($q) => $q ->where('subject_type', $subjectType) ->where('subject_id', $subjectID) @@ -67,7 +71,10 @@ function getSubscribersOf(string $subjectType, int $subjectID, ?int $reqWebsiteP } return DB::table(DB::raw("($implicitSubscriptionQry) as ua")) - ->select('User', 'EmailAddress') + ->select([ + DB::raw('COALESCE(ua.display_name, ua.User) as User'), + 'ua.EmailAddress', + ]) ->leftJoin('subscriptions as sub', fn ($join) => $join ->on('sub.user_id', '=', 'ua.ID') ->where('sub.subject_type', '=', $subjectType) @@ -146,13 +153,13 @@ function getSubscribersOfArticle( ?string $subjectAuthor = null, bool $noExplicitSubscriptions = false ): array { - $websitePrefsFilter = $noExplicitSubscriptions ? "AND (_ua.websitePrefs & $reqWebsitePrefs) != 0" : ""; + $websitePrefsFilter = $noExplicitSubscriptions ? "AND (_ua.websitePrefs & :websitePrefs) != 0" : ""; $authorQry = ($subjectAuthor === null ? "" : " UNION SELECT _ua.* FROM UserAccounts as _ua - WHERE _ua.User = '$subjectAuthor' + WHERE (_ua.User = :subjectAuthor OR _ua.display_name = :subjectAuthor) $websitePrefsFilter "); @@ -160,12 +167,21 @@ function getSubscribersOfArticle( SELECT DISTINCT _ua.* FROM Comment AS _c INNER JOIN UserAccounts as _ua ON _ua.ID = _c.user_id - WHERE _c.ArticleType = $articleType - AND _c.ArticleID = $articleID + WHERE _c.ArticleType = :articleType + AND _c.ArticleID = :articleID $websitePrefsFilter $authorQry "; + $bindings = [ + 'articleType' => $articleType, + 'articleID' => $articleID, + 'websitePrefs' => $reqWebsitePrefs, + ]; + if ($subjectAuthor !== null) { + $bindings['subjectAuthor'] = $subjectAuthor; + } + if ($noExplicitSubscriptions) { $dbResult = s_mysql_query($qry); if (!$dbResult) { @@ -182,11 +198,17 @@ function getSubscribersOfArticle( return []; } + // getSubscribersOf doesn't accept bindings, so just bind them here. + $preparedQry = $qry; + foreach ($bindings as $key => $value) { + $preparedQry = str_replace(":$key", DB::getPdo()->quote($value), $preparedQry); + } + return getSubscribersOf( $subjectType, $articleID, 1 << UserPreference::EmailOn_ActivityComment, // code suggests the value of $reqWebsitePrefs should be used, but the feature is disabled for now - $qry + $preparedQry, ); } diff --git a/app/Helpers/database/ticket.php b/app/Helpers/database/ticket.php index cf3290ffdf..7ad74ac1e3 100644 --- a/app/Helpers/database/ticket.php +++ b/app/Helpers/database/ticket.php @@ -28,7 +28,7 @@ function submitNewTicketsJSON( $returnMsg = []; /** @var User $user */ - $user = User::firstWhere('User', $userSubmitter); + $user = User::whereName($userSubmitter)->first(); if (!$user->exists() || !$user->can('create', Ticket::class)) { $returnMsg['Success'] = false; @@ -192,8 +192,8 @@ function getExistingTicketID(User $user, int $achievementID): int function getTicket(int $ticketID): ?array { $query = "SELECT tick.ID, tick.AchievementID, ach.Title AS AchievementTitle, ach.Description AS AchievementDesc, ach.type AS AchievementType, ach.Points, ach.BadgeName, - ua3.User AS AchievementAuthor, ach.GameID, c.Name AS ConsoleName, gd.Title AS GameTitle, gd.ImageIcon AS GameIcon, - tick.ReportedAt, tick.ReportType, tick.ReportState, tick.Hardcore, tick.ReportNotes, ua.User AS ReportedBy, tick.ResolvedAt, ua2.User AS ResolvedBy + COALESCE(ua3.display_name, ua3.User) AS AchievementAuthor, ach.GameID, c.Name AS ConsoleName, gd.Title AS GameTitle, gd.ImageIcon AS GameIcon, + tick.ReportedAt, tick.ReportType, tick.ReportState, tick.Hardcore, tick.ReportNotes, COALESCE(ua.display_name, ua.User) AS ReportedBy, tick.ResolvedAt, COALESCE(ua2.display_name, ua2.User) AS ResolvedBy FROM Ticket AS tick LEFT JOIN Achievements AS ach ON ach.ID = tick.AchievementID LEFT JOIN GameData AS gd ON gd.ID = ach.GameID @@ -209,14 +209,14 @@ function getTicket(int $ticketID): ?array function updateTicket(string $user, int $ticketID, int $ticketVal, ?string $reason = null): bool { - $userID = getUserIDFromUser($user); + $userModel = User::whereName($user)->first(); // get the ticket data before updating so we know what the previous state was $ticketData = getTicket($ticketID); $resolvedFields = ""; if ($ticketVal == TicketState::Resolved || $ticketVal == TicketState::Closed) { - $resolvedFields = ", ResolvedAt=NOW(), resolver_id=$userID "; + $resolvedFields = ", ResolvedAt=NOW(), resolver_id={$userModel->id} "; } elseif ($ticketData['ReportState'] == TicketState::Resolved || $ticketData['ReportState'] == TicketState::Closed) { $resolvedFields = ", ResolvedAt=NULL, resolver_id=NULL "; } @@ -245,25 +245,25 @@ function updateTicket(string $user, int $ticketID, int $ticketVal, ?string $reas case TicketState::Closed: if ($reason == TicketState::REASON_DEMOTED) { updateAchievementFlag($achID, AchievementFlag::Unofficial); - addArticleComment("Server", ArticleType::Achievement, $achID, "$user demoted this achievement to Unofficial.", $user); + addArticleComment("Server", ArticleType::Achievement, $achID, "{$userModel->display_name} demoted this achievement to Unofficial.", $userModel->display_name); } - $comment = "Ticket closed by $user. Reason: \"$reason\"."; + $comment = "Ticket closed by {$userModel->display_name}. Reason: \"$reason\"."; break; case TicketState::Open: if ($ticketData['ReportState'] == TicketState::Request) { - $comment = "Ticket reassigned to author by $user."; + $comment = "Ticket reassigned to author by {$userModel->display_name}."; } else { - $comment = "Ticket reopened by $user."; + $comment = "Ticket reopened by {$userModel->display_name}."; } break; case TicketState::Resolved: - $comment = "Ticket resolved as fixed by $user."; + $comment = "Ticket resolved as fixed by {$userModel->display_name}."; break; case TicketState::Request: - $comment = "Ticket reassigned to reporter by $user."; + $comment = "Ticket reassigned to reporter by {$userModel->display_name}."; break; } @@ -295,7 +295,7 @@ function updateTicket(string $user, int $ticketID, int $ticketVal, ?string $reas "
" . "$achTitle - $gameTitle ($consoleName)
" . "
" . - "The ticket you opened for the above achievement had its status changed to \"$status\" by \"$user\".
" . + "The ticket you opened for the above achievement had its status changed to \"$status\" by \"{$userModel->display_name}\".
" . "
Comment: $comment" . "
" . "Click $ticketID]) . "'>here to view the ticket" . diff --git a/app/Helpers/database/user-account-deletion.php b/app/Helpers/database/user-account-deletion.php index f03ce26897..9acb001304 100644 --- a/app/Helpers/database/user-account-deletion.php +++ b/app/Helpers/database/user-account-deletion.php @@ -1,6 +1,7 @@ first(); - $query = "UPDATE UserAccounts u SET u.DeleteRequested = NULL WHERE u.User = '$username'"; + $query = "UPDATE UserAccounts u + SET u.DeleteRequested = NULL + WHERE u.User = '$username' OR u.display_name = '$username'"; $dbResult = s_mysql_query($query); if ($dbResult !== false) { - addArticleComment('Server', ArticleType::UserModeration, $user['ID'], - $username . ' canceled account deletion' + addArticleComment('Server', ArticleType::UserModeration, $user->id, + $user->display_name . ' canceled account deletion' ); } diff --git a/app/Helpers/database/user-activity.php b/app/Helpers/database/user-activity.php index 95f7605023..af74fd16ec 100644 --- a/app/Helpers/database/user-activity.php +++ b/app/Helpers/database/user-activity.php @@ -65,12 +65,12 @@ function addArticleComment( // Note: $user is the person who just made a comment. - $userID = getUserIDFromUser($user); - if ($userID === 0) { + $user = User::whereName($user)->first(); + if (!$user) { return false; } - if ($user !== "Server" && getIsCommentDoublePost($userID, $articleID, $commentPayload)) { + if ($user !== "Server" && getIsCommentDoublePost($user->id, $articleID, $commentPayload)) { // Fail silently. return true; } @@ -80,11 +80,11 @@ function addArticleComment( $comment = Comment::create([ 'ArticleType' => $articleType, 'ArticleID' => $id, - 'user_id' => $userID, + 'user_id' => $user->id, 'Payload' => $commentPayload, ]); - informAllSubscribersAboutActivity($articleType, $id, $user, $comment->ID, $onBehalfOfUser); + informAllSubscribersAboutActivity($articleType, $id, $user->display_name, $comment->ID, $onBehalfOfUser); } return true; diff --git a/app/Helpers/database/user-auth.php b/app/Helpers/database/user-auth.php index 9a09a56a64..2c442045f7 100644 --- a/app/Helpers/database/user-auth.php +++ b/app/Helpers/database/user-auth.php @@ -25,13 +25,13 @@ function authenticateForConnect(?string $username, ?string $pass = null, ?string if ($passwordProvided) { // Password provided, validate it if (authenticateFromPassword($username, $pass)) { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); } $tokenProvided = false; // ignore token if provided } elseif ($tokenProvided) { // Token provided, look for match - $user = User::where('User', $username)->where('appToken', $token)->first(); + $user = User::whereName($username)->where('appToken', $token)->first(); } if (!$user) { @@ -82,7 +82,8 @@ function authenticateForConnect(?string $username, ?string $pass = null, ?string return [ 'Success' => true, - 'User' => $user->User, + 'User' => $user->display_name, + 'AvatarUrl' => $user->avatar_url, 'Token' => $user->appToken, 'Score' => $user->RAPoints, 'SoftcoreScore' => $user->RASoftcorePoints, @@ -99,8 +100,11 @@ function authenticateFromPassword(string &$username, string $password): bool } // use raw query to access non-visible fields - $query = "SELECT ID, User, Password, SaltedPass, Permissions FROM UserAccounts WHERE User=:user AND Deleted IS NULL"; - $row = legacyDbFetch($query, ['user' => $username]); + $query = "SELECT ID, User, Password, SaltedPass, Permissions + FROM UserAccounts + WHERE (User = :user OR display_name = :user2) + AND Deleted IS NULL"; + $row = legacyDbFetch($query, ['user' => $username, 'user2' => $username]); if (!$row) { return false; } @@ -144,7 +148,7 @@ function changePassword(string $username, string $password): string { $hashedPassword = Hash::make($password); - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); $user->Password = $hashedPassword; $user->SaltedPass = ''; @@ -219,11 +223,16 @@ function authenticateFromAppToken( /** @var ?User $user */ $user = auth('connect-token')->user(); - if (!$user || strcasecmp($user->User, $userOut) != 0) { + $doesUsernameMatch = $user && ( + strcasecmp($user->User, $userOut) == 0 + || strcasecmp($user->display_name, $userOut) == 0 + ); + + if (!$doesUsernameMatch) { return false; } - $userOut = $user->User; + $userOut = $user->User; // always normalize to the username field $permissionOut = $user->Permissions; return true; @@ -231,7 +240,7 @@ function authenticateFromAppToken( function generateAppToken(string $username, ?string &$tokenOut): bool { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user) { return false; } @@ -255,7 +264,7 @@ function newAppToken(): string function generateAPIKey(string $username): string { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user || !$user->isEmailVerified()) { return ''; } diff --git a/app/Helpers/database/user-email-verify.php b/app/Helpers/database/user-email-verify.php index 3c33d9d202..8d60bc9130 100644 --- a/app/Helpers/database/user-email-verify.php +++ b/app/Helpers/database/user-email-verify.php @@ -40,7 +40,7 @@ function validateEmailVerificationToken(string $emailCookie, ?string &$user): bo $user = User::find($emailConfirmation->user_id); // TODO delete after dropping User from EmailConfirmations if (!$user) { - $user = User::firstWhere('User', $emailConfirmation->User); + $user = User::whereName($emailConfirmation->User)->first(); } // ENDTODO delete after dropping User from EmailConfirmations diff --git a/app/Helpers/database/user-password-reset.php b/app/Helpers/database/user-password-reset.php index 052294ffa9..c8ea0c39aa 100644 --- a/app/Helpers/database/user-password-reset.php +++ b/app/Helpers/database/user-password-reset.php @@ -12,7 +12,8 @@ function isValidPasswordResetToken(string $usernameIn, string $passwordResetToke if (mb_strlen($passwordResetToken) == 20) { $query = "SELECT * FROM UserAccounts AS ua " - . "WHERE ua.User='$usernameIn' AND ua.PasswordResetToken='$passwordResetToken'"; + . "WHERE (ua.User='$usernameIn' OR ua.display_name='$usernameIn') " + . "AND ua.PasswordResetToken='$passwordResetToken'"; $dbResult = s_mysql_query($query); @@ -29,14 +30,14 @@ function isValidPasswordResetToken(string $usernameIn, string $passwordResetToke */ function RequestPasswordReset(User $user): bool { - $username = $user->username; + $username = $user->display_name; $emailAddress = $user->EmailAddress; $newToken = Str::random(20); s_mysql_query("UPDATE UserAccounts AS ua SET ua.PasswordResetToken = '$newToken', Updated=NOW() - WHERE ua.User='$username'"); + WHERE ua.User='$username' OR ua.display_name='$username'"); SendPasswordResetEmail($username, $emailAddress, $newToken); diff --git a/app/Helpers/database/user-permission.php b/app/Helpers/database/user-permission.php index 5edf52b091..6c98ffebf3 100644 --- a/app/Helpers/database/user-permission.php +++ b/app/Helpers/database/user-permission.php @@ -25,7 +25,7 @@ function SetAccountPermissionsJSON( ): array { $retVal = []; - $targetUser = User::firstWhere('User', $targetUsername); + $targetUser = User::whereName($targetUsername)->first(); if (!$targetUser) { $retVal['Success'] = false; $retVal['Error'] = "$targetUsername not found"; @@ -131,7 +131,7 @@ function setAccountForumPostAuth(User $sourceUser, int $sourcePermissions, User authorizeAllForumPostsForUser($targetUser); addArticleComment('Server', ArticleType::UserModeration, $targetUser->id, - $sourceUser->User . ' authorized user\'s forum posts' + $sourceUser->display_name . ' authorized user\'s forum posts' ); // SUCCESS! Upgraded $user to allow forum posts, authorised by $sourceUser ($sourcePermissions) @@ -145,7 +145,7 @@ function setAccountForumPostAuth(User $sourceUser, int $sourcePermissions, User */ function banAccountByUsername(string $username, int $permissions): void { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user) { return; diff --git a/app/Helpers/database/user-relationship.php b/app/Helpers/database/user-relationship.php index ea3cb3fa2e..7d26864354 100644 --- a/app/Helpers/database/user-relationship.php +++ b/app/Helpers/database/user-relationship.php @@ -78,7 +78,8 @@ function GetFriendList(User $user): array ->get() ->map(function ($friend) { return [ - 'Friend' => $friend->User, + 'Friend' => $friend->display_name, + 'AvatarUrl' => $friend->avatar_url, 'RAPoints' => $friend->points, 'LastSeen' => empty($friend->RichPresenceMsg) ? 'Unknown' : strip_tags($friend->RichPresenceMsg), 'ID' => $friend->id, @@ -97,7 +98,7 @@ function GetExtendedFriendsList(User $user): array ->get() ->map(function ($friend) { return [ - 'User' => $friend->User, + 'User' => $friend->display_name, 'Friendship' => (int) $friend->pivot->Friendship, 'LastGameID' => (int) $friend->LastGameID, 'LastSeen' => empty($friend->RichPresenceMsg) ? 'Unknown' : strip_tags($friend->RichPresenceMsg), @@ -110,7 +111,7 @@ function GetExtendedFriendsList(User $user): array function GetFriendsSubquery(string $user, bool $includeUser = true, bool $returnUserIds = false): string { - $userModel = User::firstWhere('User', $user); + $userModel = User::whereName($user)->first(); $userId = $userModel->id; $selectColumn = $returnUserIds ? 'ua.ID' : 'ua.User'; diff --git a/app/Helpers/database/user.php b/app/Helpers/database/user.php index 316f6f0474..b844ff5f4b 100644 --- a/app/Helpers/database/user.php +++ b/app/Helpers/database/user.php @@ -9,7 +9,7 @@ function GetUserData(string $username): ?array { - return User::firstWhere('User', $username)?->toArray(); + return User::whereName($username)->first()?->toArray(); } function getAccountDetails(?string &$username = null, ?array &$dataOut = []): bool @@ -46,7 +46,7 @@ function getUserIDFromUser(?string $user): int return 0; } - $userModel = User::firstWhere('User', $user); + $userModel = User::whereName($user)->first(); if (!$userModel) { return 0; } @@ -68,21 +68,24 @@ function getUserMetadataFromID(int $userID): ?array function validateUsername(string $userIn): ?string { - $user = User::firstWhere('User', $userIn); + $user = User::whereName($userIn)->first(); return ($user !== null) ? $user->User : null; } +/** + * @deprecated use Eloquent ORM + */ function getUserPageInfo(string $username, int $numGames = 0, int $numRecentAchievements = 0): array { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user) { return []; } $libraryOut = []; - $libraryOut['User'] = $user->User; + $libraryOut['User'] = $user->display_name; $libraryOut['MemberSince'] = $user->created_at->__toString(); $libraryOut['LastActivity'] = $user->LastLogin?->__toString(); $libraryOut['LastActivityID'] = $user->LastActivityID; @@ -147,22 +150,22 @@ function getUserListByPerms(int $sortBy, int $offset, int $count, ?array &$dataO $whereQuery = "WHERE $permsFilter "; } } else { - $whereQuery = "WHERE ( NOT ua.Untracked || ua.User = \"$requestedBy\" ) AND $permsFilter"; + $whereQuery = "WHERE ( NOT ua.Untracked || ua.User = \"$requestedBy\" OR ua.display_name = \"$requestedBy\" ) AND $permsFilter"; } $orderBy = match ($sortBy) { - 1 => "ua.User ASC ", - 11 => "ua.User DESC ", + 1 => "COALESCE(ua.display_name, ua.User) ASC ", + 11 => "COALESCE(ua.display_name, ua.User) DESC ", 2 => "ua.RAPoints DESC ", 12 => "ua.RAPoints ASC ", 3 => "NumAwarded DESC ", 13 => "NumAwarded ASC ", 4 => "ua.LastLogin DESC ", 14 => "ua.LastLogin ASC ", - default => "ua.User ASC ", + default => "COALESCE(ua.display_name, ua.User) ASC ", }; - $query = "SELECT ua.ID, ua.User, ua.RAPoints, ua.TrueRAPoints, ua.LastLogin, + $query = "SELECT ua.ID, COALESCE(ua.display_name, ua.User) AS User, ua.RAPoints, ua.TrueRAPoints, ua.LastLogin, ua.achievements_unlocked NumAwarded FROM UserAccounts AS ua $whereQuery @@ -232,7 +235,7 @@ function GetDeveloperStatsFull(int $count, int $offset = 0, int $sortBy = 0, int $populateDevs = empty($devs); foreach (legacyDbFetchAll($query) as $row) { $data[$row['ID']] = [ - 'Author' => $row['User'], + 'Author' => $row['display_name'], 'Permissions' => $row['Permissions'], 'ContribCount' => $row['ContribCount'], 'ContribYield' => $row['ContribYield'], @@ -261,7 +264,7 @@ function GetDeveloperStatsFull(int $count, int $offset = 0, int $sortBy = 0, int $devList = implode(',', $devs); // user data (this must be a LEFT JOIN to pick up users with 0 published achievements) - $query = "SELECT ua.ID, ua.User, ua.Permissions, ua.ContribCount, ua.ContribYield, + $query = "SELECT ua.ID, ua.display_name, ua.Permissions, ua.ContribCount, ua.ContribYield, ua.LastLogin, SUM(!ISNULL(ach.ID)) AS NumAchievements FROM UserAccounts ua LEFT JOIN Achievements ach ON ach.user_id = ua.ID AND ach.Flags = " . AchievementFlag::OfficialCore->value . " @@ -279,7 +282,7 @@ function GetDeveloperStatsFull(int $count, int $offset = 0, int $sortBy = 0, int LEFT JOIN Ticket tick ON tick.AchievementID=ach.ID AND tick.ReportState IN (1,3) WHERE $stateCond GROUP BY ua.ID - ORDER BY OpenTickets DESC, ua.User"; + ORDER BY OpenTickets DESC, ua.display_name"; $buildDevList($query); } elseif ($sortBy == 4) { // TicketsResolvedForOthers DESC $query = "SELECT ua.ID, SUM(!ISNULL(ach.ID)) as total @@ -288,7 +291,7 @@ function GetDeveloperStatsFull(int $count, int $offset = 0, int $sortBy = 0, int LEFT JOIN Achievements as ach ON ach.ID = tick.AchievementID AND ach.flags = 3 AND ach.user_id != ua.ID WHERE $stateCond GROUP BY ua.ID - ORDER BY total DESC, ua.User"; + ORDER BY total DESC, ua.display_name"; $buildDevList($query); } elseif ($sortBy == 7) { // ActiveClaims DESC $query = "SELECT ua.ID, SUM(!ISNULL(sc.ID)) AS ActiveClaims @@ -296,22 +299,22 @@ function GetDeveloperStatsFull(int $count, int $offset = 0, int $sortBy = 0, int LEFT JOIN SetClaim sc ON sc.user_id=ua.ID AND sc.Status IN (" . ClaimStatus::Active . ',' . ClaimStatus::InReview . ") WHERE $stateCond GROUP BY ua.ID - ORDER BY ActiveClaims DESC, ua.User"; + ORDER BY ActiveClaims DESC, ua.display_name"; $buildDevList($query); } else { $order = match ($sortBy) { - 1 => "ua.ContribYield DESC, ua.User", - 2 => "ua.ContribCount DESC, ua.User", - 5 => "ua.LastLogin DESC, ua.User", - 6 => "ua.User ASC", - default => "NumAchievements DESC, ua.User", + 1 => "ua.ContribYield DESC, ua.display_name", + 2 => "ua.ContribCount DESC, ua.display_name", + 5 => "ua.LastLogin DESC, ua.display_name", + 6 => "ua.display_name ASC", + default => "NumAchievements DESC, ua.display_name", }; // ASSERT: ContribYield cannot be > 0 unless NumAchievements > 0, so use // INNER JOIN and COUNT for maximum performance. // also, build the $dev list directly from these results instead of using // one query to build the list and a second query to fetch the user details - $query = "SELECT ua.ID, ua.User, ua.Permissions, ua.ContribCount, ua.ContribYield, + $query = "SELECT ua.ID, ua.display_name, ua.Permissions, ua.ContribCount, ua.ContribYield, ua.LastLogin, COUNT(*) AS NumAchievements FROM UserAccounts ua INNER JOIN Achievements ach ON ach.user_id = ua.ID AND ach.Flags = 3 diff --git a/app/Helpers/render/code-note.php b/app/Helpers/render/code-note.php index 7ec889bdeb..0741986751 100644 --- a/app/Helpers/render/code-note.php +++ b/app/Helpers/render/code-note.php @@ -60,7 +60,7 @@ function RenderCodeNotes(array $codeNotes, ?string $editingUser = null, ?int $ed HTML; echo ""; - echo userAvatar($nextCodeNote['User'], label: false, iconSize: 24); + echo userAvatar($nextCodeNote['DisplayName'], label: false, iconSize: 24); echo ""; if ($canEditNote) { diff --git a/app/Helpers/render/user.php b/app/Helpers/render/user.php index 60ba5dbf54..08a6f29b3d 100644 --- a/app/Helpers/render/user.php +++ b/app/Helpers/render/user.php @@ -30,7 +30,7 @@ function userAvatar( CacheKey::buildUserCardDataCacheKey($username), Carbon::now()->addMonths(3), function () use ($username): ?array { - $foundUser = User::firstWhere('User', $username); + $foundUser = User::whereName($username)->first(); return $foundUser ? $foundUser->toArray() : null; } @@ -42,6 +42,7 @@ function () use ($username): ?array { } $username = $user['User'] ?? null; + $displayName = $user['display_name'] ?? $user['User'] ?? null; if ($user['Deleted'] ?? false) { $userSanitized = $username; @@ -63,8 +64,8 @@ function () use ($username): ?array { return avatar( resource: 'user', id: $username, - label: $label !== false && ($label || !$icon) ? $username : null, - link: $link ?: route('user.show', $username), + label: $label !== false && ($label || !$icon) ? $displayName : null, + link: $link ?: route('user.show', $displayName), tooltip: is_array($tooltip) ? renderUserCard($tooltip) : $tooltip, class: 'inline whitespace-nowrap', iconUrl: $icon !== false && ($icon || !$label) ? media_asset('/UserPic/' . $username . '.png') : null, diff --git a/app/Helpers/util/mail.php b/app/Helpers/util/mail.php index a9ccef3c87..720ca172c9 100644 --- a/app/Helpers/util/mail.php +++ b/app/Helpers/util/mail.php @@ -233,11 +233,11 @@ function informAllSubscribersAboutActivity( break; case ArticleType::User: // User wall - $wallUserData = getUserMetadataFromID($articleID); + $wallUserData = User::find($articleID); $subscribers = getSubscribersOfUserWall($articleID, $wallUserData['User']); - $subjectAuthor = $wallUserData['User']; - $articleTitle = $wallUserData['User']; - $urlTarget = "user/" . $wallUserData['User']; + $subjectAuthor = $wallUserData->display_name; + $articleTitle = $wallUserData->display_name; + $urlTarget = "user/" . $wallUserData->display_name; break; case ArticleType::News: // News diff --git a/app/Http/Actions/BuildAchievementOfTheWeekDataAction.php b/app/Http/Actions/BuildAchievementOfTheWeekDataAction.php index fd79242f39..0457d27c9c 100644 --- a/app/Http/Actions/BuildAchievementOfTheWeekDataAction.php +++ b/app/Http/Actions/BuildAchievementOfTheWeekDataAction.php @@ -4,15 +4,12 @@ namespace App\Http\Actions; -use App\Models\Achievement; use App\Models\EventAchievement; -use App\Models\StaticData; use App\Platform\Data\EventAchievementData; class BuildAchievementOfTheWeekDataAction { - // TODO remove $staticData arg once event is actually run using EventAchievements - public function execute(?StaticData $staticData): ?EventAchievementData + public function execute(): ?EventAchievementData { $achievementOfTheWeek = EventAchievement::active() ->whereNotNull('active_from') @@ -24,25 +21,8 @@ public function execute(?StaticData $staticData): ?EventAchievementData ->with(['achievement.game', 'sourceAchievement.game']) ->first(); - if (!$achievementOfTheWeek || !$achievementOfTheWeek->source_achievement_id) { - if (!$staticData?->Event_AOTW_AchievementID) { - return null; - } - - $targetAchievementId = $staticData->Event_AOTW_AchievementID; - - $achievement = Achievement::find($targetAchievementId); - if (!$achievement) { - return null; - } - - // make a new EventAchievment object (and modify the related records) to - // mimic the behavior of a valid EventAchievement. DO NOT SAVE THESE! - $achievement->game->ForumTopicID = $staticData->Event_AOTW_ForumID; - - $achievementOfTheWeek = new EventAchievement(); - $achievementOfTheWeek->setRelation('achievement', $achievement); - $achievementOfTheWeek->setRelation('sourceAchievement', $achievement); + if (!$achievementOfTheWeek?->source_achievement_id) { + return null; } $data = EventAchievementData::from($achievementOfTheWeek)->include( @@ -53,7 +33,8 @@ public function execute(?StaticData $staticData): ?EventAchievementData 'sourceAchievement.game.badgeUrl', 'sourceAchievement.game.system.iconUrl', 'sourceAchievement.game.system.nameShort', - 'forumTopicId', + 'event', + 'event.legacyGame', 'activeUntil', ); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index d921654892..1f035fe090 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -41,7 +41,7 @@ public function index( $staticData = StaticData::first(); $staticDataData = StaticDataData::fromStaticData($staticData); - $achievementOfTheWeek = $buildAchievementOfTheWeekData->execute($staticData); + $achievementOfTheWeek = $buildAchievementOfTheWeekData->execute(); $mostRecentGameMastered = $buildMostRecentGameAwardData->execute($staticData, AwardType::Mastery); $mostRecentGameBeaten = $buildMostRecentGameAwardData->execute($staticData, AwardType::GameBeaten); $recentNews = $buildNewsData->execute(); diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php index 9bbade66b5..31d1115aca 100644 --- a/app/Models/Achievement.php +++ b/app/Models/Achievement.php @@ -7,6 +7,7 @@ use App\Community\Concerns\HasAchievementCommunityFeatures; use App\Community\Contracts\HasComments; use App\Community\Enums\ArticleType; +use App\Platform\Contracts\HasVersionedTrigger; use App\Platform\Enums\AchievementAuthorTask; use App\Platform\Enums\AchievementFlag; use App\Platform\Enums\AchievementType; @@ -24,6 +25,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; @@ -37,7 +40,10 @@ // TODO implements HasComments -class Achievement extends BaseModel +/** + * @implements HasVersionedTrigger + */ +class Achievement extends BaseModel implements HasVersionedTrigger { /* * Community Traits @@ -94,6 +100,7 @@ class Achievement extends BaseModel 'type', 'MemAddr', 'user_id', + 'trigger_id', ]; // TODO cast Flags to AchievementFlag if it isn't dropped from the table @@ -362,6 +369,7 @@ public function game(): BelongsTo } /** + * @deprecated use comments() * @return HasMany * * TODO use ->comments() after commentable_type and commentable_id are synced in Comments table @@ -428,6 +436,26 @@ public function visibleComments(?User $user = null): HasMany return $this->comments()->visibleTo($currentUser); } + /** + * @return BelongsTo + */ + public function currentTrigger(): BelongsTo + { + return $this->belongsTo(Trigger::class, 'trigger_id', 'ID'); + } + + public function trigger(): MorphOne + { + return $this->morphOne(Trigger::class, 'triggerable') + ->latest('version'); + } + + public function triggers(): MorphMany + { + return $this->morphMany(Trigger::class, 'triggerable') + ->orderBy('version'); + } + // == scopes /** diff --git a/app/Models/Event.php b/app/Models/Event.php index 39f8cfacf2..91e1dec7f4 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -8,6 +8,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -57,7 +58,7 @@ public function getActivitylogOptions(): LogOptions public function getTitleAttribute(): string { - return $this->game->title; + return $this->legacyGame->title; } public function getActiveThroughAttribute(): ?Carbon @@ -73,14 +74,14 @@ public function getBadgeUrlAttribute(): string public function getPermalinkAttribute(): string { // TODO: use slug (implies slug is immutable) - return $this->game->getPermalinkAttribute(); + return $this->legacyGame->getPermalinkAttribute(); } // == mutators public function setTitleAttribute(string $value): void { - $this->game->title = $value; + $this->legacyGame->title = $value; } public function setActiveThroughAttribute(Carbon|string|null $value): void @@ -94,10 +95,18 @@ public function setActiveThroughAttribute(Carbon|string|null $value): void // == relations + /** + * @return HasMany + */ + public function awards(): HasMany + { + return $this->hasMany(EventAward::class, 'event_id'); + } + /** * @return BelongsTo */ - public function game(): BelongsTo + public function legacyGame(): BelongsTo { return $this->belongsTo(Game::class, 'legacy_game_id', 'ID'); } @@ -107,13 +116,13 @@ public function game(): BelongsTo */ public function achievements(): HasManyThrough { - return $this->game->hasManyThrough( + return $this->legacyGame->hasManyThrough( EventAchievement::class, Achievement::class, 'GameID', // Achievements.GameID 'achievement_id', // event_achievements.achievement_id - 'ID', // Game.ID - 'ID', // Achievement.ID + 'ID', // GameData.ID + 'ID', // Achievements.ID )->with('achievement.game'); } @@ -122,7 +131,7 @@ public function achievements(): HasManyThrough */ public function hubs(): BelongsToMany { - return $this->game->gameSets(); + return $this->legacyGame->gameSets(); } // == scopes diff --git a/app/Models/EventAchievement.php b/app/Models/EventAchievement.php index 3ab3930f96..0dcbf59421 100644 --- a/app/Models/EventAchievement.php +++ b/app/Models/EventAchievement.php @@ -8,6 +8,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -78,6 +79,21 @@ public function setActiveThroughAttribute(Carbon|string|null $value): void // == relations + /** + * @return HasOneThrough + */ + public function event(): HasOneThrough + { + return $this->hasOneThrough( + Event::class, + Achievement::class, + 'ID', // Achievements.ID + 'legacy_game_id', // events.legacy_game_id + 'achievement_id', // event_achievements.achievement_id + 'GameID' // Achievements.GameID + ); + } + /** * @return BelongsTo */ diff --git a/app/Models/EventAward.php b/app/Models/EventAward.php new file mode 100644 index 0000000000..5b56ea04a9 --- /dev/null +++ b/app/Models/EventAward.php @@ -0,0 +1,42 @@ +image_asset_path); + } + + // == mutators + + // == relations + + /** + * @return BelongsTo + */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class, 'event_id', 'id'); + } + + // == scopes +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 39ce73b49d..24772c1ec3 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -9,6 +9,7 @@ use App\Community\Enums\ArticleType; use App\Platform\Actions\SyncGameTagsFromTitleAction; use App\Platform\Actions\WriteGameSortTitleFromGameTitleAction; +use App\Platform\Contracts\HasVersionedTrigger; use App\Platform\Enums\AchievementFlag; use App\Platform\Enums\AchievementSetType; use App\Platform\Enums\GameSetType; @@ -23,6 +24,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -37,7 +40,11 @@ use Spatie\Tags\HasTags; // TODO implements HasComments -class Game extends BaseModel implements HasMedia + +/** + * @implements HasVersionedTrigger + */ +class Game extends BaseModel implements HasMedia, HasVersionedTrigger { /* * Community Traits @@ -92,6 +99,7 @@ class Game extends BaseModel implements HasMedia 'Genre', 'released_at', 'released_at_granularity', + 'trigger_id', 'GuideURL', 'ImageIcon', 'ImageTitle', @@ -634,7 +642,43 @@ public function gameAchievementSets(): HasMany public function gameSets(): BelongsToMany { return $this->belongsToMany(GameSet::class, 'game_set_games', 'game_id', 'game_set_id') - ->withPivot('created_at', 'updated_at', 'deleted_at'); + ->withPivot(['created_at', 'updated_at', 'deleted_at']) + ->withTimestamps('created_at', 'updated_at'); + } + + /** + * @return BelongsToMany + */ + public function hubs(): BelongsToMany + { + return $this->gameSets()->whereType(GameSetType::Hub); + } + + /** + * @return BelongsToMany + */ + public function similarGames(): BelongsToMany + { + return $this->gameSets()->whereType(GameSetType::SimilarGames); + } + + /** + * @return BelongsToMany + */ + public function similarGamesList(): BelongsToMany + { + // This should always be truthy. + $gameSet = GameSet::query() + ->whereGameId($this->id) + ->whereType(GameSetType::SimilarGames) + ->first(); + + // Return an empty relationship if no game set exists. + if (!$gameSet) { + return $this->belongsToMany(Game::class, 'game_set_games')->whereRaw('1 = 0'); + } + + return $gameSet->games()->with('system')->withTimestamps(['created_at', 'updated_at']); } /** @@ -685,6 +729,26 @@ public function unresolvedTickets(): HasManyThrough return $this->tickets()->unresolved(); } + /** + * @return BelongsTo + */ + public function currentTrigger(): BelongsTo + { + return $this->belongsTo(Trigger::class, 'trigger_id', 'ID'); + } + + public function trigger(): MorphOne + { + return $this->morphOne(Trigger::class, 'triggerable') + ->latest('version'); + } + + public function triggers(): MorphMany + { + return $this->morphMany(Trigger::class, 'triggerable') + ->orderBy('version'); + } + /** * @return HasOne */ diff --git a/app/Models/GameHashSet.php b/app/Models/GameHashSet.php index bb44b2750f..9390c35eb6 100644 --- a/app/Models/GameHashSet.php +++ b/app/Models/GameHashSet.php @@ -4,17 +4,16 @@ namespace App\Models; -use App\Platform\Contracts\HasVersionedTrigger; use App\Support\Database\Eloquent\BaseModel; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\MorphOne; -use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\SoftDeletes; -class GameHashSet extends BaseModel implements HasVersionedTrigger +// currently unused +// TODO HasVersionedTrigger (?) +class GameHashSet extends BaseModel { use SoftDeletes; @@ -57,22 +56,6 @@ public function memoryNotes(): HasMany return $this->hasMany(MemoryNote::class); } - public function trigger(): MorphOne - { - return $this->morphOne(Trigger::class, 'triggerable') - ->whereNotNull('version') - ->orderByDesc('version'); - } - - /** - * @return MorphToMany - */ - public function triggers(): MorphToMany - { - return $this->morphToMany(Trigger::class, 'triggerable') - ->orderByDesc('version'); - } - // == scopes /** diff --git a/app/Models/Leaderboard.php b/app/Models/Leaderboard.php index 816371640e..0da22418e2 100644 --- a/app/Models/Leaderboard.php +++ b/app/Models/Leaderboard.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Community\Enums\ArticleType; +use App\Platform\Contracts\HasVersionedTrigger; use App\Platform\Enums\ValueFormat; use App\Support\Database\Eloquent\BaseModel; use Database\Factories\LeaderboardFactory; @@ -12,6 +13,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; @@ -21,7 +24,11 @@ use Spatie\Activitylog\Traits\LogsActivity; // TODO implements HasComments -class Leaderboard extends BaseModel + +/** + * @implements HasVersionedTrigger + */ +class Leaderboard extends BaseModel implements HasVersionedTrigger { /* * Shared Traits @@ -61,6 +68,7 @@ class Leaderboard extends BaseModel 'Format', 'LowerIsBetter', 'DisplayOrder', + 'trigger_id', ]; protected static function newFactory(): LeaderboardFactory @@ -250,6 +258,26 @@ public function visibleComments(?User $user = null): HasMany return $this->comments()->visibleTo($currentUser); } + /** + * @return BelongsTo + */ + public function currentTrigger(): BelongsTo + { + return $this->belongsTo(Trigger::class, 'trigger_id', 'ID'); + } + + public function trigger(): MorphOne + { + return $this->morphOne(Trigger::class, 'triggerable') + ->latest('version'); + } + + public function triggers(): MorphMany + { + return $this->morphMany(Trigger::class, 'triggerable') + ->orderBy('version'); + } + // == scopes /** diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 9e8e906d84..8533695c1b 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -31,6 +31,7 @@ class Ticket extends BaseModel // TODO rename Updated column to updated_at // TODO drop AchievementID, use ticketable morph instead // TODO drop Hardcore, derived from player_session + // TODO rename ticketable_model to ticketable_type protected $table = 'Ticket'; protected $primaryKey = 'ID'; diff --git a/app/Models/Trigger.php b/app/Models/Trigger.php index a97b95581d..a2425fa522 100644 --- a/app/Models/Trigger.php +++ b/app/Models/Trigger.php @@ -4,23 +4,76 @@ namespace App\Models; +use App\Platform\Enums\TriggerableType; use App\Support\Database\Eloquent\BaseModel; +use Database\Factories\TriggerFactory; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; class Trigger extends BaseModel { + /** @use HasFactory */ + use HasFactory; use SoftDeletes; protected $fillable = [ + 'triggerable_type', + 'triggerable_id', + 'user_id', 'conditions', 'version', 'parent_id', + 'created_at', // TODO remove after initial sync + 'updated_at', // TODO remove after initial sync ]; + protected $casts = [ + 'triggerable_type' => TriggerableType::class, + 'version' => 'integer', + ]; + + protected static function newFactory(): TriggerFactory + { + return TriggerFactory::new(); + } + + // == accessors + + public function getIsInitialVersionAttribute(): bool + { + return $this->parent_id === null; + } + + public function getIsLatestVersionAttribute(): bool + { + return !$this->nextVersion()->exists(); + } + + // == mutators + // == relations + /** + * @return HasOne + */ + public function nextVersion(): HasOne + { + return $this->hasOne(Trigger::class, 'parent_id'); + } + + /** + * @return BelongsTo + */ + public function previousVersion(): BelongsTo + { + return $this->belongsTo(Trigger::class, 'parent_id'); + } + /** * @return MorphTo */ @@ -28,4 +81,63 @@ public function triggerable(): MorphTo { return $this->morphTo(); } + + // == scopes + + /** + * @param Builder $query + * @return Builder + */ + public function scopeLatestVersion(Builder $query): Builder + { + return $query->whereNotExists(function ($query) { + $query->from('triggers', 't2') + ->whereColumn('t2.parent_id', 'triggers.id'); + }); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeInitialVersion(Builder $query): Builder + { + return $query->whereNull('parent_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeVersion(Builder $query, int $version): Builder + { + return $query->whereVersion($version); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeOfType(Builder $query, TriggerableType $type): Builder + { + return $query->whereTriggerableType($type); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeVersioned(Builder $query): Builder + { + return $query->whereNotNull('version'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeUnversioned(Builder $query): Builder + { + return $query->whereNull('version'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 1882b768e6..c9f1dada92 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -177,6 +177,7 @@ class User extends Authenticatable implements CommunityMember, Developer, HasLoc "ContribYield", "Created", "Deleted", + 'display_name', "ID", "isMuted", "LastLogin", @@ -280,7 +281,7 @@ public function canAccessPanel(Panel $panel): bool public function getFilamentName(): string { - return $this->username; + return $this->getDisplayNameAttribute(); } // search @@ -348,6 +349,24 @@ public function registerMediaCollections(): void $this->registerAvatarMediaCollection(); } + // == actions + + /** + * @return Builder + */ + public static function whereName(?string $displayNameOrUsername): Builder + { + if ($displayNameOrUsername === null) { + return static::query(); + } + + return static::query() + ->where(function ($query) use ($displayNameOrUsername) { + $query->where('display_name', $displayNameOrUsername) + ->orWhere('User', $displayNameOrUsername); + }); + } + // == accessors public function preferredLocale() @@ -355,12 +374,16 @@ public function preferredLocale() return $this->locale; } - public function getRouteKeyName(): string + public function getRouteKey(): string { - /* - * TODO: this might not hold up for changeable usernames -> find a better solution - */ - return 'User'; + return !empty($this->display_name) ? $this->display_name : 'User'; + } + + public function resolveRouteBinding($value, $field = null): ?self + { + return $this->where('display_name', $value) + ->orWhere('User', $value) + ->firstOrFail(); } public function isModerated(): bool @@ -414,8 +437,11 @@ public function getCreatedAtAttribute(): Carbon public function getDisplayNameAttribute(): ?string { - // return $this->attributes['display_name'] ?? $this->attributes['username'] ?? null; - return $this->getAttribute('User'); + if (!empty($this->attributes['display_name'])) { + return $this->attributes['display_name']; + } + + return $this->username ?? null; } public function getUsernameAttribute(): string @@ -547,18 +573,6 @@ public function getEmailForVerification(): string // == scopes - /** - * To make the transition to customizable usernames a little easier - * once `display_name` is populated in the database. - * - * @param Builder $query - * @return Builder - */ - public function scopeByDisplayName(Builder $query, string $username): Builder - { - return $query->where('User', $username); - } - /** * @param Builder $query * @return Builder diff --git a/app/Platform/Actions/LinkSimilarGamesAction.php b/app/Platform/Actions/LinkSimilarGamesAction.php new file mode 100644 index 0000000000..31cfa9d6cf --- /dev/null +++ b/app/Platform/Actions/LinkSimilarGamesAction.php @@ -0,0 +1,43 @@ + $parentGame->id, + 'gameIDAlt' => $gameId, + ]); + GameAlternative::create([ + 'gameID' => $gameId, + 'gameIDAlt' => $parentGame->id, + ]); + } + + $parentSimilarGamesSet = GameSet::firstOrCreate([ + 'type' => GameSetType::SimilarGames, + 'game_id' => $parentGame->id, + ]); + $parentSimilarGamesSet->games()->attach($gameIdsToLink); + + // Link each game's similar games set to include the parent game. + foreach ($gameIdsToLink as $gameId) { + $similarGamesSet = GameSet::firstOrCreate([ + 'type' => GameSetType::SimilarGames, + 'game_id' => $gameId, + ]); + $similarGamesSet->games()->attach($parentGame->id); + } + } +} diff --git a/app/Platform/Actions/RemoveLeaderboardEntryAction.php b/app/Platform/Actions/RemoveLeaderboardEntryAction.php index ad32c95d48..6504a86d91 100644 --- a/app/Platform/Actions/RemoveLeaderboardEntryAction.php +++ b/app/Platform/Actions/RemoveLeaderboardEntryAction.php @@ -36,7 +36,7 @@ public function execute(LeaderboardEntry $entry, ?string $reason): void ArticleType::Leaderboard, $entry->leaderboard->id, "{$currentUser->display_name} {$commentText}", - $currentUser->username + $currentUser->display_name ); } } diff --git a/app/Platform/Actions/RequestAccountDeletionAction.php b/app/Platform/Actions/RequestAccountDeletionAction.php index 37d30ed819..6c845f21f8 100644 --- a/app/Platform/Actions/RequestAccountDeletionAction.php +++ b/app/Platform/Actions/RequestAccountDeletionAction.php @@ -33,11 +33,11 @@ public function execute(User $user): bool $user->save(); addArticleComment('Server', ArticleType::UserModeration, $user->ID, - $user->User . ' requested account deletion' + $user->display_name . ' requested account deletion' ); mail_utf8($user->EmailAddress, "Account Deletion Request", - "Hello {$user->User},

" . + "Hello {$user->display_name},

" . "Your account has been marked for deletion.
" . "If you do not cancel this request before " . getDeleteDate($user->DeleteRequested) . ", " . "you will no longer be able to access your account.

" . diff --git a/app/Platform/Actions/ResetPlayerProgressAction.php b/app/Platform/Actions/ResetPlayerProgressAction.php index 1ba0acbf90..3b929f71b7 100644 --- a/app/Platform/Actions/ResetPlayerProgressAction.php +++ b/app/Platform/Actions/ResetPlayerProgressAction.php @@ -26,7 +26,7 @@ public function execute(User $user, ?int $achievementID = null, ?int $gameID = n $affectedAchievements = legacyDbFetchAll(" SELECT - ua.User AS Author, + COALESCE(ua.display_name, ua.User) AS Author, ach.GameID, CASE WHEN pa.unlocked_hardcore_at THEN 1 ELSE 0 END AS HardcoreMode, COUNT(ach.ID) AS Count, SUM(ach.Points) AS Points, @@ -92,7 +92,12 @@ public function execute(User $user, ?int $achievementID = null, ?int $gameID = n $user->save(); } - $authors = User::whereIn('User', $authorUsernames->unique())->get('ID'); + $authors = User::query() + ->where(function ($query) use ($authorUsernames) { + $query->whereIn('User', $authorUsernames->unique()) + ->orWhereIn('display_name', $authorUsernames->unique()); + }) + ->get('ID'); foreach ($authors as $author) { dispatch(new UpdateDeveloperContributionYieldJob($author->id)); } diff --git a/app/Platform/Actions/UnlinkSimilarGamesAction.php b/app/Platform/Actions/UnlinkSimilarGamesAction.php new file mode 100644 index 0000000000..d586627c3d --- /dev/null +++ b/app/Platform/Actions/UnlinkSimilarGamesAction.php @@ -0,0 +1,49 @@ + $parentGame->id, + 'gameIDAlt' => $gameId, + ])->delete(); + + GameAlternative::where([ + 'gameID' => $gameId, + 'gameIDAlt' => $parentGame->id, + ])->delete(); + } + + $parentSimilarGamesSet = GameSet::where([ + 'type' => GameSetType::SimilarGames, + 'game_id' => $parentGame->id, + ])->first(); + if ($parentSimilarGamesSet) { + $parentSimilarGamesSet->games()->detach($gameIdsToUnlink); + } + + // Remove parent game from each game's similar games set. + foreach ($gameIdsToUnlink as $gameId) { + $similarGamesSet = GameSet::where([ + 'type' => GameSetType::SimilarGames, + 'game_id' => $gameId, + ])->first(); + + if ($similarGamesSet) { + $similarGamesSet->games()->detach($parentGame->id); + } + } + } +} diff --git a/app/Platform/Actions/UpsertTriggerVersionAction.php b/app/Platform/Actions/UpsertTriggerVersionAction.php index bd8a15f68c..58ad76c4ca 100644 --- a/app/Platform/Actions/UpsertTriggerVersionAction.php +++ b/app/Platform/Actions/UpsertTriggerVersionAction.php @@ -4,51 +4,99 @@ namespace App\Platform\Actions; +use App\Models\Achievement; use App\Models\Trigger; +use App\Models\User; use App\Platform\Contracts\HasVersionedTrigger; use Illuminate\Database\Eloquent\Model; +use RuntimeException; class UpsertTriggerVersionAction { - public function execute(Model $triggerable, string $conditions, bool $versioned = true): ?Trigger - { + public function execute( + Model $triggerable, + string $conditions, + bool $versioned = true, + ?User $user = null, + ): ?Trigger { if (!$triggerable instanceof HasVersionedTrigger) { return null; } $triggerable->loadMissing('trigger'); - /** @var ?Trigger $currentTrigger */ - $currentTrigger = $triggerable->trigger; + // Check if this triggerable has version history (for maintaining versions when demoted). + $hasVersionHistory = $triggerable->triggers()->whereNotNull('version')->exists(); + $shouldKeepVersion = $versioned || $hasVersionHistory; - /* - * No trigger exists yet -> create one - * Attention: when $versioned = false make sure to check if another entry should even be created beforehand - * Otherwise the unique check will not be triggered and multiple unversioned triggers will be created - */ - if (!$currentTrigger) { - $trigger = new Trigger([ + // For unversioned triggers, explicitly check if one already exists. + // MySQL/MariaDB ignore uniqueness constraints on NULL column values (version), + // so if we're not careful, we can wind up with lots of unversioned triggers + // for the same triggerable asset (achievement). + if (!$shouldKeepVersion) { + $currentTrigger = $triggerable->triggers()->unversioned()->first(); + + if (!$currentTrigger && $triggerable->triggers()->unversioned()->exists()) { + throw new RuntimeException('Multiple unversioned triggers detected for ' . get_class($triggerable) . ' #' . $triggerable->getKey()); + } + + // If there's already an unversioned trigger for this asset, just update it in-place. + if ($currentTrigger) { + $currentTrigger->update([ + 'conditions' => $conditions, + 'user_id' => $user?->id, + ]); + $triggerable->update(['trigger_id' => $currentTrigger->id]); + + return $currentTrigger; + } + + // Otherwise, create a new unversioned trigger. It'll get its first version on publish. + $trigger = $triggerable->trigger()->save(new Trigger([ 'conditions' => $conditions, - 'version' => $versioned ? 1 : null, - ]); - /** @var Trigger $newTrigger */ - $newTrigger = $triggerable->trigger()->save($trigger); + 'version' => null, + 'user_id' => $user?->id, + ])); + $triggerable->update(['trigger_id' => $trigger->id]); - return $newTrigger; + return $trigger; } - if ($currentTrigger->conditions === $conditions) { + /** + * Versioned triggers are handled a bit differently than unversioned triggers. + * Versions are stored almost like a linked list in the database. Version numbers + * increase via a simple integer counter (1 -> 2 -> 3 -> etc...), and each versioned + * trigger has a parent_id which points to the previous version's row in the table. + * With this, we can build a chain of the triggerable asset's logic history. + */ + $currentTrigger = $triggerable->trigger; + + // If conditions haven't changed and we're converting unversioned -> versioned, + // just update the trigger's version in-place. + if ($currentTrigger && $currentTrigger->conditions === $conditions) { + // If conditions match and it's unversioned, convert to version 1. + if ($currentTrigger->version === null) { + $currentTrigger->update([ + 'version' => 1, + 'user_id' => $user?->id, + ]); + $triggerable->update(['trigger_id' => $currentTrigger->id]); + } + return $currentTrigger; } - $trigger = new Trigger([ + $latestVersion = $triggerable->triggers()->whereNotNull('version')->max('version') ?? 0; + + // If we ultimately made it here, create a new versioned trigger. + $trigger = $triggerable->trigger()->save(new Trigger([ 'conditions' => $conditions, - 'version' => $versioned ? $currentTrigger->version + 1 : null, - 'parent_id' => $currentTrigger->id, - ]); - /** @var Trigger $newTrigger */ - $newTrigger = $triggerable->trigger()->save($trigger); + 'version' => $latestVersion + 1, + 'parent_id' => $currentTrigger?->id, + 'user_id' => $user?->id, + ])); + $triggerable->update(['trigger_id' => $trigger->id]); - return $newTrigger; + return $trigger; } } diff --git a/app/Platform/AppServiceProvider.php b/app/Platform/AppServiceProvider.php index 171b0e2d00..4c1775b199 100644 --- a/app/Platform/AppServiceProvider.php +++ b/app/Platform/AppServiceProvider.php @@ -45,6 +45,7 @@ use App\Platform\Commands\SyncPlayerBadges; use App\Platform\Commands\SyncPlayerRichPresence; use App\Platform\Commands\SyncPlayerSession; +use App\Platform\Commands\SyncTriggers; use App\Platform\Commands\TrimGameMetadata; use App\Platform\Commands\UnlockPlayerAchievement; use App\Platform\Commands\UpdateAwardsStaticData; @@ -128,6 +129,7 @@ public function boot(): void SyncPlayerBadges::class, SyncPlayerRichPresence::class, SyncPlayerSession::class, + SyncTriggers::class, ]); } diff --git a/app/Platform/Commands/ResetPlayerAchievement.php b/app/Platform/Commands/ResetPlayerAchievement.php index e924fe7281..fe8314e485 100644 --- a/app/Platform/Commands/ResetPlayerAchievement.php +++ b/app/Platform/Commands/ResetPlayerAchievement.php @@ -34,7 +34,7 @@ public function handle(): void $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $achievements = PlayerAchievement::where('user_id', $user->id) ->whereIn('achievement_id', $achievementIds); diff --git a/app/Platform/Commands/SyncGameSetsInternalNotes.php b/app/Platform/Commands/SyncGameSetsInternalNotes.php index 2eb85c9ff5..122230782e 100644 --- a/app/Platform/Commands/SyncGameSetsInternalNotes.php +++ b/app/Platform/Commands/SyncGameSetsInternalNotes.php @@ -14,9 +14,6 @@ class SyncGameSetsInternalNotes extends Command protected $signature = 'ra:sync:game-sets:internal-notes'; protected $description = 'Sync internal notes for hub-type game sets from their legacy games.'; - /** - * Execute the console command. - */ public function handle(): void { $this->info('Starting internal notes sync for hub game sets...'); diff --git a/app/Platform/Commands/SyncTriggers.php b/app/Platform/Commands/SyncTriggers.php new file mode 100644 index 0000000000..4723a90dae --- /dev/null +++ b/app/Platform/Commands/SyncTriggers.php @@ -0,0 +1,271 @@ +info("\nDeleting any existing triggers data..."); + $this->wipeAllTriggersData(); + $this->info("Deleted all existing triggers data."); + + $this->info('Syncing triggers for achievements, leaderboards, and rich presence scripts.'); + + $this->syncAchievementTriggers(); + $this->syncLeaderboardTriggers(); + $this->syncRichPresenceTriggers(); + + $this->newLine(); + $this->info('Done syncing all triggers.'); + } + + private function syncAchievementTriggers(): void + { + $achievementCount = Achievement::where('MemAddr', '!=', '')->count(); + $this->info("Syncing triggers for {$achievementCount} achievements..."); + + $progressBar = $this->output->createProgressBar($achievementCount); + + Achievement::query() + ->where('MemAddr', '!=', '') + ->with(['comments' => function ($query) { + $query->automated() + ->whereRaw("LOWER(Payload) LIKE '% edited%logic%'") + ->latest('Submitted') + ->limit(1); + }]) + ->chunk(1000, function ($achievements) use ($progressBar) { + foreach ($achievements as $achievement) { + $lastEditor = $this->findLastEditor($achievement); + + $newTrigger = (new UpsertTriggerVersionAction())->execute( + $achievement, + $achievement->MemAddr, + versioned: $achievement->Flags === AchievementFlag::OfficialCore->value, + user: $lastEditor, + ); + + // Try our best to backdate the new trigger's timestamps. + if ($newTrigger) { + // Get the achievement's creation date for both timestamps. + // For the updated timestamp, prefer the latest logic edit comment's timestamp if it exists. + // DateModified unfortunately could be all kinds of different things other than logic. + $createdTimestamp = $achievement->DateCreated ?? now(); + $updatedTimestamp = $achievement->comments->first()?->Submitted ?? $createdTimestamp; + + $newTrigger->timestamps = false; + $newTrigger->update([ + 'created_at' => $createdTimestamp, + 'updated_at' => $updatedTimestamp, + ]); + } + + $progressBar->advance(); + } + }); + + $progressBar->finish(); + $this->newLine(); + $this->info('Done syncing triggers for achievements.'); + } + + private function syncLeaderboardTriggers(): void + { + $leaderboardCount = Leaderboard::count(); + $this->info("Syncing triggers for {$leaderboardCount} leaderboards..."); + + $progressBar = $this->output->createProgressBar($leaderboardCount); + + Leaderboard::query() + ->with(['comments' => function ($query) { + $query->automated() + ->whereRaw("LOWER(Payload) LIKE '% edited this leaderboard%'") + ->latest('Submitted') + ->limit(1); + }]) + ->chunk(1000, function ($leaderboards) use ($progressBar) { + foreach ($leaderboards as $leaderboard) { + $lastEditor = $this->findLastEditor($leaderboard); + + $newTrigger = (new UpsertTriggerVersionAction())->execute( + $leaderboard, + $leaderboard->Mem, + versioned: true, + user: $lastEditor, + ); + + // Try our best to backdate the new trigger's timestamps. + if ($newTrigger) { + // First determine the creation date. + $createdTimestamp = + $leaderboard->Created + ?? $leaderboard->Updated + ?? now(); + + // Then ensure updated is never before created. + $updatedTimestamp = max( + $createdTimestamp, + $leaderboard->comments->first()?->Submitted + ?? $leaderboard->Updated + ?? $createdTimestamp + ); + + $newTrigger->timestamps = false; + $newTrigger->update([ + 'created_at' => $createdTimestamp, + 'updated_at' => $updatedTimestamp, + ]); + } + + $progressBar->advance(); + } + }); + + $progressBar->finish(); + $this->newLine(); + $this->info('Done syncing triggers for leaderboards.'); + } + + private function syncRichPresenceTriggers(): void + { + $gamesCount = Game::whereNotNull('RichPresencePatch') + ->where('RichPresencePatch', '!=', '') + ->count(); + + $this->info("Syncing triggers for {$gamesCount} rich presence scripts..."); + + $progressBar = $this->output->createProgressBar($gamesCount); + + Game::query() + ->whereNotNull('RichPresencePatch') + ->where('RichPresencePatch', '!=', '') + ->with(['modificationsComments' => function ($query) { + $query->automated() + ->whereRaw("LOWER(Payload) LIKE '%changed the rich presence%'") + ->latest('Submitted') + ->limit(1); + }]) + ->chunk(1000, function ($games) use ($progressBar) { + foreach ($games as $game) { + $lastEditor = $this->findLastEditor($game); + + $newTrigger = (new UpsertTriggerVersionAction())->execute( + $game, + $game->RichPresencePatch, + versioned: true, + user: $lastEditor, + ); + + // Try our best to backdate the new trigger's timestamps. + if ($newTrigger) { + // When RP is edited, a comment is left. Use that comment's submitted + // date, otherwise fall back to various dates of decreasing precision. + $timestamp = + $game->modificationsComments->first()?->Submitted + ?? $game->achievements()->min('DateCreated') + ?? $game->Created + ?? $game->Updated; + + $newTrigger->timestamps = false; + $newTrigger->update([ + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + } + + $progressBar->advance(); + } + }); + + $progressBar->finish(); + $this->newLine(); + $this->info('Done syncing triggers for rich presence scripts.'); + } + + private function findLastEditor(Model $triggerable): ?User + { + if ($triggerable instanceof Game) { + $lastRichPresenceEdit = $triggerable->modificationsComments() + ->automated() + ->whereRaw("LOWER(Payload) LIKE '%changed the rich presence%'") + ->latest('Submitted') + ->first(); + + if ($lastRichPresenceEdit) { + $username = explode(' ', $lastRichPresenceEdit->Payload)[0]; + + return $this->findUserByName($username); + } + + return null; + } + + if ($triggerable instanceof Leaderboard) { + $lastLeaderboardEdit = $triggerable->comments() + ->automated() + ->whereRaw("LOWER(Payload) LIKE '% edited this leaderboard%'") + ->latest('Submitted') + ->first(); + + if ($lastLeaderboardEdit) { + $username = explode(' ', $lastLeaderboardEdit->Payload)[0]; + + return $this->findUserByName($username); + } + + return User::withTrashed()->find($triggerable->author_id); + } + + if ($triggerable instanceof Achievement) { + $lastLogicEdit = $triggerable->comments() + ->automated() + ->whereRaw("LOWER(Payload) LIKE '% edited%logic%'") + ->latest('Submitted') + ->first(); + + if ($lastLogicEdit) { + $username = explode(' ', $lastLogicEdit->Payload)[0]; + + return $this->findUserByName($username); + } + + return User::withTrashed()->find($triggerable->user_id) ?? null; + } + + return null; + } + + private function findUserByName(string $name): ?User + { + return User::withTrashed() + ->where('display_name', $name) + ->orWhere('User', $name) + ->first(); + } + + private function wipeAllTriggersData(): void + { + DB::statement('SET FOREIGN_KEY_CHECKS=0'); + Trigger::truncate(); + DB::statement('SET FOREIGN_KEY_CHECKS=1'); + } +} diff --git a/app/Platform/Commands/UnlockPlayerAchievement.php b/app/Platform/Commands/UnlockPlayerAchievement.php index b431d0f728..2ed0da83e5 100644 --- a/app/Platform/Commands/UnlockPlayerAchievement.php +++ b/app/Platform/Commands/UnlockPlayerAchievement.php @@ -36,7 +36,7 @@ public function handle(): void $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $achievements = Achievement::whereIn('id', $achievementIds)->get(); diff --git a/app/Platform/Commands/UpdateDeveloperContributionYield.php b/app/Platform/Commands/UpdateDeveloperContributionYield.php index cdb6df7fd7..3ec262d98c 100644 --- a/app/Platform/Commands/UpdateDeveloperContributionYield.php +++ b/app/Platform/Commands/UpdateDeveloperContributionYield.php @@ -24,7 +24,7 @@ public function handle(): void $username = $this->argument('username'); if (!empty($username)) { - $users = User::where('User', $username)->get(); + $users = User::whereName($username)->get(); } else { $users = User::where('ContribCount', '>', 0)->get(); } diff --git a/app/Platform/Commands/UpdatePlayerBeatenGamesStats.php b/app/Platform/Commands/UpdatePlayerBeatenGamesStats.php index dce8b1734a..993ff32c13 100644 --- a/app/Platform/Commands/UpdatePlayerBeatenGamesStats.php +++ b/app/Platform/Commands/UpdatePlayerBeatenGamesStats.php @@ -30,7 +30,7 @@ public function handle(): void if ($userId !== null) { $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $this->info('Updating beaten games stats for player [' . $user->id . ':' . $user->username . ']'); diff --git a/app/Platform/Commands/UpdatePlayerGameMetrics.php b/app/Platform/Commands/UpdatePlayerGameMetrics.php index 42c814cbee..db613f5713 100644 --- a/app/Platform/Commands/UpdatePlayerGameMetrics.php +++ b/app/Platform/Commands/UpdatePlayerGameMetrics.php @@ -34,7 +34,7 @@ public function handle(): void $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $query = $user->playerGames() ->with(['user', 'game']); diff --git a/app/Platform/Commands/UpdatePlayerMetrics.php b/app/Platform/Commands/UpdatePlayerMetrics.php index 3099f2e9c6..51c41eb92f 100644 --- a/app/Platform/Commands/UpdatePlayerMetrics.php +++ b/app/Platform/Commands/UpdatePlayerMetrics.php @@ -26,7 +26,7 @@ public function handle(): void $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $this->info('Updating metrics for player [' . $user->id . ':' . $user->username . ']'); diff --git a/app/Platform/Commands/UpdatePlayerPointsStats.php b/app/Platform/Commands/UpdatePlayerPointsStats.php index 0d6bbdc6b8..6f7b8a8a21 100644 --- a/app/Platform/Commands/UpdatePlayerPointsStats.php +++ b/app/Platform/Commands/UpdatePlayerPointsStats.php @@ -39,7 +39,7 @@ public function handle(): void if ($userId !== null) { $user = is_numeric($userId) ? User::findOrFail($userId) - : User::where('User', $userId)->firstOrFail(); + : User::whereName($userId)->firstOrFail(); $this->info("Updating points stats for player [{$user->id}:{$user->username}]"); diff --git a/app/Platform/Components/GameCard.php b/app/Platform/Components/GameCard.php index 69fa569aef..14ed723417 100644 --- a/app/Platform/Components/GameCard.php +++ b/app/Platform/Components/GameCard.php @@ -28,7 +28,7 @@ class GameCard extends Component public function __construct(int $gameId, ?string $targetUsername = null) { $this->gameId = $gameId; - $this->userContext = User::firstWhere('User', $targetUsername) ?? Auth::user() ?? null; + $this->userContext = User::whereName($targetUsername)->first() ?? Auth::user() ?? null; } public function render(): ?View @@ -92,7 +92,7 @@ private function getGameData(int $gameId): ?array $processedClaims = []; foreach ($foundClaims as $foundClaim) { $processedClaim = $foundClaim->toArray(); - $processedClaim['User'] = $foundClaim->user->username; + $processedClaim['User'] = $foundClaim->user->display_name; $processedClaims[] = $processedClaim; } diff --git a/app/Platform/Concerns/BuildsGameListQueries.php b/app/Platform/Concerns/BuildsGameListQueries.php index 2d03eb965c..b158b569e9 100644 --- a/app/Platform/Concerns/BuildsGameListQueries.php +++ b/app/Platform/Concerns/BuildsGameListQueries.php @@ -323,8 +323,8 @@ private function applyReleasedAtSorting(Builder $query, string $sortDirection = // and SQLite. This is preferable to altering the query specifically for // SQLite, because if we do so then we can't actually trust any test results. $query - ->selectRaw( - "GameData.*, + ->selectRaw(<<orderBy('normalized_released_at', $sortDirection); + END AS normalized_released_at, + CASE GameData.released_at_granularity + WHEN 'year' THEN 1 + WHEN 'month' THEN 2 + WHEN 'day' THEN 3 + ELSE 4 + END AS granularity_order + SQL) + ->orderBy('normalized_released_at', $sortDirection) + ->orderBy('granularity_order', $sortDirection); } /** diff --git a/app/Platform/Contracts/HasVersionedTrigger.php b/app/Platform/Contracts/HasVersionedTrigger.php index e70dd2852a..85d9756a46 100644 --- a/app/Platform/Contracts/HasVersionedTrigger.php +++ b/app/Platform/Contracts/HasVersionedTrigger.php @@ -5,12 +5,38 @@ namespace App\Platform\Contracts; use App\Models\Trigger; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; +/** + * @template TModel of Model + */ interface HasVersionedTrigger { /** + * Get the latest trigger version via a denormalized trigger_id column. + * Used for efficient reading of the current trigger. + * + * trigger() as a MorphOne must remain for Eloquent ORM polymorphic + * queries to continue working correctly. + * + * @return BelongsTo + */ + public function currentTrigger(): BelongsTo; + + /** + * Get the latest trigger version. + * * @return MorphOne */ public function trigger(): MorphOne; + + /** + * Get all trigger versions + * + * @return MorphMany + */ + public function triggers(): MorphMany; } diff --git a/app/Platform/Data/EventAchievementData.php b/app/Platform/Data/EventAchievementData.php index 4ce4564deb..eee93e0375 100644 --- a/app/Platform/Data/EventAchievementData.php +++ b/app/Platform/Data/EventAchievementData.php @@ -16,6 +16,7 @@ class EventAchievementData extends Data public function __construct( public Lazy|AchievementData $achievement, public Lazy|AchievementData $sourceAchievement, + public Lazy|EventData $event, public Lazy|Carbon $activeUntil, public Lazy|int $forumTopicId, ) { @@ -27,6 +28,7 @@ public static function fromEventAchievement( return new self( achievement: Lazy::create(fn () => AchievementData::fromAchievement($eventAchievement->achievement)), sourceAchievement: Lazy::create(fn () => AchievementData::fromAchievement($eventAchievement->sourceAchievement)), + event: Lazy::create(fn () => EventData::fromEvent($eventAchievement->event)), activeUntil: Lazy::create(fn () => $eventAchievement->active_until), forumTopicId: Lazy::create(fn () => $eventAchievement->achievement->game->ForumTopicID), ); diff --git a/app/Platform/Data/EventData.php b/app/Platform/Data/EventData.php new file mode 100644 index 0000000000..2667d5efa8 --- /dev/null +++ b/app/Platform/Data/EventData.php @@ -0,0 +1,28 @@ +id, + legacyGame: Lazy::create(fn () => GameData::fromGame($event->legacyGame)), + ); + } +} diff --git a/app/Platform/Data/TicketData.php b/app/Platform/Data/TicketData.php new file mode 100644 index 0000000000..460e5a234e --- /dev/null +++ b/app/Platform/Data/TicketData.php @@ -0,0 +1,33 @@ +id, + ticketableType: TicketableType::Achievement, + state: Lazy::create(fn () => $ticket->state), + ticketable: Lazy::create(fn () => AchievementData::fromAchievement($ticket->achievement)->include('badgeUnlockedUrl')) + ); + } +} diff --git a/app/Platform/Enums/TicketableType.php b/app/Platform/Enums/TicketableType.php new file mode 100644 index 0000000000..0b21b1f3c2 --- /dev/null +++ b/app/Platform/Enums/TicketableType.php @@ -0,0 +1,15 @@ +first(); + $foundUserByFilter = User::whereName($validatedData['filter']['user'])->first(); if (!$foundUserByFilter) { return ['redirect' => route('ranking.beaten-games')]; diff --git a/app/Policies/EventAwardPolicy.php b/app/Policies/EventAwardPolicy.php new file mode 100644 index 0000000000..3689dbbae7 --- /dev/null +++ b/app/Policies/EventAwardPolicy.php @@ -0,0 +1,57 @@ +hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } + + public function viewAny(?User $user): bool + { + return true; + } + + public function view(?User $user, EventAward $eventAward): bool + { + return true; + } + + public function create(User $user): bool + { + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } + + public function update(User $user, EventAward $eventAward): bool + { + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } + + public function delete(User $user, EventAward $eventAward): bool + { + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } +} diff --git a/app/Policies/ForumTopicCommentPolicy.php b/app/Policies/ForumTopicCommentPolicy.php index 392c69e489..5891df911d 100644 --- a/app/Policies/ForumTopicCommentPolicy.php +++ b/app/Policies/ForumTopicCommentPolicy.php @@ -4,7 +4,6 @@ namespace App\Policies; -use App\Enums\Permissions; use App\Models\ForumTopic; use App\Models\ForumTopicComment; use App\Models\Role; @@ -18,10 +17,10 @@ class ForumTopicCommentPolicy public function manage(User $user): bool { return $user->hasAnyRole([ + Role::ADMINISTRATOR, Role::MODERATOR, Role::FORUM_MANAGER, - ]) - || $user->getAttribute('Permissions') >= Permissions::Moderator; + ]); } public function viewAny(?User $user): bool diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index b005be152d..a823dc802f 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -11,6 +11,7 @@ use App\Enums\Permissions; use App\Http\Responses\LoginResponse; use App\Models\User; +use Hash; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; @@ -56,12 +57,48 @@ public function register(): void $this->app->singleton(LoginResponseContract::class, LoginResponse::class); $this->app->singleton(TwoFactorLoginResponse::class, LoginResponse::class); + Fortify::authenticateUsing(function (Request $request) { + $user = User::where('User', $request->input(Fortify::username())) + ->orWhere('display_name', $request->input(Fortify::username())) + ->first(); + + if (!$user) { + return null; + } + + // banned users should not have a password anymore. make sure they cannot get back in when a password still exists + if ($user->getAttribute('Permissions') < Permissions::Unregistered) { + return null; + } + + // if the user hasn't logged in for a while, they may still have a salted password, upgrade it + if (mb_strlen($user->SaltedPass) === 32) { + $pepperedPassword = md5($request->input('password') . config('app.legacy_password_salt')); + if ($user->SaltedPass === $pepperedPassword) { + changePassword($user->User, $request->input('password')); + + return $user; + } + + return null; + } + + // Standard password check + if (Hash::check($request->input('password'), $user->Password)) { + return $user; + } + + return null; + }); + Fortify::authenticateThrough(function (Request $request) { return array_filter([ config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class, Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorAuthenticatable::class : null, function ($request, $next) { - $user = User::firstWhere(Fortify::username(), $request->input(Fortify::username())); + $user = User::where('User', $request->input(Fortify::username())) + ->orWhere('display_name', $request->input(Fortify::username())) + ->first(); // banned users should not have a password anymore. make sure they cannot get back in when a password still exists if ($user && $user->getAttribute('Permissions') < Permissions::Unregistered) { diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 1398c68b23..734a71f433 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -8,6 +8,7 @@ use App\Http\Controllers\HomeController; use App\Http\Controllers\RedirectController; use App\Http\Controllers\UserController; +use App\Models\User; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -31,21 +32,6 @@ public function boot(): void */ Route::pattern('slug', '-[a-zA-Z0-9_-]+'); Route::pattern('user', '[a-zA-Z0-9_]{1,20}'); - - // TODO v2 - // Route::bind('user', function ($value) { - // /** - // * TODO: resolve user by username, hashId, or both - // */ - // $query = User::where('username', Str::lower($value)); - // - // /* - // * add last activity - // */ - // $query->withLastActivity(); - // - // return $query->firstOrFail(); - // }); } public function map(): void diff --git a/app/Support/Shortcode/Shortcode.php b/app/Support/Shortcode/Shortcode.php index 8e8102ff85..be493f2a6a 100644 --- a/app/Support/Shortcode/Shortcode.php +++ b/app/Support/Shortcode/Shortcode.php @@ -60,16 +60,29 @@ public static function convertUserShortcodesToUseIds(string $input): string // Fetch all users by username in a single query. $users = User::withTrashed() - ->whereIn(DB::raw('LOWER(User)'), $normalizedUsernames) - ->get(['ID', 'User']) - ->keyBy(fn ($user) => strtolower($user->User)); + ->where(function ($query) use ($normalizedUsernames) { + $query->whereIn(DB::raw('LOWER(User)'), $normalizedUsernames) + ->orWhereIn(DB::raw('LOWER(display_name)'), $normalizedUsernames); + }) + ->get(['ID', 'User', 'display_name']); + + // Create a lookup map that includes both username and display name as keys. + $userMap = collect(); + foreach ($users as $user) { + if ($user->User) { + $userMap[strtolower($user->username)] = $user; + } + if ($user->display_name) { + $userMap[strtolower($user->display_name)] = $user; + } + } // Replace each username with the corresponding user ID. - return preg_replace_callback('/\[user=(.*?)\]/', function ($matches) use ($users) { + return preg_replace_callback('/\[user=(.*?)\]/', function ($matches) use ($userMap) { $username = strtolower($matches[1]); - $user = $users->get($username); + $user = $userMap->get($username); - return $user ? "[user={$user->id}]" : $matches[0]; + return $user ? "[user={$user->ID}]" : $matches[0]; }, $input); } diff --git a/app/Support/Sync/SyncTrait.php b/app/Support/Sync/SyncTrait.php index 675db8c077..183ff5ce49 100644 --- a/app/Support/Sync/SyncTrait.php +++ b/app/Support/Sync/SyncTrait.php @@ -542,7 +542,7 @@ protected function getUserId(string $username): ?int $userId = $this->userIds[$username] ?? null; if ($userId === null) { /** @var ?User $user */ - $user = User::where('User', Str::lower($username))->first(); + $user = User::whereName(Str::lower($username))->first(); if (!$user) { $this->userIds[$username] = 0; diff --git a/config/missing-page-redirector.php b/config/missing-page-redirector.php index 1588a7b658..25febd4f08 100644 --- a/config/missing-page-redirector.php +++ b/config/missing-page-redirector.php @@ -98,7 +98,7 @@ '/userList.php' => '/users', '/history.php' => '/user/{u}/history', '/historyexamine.php' => '/user/{u}/history/{d}', - '/usergameactivity.php' => '/user/{f}/game/{ID}', + // '/usergameactivity.php' => '/user/{f}/game/{ID}', '/gamecompare.php' => '/user/{f}/game/{ID}/compare', /* diff --git a/database/factories/TriggerFactory.php b/database/factories/TriggerFactory.php new file mode 100644 index 0000000000..6d21920776 --- /dev/null +++ b/database/factories/TriggerFactory.php @@ -0,0 +1,34 @@ + + */ +class TriggerFactory extends Factory +{ + protected $model = Trigger::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'conditions' => '0xH00fffb=0_0xH00fe10=1_0xH00fe11=2_0xH00f7d0>=32', + 'version' => 1, + 'parent_id' => null, + 'user_id' => User::factory()->create()->id, + 'triggerable_type' => TriggerableType::Achievement, + 'triggerable_id' => Achievement::factory()->create()->id, + ]; + } +} diff --git a/database/migrations/2025_01_07_000000_denormalize_triggerables.php b/database/migrations/2025_01_07_000000_denormalize_triggerables.php new file mode 100644 index 0000000000..489b097eaf --- /dev/null +++ b/database/migrations/2025_01_07_000000_denormalize_triggerables.php @@ -0,0 +1,63 @@ +foreignId('trigger_id') + ->nullable() + ->after('user_id') + ->constrained('triggers') + ->nullOnDelete(); + + $table->index('trigger_id'); + }); + + Schema::table('LeaderboardDef', function (Blueprint $table) { + $table->foreignId('trigger_id') + ->nullable() + ->after('author_id') + ->constrained('triggers') + ->nullOnDelete(); + + $table->index('trigger_id'); + }); + + Schema::table('GameData', function (Blueprint $table) { + $table->foreignId('trigger_id') + ->nullable() + ->after('releases') + ->constrained('triggers') + ->nullOnDelete(); + + $table->index('trigger_id'); + }); + } + + public function down(): void + { + Schema::table('GameData', function (Blueprint $table) { + $table->dropForeign(['trigger_id']); + $table->dropIndex(['trigger_id']); + $table->dropColumn('trigger_id'); + }); + + Schema::table('LeaderboardDef', function (Blueprint $table) { + $table->dropForeign(['trigger_id']); + $table->dropIndex(['trigger_id']); + $table->dropColumn('trigger_id'); + }); + + Schema::table('Achievements', function (Blueprint $table) { + $table->dropForeign(['trigger_id']); + $table->dropIndex(['trigger_id']); + $table->dropColumn('trigger_id'); + }); + } +}; diff --git a/database/migrations/2025_01_07_000001_update_triggers_table.php b/database/migrations/2025_01_07_000001_update_triggers_table.php new file mode 100644 index 0000000000..2d630ac860 --- /dev/null +++ b/database/migrations/2025_01_07_000001_update_triggers_table.php @@ -0,0 +1,38 @@ +foreign('parent_id') + ->references('id') + ->on('triggers') + ->nullOnDelete(); + + $table->index('parent_id'); + }); + + Schema::table('triggers', function (Blueprint $table) { + $table->dropColumn(['type', 'stat', 'stat_goal', 'stat_format']); + }); + } + + public function down(): void + { + Schema::table('triggers', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropIndex(['parent_id']); + + $table->text('type')->nullable(); + $table->string('stat')->nullable(); + $table->string('stat_goal')->nullable(); + $table->string('stat_format', 50)->nullable(); + }); + } +}; diff --git a/database/migrations/2026_01_10_000000_create_event_awards_table.php b/database/migrations/2026_01_10_000000_create_event_awards_table.php new file mode 100644 index 0000000000..e04e00c456 --- /dev/null +++ b/database/migrations/2026_01_10_000000_create_event_awards_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->unsignedBigInteger('event_id'); + $table->integer('tier_index'); + $table->string('label', 40); + $table->integer('achievements_required'); + $table->string('image_asset_path', 50); + $table->timestamps(); + }); + + Schema::table('event_awards', function (Blueprint $table) { + $table->foreign('event_id') + ->references('id') + ->on('events') + ->onDelete('cascade'); + + $table->unique(['event_id', 'tier_index']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_awards'); + } +}; diff --git a/lang/de_DE.json b/lang/de_DE.json index 0afcd46fb0..f7568a555c 100644 --- a/lang/de_DE.json +++ b/lang/de_DE.json @@ -14,6 +14,7 @@ "Accountability for content": "Verantwortung über Inhalte", "Accountability for links": "Verantwortung über Links", "Achievement": "Erfolg", + "Achievement Checklist": "Erfolgs-Checkliste", "Achievement Unlocks": "Freigeschaltene Erfolge", "Achievement of the Week": "Erfolg der Woche", "Achievements": "Erfolge", @@ -135,6 +136,7 @@ "Forum Index": "Forum-Index", "Forum Posts": "Forumsbeiträge", "Forum Posts - {{user}}": "Forumsbeiträge - {{user}}", + "from": "von", "Game": "Spiel", "Game Details": "Spiel-Details", "Games": "Spiele", @@ -165,6 +167,7 @@ "Important": "Wichtig", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Das Erwähnen deiner korrekten Emulator-Version hilft Entwicklern Probleme schneller zu erkennen und zu lösen.", "Information about cookies": "Information zu Cookies", + "Invalid list": "Ungültige Liste", "Issue": "Problem", "Join us on Discord": "Trette unserem Discord bei", "Just Released": "Vor kurzem veröffentlicht", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Tippe / um das Suchfeld zu fokussieren.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Schreibe deinen Kommentar hier. Poste oder fordere keine Links zu urheberrechtlich geschützten ROMs.", "Undo": "Rückgängig", + "Unlocked {{when}}": "Freigeschaltet am {{when}}", "Unpublished": "Unveröffentlicht", "Unsubscribe": "Abonnent beenden", "Unsubscribed!": "Abonnent wurde beendet!", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} hat <1>{{game}} nicht gespielt.", "Start of reconstructed timeline.": "Beginn der rekonstruierten Zeitleiste.", "Manually unlocked by <1>{{user}}.": "Manuell freigeschaltet von <1>{{user}}.", + "Game Suggestions - {{user}}": "Game Suggestions - {{user}}", + "Reasoning": "Reasoning", + "Game Suggestions": "Game Suggestions", + "Personalized Game Suggestions": "Personalized Game Suggestions", + "In <1><2><3>hub<4>{{hubName}} with": "In <1><2><3>hub<4>{{hubName}} with", + "Same <1>dev as": "Same <1>dev as", + "By <1>same developer as": "By <1>same developer as", + "Similar to beaten": "Similar to beaten", + "Similar to mastered": "Similar to mastered", + "Similar to backlog": "Similar to backlog", + "Random": "Random", + "Randomly selected": "Randomly selected", + "In your backlog": "In your backlog", + "Revised": "Revised", + "Similar beats": "Similar beats", + "Similar masteries": "Similar masteries", + "Beaten by players of": "Beaten by players of", + "Mastered by players of": "Mastered by players of", + "Roll again": "Roll again", "supportedGameFilesCountLabel_one": "Aktuell gibt es <1>{{count, number}} registrierten Spieldatei-Hash, welcher für dieses Spiel unterstützt wird.", "supportedGameFilesCountLabel_other": "Aktuell gibt es <1>{{count, number}} registrierte Spieldatei-Hashes, welche für dieses Spiel unterstützt werden.", "userCount_one": "<1>{{userCount, number}} Benutzer ist gerade online.", diff --git a/lang/en_GB.json b/lang/en_GB.json index 7196d231f1..1bc3af44fc 100644 --- a/lang/en_GB.json +++ b/lang/en_GB.json @@ -14,6 +14,7 @@ "Accountability for content": "Accountability for content", "Accountability for links": "Accountability for links", "Achievement": "Achievement", + "Achievement Checklist": "Achievement Checklist", "Achievement Unlocks": "Achievement Unlocks", "Achievement of the Week": "Achievement of the Week", "Achievements": "Achievements", @@ -135,6 +136,7 @@ "Forum Index": "Forum Index", "Forum Posts": "Forum Posts", "Forum Posts - {{user}}": "Forum Posts - {{user}}", + "from": "from", "Game": "Game", "Game Details": "Game Details", "Games": "Games", @@ -165,6 +167,7 @@ "Important": "Important", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Including your correct emulator version helps developers more quickly identify and resolve issues.", "Information about cookies": "Information about cookies", + "Invalid list": "Invalid list", "Issue": "Issue", "Join us on Discord": "Join us on Discord", "Just Released": "Just Released", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Type / to focus the search field.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Type your comment here. Do not post or request any links to copyrighted ROMs.", "Undo": "Undo", + "Unlocked {{when}}": "Unlocked {{when}}", "Unpublished": "Unpublished", "Unsubscribe": "Unsubscribe", "Unsubscribed!": "Unsubscribed!", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} has not played <1>{{game}}.", "Start of reconstructed timeline.": "Start of reconstructed timeline.", "Manually unlocked by <1>{{user}}.": "Manually unlocked by <1>{{user}}.", + "Game Suggestions - {{user}}": "Game Suggestions - {{user}}", + "Reasoning": "Reasoning", + "Game Suggestions": "Game Suggestions", + "Personalized Game Suggestions": "Personalised Game Suggestions", + "In <1><2><3>hub<4>{{hubName}} with": "In <1><2><3>hub<4>{{hubName}} with", + "Same <1>dev as": "Same <1>dev as", + "By <1>same developer as": "By <1>same developer as", + "Similar to beaten": "Similar to beaten", + "Similar to mastered": "Similar to mastered", + "Similar to backlog": "Similar to backlog", + "Random": "Random", + "Randomly selected": "Randomly selected", + "In your backlog": "In your backlog", + "Revised": "Revised", + "Similar beats": "Similar beats", + "Similar masteries": "Similar masteries", + "Beaten by players of": "Beaten by players of", + "Mastered by players of": "Mastered by players of", + "Roll again": "Roll again", "supportedGameFilesCountLabel_one": "There is currently <1>{{count, number}} supported game file hash registered for this game.", "supportedGameFilesCountLabel_other": "There are currently <1>{{count, number}} supported game file hashes registered for this game.", "userCount_one": "<1>{{userCount, number}} user is currently online.", diff --git a/lang/en_US.json b/lang/en_US.json index d406bd1efb..ae23e31f19 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -177,7 +177,6 @@ "Latest": "Latest", "Latest Sets in Progress": "Latest Sets in Progress", "Leaderboards": "Leaderboards", - "Learn more about this event": "Learn more about this event", "Legal & Terms": "Legal & Terms", "Less": "Less", "Load more": "Load more", @@ -595,6 +594,20 @@ "{{user}} has not played <1>{{game}}.": "{{user}} has not played <1>{{game}}.", "Start of reconstructed timeline.": "Start of reconstructed timeline.", "Manually unlocked by <1>{{user}}.": "Manually unlocked by <1>{{user}}.", + "Edit Post": "Edit Post", + "Bold": "Bold", + "Italic": "Italic", + "Underline": "Underline", + "Strikethrough": "Strikethrough", + "Code": "Code", + "Spoiler": "Spoiler", + "Image": "Image", + "Link": "Link", + "Ticket": "Ticket", + "Ticket #{{ticketId}}": "Ticket #{{ticketId}}", + "Body": "Body", + "Preview": "Preview", + "View this year's event": "View this year's event", "Game Suggestions - {{user}}": "Game Suggestions - {{user}}", "Reasoning": "Reasoning", "Game Suggestions": "Game Suggestions", diff --git a/lang/es_ES.json b/lang/es_ES.json index cc684f7a94..a06d33ab6f 100644 --- a/lang/es_ES.json +++ b/lang/es_ES.json @@ -14,6 +14,7 @@ "Accountability for content": "Responsabilidad por el contenido", "Accountability for links": "Responsabilidad por enlaces", "Achievement": "Logro", + "Achievement Checklist": "Lista de verificación de logros", "Achievement Unlocks": "Logros desbloqueados", "Achievement of the Week": "Logro de la semana", "Achievements": "Logros", @@ -135,6 +136,7 @@ "Forum Index": "Índice del foro", "Forum Posts": "Publicaciones en el foro", "Forum Posts - {{user}}": "Publicaciones en el foro - {{user}}", + "from": "en", "Game": "Juego", "Game Details": "Detalles del juego", "Games": "Juegos", @@ -165,6 +167,7 @@ "Important": "Importante", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Incluyendo la versión correcta de tu emulador ayudará a los desarrolladores a identificar y a resolver el problema más rápidamente.", "Information about cookies": "Información sobre cookies", + "Invalid list": "Lista incorrecta", "Issue": "Problema", "Join us on Discord": "Únete a nosotros en Discord", "Just Released": "Recién lanzados", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Escribe / para poner el foco en el campo de búsqueda.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Escriba aquí tu comentario. No publiques ni solicites enlaces a ROM protegidas con derechos de autor.", "Undo": "Deshacer", + "Unlocked {{when}}": "Desbloqueado el {{when}}", "Unpublished": "Sin publicar", "Unsubscribe": "Cancelar subscripción", "Unsubscribed!": "¡Subscripción cancelada!", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} no ha jugado a <1>{{game}}.", "Start of reconstructed timeline.": "Inicio de la línea de tiempo recreada.", "Manually unlocked by <1>{{user}}.": "Desbloqueado manualmente por <1>{{user}}.", + "Game Suggestions - {{user}}": "Sugerencias de juegos - {{user}}", + "Reasoning": "Justificación", + "Game Suggestions": "Sugerencias de juegos", + "Personalized Game Suggestions": "Sugerencias de juegos personalizadas", + "In <1><2><3>hub<4>{{hubName}} with": "En <1><2><3>hub<4>{{hubName}} con", + "Same <1>dev as": "El mismo <1>desarrollador que", + "By <1>same developer as": "Del <1>mismo desarrollador que", + "Similar to beaten": "Similar al superado", + "Similar to mastered": "Similar al dominado", + "Similar to backlog": "Similar al pendiente", + "Random": "Aleatorio", + "Randomly selected": "Seleccionado aleatoriamente", + "In your backlog": "En tu lista de pendientes", + "Revised": "Revisado", + "Similar beats": "Superado similar", + "Similar masteries": "Dominado similar", + "Beaten by players of": "Superado por jugadores de", + "Mastered by players of": "Dominado por jugadores de", + "Roll again": "Probar de nuevo", "supportedGameFilesCountLabel_one": "Actualmente <1>{{count, number}} registro de hash de archivo soportado registrado para este juego.", "supportedGameFilesCountLabel_other": "Actualmente hay registrados <1>{{count, number}} hashes de archivos soportados registrados para este juego.", "userCount_one": "Hay <1>{{userCount, number}} usuario en línea.", diff --git a/lang/fr_FR.json b/lang/fr_FR.json index 07826198be..d0fe7569df 100644 --- a/lang/fr_FR.json +++ b/lang/fr_FR.json @@ -14,6 +14,7 @@ "Accountability for content": "Responsabilité pour le contenu", "Accountability for links": "Responsabilité pour les liens", "Achievement": "Succès", + "Achievement Checklist": "Liste de Succès", "Achievement Unlocks": "Obtentions de Succès", "Achievement of the Week": "Succès de la Semaine", "Achievements": "Succès", @@ -135,6 +136,7 @@ "Forum Index": "Index du Forum", "Forum Posts": "Messages du Forum", "Forum Posts - {{user}}": "Messages du Forum - {{user}}", + "from": "de", "Game": "Jeu", "Game Details": "Détails du jeu", "Games": "Jeux", @@ -165,6 +167,7 @@ "Important": "Important", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Inclure la version correcte de votre émulateur aide les développeurs à identifier et résoudre les problèmes plus rapidement.", "Information about cookies": "Informations sur les cookies", + "Invalid list": "Liste invalide", "Issue": "Problème", "Join us on Discord": "Rejoignez-nous sur Discord", "Just Released": "Vient de sortir", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Tapez / pour focaliser le champ de recherche.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Tapez votre commentaire ici. Ne publiez ni ne demandez de liens vers des ROMs protégées par le droit d'auteur.", "Undo": "Annuler", + "Unlocked {{when}}": "Débloqué le {{when}}", "Unpublished": "Non-publié", "Unsubscribe": "Se désabonner", "Unsubscribed!": "Désabonné !", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} n'a pas joué à <1>{{game}}.", "Start of reconstructed timeline.": "Début de la timeline reconstruite.", "Manually unlocked by <1>{{user}}.": "Débloqué manuellement par <1>{{user}}.", + "Game Suggestions - {{user}}": "Game Suggestions - {{user}}", + "Reasoning": "Reasoning", + "Game Suggestions": "Game Suggestions", + "Personalized Game Suggestions": "Personalized Game Suggestions", + "In <1><2><3>hub<4>{{hubName}} with": "In <1><2><3>hub<4>{{hubName}} with", + "Same <1>dev as": "Same <1>dev as", + "By <1>same developer as": "By <1>same developer as", + "Similar to beaten": "Similar to beaten", + "Similar to mastered": "Similar to mastered", + "Similar to backlog": "Similar to backlog", + "Random": "Random", + "Randomly selected": "Randomly selected", + "In your backlog": "In your backlog", + "Revised": "Revised", + "Similar beats": "Similar beats", + "Similar masteries": "Similar masteries", + "Beaten by players of": "Beaten by players of", + "Mastered by players of": "Mastered by players of", + "Roll again": "Roll again", "supportedGameFilesCountLabel_one": "Il y a actuellement <1>{{count, number}} empreinte de fichier de jeu pris en charge enregistrée pour ce jeu.", "supportedGameFilesCountLabel_other": "Il y a actuellement <1>{{count, number}} empreintes de fichiers de jeu pris en charge enregistrées pour ce jeu.", "userCount_one": "<1>{{userCount, number}} utilisateur est actuellement en ligne.", diff --git a/lang/pl_PL.json b/lang/pl_PL.json index f7291af580..c27a04452e 100644 --- a/lang/pl_PL.json +++ b/lang/pl_PL.json @@ -14,6 +14,7 @@ "Accountability for content": "Odpowiedzialność za treści", "Accountability for links": "Odpowiedzialność za linki", "Achievement": "Osiągnięcie", + "Achievement Checklist": "Achievement Checklist", "Achievement Unlocks": "Odblokowane osiągnięcia", "Achievement of the Week": "Achievement of the Week", "Achievements": "Osiągnięcia", @@ -135,6 +136,7 @@ "Forum Index": "Forum", "Forum Posts": "Posty na Forum", "Forum Posts - {{user}}": "Posty na forum - {{user}}", + "from": "from", "Game": "Gra", "Game Details": "Szczegóły gry", "Games": "Gry", @@ -165,6 +167,7 @@ "Important": "Ważne", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Zamieszczenie właściwej wersji emulatora pomaga deweloperom szybciej zidentyfikować i naprawić problemy.", "Information about cookies": "Informacja o cookies", + "Invalid list": "Invalid list", "Issue": "Problem", "Join us on Discord": "Dołącz do nas na Discord", "Just Released": "Ostatnio wydane", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Naciśnij / aby przejść do pola wyszukiwania.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Wpisz swój komentarz tutaj. Nie umieszczaj próśb ani żadnych linków do ROM-ów objętych prawami autorskimi.", "Undo": "Cofnij", + "Unlocked {{when}}": "Odblokowano {{when}}", "Unpublished": "Nieopublikowane", "Unsubscribe": "Wypisz się", "Unsubscribed!": "Wypisano!", @@ -474,13 +478,13 @@ "Awards - Exact": "Nagrody - dokładnie", "Special Filters": "Specjalne filtry", "All {{systemName}} Games": "Wszystkie gry {{systemName}}", - "{{hubTitle}} (Hub)": "{{hubTitle}} (Hub)", - "Related Hubs": "Related Hubs", - "Manage": "Manage", - "Hub": "Hub", - "Links": "Links", - "No related hubs.": "No related hubs.", - "All Hubs": "All Hubs", + "{{hubTitle}} (Hub)": "{{hubTitle}} (Zbiór)", + "Related Hubs": "Powiązane zbiory", + "Manage": "Zarządzaj", + "Hub": "Zbiór", + "Links": "Powiązania", + "No related hubs.": "Brak powiązanych zbiorów.", + "All Hubs": "Wszystkie zbiory", "Go to previous news page": "Przejdź na poprzednią stronę nowości", "Go to next news page": "Przejdź na następną stronę nowości", "Swipe to view more": "Przeciągnij, aby zobaczyć więcej", @@ -506,17 +510,17 @@ "Leaderboard": "Tabela wyników", "Entry": "Wpis", "Submitted": "Przesłane", - "Unlocked": "Unlocked", - "Recent Unlocks": "Recent Unlocks", + "Unlocked": "Odblokowane", + "Recent Unlocks": "Ostatnie odblokowania", "Award Kind": "Award Kind", "Earned": "Earned", "Recent Awards": "Ostatnie nagrody", "Developer Feed - {{user}}": "Developer Feed - {{user}}", - "Current Players": "Current Players", + "Current Players": "Obecni gracze", "Couldn't find any recent achievement unlocks.": "Couldn't find any recent achievement unlocks.", "Couldn't find any recent leaderboard entries.": "Couldn't find any recent leaderboard entries.", "Couldn't find any recent awards.": "Couldn't find any recent awards.", - "Pinned {{pinnedAt}}": "Pinned {{pinnedAt}}", + "Pinned {{pinnedAt}}": "Przypięto {{pinnedAt}}", "Pinned": "Przypięte", "new": "nowe", "View Forum Topic": "Zobacz wątek na forum", @@ -538,10 +542,10 @@ "{{earned, number}} of {{total, number}}": "{{earned, number}} z {{total, number}}", "No sessions": "Brak sesji", "1 session": "1 sesja", - "{{count}} sessions over {{days}} day": "{{count}} sessions over {{days}} day", - "{{count}} sessions over {{days}} days": "{{count}} sessions over {{days}} days", - "{{count}} sessions over 1 hour": "{{count}} sessions over 1 hour", - "{{count}} sessions over {{hours}} hours": "{{count}} sessions over {{hours}} hours", + "{{count}} sessions over {{days}} day": "{{count}} sesji w ciągu {{days}} dnia", + "{{count}} sessions over {{days}} days": "{{count}} sesji w ciągu {{days}} dni", + "{{count}} sessions over 1 hour": "{{count}} sesji w ciągu godziny", + "{{count}} sessions over {{hours}} hours": "{{count}} sesji w ciągu {{hours}} godzin", "All time the player spent in the game across all sessions, even when they did not earn achievements.": "All time the player spent in the game across all sessions, even when they did not earn achievements.", "Only counts time from sessions where achievements were unlocked.": "Only counts time from sessions where achievements were unlocked.", "The count of sessions where achievements were unlocked.": "The count of sessions where achievements were unlocked.", @@ -554,17 +558,17 @@ "Immediately after previous.": "Immediately after previous.", "Rich Presence": "Rich Presence", "Unknown": "Unknown", - "(estimated)": "(estimated)", + "(estimated)": "(szacowane)", "This player has sessions for the game where the playtime recorded to the server is not precise.": "This player has sessions for the game where the playtime recorded to the server is not precise.", - "Unofficial Achievement.": "Unofficial Achievement.", + "Unofficial Achievement.": "Nieoficjalne osiągnięcie.", "This is a game with multiple discs. The hash shown here will only reflect the first disc loaded in the session.": "This is a game with multiple discs. The hash shown here will only reflect the first disc loaded in the session.", - "<1>{{user}} awarded a Manual Unlock": "<1>{{user}} awarded a Manual Unlock", + "<1>{{user}} awarded a Manual Unlock": "<1>{{user}} przyznał ręczne odblokowanie", "Boot Only": "Boot Only", "The player launched the game and then either experienced network issues or closed the game within 60 seconds.": "The player launched the game and then either experienced network issues or closed the game within 60 seconds.", "Unknown Hash": "Nieznany hash", "Either the user's emulator isn't reporting what hash they're using, the user's session was created before we started recording hashes, the user closed the game very quickly, or the user's hash was later unlinked from the game.": "Either the user's emulator isn't reporting what hash they're using, the user's session was created before we started recording hashes, the user closed the game very quickly, or the user's hash was later unlinked from the game.", "(Softcore)": "(Softcore)", - "No emulator usage data is available.": "No emulator usage data is available.", + "No emulator usage data is available.": "Brak danych dotyczących użycia emulatora.", "Hide all player sessions where achievements were not earned": "Hide all player sessions where achievements were not earned", "Emulator usage breakdown": "Emulator usage breakdown", "+{{count}} more": "+{{count}} więcej", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} nie zagrał w <1>{{game}}.", "Start of reconstructed timeline.": "Początek zrekonstruowanej osi czasu.", "Manually unlocked by <1>{{user}}.": "Ręcznie odblokowane przez <1>{{user}}.", + "Game Suggestions - {{user}}": "Propozycje gier - {{user}}", + "Reasoning": "Reasoning", + "Game Suggestions": "Propozycje gier", + "Personalized Game Suggestions": "Spersonalizowane propozycje gier", + "In <1><2><3>hub<4>{{hubName}} with": "W <1><2><3>zbiorze<4>{{hubName}} z", + "Same <1>dev as": "Ten sam <1>dev jak w", + "By <1>same developer as": "Przez <1>tego samego dewelopera jak w", + "Similar to beaten": "Podobne do ukończonego", + "Similar to mastered": "Podobne do wymasterowanego", + "Similar to backlog": "Podobne do zaległego", + "Random": "Losowy", + "Randomly selected": "Losowo wybrane", + "In your backlog": "W Twoich zaległościach", + "Revised": "Zrewizowany", + "Similar beats": "Podobne do ukończonych", + "Similar masteries": "Podobne do wymasterowanych", + "Beaten by players of": "Ukończone przez graczy z", + "Mastered by players of": "Wymasterowane przez graczy z", + "Roll again": "Losuj ponownie", "supportedGameFilesCountLabel_one": "Obecnie jest <1>{{count, number}} zarejestrowany hash pliku gry", "supportedGameFilesCountLabel_few": "Obecnie są <1>{{count, number}} zarejestrowane hashe plików gry", "supportedGameFilesCountLabel_many": "Obecnie jest <1>{{count, number}} zarejestrowanych hashów plików gry", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 0b0c2f6fbf..d03bcee73e 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -1,21 +1,22 @@ { "(Hardcore)": "(Hardcore)", - "<1>Be very descriptive about what you were doing when the problem happened. Mention if you were using any <2>non-default settings, a non-English language, in-game cheats, glitches or were otherwise playing in some unusual way. If possible, include a <3>link to a save state or save game to help us reproduce the issue.": "<1>Seja bem detalhado sobre o que estava fazendo quando o problema aconteceu. Mencione se estava usando alguma <2>configuração não padrão, um idioma diferente, trapaças no jogo, ou se estava jogando de alguma maneira <2>não usual. Se possível, inclua um <3>link para um estado de salvamento ou arquivo de jogo para nos ajudar a reproduzir o problema.", + "<1>Be very descriptive about what you were doing when the problem happened. Mention if you were using any <2>non-default settings, a non-English language, in-game cheats, glitches or were otherwise playing in some unusual way. If possible, include a <3>link to a save state or save game to help us reproduce the issue.": "<1>Seja bem descritivo sobre o que você estava fazendo quando o problema aconteceu. Mencione se estava usando alguma <2>configuração não padrão, uma língua diferente, trapaças no jogo, ou se estava jogando de alguma maneira não usual. Se possível, inclua um <3>link para um save state ou save de jogo seu para nos ajudar a reproduzir o problema.", "<1>Send a message to DevCompliance for:": "<1>Enviar uma mensagem para DevCompliance para:", "<1>Send a message to DevQuest for submissions, questions, ideas, or reporting issues related to <2>DevQuest.": "<1>Enviar uma mensagem para DevQuest para submissões, perguntas, ideias ou reportar problemas relacionados a <2>DevQuest.", "<1>Send a message to QATeam for:": "<1>Enviar uma mensagem para QATeam para:", "<1>Send a message to RAArtTeam for:": "<1>Enviar uma mensagem para RAArtTeam para:", - "<1>Send a message to RACheats if you believe someone is in violation of our <2>Global Leaderboard and Achievement Hunting Rules.": "<1>Enviar uma mensagem para RACheats se você acredita que alguém está violando nossos <2>Regras Globais de Leaderboard e Caça a Conquistas.", + "<1>Send a message to RACheats if you believe someone is in violation of our <2>Global Leaderboard and Achievement Hunting Rules.": "<1>Envie uma mensagem para RACheats se você acredita que alguém está violando nossas <2>Regras Globais de Leaderboard e Caçada de Conquistas.", "<1>Send a message to RAEvents for submissions, questions, ideas, or reporting issues related to <2>community events.": "<1>Enviar uma mensagem para RAEvents para submissões, perguntas, ideias ou reportar problemas relacionados a <2>eventos comunitários.", "<1>Send a message to RANews for:": "<1>Enviar uma mensagem para RANews para:", "<1>Send a message to RAdmin for:": "<1>Enviar uma mensagem para RAdmin para:", "<1>Send a message to TheUnwanted for submissions, questions, ideas, or reporting issues specifically related to <2>The Unwanted.": "<1>Enviar uma mensagem para TheUnwanted para submissões, perguntas, ideias ou reportar problemas especificamente relacionados a <2>The Unwanted.", - "<1>Send a message to WritingTeam for:": "<1>Enviar uma mensagem para WritingTeam para:", + "<1>Send a message to WritingTeam for:": "<1>Envie uma mensagem para WritingTeam para:", "Accountability for content": "Responsabilidade pelo conteúdo", "Accountability for links": "Responsabilidade por links", "Achievement": "Conquista", + "Achievement Checklist": "Checklist de Conquistas", "Achievement Unlocks": "Conquistas Desbloqueadas", - "Achievement of the Week": "Conquista da semana", + "Achievement of the Week": "Conquista da Semana", "Achievements": "Conquistas", "Active Players": "Jogadores Ativos", "Add to Want to Play Games": "Adicionar a Jogos que Quero Jogar", @@ -28,7 +29,7 @@ "After requesting account deletion you may cancel your request within 14 days.": "Após solicitar a exclusão da conta, você pode cancelar sua solicitação dentro de 14 dias.", "After uploading, press Ctrl + F5. This refreshes your browser cache making the new image visible.": "Após o envio, pressione Ctrl + F5. Isso atualiza o cache do seu navegador, tornando a nova imagem visível.", "All Games": "Todos os Jogos", - "All Systems": "Todos as Consoles", + "All Systems": "Todos os Consoles", "All Users": "Todos os Usuários", "All won achievements for this game": "Todas as conquistas desbloqueadas para este jogo", "All-time High: {{val, number}} ({{date}})": "Máximo Histórico: {{val, number}} ({{date}})", @@ -46,7 +47,7 @@ "Ascending (A - Z)": "Ascendente (A - Z)", "Automatically opt in to all game sets": "Inscrever-se automaticamente em todos os sets de jogos", "Avatar": "Foto de perfil", - "Be as descriptive as possible. Give exact steps to reproduce the issue. Consider linking to a save state.": "Seja o mais descritivo possível. Dê os passos exatos para reproduzir o problema. Considere incluir um link para um estado de salvamento.", + "Be as descriptive as possible. Give exact steps to reproduce the issue. Consider linking to a save state.": "Seja o mais descritivo possível. Dê os passos exatos para reproduzir o problema. Considere incluir um link para um save state seu.", "Beaten": "Zerado", "Beaten (softcore)": "Zerado (softcore)", "Become a Patron": "Torne-se um Apoiador", @@ -80,7 +81,7 @@ "Comments on a forum topic I'm involved in": "Comentários em um tópico do fórum do qual participo", "Comments on an achievement I created": "Comentários em uma conquista que criei", "Comments on my activity": "Comentários na minha atividade", - "Comments on my user wall": "Comentários na minha parede de usuário", + "Comments on my user wall": "Comentários no meu mural", "Comments: {{leaderboardTitle}}": "Comentários: {{leaderboardTitle}}", "Completed": "Completado", "Confirm New Email Address": "Confirmar Novo Endereço de E-mail", @@ -97,12 +98,12 @@ "Create Ticket": "Criar Ticket", "Create Ticket - {{achievementTitle}}": "Criar Ticket - {{achievementTitle}}", "Current Email Address": "Endereço de E-mail Atual", - "Current Locale": "Local Atual", + "Current Locale": "Idioma Atual", "Current Password": "Senha Atual", "Currently Online": "Online", "Customize View": "Personalizar Visualização", "Delete Account": "Excluir Conta", - "Delete All Comments on My User Wall": "Excluir Todos os Comentários na Minha Parede de Usuário", + "Delete All Comments on My User Wall": "Excluir Todos os Comentários no Meu Mural", "Delete comment": "Excluir comentário", "Deleted!": "Excluído!", "Deleting...": "Excluindo...", @@ -112,7 +113,7 @@ "Details on how the hash is generated for each system can be found <1>here.": "Detalhes sobre como o hash é gerado para cada console podem ser encontrados <1>aqui.", "Dev": "Desenvolvedor", "DevQuest": "DevQuest", - "Developer Compliance": "Conformidade de Desenvolvedor", + "Developer Compliance": "Developer Compliance", "Did not trigger": "Não foi ativado", "Disclaimer about ROMs": "Aviso sobre as ROMs", "Disclaimers": "Isenções de Responsabilidade", @@ -135,6 +136,7 @@ "Forum Index": "Índice do Fórum", "Forum Posts": "Postagens no Fórum", "Forum Posts - {{user}}": "Postagens no Fórum - {{user}}", + "from": "de", "Game": "Jogo", "Game Details": "Detalhes do Jogo", "Games": "Jogos", @@ -165,6 +167,7 @@ "Important": "Importante", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Incluir a versão correta do seu emulador ajuda os desenvolvedores a identificar e resolver problemas mais rapidamente.", "Information about cookies": "Informações sobre cookies", + "Invalid list": "Lista inválida", "Issue": "Problema", "Join us on Discord": "Entre no Discord", "Just Released": "Lançamento recente", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Digite / para focar no campo de pesquisa.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Digite seu comentário aqui. Não poste ou solicite links de ROMs protegidas por direitos autorais.", "Undo": "Desfazer", + "Unlocked {{when}}": "Desbloqueado {{when}}", "Unpublished": "Não publicado", "Unsubscribe": "Cancelar inscrição", "Unsubscribed!": "Inscrição cancelada!", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} não jogou <1>{{game}}.", "Start of reconstructed timeline.": "Início da linha do tempo reconstruída.", "Manually unlocked by <1>{{user}}.": "Desbloqueado manualmente por <1>{{user}}.", + "Game Suggestions - {{user}}": "Sugestões de Jogo - {{user}}", + "Reasoning": "Justificativa", + "Game Suggestions": "Sugestões de Jogo", + "Personalized Game Suggestions": "Sugestões Personalizadas de Jogos", + "In <1><2><3>hub<4>{{hubName}} with": "No <1><2><3>centro<4>{{hubName}} com", + "Same <1>dev as": "Mesmo <1>desenvolvedor de", + "By <1>same developer as": "Pelo <1>mesmo desenvolvedor de", + "Similar to beaten": "Similar ao zerado", + "Similar to mastered": "Similar à platina", + "Similar to backlog": "Similar à atividade", + "Random": "Aleatório", + "Randomly selected": "Selecionado aleatoriamente", + "In your backlog": "Em seu registro", + "Revised": "Revisado", + "Similar beats": "Concluídos similares", + "Similar masteries": "Platinas similares", + "Beaten by players of": "Zerado por jogadores de", + "Mastered by players of": "Platinado por jogadores de", + "Roll again": "Rolar novamente", "supportedGameFilesCountLabel_one": "Atualmente existe <1>{{count, number}} hash de arquivo de jogo suportado registrado para este jogo.", "supportedGameFilesCountLabel_other": "Atualmente existem <1>{{count, number}} hashes de arquivos de jogo suportados registrados para este jogo.", "userCount_one": "<1>{{userCount, number}} usuário está atualmente online.", diff --git a/lang/ru_RU.json b/lang/ru_RU.json index 3ddba4ca0d..1cc9342018 100644 --- a/lang/ru_RU.json +++ b/lang/ru_RU.json @@ -14,6 +14,7 @@ "Accountability for content": "Ответственность за контент", "Accountability for links": "Ответственность за ссылки", "Achievement": "Достижение", + "Achievement Checklist": "Чек-лист достижений", "Achievement Unlocks": "Разблокированных достижений", "Achievement of the Week": "Достижение Недели", "Achievements": "Достижения", @@ -135,6 +136,7 @@ "Forum Index": "Список форумов", "Forum Posts": "Посты на форуме", "Forum Posts - {{user}}": "Посты на форуме - {{user}}", + "from": "от", "Game": "Игра", "Game Details": "Подробности игры", "Games": "Игры", @@ -165,6 +167,7 @@ "Important": "Важно", "Including your correct emulator version helps developers more quickly identify and resolve issues.": "Указание точной версии вашего эмулятора помогает разработчикам быстрее выявить и решить проблемы.", "Information about cookies": "Информация о cookies", + "Invalid list": "Неверный список", "Issue": "Проблема", "Join us on Discord": "Присоединяйтесь к нам в Discord", "Just Released": "Только что вышедшее", @@ -352,6 +355,7 @@ "Type / to focus the search field.": "Напишите / чтобы сфокусироваться на поле поиска.", "Type your comment here. Do not post or request any links to copyrighted ROMs.": "Пишите свой комментарий здесь. Не публикуйте и не запрашивайте любые ссылки на защищённые авторским правом ROM файлы.", "Undo": "Отменить", + "Unlocked {{when}}": "Разблокировано {{when}}", "Unpublished": "Не опубликовано", "Unsubscribe": "Отписаться", "Unsubscribed!": "Вы отписались!", @@ -572,6 +576,25 @@ "{{user}} has not played <1>{{game}}.": "{{user}} не играл <1>{{game}}.", "Start of reconstructed timeline.": "Начало воссозданного таймлайна.", "Manually unlocked by <1>{{user}}.": "Разблокировано вручную <1>{{user}}.", + "Game Suggestions - {{user}}": "Game Suggestions - {{user}}", + "Reasoning": "Reasoning", + "Game Suggestions": "Game Suggestions", + "Personalized Game Suggestions": "Personalized Game Suggestions", + "In <1><2><3>hub<4>{{hubName}} with": "In <1><2><3>hub<4>{{hubName}} with", + "Same <1>dev as": "Same <1>dev as", + "By <1>same developer as": "By <1>same developer as", + "Similar to beaten": "Similar to beaten", + "Similar to mastered": "Similar to mastered", + "Similar to backlog": "Similar to backlog", + "Random": "Random", + "Randomly selected": "Randomly selected", + "In your backlog": "In your backlog", + "Revised": "Revised", + "Similar beats": "Similar beats", + "Similar masteries": "Similar masteries", + "Beaten by players of": "Beaten by players of", + "Mastered by players of": "Mastered by players of", + "Roll again": "Roll again", "supportedGameFilesCountLabel_one": "На данный момент известен <1>{{count, number}} поддерживаемый хэш ROM файлов для этой игры.", "supportedGameFilesCountLabel_few": "На данный момент известны <1>{{count, number}} поддерживаемых хэша ROM файлов для этой игры.", "supportedGameFilesCountLabel_many": "На данный момент известны <1>{{count, number}} поддерживаемый хэшей ROM файлов для этой игры.", diff --git a/package.json b/package.json index 1faf392625..9e1d73d9fe 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,16 @@ "crowdin:upload": "tsx resources/js/tools/crowdin-upload.ts" }, "dependencies": { + "@bbob/plugin-helper": "^4.2.0", + "@bbob/preset-react": "^4.2.0", + "@bbob/react": "^4.2.0", "@floating-ui/core": "^1.6.8", "@floating-ui/dom": "^1.5.1", "@hookform/resolvers": "^3.9.0", "@inertiajs/core": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", @@ -131,5 +135,11 @@ "engines": { "node": "20.x", "pnpm": ">=9" + }, + "pnpm": { + "patchedDependencies": { + "@bbob/react": "patches/@bbob__react.patch", + "@bbob/preset-react": "patches/@bbob__preset-react.patch" + } } } diff --git a/patches/@bbob__preset-react.patch b/patches/@bbob__preset-react.patch new file mode 100644 index 0000000000..125cb28aac --- /dev/null +++ b/patches/@bbob__preset-react.patch @@ -0,0 +1,77 @@ +diff --git a/es/index.js b/es/index.js +index ae798e7be9727d886cb248ce6eb08cb9bb4a80f4..d58beb5a14f775f98cf376fdeef8338fb454e900 100644 +--- a/es/index.js ++++ b/es/index.js +@@ -1,34 +1,38 @@ +-import presetHTML5 from '@bbob/preset-html5'; +-const tagAttr = (style)=>({ +- attrs: { +- style +- } +- }); +-const presetReact = presetHTML5.extend((tags)=>({ +- ...tags, +- b: (...args)=>({ +- ...tags.b(...args), +- ...tagAttr({ +- fontWeight: 'bold' +- }) +- }), +- i: (...args)=>({ +- ...tags.i(...args), +- ...tagAttr({ +- fontStyle: 'italic' +- }) +- }), +- u: (...args)=>({ +- ...tags.u(...args), +- ...tagAttr({ +- textDecoration: 'underline' +- }) +- }), +- s: (...args)=>({ +- ...tags.s(...args), +- ...tagAttr({ +- textDecoration: 'line-through' +- }) +- }) +- })); +-export default presetReact; ++const presetHTML5 = require('@bbob/preset-html5').default; ++ ++const tagAttr = (style) => ({ ++ attrs: { ++ style ++ } ++}); ++ ++const presetReact = presetHTML5.extend((tags) => ({ ++ ...tags, ++ b: (...args) => ({ ++ ...tags.b(...args), ++ ...tagAttr({ ++ fontWeight: 'bold' ++ }) ++ }), ++ i: (...args) => ({ ++ ...tags.i(...args), ++ ...tagAttr({ ++ fontStyle: 'italic' ++ }) ++ }), ++ u: (...args) => ({ ++ ...tags.u(...args), ++ ...tagAttr({ ++ textDecoration: 'underline' ++ }) ++ }), ++ s: (...args) => ({ ++ ...tags.s(...args), ++ ...tagAttr({ ++ textDecoration: 'line-through' ++ }) ++ }) ++})); ++ ++module.exports = presetReact; ++module.exports.default = presetReact; diff --git a/patches/@bbob__react.patch b/patches/@bbob__react.patch new file mode 100644 index 0000000000..fea7b05b9f --- /dev/null +++ b/patches/@bbob__react.patch @@ -0,0 +1,113 @@ +diff --git a/es/Component.js b/es/Component.js +index a4ab307b0c48628dedec0431845f972f2bde005c..a349964c49382b691ae1477fab6fae3ea3949f69 100644 +--- a/es/Component.js ++++ b/es/Component.js +@@ -1,10 +1,15 @@ +-import React from 'react'; +-import { render } from './render'; +-const content = (children, plugins, options)=>React.Children.map(children, (child)=>{ +- if (typeof child === 'string') { +- return render(child, plugins, options); +- } +- return child; +- }); +-const Component = ({ container = 'span', componentProps = {}, children, plugins = [], options = {} })=>React.createElement(container, componentProps, content(children, plugins, options)); +-export default Component; ++const React = require('react'); ++const render = require('./render').render; ++ ++const content = (children, plugins, options) => React.Children.map(children, (child) => { ++ if (typeof child === 'string') { ++ return render(child, plugins, options); ++ } ++ return child; ++}); ++ ++const Component = ({ container = 'span', componentProps = {}, children, plugins = [], options = {} }) => ++ React.createElement(container, componentProps, content(children, plugins, options)); ++ ++module.exports = Component; ++module.exports.default = Component; +diff --git a/es/index.js b/es/index.js +index 5a4aadb175e5607a6e0128292ebb6101197dff5a..ac4ea782b745bb5b30a86ccd676cd7bc9a9f1f54 100644 +--- a/es/index.js ++++ b/es/index.js +@@ -1,2 +1,6 @@ +-export { default } from './Component'; +-export { render } from './render'; ++const Component = require('./Component'); ++const render = require('./render').render; ++ ++module.exports = Component; ++module.exports.default = Component; ++module.exports.render = render; +diff --git a/es/render.js b/es/render.js +index 4d996dc9821c8b2beb58465fd28d7bc12b4104a7..78ad60ef4064b685a14376b024bc0f20cfc7161e 100644 +--- a/es/render.js ++++ b/es/render.js +@@ -1,16 +1,19 @@ +-/* eslint-disable no-use-before-define */ import React from "react"; +-import { render as htmlrender } from "@bbob/html"; +-import core from "@bbob/core"; +-import { isTagNode, isStringNode, isEOL } from "@bbob/plugin-helper"; +-const toAST = (source, plugins, options)=>core(plugins).process(source, { +- ...options, +- render: (input)=>{ +- return htmlrender(input, { +- stripTags: true +- }); +- } +- }).tree; +-const isContentEmpty = (content)=>{ ++/* eslint-disable no-use-before-define */ ++const React = require("react"); ++const htmlrender = require("@bbob/html").render; ++const core = require("@bbob/core").default; ++const { isTagNode, isStringNode, isEOL } = require("@bbob/plugin-helper"); ++ ++const toAST = (source, plugins, options) => core(plugins).process(source, { ++ ...options, ++ render: (input) => { ++ return htmlrender(input, { ++ stripTags: true ++ }); ++ } ++}).tree; ++ ++const isContentEmpty = (content) => { + if (!content) { + return true; + } +@@ -19,15 +22,17 @@ const isContentEmpty = (content)=>{ + } + return Array.isArray(content) ? content.length === 0 : !content; + }; ++ + function tagToReactElement(node, index) { + return React.createElement(node.tag, { + ...node.attrs, + key: index + }, isContentEmpty(node.content) ? null : renderToReactNodes(node.content)); + } ++ + function renderToReactNodes(nodes) { + if (nodes && Array.isArray(nodes) && nodes.length) { +- return nodes.reduce((arr, node, index)=>{ ++ return nodes.reduce((arr, node, index) => { + if (isTagNode(node)) { + arr.push(tagToReactElement(node, index)); + return arr; +@@ -51,8 +56,11 @@ function renderToReactNodes(nodes) { + } + return []; + } ++ + function render(source, plugins, options) { + return renderToReactNodes(toAST(source, plugins, options)); + } +-export { render }; +-export default render; ++ ++module.exports = render; ++module.exports.render = render; ++module.exports.default = render; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aa2ac48e3..e2c5c55eba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,10 +4,27 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@bbob/preset-react': + hash: 6fcyyetkfscdlydksvco5tvshq + path: patches/@bbob__preset-react.patch + '@bbob/react': + hash: 4mxrnkjgb572sb5wk5jdditwqy + path: patches/@bbob__react.patch + importers: .: dependencies: + '@bbob/plugin-helper': + specifier: ^4.2.0 + version: 4.2.0 + '@bbob/preset-react': + specifier: ^4.2.0 + version: 4.2.0(patch_hash=6fcyyetkfscdlydksvco5tvshq)(react@18.3.1) + '@bbob/react': + specifier: ^4.2.0 + version: 4.2.0(patch_hash=4mxrnkjgb572sb5wk5jdditwqy)(react@18.3.1) '@floating-ui/core': specifier: ^1.6.8 version: 1.6.8 @@ -26,6 +43,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -424,6 +444,37 @@ packages: resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} + '@bbob/core@4.2.0': + resolution: {integrity: sha512-i5VUIO6xx+TCrBE8plQF9KpW1z+0QcCh9GawCCWYG9KcZrEsyRy57my5/kem0a2lFxXjh0o+tgjQY9sCP5b3bw==} + + '@bbob/html@4.2.0': + resolution: {integrity: sha512-mZG7MmE/YluH1/FNCr2Jv/Vz3Mq5stZ5tFAjBmYpVVN6Ndus4+FA84vKoHFdUHDiuU1p/e2/o/VUrwsWwLdoIA==} + + '@bbob/parser@4.2.0': + resolution: {integrity: sha512-l8BppXjdQClrUiv+qIN0Oe6aS/vlH7CEduMreDaxYDadUPC0RMzxj9lXVjO0xTbFEVEIUJCGc53qnpiApLbx2g==} + + '@bbob/plugin-helper@4.2.0': + resolution: {integrity: sha512-Uxs/UJROnkpcq5EJfz/8NCEYAcme8l6oAgMeWLX1nxWeieUhRgpY8BWQ9eQwUOXEKa0tsKVPnlmbUAjFhppVPw==} + + '@bbob/preset-html5@4.2.0': + resolution: {integrity: sha512-X2EIeb2vqTz/n34KWQXEL5IC0SuB6i2/ltRpEaMTyK7IoDEs0XGIWcqVpenb7Z2d4eNA3gTWhkoUUkKlZmfQRQ==} + + '@bbob/preset-react@4.2.0': + resolution: {integrity: sha512-f6VoTaFVxtx4dVWCYjbSkeK2roYgjj/mMc7TJtPOReSjLZqxk0sV0LUcNfbEeNdCcygASjKZAX5FOhDZ4qADng==} + peerDependencies: + react: '> 15.0' + + '@bbob/preset@4.2.0': + resolution: {integrity: sha512-IA+kxlrRcYBXE634B8W6uU2DO7VvrxeOqWUzIDKV2CgzTMpmCG875ihGp+Q3T+QxGwzG5S0L4GdmCIULTbzC4A==} + + '@bbob/react@4.2.0': + resolution: {integrity: sha512-gmcTg3vrN5Qc6Epp91f4pU4rf8GCb9AO8CGaTgdvmLMYH4LWoAyZdTGn4J0m3QKAhTdUWvnZjG9YOkI4zXw94A==} + peerDependencies: + react: '> 15.0' + + '@bbob/types@4.2.0': + resolution: {integrity: sha512-bSCZnNg0VrPosBBUVkjBDYVKNEqwT0jTNuzvAuNrZkuILgUKtLYI9GHuT8rY4lZecCb7UqGDsHsYt0YJO36eJg==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1052,6 +1103,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.2': + resolution: {integrity: sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.0': resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: @@ -4936,6 +5000,54 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@bbob/core@4.2.0': + dependencies: + '@bbob/parser': 4.2.0 + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/html@4.2.0': + dependencies: + '@bbob/core': 4.2.0 + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/parser@4.2.0': + dependencies: + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/plugin-helper@4.2.0': + dependencies: + '@bbob/types': 4.2.0 + + '@bbob/preset-html5@4.2.0': + dependencies: + '@bbob/plugin-helper': 4.2.0 + '@bbob/preset': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/preset-react@4.2.0(patch_hash=6fcyyetkfscdlydksvco5tvshq)(react@18.3.1)': + dependencies: + '@bbob/preset-html5': 4.2.0 + '@bbob/types': 4.2.0 + react: 18.3.1 + + '@bbob/preset@4.2.0': + dependencies: + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/react@4.2.0(patch_hash=4mxrnkjgb572sb5wk5jdditwqy)(react@18.3.1)': + dependencies: + '@bbob/core': 4.2.0 + '@bbob/html': 4.2.0 + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + react: 18.3.1 + + '@bbob/types@4.2.0': {} + '@bcoe/v8-coverage@0.2.3': {} '@crowdin/crowdin-api-client@1.39.1': @@ -5364,6 +5476,22 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-collapsible@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) diff --git a/public/API/API_GetAchievementOfTheWeek.php b/public/API/API_GetAchievementOfTheWeek.php index 2b9ec1757f..046f1dcd16 100644 --- a/public/API/API_GetAchievementOfTheWeek.php +++ b/public/API/API_GetAchievementOfTheWeek.php @@ -3,8 +3,6 @@ use App\Http\Actions\BuildAchievementOfTheWeekDataAction; use App\Models\Achievement; use App\Models\EventAchievement; -use App\Models\StaticData; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; /* @@ -49,20 +47,20 @@ * filters the Unlocks to just those entries after StartAt. */ -$staticData = StaticData::first(); -$aotwData = (new BuildAchievementOfTheWeekDataAction())->execute($staticData); +$aotwData = (new BuildAchievementOfTheWeekDataAction())->execute(); $achievementId = $aotwData->achievement->id ?? 0; $eventAchievement = EventAchievement::active()->where('achievement_id', $achievementId)->first(); -$sourceAchievement = $eventAchievement ? $eventAchievement->sourceAchievement : Achievement::find($achievementId); -if (!$sourceAchievement) { +if (!$eventAchievement?->sourceAchievement) { return response()->json([ 'Achievement' => ['ID' => null], 'StartAt' => null, ]); } +$sourceAchievement = $eventAchievement->sourceAchievement; + $achievement = [ 'ID' => $sourceAchievement->ID ?? null, 'Title' => $sourceAchievement->Title ?? null, @@ -91,54 +89,33 @@ 'ID' => $aotwData->forumTopicId->resolve() ?? null, ]; -if ($eventAchievement) { - $unlocks = collect(); - - $playerAchievements = $eventAchievement->achievement->playerAchievements() - ->with('user') - ->orderByDesc(DB::raw('IFNULL(unlocked_hardcore_at, unlocked_at)')) // newest winners first - ->limit(500) - ->get(); - $numWinners = $playerAchievements->count(); - $numWinnersHardcore = 0; - - foreach ($playerAchievements as $playerAchievement) { - $unlocks[] = [ - 'User' => $playerAchievement->user->display_name, - 'RAPoints' => $playerAchievement->user->RAPoints, - 'RASoftcorePoints' => $playerAchievement->user->RASoftcorePoints, - 'HardcoreMode' => $playerAchievement->unlocked_hardcore_at !== null ? 1 : 0, - 'DateAwarded' => $playerAchievement->unlocked_hardcore_at ?? $playerAchievement->unlocked_at, - ]; - - if ($playerAchievement->unlocked_hardcore_at !== null) { - $numWinnersHardcore++; - } - } - - $numPossibleWinners = $eventAchievement->achievement->game->players_total; - $startAt = $eventAchievement->active_from; - -} else { - $parentGame = getParentGameFromGameTitle($game['Title'], $console['ID']); - $unlocks = getAchievementUnlocksData($achievementId, null, $numWinners, $numWinnersHardcore, $numPossibleWinners, $parentGame?->id, 0, 500); - - /* - * reset unlocks if there is no start date to prevent listing invalid entries - */ - $startAt = $staticData->Event_AOTW_StartAt; - if (empty($startAt)) { - $unlocks = collect(); +$unlocks = collect(); + +$playerAchievements = $eventAchievement->achievement->playerAchievements() + ->with('user') + ->orderByDesc(DB::raw('IFNULL(unlocked_hardcore_at, unlocked_at)')) // newest winners first + ->limit(500) + ->get(); +$numWinners = $playerAchievements->count(); +$numWinnersHardcore = 0; + +foreach ($playerAchievements as $playerAchievement) { + $unlocks[] = [ + 'User' => $playerAchievement->user->display_name, + 'RAPoints' => $playerAchievement->user->RAPoints, + 'RASoftcorePoints' => $playerAchievement->user->RASoftcorePoints, + 'HardcoreMode' => $playerAchievement->unlocked_hardcore_at !== null ? 1 : 0, + 'DateAwarded' => $playerAchievement->unlocked_hardcore_at ?? $playerAchievement->unlocked_at, + ]; + + if ($playerAchievement->unlocked_hardcore_at !== null) { + $numWinnersHardcore++; } - - if (!empty($startAt)) { - $unlocks = $unlocks->filter(fn ($unlock) => Carbon::parse($unlock['DateAwarded'])->gte($startAt)); - } - - // sort so newest winners are first - $unlocks->sortByDesc('DateAwarded'); } +$numPossibleWinners = $eventAchievement->achievement->game->players_total; +$startAt = $eventAchievement->active_from; + return response()->json([ 'Achievement' => $achievement, 'Console' => $console, @@ -148,5 +125,5 @@ 'TotalPlayers' => $numPossibleWinners ?? 0, 'Unlocks' => $unlocks->values(), 'UnlocksCount' => $numWinners ?? 0, - 'UnlocksHardcoreCount' => $numWinnersHardcore ?? 0, + 'UnlocksHardcoreCount' => $numWinnersHardcore, ]); diff --git a/public/API/API_GetAchievementsEarnedBetween.php b/public/API/API_GetAchievementsEarnedBetween.php index a9dcaae492..fad537532d 100644 --- a/public/API/API_GetAchievementsEarnedBetween.php +++ b/public/API/API_GetAchievementsEarnedBetween.php @@ -29,7 +29,7 @@ use App\Models\User; -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([]); } diff --git a/public/API/API_GetAchievementsEarnedOnDay.php b/public/API/API_GetAchievementsEarnedOnDay.php index 07ebf70ab6..8b82f7c935 100644 --- a/public/API/API_GetAchievementsEarnedOnDay.php +++ b/public/API/API_GetAchievementsEarnedOnDay.php @@ -35,7 +35,7 @@ 'd' => ['required', 'date'], ]); -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([]); } diff --git a/public/API/API_GetComments.php b/public/API/API_GetComments.php index 4267a0eb32..acf8400f4d 100644 --- a/public/API/API_GetComments.php +++ b/public/API/API_GetComments.php @@ -66,7 +66,7 @@ $userPolicy = new UserCommentPolicy(); if ($username) { - $user = User::firstWhere('User', $username); + $user = User::whereName($username)->first(); if (!$user || !$userPolicy->viewAny(null, $user)) { return response()->json([], 404); @@ -104,7 +104,7 @@ return $commentPolicy->view($user, $nextComment); })->map(function ($nextComment) { return [ - 'User' => $nextComment->user->username, + 'User' => $nextComment->user->display_name, 'Submitted' => $nextComment->Submitted, 'CommentText' => $nextComment->Payload, ]; diff --git a/public/API/API_GetGameInfoAndUserProgress.php b/public/API/API_GetGameInfoAndUserProgress.php index 6309d2554c..6d6a3869b4 100644 --- a/public/API/API_GetGameInfoAndUserProgress.php +++ b/public/API/API_GetGameInfoAndUserProgress.php @@ -58,7 +58,7 @@ use Illuminate\Support\Carbon; $gameID = (int) request()->query('g'); -$targetUser = User::firstWhere('User', request()->query('u')); +$targetUser = User::whereName(request()->query('u'))->first(); if (!$targetUser) { return response()->json([]); } diff --git a/public/API/API_GetGameLeaderboards.php b/public/API/API_GetGameLeaderboards.php index a3aee8d094..ab60863320 100644 --- a/public/API/API_GetGameLeaderboards.php +++ b/public/API/API_GetGameLeaderboards.php @@ -62,7 +62,7 @@ if ($leaderboard->topEntry) { $topEntry = [ - 'User' => $leaderboard->topEntry->user->User, + 'User' => $leaderboard->topEntry->user->display_name, 'Score' => $leaderboard->topEntry->score, 'FormattedScore' => ValueFormat::format($leaderboard->topEntry->score, $leaderboard->Format), ]; diff --git a/public/API/API_GetGameRankAndScore.php b/public/API/API_GetGameRankAndScore.php index 5d711e1225..0a25fb3697 100644 --- a/public/API/API_GetGameRankAndScore.php +++ b/public/API/API_GetGameRankAndScore.php @@ -41,7 +41,7 @@ // or the top earners if there are less than 10 masteries. foreach ($topAchievers as $playerGame) { $gameTopAchievers[] = [ - 'User' => User::find($playerGame['user_id'])->User, + 'User' => User::find($playerGame['user_id'])->display_name, // FIXME: N+1 query problem 'NumAchievements' => $playerGame['achievements_unlocked_hardcore'], 'TotalScore' => $playerGame['points_hardcore'], 'LastAward' => Carbon::createFromTimestamp($playerGame['last_unlock_hardcore_at'])->format('Y-m-d H:i:s'), diff --git a/public/API/API_GetTicketData.php b/public/API/API_GetTicketData.php index 7273febbca..4a737e736f 100644 --- a/public/API/API_GetTicketData.php +++ b/public/API/API_GetTicketData.php @@ -179,12 +179,12 @@ // getting ticket info for a specific user $assignedToUser = request()->query('u'); if (!empty($assignedToUser)) { - $foundUser = User::firstWhere('User', $assignedToUser); + $foundUser = User::whereName($assignedToUser)->first(); if (!$foundUser) { return response()->json(['error' => "User $assignedToUser not found"], 404); } - $ticketData['User'] = $assignedToUser; + $ticketData['User'] = $foundUser->display_name; $ticketData['Open'] = 0; $ticketData['Closed'] = 0; $ticketData['Resolved'] = 0; @@ -211,7 +211,7 @@ $prevID = $ticket['AchievementID']; } } - $ticketData['URL'] = route('developer.tickets', ['user' => $assignedToUser]); + $ticketData['URL'] = route('developer.tickets', ['user' => $foundUser->display_name]); return response()->json($ticketData); } @@ -246,9 +246,9 @@ 'ReportType' => $ticket->ReportType, 'ReportTypeDescription' => TicketType::toString($ticket->ReportType), 'ReportNotes' => $ticket->ReportNotes, - 'ReportedBy' => $ticket->reporter?->User, + 'ReportedBy' => $ticket->reporter?->display_name, 'ResolvedAt' => $ticket->ResolvedAt?->__toString(), - 'ResolvedBy' => $ticket->resolver?->User, + 'ResolvedBy' => $ticket->resolver?->display_name, 'ReportState' => $ticket->ReportState, 'ReportStateDescription' => TicketState::toString($ticket->ReportState), 'Hardcore' => $ticket->Hardcore, diff --git a/public/API/API_GetUserAwards.php b/public/API/API_GetUserAwards.php index 0713fa8d22..cf1a31cf55 100644 --- a/public/API/API_GetUserAwards.php +++ b/public/API/API_GetUserAwards.php @@ -40,7 +40,7 @@ $user = request()->query('u'); -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $userAwards = getUsersSiteAwards($userModel); [$gameMasteryAwards, $eventAwards, $siteAwards] = SeparateAwards($userAwards); diff --git a/public/API/API_GetUserCompletionProgress.php b/public/API/API_GetUserCompletionProgress.php index 25aef0491d..f2a11c9d5a 100644 --- a/public/API/API_GetUserCompletionProgress.php +++ b/public/API/API_GetUserCompletionProgress.php @@ -42,7 +42,7 @@ $count = $input['c'] ?? 100; $user = request()->query('u'); -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $playerProgressionService = new PlayerProgressionService(); diff --git a/public/API/API_GetUserGameLeaderboards.php b/public/API/API_GetUserGameLeaderboards.php index eed0208594..1a0adb2414 100644 --- a/public/API/API_GetUserGameLeaderboards.php +++ b/public/API/API_GetUserGameLeaderboards.php @@ -43,7 +43,7 @@ $offset = $input['o'] ?? 0; $count = $input['c'] ?? 200; -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json(['User not found'], 404); } diff --git a/public/API/API_GetUserGameRankAndScore.php b/public/API/API_GetUserGameRankAndScore.php index f3d6f58422..357ea932c7 100644 --- a/public/API/API_GetUserGameRankAndScore.php +++ b/public/API/API_GetUserGameRankAndScore.php @@ -17,7 +17,7 @@ $gameId = (int) request()->query('g'); -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([]); } diff --git a/public/API/API_GetUserPoints.php b/public/API/API_GetUserPoints.php index 95e946e3f2..45f98245e6 100644 --- a/public/API/API_GetUserPoints.php +++ b/public/API/API_GetUserPoints.php @@ -8,15 +8,19 @@ * int SoftcorePoints number of softcore points the user has */ -$user = request()->query('u'); +use App\Models\User; -if (!getPlayerPoints($user, $userDetails)) { +$username = request()->query('u'); + +$foundUser = User::whereName($username)->first(); + +if (!$foundUser) { return response()->json([ - 'User' => $user, + 'User' => $username, ], 404); } return response()->json(array_map('intval', [ - 'Points' => $userDetails['RAPoints'], - 'SoftcorePoints' => $userDetails['RASoftcorePoints'], + 'Points' => $foundUser->points, + 'SoftcorePoints' => $foundUser->points_softcore, ])); diff --git a/public/API/API_GetUserProfile.php b/public/API/API_GetUserProfile.php index bb2af5dcec..7f193ea760 100644 --- a/public/API/API_GetUserProfile.php +++ b/public/API/API_GetUserProfile.php @@ -30,15 +30,15 @@ 'u' => ['required', 'min:2', 'max:20', new CtypeAlnum()], ]); -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([], 404); } return response()->json([ - 'User' => $user->User, - 'UserPic' => sprintf("/UserPic/%s.png", $user->User), + 'User' => $user->display_name, + 'UserPic' => sprintf("/UserPic/%s.png", $user->username), 'MemberSince' => $user->created_at->toDateTimeString(), 'RichPresenceMsg' => empty($user->RichPresenceMsg) || $user->RichPresenceMsg === 'Unknown' ? null : $user->RichPresenceMsg, 'LastGameID' => $user->LastGameID, diff --git a/public/API/API_GetUserProgress.php b/public/API/API_GetUserProgress.php index 9d6e9f36f3..0e45a1479c 100644 --- a/public/API/API_GetUserProgress.php +++ b/public/API/API_GetUserProgress.php @@ -17,7 +17,7 @@ use App\Models\User; -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([]); } diff --git a/public/API/API_GetUserRankAndScore.php b/public/API/API_GetUserRankAndScore.php index d8b7e774a5..e083be8ac5 100644 --- a/public/API/API_GetUserRankAndScore.php +++ b/public/API/API_GetUserRankAndScore.php @@ -10,6 +10,7 @@ * int TotalRanked total number of ranked users */ +use App\Models\User; use App\Support\Rules\CtypeAlnum; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; @@ -18,18 +19,21 @@ 'u' => ['required', 'min:2', 'max:20', new CtypeAlnum()], ]); -$user = request()->query('u'); +$username = request()->query('u'); $points = 0; $softcorePoints = 0; -if (getPlayerPoints($user, $playerPoints)) { - $points = $playerPoints['RAPoints']; - $softcorePoints = $playerPoints['RASoftcorePoints']; + +$foundUser = User::whereName($username)->first(); + +if ($foundUser) { + $points = $foundUser?->points ?? 0; + $softcorePoints = $foundUser?->points_softcore ?? 0; } return response()->json([ 'Score' => $points, 'SoftcoreScore' => $softcorePoints, - 'Rank' => getUserRank($user), + 'Rank' => $foundUser ? getUserRank($foundUser->display_name) : null, 'TotalRanked' => countRankedUsers(), ]); diff --git a/public/API/API_GetUserRecentAchievements.php b/public/API/API_GetUserRecentAchievements.php index 1aaebee4f5..683c184898 100644 --- a/public/API/API_GetUserRecentAchievements.php +++ b/public/API/API_GetUserRecentAchievements.php @@ -29,7 +29,7 @@ * string GameURL site-relative path to the game page */ -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([]); } diff --git a/public/API/API_GetUserRecentlyPlayedGames.php b/public/API/API_GetUserRecentlyPlayedGames.php index df402c9f2f..76caf8fdfb 100644 --- a/public/API/API_GetUserRecentlyPlayedGames.php +++ b/public/API/API_GetUserRecentlyPlayedGames.php @@ -36,7 +36,7 @@ 'o' => 'nullable|integer|min:0', ]); -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([]); } diff --git a/public/API/API_GetUserSetRequests.php b/public/API/API_GetUserSetRequests.php index abb4e241e5..dce8f0053d 100644 --- a/public/API/API_GetUserSetRequests.php +++ b/public/API/API_GetUserSetRequests.php @@ -28,7 +28,7 @@ 't' => ['nullable', 'in:0,1'], ]); -$user = User::firstWhere('User', request()->query('u')); +$user = User::whereName(request()->query('u'))->first(); if (!$user) { return response()->json([], 404); } diff --git a/public/API/API_GetUserSummary.php b/public/API/API_GetUserSummary.php index f5637eb891..048d6d4edb 100644 --- a/public/API/API_GetUserSummary.php +++ b/public/API/API_GetUserSummary.php @@ -86,6 +86,7 @@ * int ContribYield points awarded to others */ +use App\Models\User; use App\Support\Rules\CtypeAlnum; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; @@ -96,7 +97,7 @@ 'a' => 'nullable|integer|min:0', ]); -$user = request()->query('u'); +$username = request()->query('u'); $recentGamesPlayed = (int) request()->query('g', '0'); $recentAchievementsEarned = (int) request()->query('a', '10'); @@ -105,16 +106,17 @@ $recentGamesPlayed = 100; } -$retVal = getUserPageInfo($user, $recentGamesPlayed, $recentAchievementsEarned); +$userModel = User::whereName($username)->first(); +$retVal = getUserPageInfo($username, $recentGamesPlayed, $recentAchievementsEarned); if (empty($retVal)) { return response()->json([ 'ID' => null, - 'User' => $user, + 'User' => $username, ], 404); } -$retVal['UserPic'] = "/UserPic/" . $retVal['User'] . ".png"; +$retVal['UserPic'] = "/UserPic/" . $userModel->username . ".png"; $retVal['TotalRanked'] = countRankedUsers(); // assume caller doesn't care about the rich presence script for the last game played diff --git a/public/API/API_GetUserWantToPlayList.php b/public/API/API_GetUserWantToPlayList.php index f3355245f3..35b574e87b 100644 --- a/public/API/API_GetUserWantToPlayList.php +++ b/public/API/API_GetUserWantToPlayList.php @@ -37,7 +37,7 @@ $offset = $input['o'] ?? 0; $count = $input['c'] ?? 100; -$targetUser = User::firstWhere('User', request()->query('u')); +$targetUser = User::whereName(request()->query('u'))->first(); if (!$targetUser) { return response()->json([], 404); } diff --git a/public/dorequest.php b/public/dorequest.php index e43d45f71a..44cdeac2bb 100644 --- a/public/dorequest.php +++ b/public/dorequest.php @@ -149,7 +149,7 @@ function DoRequestError(string $error, ?int $status = 200, ?string $code = null) return DoRequestError('Access denied.', 405, 'access_denied'); } - $foundDelegateToUser = User::firstWhere('User', $delegateTo); + $foundDelegateToUser = User::whereName($delegateTo)->first(); if (!$foundDelegateToUser) { return DoRequestError("The target user couldn't be found.", 404, 'not_found'); } @@ -454,7 +454,7 @@ function DoRequestError(string $error, ?int $status = 200, ?string $code = null) return DoRequestError('Access denied.', 403, 'access_denied'); } - $targetUser = User::firstWhere('User', $delegateTo); + $targetUser = User::whereName($delegateTo)->first(); if (!$targetUser) { return DoRequestError("The target user couldn't be found.", 404, 'not_found'); } @@ -543,7 +543,7 @@ function DoRequestError(string $error, ?int $status = 200, ?string $code = null) // TBD: friendsOnly $leaderboard = Leaderboard::find($lbID); $response['LeaderboardData'] = $leaderboard ? - GetLeaderboardData($leaderboard, User::firstWhere('User', $username), $count, $offset, nearby: true) : []; + GetLeaderboardData($leaderboard, User::whereName($username)->first(), $count, $offset, nearby: true) : []; break; case "patch": @@ -621,7 +621,7 @@ function DoRequestError(string $error, ?int $status = 200, ?string $code = null) PlayerSessionHeartbeat::dispatch($user, $game, null, $gameHash); $response['Success'] = true; - $userModel = User::firstWhere('User', $username); + $userModel = User::whereName($username)->first(); $userUnlocks = getUserAchievementUnlocksForGame($userModel, $gameID); $userUnlocks = reactivateUserEventAchievements($userModel, $userUnlocks); foreach ($userUnlocks as $achId => $unlock) { @@ -744,7 +744,7 @@ function DoRequestError(string $error, ?int $status = 200, ?string $code = null) case "unlocks": $hardcoreMode = (int) request()->input('h', 0) === UnlockMode::Hardcore; - $userModel = User::firstWhere('User', $username); + $userModel = User::whereName($username)->first(); $userUnlocks = getUserAchievementUnlocksForGame($userModel, $gameID); if ($hardcoreMode) { $userUnlocks = reactivateUserEventAchievements($userModel, $userUnlocks); diff --git a/public/js/all.js b/public/js/all.js index 0fb529de63..327a00dcef 100644 --- a/public/js/all.js +++ b/public/js/all.js @@ -59,15 +59,15 @@ jQuery(document).ready(function onReady($) { select: function (event, ui) { var TABKEY = 9; if (event.keyCode === TABKEY) { - $('.searchusericon').attr('src', mediaAsset('/UserPic/' + ui.item.label + '.png')); + $('.searchusericon').attr('src', mediaAsset('/UserPic/' + ui.item.username + '.png')); } return false; }, }); $searchUser.on('autocompleteselect', function (event, ui) { - $searchUser.val(ui.item.label); - $('.searchusericon').attr('src', mediaAsset('/UserPic/' + ui.item.label + '.png')); + $searchUser.val(ui.item.username); + $('.searchusericon').attr('src', mediaAsset('/UserPic/' + ui.item.username + '.png')); return false; }); diff --git a/public/request/achievement/update-display-order.php b/public/request/achievement/update-display-order.php index 59738198b4..71b0a2452c 100644 --- a/public/request/achievement/update-display-order.php +++ b/public/request/achievement/update-display-order.php @@ -20,7 +20,7 @@ $gameId = (int) $input['game']; $number = (int) $input['number']; -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); // Only allow jr. devs to update the display order if they are the sole author of the set or have the primary claim if ( diff --git a/public/request/achievement/update-flag.php b/public/request/achievement/update-flag.php index 6703201989..90c789a1dc 100644 --- a/public/request/achievement/update-flag.php +++ b/public/request/achievement/update-flag.php @@ -2,6 +2,7 @@ use App\Community\Enums\ArticleType; use App\Enums\Permissions; +use App\Models\User; use App\Platform\Enums\AchievementFlag; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; @@ -26,6 +27,8 @@ updateAchievementFlag($achievementIds, $flag); +$userModel = User::whereName($user)->first(); + $commentText = ''; if ($flag === AchievementFlag::OfficialCore) { $commentText = 'promoted this achievement to the Core set'; @@ -33,7 +36,7 @@ if ($flag === AchievementFlag::Unofficial) { $commentText = 'demoted this achievement to Unofficial'; } -addArticleComment("Server", ArticleType::Achievement, $achievementIds, "$user $commentText.", $user); +addArticleComment("Server", ArticleType::Achievement, $achievementIds, "{$userModel->display_name} $commentText.", $userModel->display_name); expireGameTopAchievers($achievement['GameID']); return response()->json(['message' => __('legacy.success.ok')]); diff --git a/public/request/achievement/update-image.php b/public/request/achievement/update-image.php index fffb56a68b..72be26efb3 100644 --- a/public/request/achievement/update-image.php +++ b/public/request/achievement/update-image.php @@ -3,6 +3,7 @@ use App\Community\Enums\ArticleType; use App\Enums\Permissions; use App\Models\Achievement; +use App\Models\User; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; @@ -37,6 +38,14 @@ return back()->withErrors(__('legacy.error.image_upload')); } -addArticleComment('Server', ArticleType::Achievement, $achievementId, "$user edited this achievement's badge.", $user); +$userModel = User::whereName($user)->first(); + +addArticleComment( + 'Server', + ArticleType::Achievement, + $achievementId, + "{$userModel->display_name} edited this achievement's badge.", + $userModel->display_name +); return back()->with('success', __('legacy.success.image_upload')); diff --git a/public/request/achievement/update-type.php b/public/request/achievement/update-type.php index abc08b6619..a61a2fdde5 100644 --- a/public/request/achievement/update-type.php +++ b/public/request/achievement/update-type.php @@ -13,7 +13,7 @@ abort(401); } -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $input = Validator::validate(Arr::wrap(request()->post()), [ 'achievements' => 'required', @@ -56,6 +56,6 @@ if (!$value) { $commentText = "removed this achievement's type"; } -addArticleComment("Server", ArticleType::Achievement, $achievementIds, "$user $commentText.", $user); +addArticleComment("Server", ArticleType::Achievement, $achievementIds, "{$userModel->display_name} $commentText.", $userModel->display_name); return response()->json(['message' => __('legacy.success.ok')]); diff --git a/public/request/achievement/update-video.php b/public/request/achievement/update-video.php index 82bc2ea329..c4cb266782 100644 --- a/public/request/achievement/update-video.php +++ b/public/request/achievement/update-video.php @@ -3,6 +3,7 @@ use App\Community\Enums\ArticleType; use App\Enums\Permissions; use App\Models\Achievement; +use App\Models\User; use App\Platform\Enums\AchievementFlag; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; @@ -31,11 +32,13 @@ abort(403); } +$userModel = User::whereName($user)->first(); + $achievement->AssocVideo = strip_tags($embedUrl); $achievement->save(); -$auditLog = "$user set this achievement's embed URL."; +$auditLog = "{$userModel->display_name} set this achievement's embed URL."; -addArticleComment('Server', ArticleType::Achievement, $achievementId, $auditLog, $user); +addArticleComment('Server', ArticleType::Achievement, $achievementId, $auditLog, $userModel->display_name); return response()->json(['message' => __('legacy.success.ok')]); diff --git a/public/request/admin.php b/public/request/admin.php index d47fe6c991..283b720519 100644 --- a/public/request/admin.php +++ b/public/request/admin.php @@ -21,7 +21,7 @@ if (isset($awardAchievementID) && isset($awardAchievementUser)) { $usersToAward = preg_split('/\W+/', $awardAchievementUser); foreach ($usersToAward as $nextUser) { - $player = User::firstWhere('User', $nextUser); + $player = User::whereName($nextUser)->first(); if (!$player) { continue; } @@ -82,23 +82,3 @@ return back()->with('success', __('legacy.success.ok')); } - -if ($action === 'aotw') { - $aotwAchID = requestInputSanitized('a', 0, 'integer'); - $aotwForumID = requestInputSanitized('f', 0, 'integer'); - $aotwStartAt = requestInputSanitized('s', null, 'string'); - - $query = "UPDATE StaticData SET - Event_AOTW_AchievementID='$aotwAchID', - Event_AOTW_ForumID='$aotwForumID', - Event_AOTW_StartAt='$aotwStartAt'"; - - $db = getMysqliConnection(); - $result = s_mysql_query($query); - - if ($result) { - return back()->with('success', __('legacy.success.ok')); - } - - return back()->withErrors(__('legacy.error.error')); -} diff --git a/public/request/auth/register.php b/public/request/auth/register.php index 0773d98f56..961956d289 100644 --- a/public/request/auth/register.php +++ b/public/request/auth/register.php @@ -10,6 +10,7 @@ 'username' => [ 'required', 'unique:mysql.UserAccounts,User', + 'unique:mysql.UserAccounts,display_name', 'min:4', 'max:20', new CtypeAlnum(), @@ -49,8 +50,8 @@ $hashedPassword = Hash::make($pass); -$query = "INSERT INTO UserAccounts (User, Password, SaltedPass, EmailAddress, Permissions, RAPoints, fbUser, fbPrefs, cookie, appToken, appTokenExpiry, websitePrefs, LastLogin, LastActivityID, Motto, ContribCount, ContribYield, APIKey, APIUses, LastGameID, RichPresenceMsg, RichPresenceMsgDate, ManuallyVerified, UnreadMessageCount, TrueRAPoints, UserWallActive, PasswordResetToken, Untracked, email_backup) -VALUES ( '$username', '$hashedPassword', '', '$email', 0, 0, 0, 0, '', '', NULL, 127, null, 0, '', 0, 0, '', 0, 0, '', NULL, 0, 0, 0, 1, NULL, false, '$email')"; +$query = "INSERT INTO UserAccounts (User, display_name, Password, SaltedPass, EmailAddress, Permissions, RAPoints, fbUser, fbPrefs, cookie, appToken, appTokenExpiry, websitePrefs, LastLogin, LastActivityID, Motto, ContribCount, ContribYield, APIKey, APIUses, LastGameID, RichPresenceMsg, RichPresenceMsgDate, ManuallyVerified, UnreadMessageCount, TrueRAPoints, UserWallActive, PasswordResetToken, Untracked, email_backup) +VALUES ( '$username', '$username', '$hashedPassword', '', '$email', 0, 0, 0, 0, '', '', NULL, 127, null, 0, '', 0, 0, '', 0, 0, '', NULL, 0, 0, 0, 1, NULL, false, '$email')"; $dbResult = s_mysql_query($query); if (!$dbResult) { @@ -63,7 +64,7 @@ // Registered::dispatch($user); // Create an email validation token and send an email -$userModel = User::firstWhere('User', $username); +$userModel = User::whereName($username)->first(); sendValidationEmail($userModel, $email); return back()->with('message', __('legacy.email_validate')); diff --git a/public/request/auth/reset-password.php b/public/request/auth/reset-password.php index 8ad857bf65..2b6e25b9f0 100644 --- a/public/request/auth/reset-password.php +++ b/public/request/auth/reset-password.php @@ -1,12 +1,13 @@ post()), [ - 'username' => 'required|string|exists:UserAccounts,User|alpha_num|max:20', + 'username' => ['required', 'min:2', 'max:20', new CtypeAlnum()], 'token' => 'required', 'password' => 'required|confirmed|min:8|different:username', ]); @@ -14,7 +15,7 @@ $passResetToken = $input['token']; $newPass = $input['password']; -$targetUser = User::firstWhere('User', $input['username']); +$targetUser = User::whereName($input['username'])->first(); if (!$targetUser || $targetUser->isBanned() || !isValidPasswordResetToken($targetUser->username, $passResetToken)) { return back()->withErrors(__('legacy.error.token')); diff --git a/public/request/auth/send-password-reset-email.php b/public/request/auth/send-password-reset-email.php index 52a38cc6e9..821554abb9 100644 --- a/public/request/auth/send-password-reset-email.php +++ b/public/request/auth/send-password-reset-email.php @@ -8,7 +8,7 @@ 'username' => 'required', ]); -$targetUser = User::firstWhere('User', $input['username']); +$targetUser = User::whereName($input['username'])->first(); if ($targetUser && !$targetUser->isBanned()) { RequestPasswordReset($targetUser); diff --git a/public/request/forum-topic-comment/create.php b/public/request/forum-topic-comment/create.php index 3f5d616f7e..825d59e517 100644 --- a/public/request/forum-topic-comment/create.php +++ b/public/request/forum-topic-comment/create.php @@ -20,7 +20,7 @@ ], ]); -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $forumTopic = ForumTopic::find((int) $input['topic']); if (!$forumTopic || !$userModel->can('create', [App\Models\ForumTopicComment::class, $forumTopic])) { diff --git a/public/request/forum-topic/delete.php b/public/request/forum-topic/delete.php index a362ffb460..a0fab431f7 100644 --- a/public/request/forum-topic/delete.php +++ b/public/request/forum-topic/delete.php @@ -16,7 +16,7 @@ /** @var ForumTopic $topic */ $topic = ForumTopic::find($input['topic']); -$userModel = User::firstWhere('User', $username); +$userModel = User::whereName($username)->first(); if (!$userModel->can('delete', $topic)) { return back()->withErrors(__('legacy.error.permissions')); diff --git a/public/request/forum-topic/update-permissions.php b/public/request/forum-topic/update-permissions.php index 145c27d18c..bb94de21da 100644 --- a/public/request/forum-topic/update-permissions.php +++ b/public/request/forum-topic/update-permissions.php @@ -10,7 +10,7 @@ return back()->withErrors(__('legacy.error.permissions')); } -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); if (!$userModel->can('manage', App\Models\ForumTopic::class)) { return back()->withErrors(__('legacy.error.permissions')); diff --git a/public/request/forum-topic/update-title.php b/public/request/forum-topic/update-title.php index 30ceda2760..6e04c86fc9 100644 --- a/public/request/forum-topic/update-title.php +++ b/public/request/forum-topic/update-title.php @@ -14,7 +14,7 @@ 'title' => 'required|string|max:255', ]); -$userModel = User::firstWhere('User', $username); +$userModel = User::whereName($username)->first(); /** @var ForumTopic $forumTopic */ $forumTopic = ForumTopic::find((int) $input['topic']); diff --git a/public/request/game-relation/create.php b/public/request/game-relation/create.php deleted file mode 100644 index 62e4edbb5a..0000000000 --- a/public/request/game-relation/create.php +++ /dev/null @@ -1,25 +0,0 @@ -withErrors(__('legacy.error.permissions')); -} - -$input = Validator::validate(Arr::wrap(request()->post()), [ - 'game' => 'required|integer|exists:GameData,ID', - 'relations' => 'required|string', -]); - -$gameId = (int) $input['game']; - -// Filter out instances where a game might be linked to itself. -$relationsArray = explode(",", $input['relations']); -$filteredArray = array_diff($relationsArray, [$input['game']]); -$filteredRelationsCsv = implode(",", $filteredArray); - -modifyGameAlternatives($user, $gameId, toAdd: $filteredRelationsCsv); - -return back()->with('success', __('legacy.success.ok')); diff --git a/public/request/game-relation/delete.php b/public/request/game-relation/delete.php deleted file mode 100644 index be9c3cc90e..0000000000 --- a/public/request/game-relation/delete.php +++ /dev/null @@ -1,18 +0,0 @@ -withErrors(__('legacy.error.permissions')); -} - -$input = Validator::validate(Arr::wrap(request()->post()), [ - 'game' => 'required|integer|exists:GameData,ID', - 'relations' => 'required|array', -]); - -modifyGameAlternatives($user, (int) $input['game'], toRemove: $input['relations']); - -return back()->with('success', __('legacy.success.ok')); diff --git a/public/request/game/generate-forum-topic.php b/public/request/game/generate-forum-topic.php index 887a886401..088a956846 100644 --- a/public/request/game/generate-forum-topic.php +++ b/public/request/game/generate-forum-topic.php @@ -13,7 +13,7 @@ 'game' => 'required|integer|exists:GameData,ID', ]); -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $forumTopicComment = generateGameForumTopic($userModel, (int) $input['game']); if ($forumTopicComment) { diff --git a/public/request/game/update-image.php b/public/request/game/update-image.php index 436cd191f7..45a1a3732c 100644 --- a/public/request/game/update-image.php +++ b/public/request/game/update-image.php @@ -35,7 +35,7 @@ $gameID = (int) $input['game']; $imageType = $input['type']; -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); // Only allow jr. devs if they are the sole author of the set or have the primary claim if ( @@ -95,6 +95,6 @@ default => '?', // should never hit this because of the match above }; -addArticleComment('Server', ArticleType::GameModification, $gameID, "$user changed the $label"); +addArticleComment('Server', ArticleType::GameModification, $gameID, "{$userModel->display_name} changed the $label"); return back()->with('success', __('legacy.success.image_upload')); diff --git a/public/request/game/update-meta.php b/public/request/game/update-meta.php index 4e869a6ae6..37aef19d86 100644 --- a/public/request/game/update-meta.php +++ b/public/request/game/update-meta.php @@ -26,7 +26,7 @@ $gameId = (int) $input['game']; -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); // Only allow jr. devs if they are the sole author of the set or have the primary claim if ( @@ -36,7 +36,7 @@ return back()->withErrors(__('legacy.error.permissions')); } -if (modifyGameData($user, $gameId, $input['developer'], $input['publisher'], $input['genre'], $input['guide_url'])) { +if (modifyGameData($userModel, $gameId, $input['developer'], $input['publisher'], $input['genre'], $input['guide_url'])) { return back()->with('success', __('legacy.success.update')); } diff --git a/public/request/game/update-rich-presence.php b/public/request/game/update-rich-presence.php index e0d3230e44..75e9fa6911 100644 --- a/public/request/game/update-rich-presence.php +++ b/public/request/game/update-rich-presence.php @@ -17,7 +17,7 @@ $gameId = (int) $input['game']; -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); // Only allow jr. devs if they are the sole author of the set or have the primary claim if ( @@ -27,7 +27,7 @@ return back()->withErrors(__('legacy.error.permissions')); } -if (modifyGameRichPresence($user, $gameId, (string) $input['rich_presence'])) { +if (modifyGameRichPresence($userModel, $gameId, (string) $input['rich_presence'])) { return back()->with('success', __('legacy.success.ok')); } diff --git a/public/request/leaderboard/remove-entry.php b/public/request/leaderboard/remove-entry.php index 1101726bc9..e469f6807b 100644 --- a/public/request/leaderboard/remove-entry.php +++ b/public/request/leaderboard/remove-entry.php @@ -14,13 +14,13 @@ $currentUser = User::find($userDetails['ID']); $input = Validator::validate(Arr::wrap(request()->post()), [ - 'user' => 'required|string|exists:UserAccounts,User', + 'user' => 'required|string|exists:UserAccounts,display_name', 'leaderboard' => 'required|integer|exists:LeaderboardDef,ID', 'reason' => 'nullable|string|max:200', ]); $leaderboardId = (int) $input['leaderboard']; -$targetUser = User::firstWhere('User', $input['user']); +$targetUser = User::whereName($input['user'])->first(); $reason = $input['reason']; if (!$targetUser) { diff --git a/public/request/search.php b/public/request/search.php index 8894343c3e..af2a7ac824 100644 --- a/public/request/search.php +++ b/public/request/search.php @@ -32,10 +32,16 @@ $dataOut = []; foreach ($results as $nextRow) { - $dataOut[] = [ + $result = [ 'label' => $nextRow['Title'] ?? null, 'mylink' => $nextRow['Target'] ?? null, ]; + + if ($source === 'user' || $source === 'game-compare') { + $result['username'] = $nextRow['ID'] ?? null; + } + + $dataOut[] = $result; } return response()->json($dataOut); diff --git a/public/request/user-game-list/set-requests.php b/public/request/user-game-list/set-requests.php index 0a9cb8f1eb..ddad600b3e 100644 --- a/public/request/user-game-list/set-requests.php +++ b/public/request/user-game-list/set-requests.php @@ -6,13 +6,13 @@ $input = Validator::validate(Arr::wrap(request()->post()), [ 'game' => 'required|integer|exists:GameData,ID', - 'user' => 'required|string|exists:UserAccounts,User', + 'user' => 'required|string|exists:UserAccounts,display_name', ]); $gameId = (int) $input['game']; $user = $input['user']; -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $totalRequests = getUserRequestsInformation($userModel, $gameId); $totalRequests['gameRequests'] = getSetRequestCount($gameId); diff --git a/public/request/user/award-achievement.php b/public/request/user/award-achievement.php index 46cca63e03..277edea528 100644 --- a/public/request/user/award-achievement.php +++ b/public/request/user/award-achievement.php @@ -17,12 +17,12 @@ } $input = Validator::validate(Arr::wrap(request()->post()), [ - 'user' => 'required|string|exists:UserAccounts,User', + 'user' => 'required|string|exists:UserAccounts,display_name', 'achievement' => 'required|integer|exists:Achievements,ID', 'hardcore' => 'required|integer|min:0|max:1', ]); -$player = User::firstWhere('User', $input['user']); +$player = User::whereName($input['user'])->first(); $achievementId = $input['achievement']; $awardHardcore = (bool) $input['hardcore']; diff --git a/public/request/user/update-relationship.php b/public/request/user/update-relationship.php index 6d85b8f935..0663fa872a 100644 --- a/public/request/user/update-relationship.php +++ b/public/request/user/update-relationship.php @@ -3,6 +3,7 @@ use App\Community\Enums\UserRelationship; use App\Models\User; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -11,12 +12,13 @@ } $input = Validator::validate(Arr::wrap(request()->post()), [ - 'user' => 'required|string|exists:UserAccounts,User', + 'user' => 'required|string|exists:UserAccounts,display_name', 'action' => ['required', 'integer', Rule::in(UserRelationship::cases())], ]); -$senderUser = auth()->user(); -$targetUser = User::firstWhere('User', $input['user']); +/** @var User $senderUser */ +$senderUser = Auth::user(); +$targetUser = User::whereName($input['user'])->first(); if (!$targetUser) { return back()->withErrors(__('legacy.error.error')); diff --git a/public/request/user/update-site-awards.php b/public/request/user/update-site-awards.php index 123bc0e768..24875ccd08 100644 --- a/public/request/user/update-site-awards.php +++ b/public/request/user/update-site-awards.php @@ -76,7 +76,7 @@ } } -$userModel = User::firstWhere('User', $user); +$userModel = User::whereName($user)->first(); $userAwards = getUsersSiteAwards($userModel); $updatedAwardsHTML = ''; ob_start(); diff --git a/public/request/user/update.php b/public/request/user/update.php index 9247462619..eb1cd2a910 100644 --- a/public/request/user/update.php +++ b/public/request/user/update.php @@ -14,7 +14,7 @@ } $input = Validator::validate(Arr::wrap(request()->post()), [ - 'target' => 'required|string|exists:UserAccounts,User', + 'target' => 'required|string|exists:UserAccounts,display_name', 'property' => ['required', 'integer', Rule::in(UserAction::cases())], 'value' => 'required|integer', ]); @@ -23,11 +23,11 @@ $propertyType = (int) $input['property']; $value = (int) $input['value']; -$foundSourceUser = User::firstWhere('User', $user); -$foundTargetUser = User::firstWhere('User', $targetUsername); +$foundSourceUser = User::whereName($user)->first(); +$foundTargetUser = User::whereName($targetUsername)->first(); if ($propertyType === UserAction::UpdatePermissions) { - $response = SetAccountPermissionsJSON($foundSourceUser->User, $permissions, $targetUsername, $value); + $response = SetAccountPermissionsJSON($foundSourceUser->display_name, $permissions, $targetUsername, $value); if ($response['Success']) { // Auto-apply forums permissions. @@ -79,7 +79,7 @@ 'Server', ArticleType::UserModeration, $foundTargetUser->id, - $foundSourceUser->User . ($hasBadge ? ' revoked' : ' awarded') . ' Patreon badge' + $foundSourceUser->display_name . ($hasBadge ? ' revoked' : ' awarded') . ' Patreon badge' ); } @@ -95,7 +95,7 @@ 'Server', ArticleType::UserModeration, $foundTargetUser->id, - $foundSourceUser->User . ($hasBadge ? ' revoked' : ' awarded') . ' Certified Legend badge' + $foundSourceUser->display_name . ($hasBadge ? ' revoked' : ' awarded') . ' Certified Legend badge' ); } @@ -110,7 +110,7 @@ 'Server', ArticleType::UserModeration, $foundTargetUser->id, - $foundSourceUser->User . ' set status to ' . ($value ? 'Untracked' : 'Tracked') + $foundSourceUser->display_name . ' set status to ' . ($value ? 'Untracked' : 'Tracked') ); } diff --git a/resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx b/resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx new file mode 100644 index 0000000000..75919a8cf2 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { useImperativeHandle } from 'react'; +import { useIsomorphicLayoutEffect } from 'react-use'; + +import { cn } from '@/common/utils/cn'; + +interface UseBaseAutosizeTextAreaProps { + textAreaRef: React.MutableRefObject; + minHeight?: number; + maxHeight?: number; + triggerAutoSize: string; +} + +export const useBaseAutosizeTextArea = ({ + textAreaRef, + triggerAutoSize, + maxHeight = Number.MAX_SAFE_INTEGER, + minHeight = 0, +}: UseBaseAutosizeTextAreaProps) => { + const [init, setInit] = React.useState(true); + + // Use useIsomorphicLayoutEffect to prevent layout flickering on client hydration. + useIsomorphicLayoutEffect(() => { + // We need to reset the height momentarily to get the correct scrollHeight for the textarea. + const offsetBorder = 6; + const textAreaElement = textAreaRef.current; + + if (textAreaElement) { + if (init) { + textAreaElement.style.minHeight = `${minHeight + offsetBorder}px`; + if (maxHeight > minHeight) { + textAreaElement.style.maxHeight = `${maxHeight}px`; + } + setInit(false); + } + + // Store the current scroll position to restore it after resizing. + const scrollPos = window.scrollY; + + textAreaElement.style.height = `${minHeight + offsetBorder}px`; + const scrollHeight = textAreaElement.scrollHeight; + + // We then set the height directly, outside of the render loop. + // Trying to set this with state or a ref will produce an incorrect value. + if (scrollHeight > maxHeight) { + textAreaElement.style.height = `${maxHeight}px`; + } else { + textAreaElement.style.height = `${scrollHeight + offsetBorder}px`; + } + + // Restore the scroll position to prevent page jumps. + if (typeof window !== 'undefined') { + window.scrollTo(0, scrollPos); + } + } + }, [textAreaRef.current, triggerAutoSize, init, maxHeight, minHeight]); +}; + +export type BaseAutosizeTextAreaRef = { + textArea: HTMLTextAreaElement; + maxHeight: number; + minHeight: number; + focus: () => void; +}; + +type BaseAutosizeTextAreaProps = { + maxHeight?: number; + minHeight?: number; +} & React.TextareaHTMLAttributes; + +export const BaseAutosizeTextarea = React.forwardRef< + BaseAutosizeTextAreaRef, + BaseAutosizeTextAreaProps +>( + ( + { + maxHeight = Number.MAX_SAFE_INTEGER, + minHeight = 52, + className, + onChange, + value, + style, + ...props + }: BaseAutosizeTextAreaProps, + ref: React.Ref, + ) => { + const textAreaRef = React.useRef(null); + const [triggerAutoSize, setTriggerAutoSize] = React.useState(''); + + // Set initial height for SSR to prevent layout shift. + const initialStyle = { + ...style, + height: `${minHeight}px`, + minHeight: `${minHeight}px`, + ...(maxHeight > minHeight ? { maxHeight: `${maxHeight}px` } : {}), + }; + + useBaseAutosizeTextArea({ + textAreaRef, + triggerAutoSize, + maxHeight, + minHeight, + }); + + useImperativeHandle(ref, () => ({ + textArea: textAreaRef.current as HTMLTextAreaElement, + focus: () => textAreaRef?.current?.focus(), + maxHeight, + minHeight, + })); + + React.useEffect(() => { + setTriggerAutoSize(value as string); + }, [props?.defaultValue, value]); + + return ( +