diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 27ef3600fa..a473793862 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -102,6 +102,7 @@ rules: react/no-unescaped-entities: off react/prop-types: off react/react-in-jsx-scope: off + react/jsx-no-target-blank: off # we don't support the old browsers this rule tries to protect # disable some of the more aggressive unicorn rules unicorn/filename-case: off diff --git a/app/Community/Controllers/UserSettingsController.php b/app/Community/Controllers/UserSettingsController.php index 9c76a317b9..c9402b6056 100644 --- a/app/Community/Controllers/UserSettingsController.php +++ b/app/Community/Controllers/UserSettingsController.php @@ -8,6 +8,7 @@ use App\Community\Data\UpdatePasswordData; use App\Community\Data\UpdateProfileData; use App\Community\Data\UpdateWebsitePrefsData; +use App\Community\Data\UserSettingsPagePropsData; use App\Community\Enums\ArticleType; use App\Community\Requests\ResetConnectApiKeyRequest; use App\Community\Requests\ResetWebApiKeyRequest; @@ -15,27 +16,43 @@ use App\Community\Requests\UpdatePasswordRequest; use App\Community\Requests\UpdateProfileRequest; use App\Community\Requests\UpdateWebsitePrefsRequest; +use App\Data\UserData; +use App\Data\UserPermissionsData; use App\Enums\Permissions; use App\Http\Controller; use App\Models\User; -use Illuminate\Contracts\View\View; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; class UserSettingsController extends Controller { - /** - * Show the form for editing the specified resource. - */ - public function edit(Request $request, string $section = 'profile'): View + public function show(): InertiaResponse { - $this->authorize('updateSettings', $section); + $this->authorize('updateSettings'); - if (!view()->exists("settings.$section")) { - abort(404, 'Not found'); - } + /** @var User $user */ + $user = Auth::user(); + + $userSettings = UserData::fromUser($user)->include( + 'apiKey', + 'deleteRequested', + 'emailAddress', + 'motto', + 'userWallActive', + 'visibleRole', + ); + + $can = UserPermissionsData::fromUser($user)->include( + 'manipulateApiKeys', + 'updateAvatar', + 'updateMotto' + ); + + $props = new UserSettingsPagePropsData($userSettings, $can); - return view("settings.$section"); + return Inertia::render('settings', $props); } public function updatePassword(UpdatePasswordRequest $request): JsonResponse diff --git a/app/Community/Data/UserSettingsPagePropsData.php b/app/Community/Data/UserSettingsPagePropsData.php new file mode 100644 index 0000000000..4888f21a90 --- /dev/null +++ b/app/Community/Data/UserSettingsPagePropsData.php @@ -0,0 +1,20 @@ +group(function () { Route::middleware(['inertia'])->group(function () { + Route::get('settings', [UserSettingsController::class, 'show'])->name('settings.show'); + Route::get('forums/recent-posts', [ForumTopicController::class, 'recentlyActive'])->name('forum.recent-posts'); }); diff --git a/app/Data/UserData.php b/app/Data/UserData.php index cabf3e9100..bbf69a1f53 100644 --- a/app/Data/UserData.php +++ b/app/Data/UserData.php @@ -4,6 +4,7 @@ namespace App\Data; +use App\Enums\Permissions; use App\Models\User; use Spatie\LaravelData\Data; use Spatie\LaravelData\Lazy; @@ -17,9 +18,11 @@ class UserData extends Data public function __construct( public string $displayName, public string $avatarUrl, + public bool $isMuted, public Lazy|int $id, public Lazy|string|null $username, + public Lazy|string $motto, public Lazy|int|null $legacyPermissions, #[TypeScriptType([ @@ -30,7 +33,13 @@ public function __construct( #[LiteralTypeScriptType('App.Models.UserRole[]')] public Lazy|array|null $roles, + public Lazy|string|null $apiKey, + public Lazy|string|null $deleteRequested, + public Lazy|string|null $emailAddress, public Lazy|int|null $unreadMessageCount, + public Lazy|bool|null $userWallActive, + public Lazy|string|null $visibleRole, + public Lazy|int|null $websitePrefs, ) { } @@ -39,24 +48,37 @@ public static function fromRecentForumTopic(array $topic): self return new self( displayName: $topic['AuthorDisplayName'] ?? $topic['Author'], avatarUrl: media_asset('UserPic/' . $topic['Author'] . '.png'), + isMuted: false, id: Lazy::create(fn () => (int) $topic['author_id']), username: Lazy::create(fn () => $topic['Author']), legacyPermissions: null, preferences: null, roles: null, + motto: '', + + apiKey: null, + deleteRequested: null, + emailAddress: null, unreadMessageCount: null, + userWallActive: null, + visibleRole: null, + websitePrefs: null, ); } public static function fromUser(User $user): self { + $legacyPermissions = (int) $user->getAttribute('Permissions'); + return new self( displayName: $user->display_name, avatarUrl: $user->avatar_url, + isMuted: $user->isMuted(), id: Lazy::create(fn () => $user->id), username: Lazy::create(fn () => $user->username), + motto: Lazy::create(fn () => $user->Motto), legacyPermissions: Lazy::create(fn () => (int) $user->getAttribute('Permissions')), preferences: Lazy::create( fn () => [ @@ -64,7 +86,14 @@ public static function fromUser(User $user): self ] ), roles: Lazy::create(fn () => $user->getRoleNames()->toArray()), + + apiKey: Lazy::create(fn () => $user->APIKey), + deleteRequested: Lazy::create(fn () => $user->DeleteRequested), + emailAddress: Lazy::create(fn () => $user->EmailAddress), unreadMessageCount: Lazy::create(fn () => $user->UnreadMessageCount), + userWallActive: Lazy::create(fn () => $user->UserWallActive), + visibleRole: Lazy::create(fn () => $legacyPermissions > 1 ? Permissions::toString($legacyPermissions) : null), + websitePrefs: Lazy::create(fn () => $user->websitePrefs), ); } } diff --git a/app/Data/UserPermissionsData.php b/app/Data/UserPermissionsData.php new file mode 100644 index 0000000000..204170b176 --- /dev/null +++ b/app/Data/UserPermissionsData.php @@ -0,0 +1,32 @@ + $user ? $user->can('manage', \App\Models\GameHash::class) : false), + manipulateApiKeys: Lazy::create(fn () => $user ? $user->can('manipulateApiKeys', $user) : false), + updateAvatar: Lazy::create(fn () => $user ? $user->can('updateAvatar', $user) : false), + updateMotto: Lazy::create(fn () => $user ? $user->can('updateMotto', $user) : false), + ); + } +} diff --git a/app/Enums/UserPreference.php b/app/Enums/UserPreference.php index 840d070d65..b6a0e9dcf5 100644 --- a/app/Enums/UserPreference.php +++ b/app/Enums/UserPreference.php @@ -4,6 +4,9 @@ namespace App\Enums; +use Spatie\TypeScriptTransformer\Attributes\TypeScript; + +#[TypeScript('UserPreference')] abstract class UserPreference { public const EmailOn_ActivityComment = 0; diff --git a/app/Filament/Resources/GameResource/RelationManagers/GameHashesRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/GameHashesRelationManager.php index e82756c6b3..b2b0b24517 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/GameHashesRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/GameHashesRelationManager.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\GameResource\RelationManagers; use App\Community\Enums\ArticleType; +use App\Models\Comment; use App\Models\Game; use App\Models\GameHash; use App\Models\User; @@ -36,6 +37,11 @@ public function form(Form $form): Form public function table(Table $table): Table { + $nonAutomatedCommentsCount = Comment::where('ArticleType', ArticleType::GameHash) + ->where('ArticleID', $this->ownerRecord->id) + ->notAutomated() + ->count(); + return $table ->recordTitleAttribute('name') ->columns([ @@ -61,7 +67,10 @@ public function table(Table $table): Table ]) ->headerActions([ - + Tables\Actions\Action::make('view-comments') + ->color($nonAutomatedCommentsCount > 0 ? 'info' : 'gray') + ->label("View Comments ({$nonAutomatedCommentsCount})") + ->url(route('game.hashes.comments', ['game' => $this->ownerRecord->id])), ]) ->actions([ Tables\Actions\EditAction::make() diff --git a/app/Helpers/database/ticket.php b/app/Helpers/database/ticket.php index e98a3f9950..84a5c76ab6 100644 --- a/app/Helpers/database/ticket.php +++ b/app/Helpers/database/ticket.php @@ -4,9 +4,9 @@ use App\Community\Enums\SubscriptionSubjectType; use App\Community\Enums\TicketState; use App\Community\ViewModels\Ticket as TicketViewModel; +use App\Enums\UserPreference; use App\Models\Achievement; use App\Models\Game; -use App\Models\NotificationPreferences; use App\Models\Ticket; use App\Models\User; use App\Platform\Enums\AchievementFlag; @@ -120,7 +120,7 @@ function sendInitialTicketEmailToAssignee(Ticket $ticket, Game $game, Achievemen $achievement, ); - if ($achievement->developer && BitSet($achievement->developer->websitePrefs, NotificationPreferences::EmailOn_PrivateMessage)) { + if ($achievement->developer && BitSet($achievement->developer->websitePrefs, UserPreference::EmailOn_PrivateMessage)) { $emailBody = "Hi, {$achievement->developer->display_name}! {$ticket->reporter->display_name} would like to report a bug with an achievement you've created: @@ -138,7 +138,7 @@ function sendInitialTicketEmailsToSubscribers(Ticket $ticket, Game $game, Achiev $achievement, ); - $subscribers = getSubscribersOf(SubscriptionSubjectType::GameTickets, $game->id, 1 << NotificationPreferences::EmailOn_PrivateMessage); + $subscribers = getSubscribersOf(SubscriptionSubjectType::GameTickets, $game->id, 1 << UserPreference::EmailOn_PrivateMessage); foreach ($subscribers as $sub) { if ($sub['User'] !== $achievement->developer->User && $sub['User'] != $ticket->reporter->username) { $emailBody = "Hi, " . $sub['User'] . "! diff --git a/app/Helpers/database/user-email-verify.php b/app/Helpers/database/user-email-verify.php index 99044d499d..3c33d9d202 100644 --- a/app/Helpers/database/user-email-verify.php +++ b/app/Helpers/database/user-email-verify.php @@ -57,11 +57,12 @@ function validateEmailVerificationToken(string $emailCookie, ?string &$user): bo $response = SetAccountPermissionsJSON('Server', Permissions::Moderator, $user->username, Permissions::Registered); if ($response['Success']) { static_addnewregistereduser($user->username); - generateAPIKey($user->username); $user->email_verified_at = Carbon::now(); $user->save(); + generateAPIKey($user->username); + // SUCCESS: validated email address for $user return true; } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 5cce63d02f..b38a48a67e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,11 +42,13 @@ public function share(Request $request): array return array_merge(parent::share($request), [ 'auth' => $user ? [ 'user' => UserData::fromUser($user)->include( + 'id', 'legacyPermissions', 'preferences', 'roles', 'unreadMessageCount', 'username', + 'websitePrefs', ), ] : null, diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 51473babc4..979f1734e9 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -7,6 +7,7 @@ use App\Support\Database\Eloquent\BaseModel; use Database\Factories\CommentFactory; use Exception; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -35,6 +36,8 @@ class Comment extends BaseModel public const CREATED_AT = 'Submitted'; public const UPDATED_AT = 'Edited'; + public const SYSTEM_USER_ID = 14188; + protected $fillable = [ 'Payload', ]; @@ -105,4 +108,15 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id', 'ID')->withDefault(['username' => 'Deleted User']); } + + // == scopes + + /** + * @param Builder $query + * @return Builder + */ + public function scopeNotAutomated(Builder $query): Builder + { + return $query->where('user_id', '!=', self::SYSTEM_USER_ID); + } } diff --git a/app/Models/Forum.php b/app/Models/Forum.php index 497730d5b1..25291e8c42 100644 --- a/app/Models/Forum.php +++ b/app/Models/Forum.php @@ -108,7 +108,7 @@ public function category(): BelongsTo */ public function topics(): HasMany { - return $this->hasMany(ForumTopic::class); + return $this->hasMany(ForumTopic::class, 'ForumID', 'ID'); } /** diff --git a/app/Models/NotificationPreferences.php b/app/Models/NotificationPreferences.php deleted file mode 100644 index ad2cc4e59e..0000000000 --- a/app/Models/NotificationPreferences.php +++ /dev/null @@ -1,37 +0,0 @@ -attributes['Name']; + return $this->attributes['Name'] ?? ''; } // == mutators diff --git a/app/Platform/Controllers/GameController.php b/app/Platform/Controllers/GameController.php index d154a1eadb..1a7813ff19 100644 --- a/app/Platform/Controllers/GameController.php +++ b/app/Platform/Controllers/GameController.php @@ -6,6 +6,7 @@ use App\Http\Controller; use App\Models\Game; +use App\Models\System; use App\Platform\Requests\GameRequest; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; @@ -106,4 +107,16 @@ public function destroy(Game $game): void { $this->authorize('delete', $game); } + + public function random(): RedirectResponse + { + $this->authorize('viewAny', Game::class); + + $randomGameWithAchievements = Game::whereNotIn('ConsoleID', System::getNonGameSystems()) + ->where('achievements_published', '>=', 6) + ->inRandomOrder() + ->firstOrFail(); + + return redirect(route('game.show', ['game' => $randomGameWithAchievements])); + } } diff --git a/app/Platform/Controllers/GameHashController.php b/app/Platform/Controllers/GameHashController.php index 2e0714f0aa..af5996149d 100644 --- a/app/Platform/Controllers/GameHashController.php +++ b/app/Platform/Controllers/GameHashController.php @@ -5,13 +5,19 @@ namespace App\Platform\Controllers; use App\Community\Enums\ArticleType; +use App\Data\UserPermissionsData; use App\Http\Controller; +use App\Models\Game; use App\Models\GameHash; use App\Models\User; -use Illuminate\Contracts\View\View; +use App\Platform\Data\GameData; +use App\Platform\Data\GameHashData; +use App\Platform\Data\GameHashesPagePropsData; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; class GameHashController extends Controller { @@ -20,12 +26,17 @@ protected function resourceName(): string return 'game-hash'; } - public function index(Request $request): View + public function index(Request $request, Game $game): InertiaResponse { $this->authorize('viewAny', $this->resourceClass()); - return view('resource.index') - ->with('resource', $this->resourceName()); + $gameData = GameData::fromGame($game)->include('badgeUrl', 'forumTopicId', 'system'); + $hashes = GameHashData::fromCollection($game->hashes); + $can = UserPermissionsData::fromUser($request->user())->include('manageGameHashes'); + + $props = new GameHashesPagePropsData($gameData, $hashes, $can); + + return Inertia::render('game/[game]/hashes', $props); } public function show(GameHash $gameHash): void @@ -70,7 +81,10 @@ public function update(Request $request, GameHash $gameHash): JsonResponse } $gameHash->update($updatedAttributes); - $this->logGameHashUpdate($gameHash, $changedAttributes, Auth::user()); + + /** @var User $user */ + $user = Auth::user(); + $this->logGameHashUpdate($gameHash, $changedAttributes, $user); return response()->json(['message' => __('legacy.success.update')]); } diff --git a/app/Platform/Data/GameData.php b/app/Platform/Data/GameData.php new file mode 100644 index 0000000000..e91934c713 --- /dev/null +++ b/app/Platform/Data/GameData.php @@ -0,0 +1,34 @@ +id, + title: $game->title, + badgeUrl: Lazy::create(fn () => $game->badge_url), + forumTopicId: Lazy::create(fn () => $game->ForumTopicID), + system: Lazy::create(fn () => SystemData::fromSystem($game->system)) + ); + } +} diff --git a/app/Platform/Data/GameHashData.php b/app/Platform/Data/GameHashData.php new file mode 100644 index 0000000000..cb71d763bf --- /dev/null +++ b/app/Platform/Data/GameHashData.php @@ -0,0 +1,48 @@ +id, + md5: $gameHash->md5, + name: $gameHash->name, + labels: GameHashLabelData::fromLabelsString($gameHash->labels), + patchUrl: $gameHash->patch_url, + ); + } + + /** + * @param Collection $gameHashes + * @return GameHashData[] + */ + public static function fromCollection(Collection $gameHashes): array + { + return array_map( + fn ($gameHash) => self::fromGameHash($gameHash), + $gameHashes->all() + ); + } +} diff --git a/app/Platform/Data/GameHashLabelData.php b/app/Platform/Data/GameHashLabelData.php new file mode 100644 index 0000000000..516f1612a6 --- /dev/null +++ b/app/Platform/Data/GameHashLabelData.php @@ -0,0 +1,42 @@ +id, + name: $system->name, + nameFull: Lazy::create(fn () => $system->name_full), + nameShort: Lazy::create(fn () => $system->name_short), + ); + } +} diff --git a/app/Platform/Enums/AchievementFlag.php b/app/Platform/Enums/AchievementFlag.php index fd5086cda9..7144eaede1 100644 --- a/app/Platform/Enums/AchievementFlag.php +++ b/app/Platform/Enums/AchievementFlag.php @@ -4,9 +4,6 @@ namespace App\Platform\Enums; -use Spatie\TypeScriptTransformer\Attributes\TypeScript; - -#[TypeScript('AchievementFlag')] abstract class AchievementFlag { public const OfficialCore = 3; diff --git a/app/Platform/RouteServiceProvider.php b/app/Platform/RouteServiceProvider.php index d6ebb6313a..3d1a74e2f9 100755 --- a/app/Platform/RouteServiceProvider.php +++ b/app/Platform/RouteServiceProvider.php @@ -6,6 +6,7 @@ use App\Models\GameHash; use App\Platform\Controllers\AchievementController; +use App\Platform\Controllers\GameController; use App\Platform\Controllers\GameHashController; use App\Platform\Controllers\PlayerAchievementController; use App\Platform\Controllers\PlayerGameController; @@ -41,6 +42,10 @@ public function map(): void protected function mapWebRoutes(): void { Route::middleware(['web', 'csp'])->group(function () { + Route::middleware(['inertia'])->group(function () { + Route::get('game/{game}/hashes', [GameHashController::class, 'index'])->name('game.hashes.index'); + }); + // Route::get('achievement/{achievement}{slug?}', [AchievementController::class, 'show'])->name('achievement.show'); // Route::resource('achievements', AchievementController::class)->only('index')->names(['index' => 'achievement.index']); // Route::get( @@ -63,6 +68,7 @@ protected function mapWebRoutes(): void // Route::get('game/{game}/badges', [GameBadgeController::class, 'index'])->name('game.badge.index'); // Route::get('game/{game}/assets', [GameAssetsController::class, 'index'])->name('game.asset.index'); // Route::get('game/{game}/players', [GamePlayerController::class, 'index'])->name('game.player.index'); + Route::get('game/random', [GameController::class, 'random'])->name('game.random'); // Route::get('create', CreateController::class)->name('create'); // Route::resource('developers', DeveloperController::class)->only('index'); diff --git a/app/Platform/Services/GameTopAchieversService.php b/app/Platform/Services/GameTopAchieversService.php index 2a3d08a732..48c192a610 100644 --- a/app/Platform/Services/GameTopAchieversService.php +++ b/app/Platform/Services/GameTopAchieversService.php @@ -148,6 +148,7 @@ private function convertPlayerGames(Collection $playerGames): array 'achievements_unlocked_hardcore' => $playerGame->achievements_unlocked_hardcore, 'points_hardcore' => $playerGame->points_hardcore, 'last_unlock_hardcore_at' => $playerGame->last_unlock_hardcore_at?->unix() ?? 0, + 'beaten_at' => $playerGame->beaten_at?->unix() ?? 0, ]; } diff --git a/app/Policies/GameHashPolicy.php b/app/Policies/GameHashPolicy.php index bce9844f9a..fd80f1e776 100644 --- a/app/Policies/GameHashPolicy.php +++ b/app/Policies/GameHashPolicy.php @@ -4,7 +4,6 @@ namespace App\Policies; -use App\Enums\Permissions; use App\Models\GameHash; use App\Models\Role; use App\Models\User; @@ -20,8 +19,7 @@ public function manage(User $user): bool Role::GAME_HASH_MANAGER, Role::DEVELOPER_STAFF, Role::DEVELOPER, - ]) - || $user->getAttribute('Permissions') >= Permissions::Developer; + ]); } public function viewAny(?User $user): bool @@ -47,8 +45,9 @@ public function update(User $user): bool { return $user->hasAnyRole([ Role::GAME_HASH_MANAGER, - ]) - || $user->getAttribute('Permissions') >= Permissions::Developer; + Role::DEVELOPER_STAFF, + Role::DEVELOPER, + ]); } public function delete(User $user, GameHash $gameHash): bool diff --git a/app/Support/Shortcode/Shortcode.php b/app/Support/Shortcode/Shortcode.php index 1ad30968ad..1e357dd9a7 100644 --- a/app/Support/Shortcode/Shortcode.php +++ b/app/Support/Shortcode/Shortcode.php @@ -385,7 +385,7 @@ private function autolinkRetroachievementsUrls(string $text): string ) # End negative lookahead assertion. ~ix', function ($matches) { - $subdomain = isset($matches[1]) ? $matches[1] : ''; + $subdomain = $matches[1]; $path = isset($matches[2]) ? '/' . $matches[2] : ''; return 'https://' . $subdomain . 'retroachievements.org' . $path . ''; @@ -457,19 +457,16 @@ function ($matches) { $videoId = $matches[1]; $query = []; - // Are there additional query parameters in the URL? - if (isset($matches[2])) { - // Parse the query parameters and populate them into $query. - parse_str(ltrim($matches[2], '?'), $query); + // Parse the query parameters and populate them into $query. + parse_str(ltrim($matches[2], '?'), $query); - // Check if the "t" parameter (timestamp) is present. - if (isset($query['t'])) { - // "t" has to be converted to a time compatible with youtube-nocookie.com embeds. - $query['start'] = $this->convertYouTubeTime($query['t']); + // Check if the "t" parameter (timestamp) is present. + if (isset($query['t'])) { + // "t" has to be converted to a time compatible with youtube-nocookie.com embeds. + $query['start'] = $this->convertYouTubeTime($query['t']); - // Once converted, remove the "t" parameter so we don't accidentally duplicate it. - unset($query['t']); - } + // Once converted, remove the "t" parameter so we don't accidentally duplicate it. + unset($query['t']); } $query = http_build_query($query); diff --git a/composer.json b/composer.json index 1286982779..8a3502f4ff 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "aws/aws-sdk-php": "^3.235", "doctrine/dbal": "^3.4", "fico7489/laravel-pivot": "^3.0", - "filament/filament": "^3.2.72", + "filament/filament": "^3.2.110", "graham-campbell/markdown": "^15.0", "guzzlehttp/guzzle": "^7.5", "inertiajs/inertia-laravel": "^1.3", @@ -71,7 +71,7 @@ "torann/geoip": "^3.0" }, "require-dev": { - "barryvdh/laravel-debugbar": "^3.7", + "barryvdh/laravel-debugbar": "^3.13.5", "barryvdh/laravel-ide-helper": "^3.1", "brianium/paratest": "^7.2", "driftingly/rector-laravel": "^0.26.0", diff --git a/composer.lock b/composer.lock index 695cd1028a..3f8b720efb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e92f1e18e7945168f9cea84889c1fcad", + "content-hash": "d568f28e37df3fa9d1e4e13f29d67190", "packages": [ { "name": "amphp/amp", @@ -987,16 +987,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.320.4", + "version": "3.321.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "e6af3e760864d43a30d8b7deb4f9dc6a49a5f66a" + "reference": "c04f8f30891cee8480c132778cd4cc486467e77a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e6af3e760864d43a30d8b7deb4f9dc6a49a5f66a", - "reference": "e6af3e760864d43a30d8b7deb4f9dc6a49a5f66a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c04f8f30891cee8480c132778cd4cc486467e77a", + "reference": "c04f8f30891cee8480c132778cd4cc486467e77a", "shasum": "" }, "require": { @@ -1079,9 +1079,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.320.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.321.2" }, - "time": "2024-08-20T18:20:32+00:00" + "time": "2024-08-30T18:14:40+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2002,16 +2002,16 @@ }, { "name": "doctrine/dbal", - "version": "3.9.0", + "version": "3.9.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6" + "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/d8f68ea6cc00912e5313237130b8c8decf4d28c6", - "reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", + "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", "shasum": "" }, "require": { @@ -2027,7 +2027,7 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.11.7", + "phpstan/phpstan": "1.12.0", "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "9.6.20", "psalm/plugin-phpunit": "0.18.4", @@ -2095,7 +2095,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.0" + "source": "https://github.com/doctrine/dbal/tree/3.9.1" }, "funding": [ { @@ -2111,7 +2111,7 @@ "type": "tidelift" } ], - "time": "2024-08-15T07:34:42+00:00" + "time": "2024-09-01T13:49:23+00:00" }, { "name": "doctrine/deprecations", @@ -2658,16 +2658,16 @@ }, { "name": "filament/actions", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "1ce746a4a75975f1844c175201f1f03443c48c95" + "reference": "5d6e4fe444f1ef04d373518248a445bbcc3ca272" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/1ce746a4a75975f1844c175201f1f03443c48c95", - "reference": "1ce746a4a75975f1844c175201f1f03443c48c95", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/5d6e4fe444f1ef04d373518248a445bbcc3ca272", + "reference": "5d6e4fe444f1ef04d373518248a445bbcc3ca272", "shasum": "" }, "require": { @@ -2707,20 +2707,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2024-08-14T16:52:38+00:00" + "time": "2024-08-26T07:22:35+00:00" }, { "name": "filament/filament", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "2675472f2bdd4e765a1f3e533231bda7750a2881" + "reference": "130636e90e821154e0ce60dcbc7b358d2a1a716f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/2675472f2bdd4e765a1f3e533231bda7750a2881", - "reference": "2675472f2bdd4e765a1f3e533231bda7750a2881", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/130636e90e821154e0ce60dcbc7b358d2a1a716f", + "reference": "130636e90e821154e0ce60dcbc7b358d2a1a716f", "shasum": "" }, "require": { @@ -2772,20 +2772,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2024-08-20T08:32:50+00:00" + "time": "2024-08-30T01:52:09+00:00" }, { "name": "filament/forms", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "23b1c5d35e3181d5c43d92be0005d0b320a2a96e" + "reference": "02fe2e211993f6291b719a093ed6f63e17125e9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/23b1c5d35e3181d5c43d92be0005d0b320a2a96e", - "reference": "23b1c5d35e3181d5c43d92be0005d0b320a2a96e", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/02fe2e211993f6291b719a093ed6f63e17125e9a", + "reference": "02fe2e211993f6291b719a093ed6f63e17125e9a", "shasum": "" }, "require": { @@ -2828,11 +2828,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2024-08-20T08:32:33+00:00" + "time": "2024-08-30T18:04:06+00:00" }, { "name": "filament/infolists", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", @@ -2883,7 +2883,7 @@ }, { "name": "filament/notifications", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -2935,16 +2935,16 @@ }, { "name": "filament/support", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "544e521a310b1b4e7783d8ad1781ef7b1915232a" + "reference": "78e25428c754fcbb30c321d5dda439c760de9837" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/544e521a310b1b4e7783d8ad1781ef7b1915232a", - "reference": "544e521a310b1b4e7783d8ad1781ef7b1915232a", + "url": "https://api.github.com/repos/filamentphp/support/zipball/78e25428c754fcbb30c321d5dda439c760de9837", + "reference": "78e25428c754fcbb30c321d5dda439c760de9837", "shasum": "" }, "require": { @@ -2990,20 +2990,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2024-08-20T12:51:40+00:00" + "time": "2024-08-26T07:22:57+00:00" }, { "name": "filament/tables", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "d801f70146d07fbf3cbbb9d9c09d8daf0ff530b5" + "reference": "129943d1b4e6c1edeef53e804eb56ef78a932a6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/d801f70146d07fbf3cbbb9d9c09d8daf0ff530b5", - "reference": "d801f70146d07fbf3cbbb9d9c09d8daf0ff530b5", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/129943d1b4e6c1edeef53e804eb56ef78a932a6c", + "reference": "129943d1b4e6c1edeef53e804eb56ef78a932a6c", "shasum": "" }, "require": { @@ -3042,11 +3042,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2024-08-20T08:32:55+00:00" + "time": "2024-08-30T01:52:14+00:00" }, { "name": "filament/widgets", - "version": "v3.2.105", + "version": "v3.2.110", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", @@ -7503,16 +7503,16 @@ }, { "name": "opcodesio/log-viewer", - "version": "v3.10.2", + "version": "v3.11.1", "source": { "type": "git", "url": "https://github.com/opcodesio/log-viewer.git", - "reference": "8d44a049fce71753905ac4cd35e01c00544b17cb" + "reference": "608cde8a5fbac1e9959c060b780ef3ed26598aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opcodesio/log-viewer/zipball/8d44a049fce71753905ac4cd35e01c00544b17cb", - "reference": "8d44a049fce71753905ac4cd35e01c00544b17cb", + "url": "https://api.github.com/repos/opcodesio/log-viewer/zipball/608cde8a5fbac1e9959c060b780ef3ed26598aae", + "reference": "608cde8a5fbac1e9959c060b780ef3ed26598aae", "shasum": "" }, "require": { @@ -7575,7 +7575,7 @@ ], "support": { "issues": "https://github.com/opcodesio/log-viewer/issues", - "source": "https://github.com/opcodesio/log-viewer/tree/v3.10.2" + "source": "https://github.com/opcodesio/log-viewer/tree/v3.11.1" }, "funding": [ { @@ -7587,7 +7587,7 @@ "type": "github" } ], - "time": "2024-08-03T17:20:45+00:00" + "time": "2024-08-23T07:25:44+00:00" }, { "name": "opcodesio/mail-parser", @@ -8343,16 +8343,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.29.1", + "version": "1.30.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4" + "reference": "5ceb0e384997db59f38774bf79c2a6134252c08f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fcaefacf2d5c417e928405b71b400d4ce10daaf4", - "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/5ceb0e384997db59f38774bf79c2a6134252c08f", + "reference": "5ceb0e384997db59f38774bf79c2a6134252c08f", "shasum": "" }, "require": { @@ -8384,9 +8384,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.29.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.30.0" }, - "time": "2024-05-31T08:52:43+00:00" + "time": "2024-08-29T09:54:52+00:00" }, { "name": "pragmarx/google2fa", @@ -8802,16 +8802,16 @@ }, { "name": "psr/log", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + "reference": "79dff0b268932c640297f5208d6298f71855c03e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", + "reference": "79dff0b268932c640297f5208d6298f71855c03e", "shasum": "" }, "require": { @@ -8846,9 +8846,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.1" }, - "time": "2021-07-14T16:46:02+00:00" + "time": "2024-08-21T13:31:24+00:00" }, { "name": "psr/simple-cache", @@ -10199,16 +10199,16 @@ }, { "name": "spatie/laravel-data", - "version": "4.8.1", + "version": "4.8.2", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "597961b02b2f7722246f21dad432bf24c2abb9d6" + "reference": "1732519693ac738cbc9cb21fdd00446c7a6a46e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/597961b02b2f7722246f21dad432bf24c2abb9d6", - "reference": "597961b02b2f7722246f21dad432bf24c2abb9d6", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/1732519693ac738cbc9cb21fdd00446c7a6a46e6", + "reference": "1732519693ac738cbc9cb21fdd00446c7a6a46e6", "shasum": "" }, "require": { @@ -10271,7 +10271,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.8.1" + "source": "https://github.com/spatie/laravel-data/tree/4.8.2" }, "funding": [ { @@ -10279,20 +10279,20 @@ "type": "github" } ], - "time": "2024-08-13T13:53:42+00:00" + "time": "2024-08-30T13:53:18+00:00" }, { "name": "spatie/laravel-medialibrary", - "version": "11.8.3", + "version": "11.9.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-medialibrary.git", - "reference": "453054178128b4e12e902651fe8f2afe37a5ba34" + "reference": "ff589ea5532a33d84faeb64bfdfd59057b4148b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/453054178128b4e12e902651fe8f2afe37a5ba34", - "reference": "453054178128b4e12e902651fe8f2afe37a5ba34", + "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/ff589ea5532a33d84faeb64bfdfd59057b4148b8", + "reference": "ff589ea5532a33d84faeb64bfdfd59057b4148b8", "shasum": "" }, "require": { @@ -10376,7 +10376,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-medialibrary/issues", - "source": "https://github.com/spatie/laravel-medialibrary/tree/11.8.3" + "source": "https://github.com/spatie/laravel-medialibrary/tree/11.9.1" }, "funding": [ { @@ -10388,7 +10388,7 @@ "type": "github" } ], - "time": "2024-08-20T09:30:53+00:00" + "time": "2024-09-02T06:32:15+00:00" }, { "name": "spatie/laravel-missing-page-redirector", @@ -10459,16 +10459,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.16.4", + "version": "1.16.5", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "ddf678e78d7f8b17e5cdd99c0c3413a4a6592e53" + "reference": "c7413972cf22ffdff97b68499c22baa04eddb6a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/ddf678e78d7f8b17e5cdd99c0c3413a4a6592e53", - "reference": "ddf678e78d7f8b17e5cdd99c0c3413a4a6592e53", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/c7413972cf22ffdff97b68499c22baa04eddb6a2", + "reference": "c7413972cf22ffdff97b68499c22baa04eddb6a2", "shasum": "" }, "require": { @@ -10507,7 +10507,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.16.4" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.16.5" }, "funding": [ { @@ -10515,7 +10515,7 @@ "type": "github" } ], - "time": "2024-03-20T07:29:11+00:00" + "time": "2024-08-27T18:56:10+00:00" }, { "name": "spatie/laravel-permission", @@ -11100,16 +11100,16 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.1.2", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "0bfebf609b2047360cdca102d2c08fb78b393927" + "reference": "271542206169d95dd2ffe346ddf11f37672553a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/0bfebf609b2047360cdca102d2c08fb78b393927", - "reference": "0bfebf609b2047360cdca102d2c08fb78b393927", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/271542206169d95dd2ffe346ddf11f37672553a2", + "reference": "271542206169d95dd2ffe346ddf11f37672553a2", "shasum": "" }, "require": { @@ -11168,7 +11168,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.1.2" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.2.0" }, "funding": [ { @@ -11176,7 +11176,7 @@ "type": "github" } ], - "time": "2024-08-13T15:00:59+00:00" + "time": "2024-08-29T10:43:45+00:00" }, { "name": "spatie/query-string", @@ -11616,16 +11616,16 @@ }, { "name": "symfony/console", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "cb1dcb30ebc7005c29864ee78adb47b5fb7c3cd9" + "reference": "1eed7af6961d763e7832e874d7f9b21c3ea9c111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/cb1dcb30ebc7005c29864ee78adb47b5fb7c3cd9", - "reference": "cb1dcb30ebc7005c29864ee78adb47b5fb7c3cd9", + "url": "https://api.github.com/repos/symfony/console/zipball/1eed7af6961d763e7832e874d7f9b21c3ea9c111", + "reference": "1eed7af6961d763e7832e874d7f9b21c3ea9c111", "shasum": "" }, "require": { @@ -11689,7 +11689,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.1.3" + "source": "https://github.com/symfony/console/tree/v7.1.4" }, "funding": [ { @@ -11705,7 +11705,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:41:01+00:00" + "time": "2024-08-15T22:48:53+00:00" }, { "name": "symfony/css-selector", @@ -12138,16 +12138,16 @@ }, { "name": "symfony/finder", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "717c6329886f32dc65e27461f80f2a465412fdca" + "reference": "d95bbf319f7d052082fb7af147e0f835a695e823" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/717c6329886f32dc65e27461f80f2a465412fdca", - "reference": "717c6329886f32dc65e27461f80f2a465412fdca", + "url": "https://api.github.com/repos/symfony/finder/zipball/d95bbf319f7d052082fb7af147e0f835a695e823", + "reference": "d95bbf319f7d052082fb7af147e0f835a695e823", "shasum": "" }, "require": { @@ -12182,7 +12182,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.1.3" + "source": "https://github.com/symfony/finder/tree/v7.1.4" }, "funding": [ { @@ -12198,7 +12198,7 @@ "type": "tidelift" } ], - "time": "2024-07-24T07:08:44+00:00" + "time": "2024-08-13T14:28:19+00:00" }, { "name": "symfony/html-sanitizer", @@ -12348,16 +12348,16 @@ }, { "name": "symfony/http-kernel", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "db9702f3a04cc471ec8c70e881825db26ac5f186" + "reference": "6efcbd1b3f444f631c386504fc83eeca25963747" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/db9702f3a04cc471ec8c70e881825db26ac5f186", - "reference": "db9702f3a04cc471ec8c70e881825db26ac5f186", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6efcbd1b3f444f631c386504fc83eeca25963747", + "reference": "6efcbd1b3f444f631c386504fc83eeca25963747", "shasum": "" }, "require": { @@ -12442,7 +12442,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.1.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.1.4" }, "funding": [ { @@ -12458,7 +12458,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T14:58:15+00:00" + "time": "2024-08-30T17:02:28+00:00" }, { "name": "symfony/intl", @@ -12625,16 +12625,16 @@ }, { "name": "symfony/mime", - "version": "v7.1.2", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "26a00b85477e69a4bab63b66c5dce64f18b0cbfc" + "reference": "ccaa6c2503db867f472a587291e764d6a1e58758" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/26a00b85477e69a4bab63b66c5dce64f18b0cbfc", - "reference": "26a00b85477e69a4bab63b66c5dce64f18b0cbfc", + "url": "https://api.github.com/repos/symfony/mime/zipball/ccaa6c2503db867f472a587291e764d6a1e58758", + "reference": "ccaa6c2503db867f472a587291e764d6a1e58758", "shasum": "" }, "require": { @@ -12689,7 +12689,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.1.2" + "source": "https://github.com/symfony/mime/tree/v7.1.4" }, "funding": [ { @@ -12705,7 +12705,7 @@ "type": "tidelift" } ], - "time": "2024-06-28T10:03:55+00:00" + "time": "2024-08-13T14:28:19+00:00" }, { "name": "symfony/polyfill-ctype", @@ -13480,16 +13480,16 @@ }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "1365d10f5476f74a27cf9c2d1eee70c069019db0" + "reference": "405a7bcd872f1563966f64be19f1362d94ce71ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/1365d10f5476f74a27cf9c2d1eee70c069019db0", - "reference": "1365d10f5476f74a27cf9c2d1eee70c069019db0", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/405a7bcd872f1563966f64be19f1362d94ce71ab", + "reference": "405a7bcd872f1563966f64be19f1362d94ce71ab", "shasum": "" }, "require": { @@ -13543,7 +13543,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.1.3" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.1.4" }, "funding": [ { @@ -13559,20 +13559,20 @@ "type": "tidelift" } ], - "time": "2024-07-17T06:10:24+00:00" + "time": "2024-08-15T22:48:53+00:00" }, { "name": "symfony/routing", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8a908a3f22d5a1b5d297578c2ceb41b02fa916d0" + "reference": "1500aee0094a3ce1c92626ed8cf3c2037e86f5a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8a908a3f22d5a1b5d297578c2ceb41b02fa916d0", - "reference": "8a908a3f22d5a1b5d297578c2ceb41b02fa916d0", + "url": "https://api.github.com/repos/symfony/routing/zipball/1500aee0094a3ce1c92626ed8cf3c2037e86f5a7", + "reference": "1500aee0094a3ce1c92626ed8cf3c2037e86f5a7", "shasum": "" }, "require": { @@ -13624,7 +13624,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.1.3" + "source": "https://github.com/symfony/routing/tree/v7.1.4" }, "funding": [ { @@ -13640,7 +13640,7 @@ "type": "tidelift" } ], - "time": "2024-07-17T06:10:24+00:00" + "time": "2024-08-29T08:16:25+00:00" }, { "name": "symfony/service-contracts", @@ -13727,16 +13727,16 @@ }, { "name": "symfony/string", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ea272a882be7f20cad58d5d78c215001617b7f07" + "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ea272a882be7f20cad58d5d78c215001617b7f07", - "reference": "ea272a882be7f20cad58d5d78c215001617b7f07", + "url": "https://api.github.com/repos/symfony/string/zipball/6cd670a6d968eaeb1c77c2e76091c45c56bc367b", + "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b", "shasum": "" }, "require": { @@ -13794,7 +13794,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.3" + "source": "https://github.com/symfony/string/tree/v7.1.4" }, "funding": [ { @@ -13810,7 +13810,7 @@ "type": "tidelift" } ], - "time": "2024-07-22T10:25:37+00:00" + "time": "2024-08-12T09:59:40+00:00" }, { "name": "symfony/translation", @@ -13987,16 +13987,16 @@ }, { "name": "symfony/uid", - "version": "v7.1.1", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "bb59febeecc81528ff672fad5dab7f06db8c8277" + "reference": "82177535395109075cdb45a70533aa3d7a521cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/bb59febeecc81528ff672fad5dab7f06db8c8277", - "reference": "bb59febeecc81528ff672fad5dab7f06db8c8277", + "url": "https://api.github.com/repos/symfony/uid/zipball/82177535395109075cdb45a70533aa3d7a521cdf", + "reference": "82177535395109075cdb45a70533aa3d7a521cdf", "shasum": "" }, "require": { @@ -14041,7 +14041,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.1.1" + "source": "https://github.com/symfony/uid/tree/v7.1.4" }, "funding": [ { @@ -14057,20 +14057,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-08-12T09:59:40+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.1.3", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "86af4617cca75a6e28598f49ae0690f3b9d4591f" + "reference": "a5fa7481b199090964d6fd5dab6294d5a870c7aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/86af4617cca75a6e28598f49ae0690f3b9d4591f", - "reference": "86af4617cca75a6e28598f49ae0690f3b9d4591f", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/a5fa7481b199090964d6fd5dab6294d5a870c7aa", + "reference": "a5fa7481b199090964d6fd5dab6294d5a870c7aa", "shasum": "" }, "require": { @@ -14124,7 +14124,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.1.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.1.4" }, "funding": [ { @@ -14140,7 +14140,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:41:01+00:00" + "time": "2024-08-30T16:12:47+00:00" }, { "name": "thecodingmachine/safe", @@ -15202,16 +15202,16 @@ }, { "name": "composer/pcre", - "version": "3.3.0", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81" + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/1637e067347a0c40bbb1e3cd786b20dcab556a81", - "reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81", + "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", "shasum": "" }, "require": { @@ -15261,7 +15261,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.0" + "source": "https://github.com/composer/pcre/tree/3.3.1" }, "funding": [ { @@ -15277,7 +15277,7 @@ "type": "tidelift" } ], - "time": "2024-08-19T19:43:53+00:00" + "time": "2024-08-27T18:44:43+00:00" }, { "name": "driftingly/rector-laravel", @@ -15378,16 +15378,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "f92996c4d5c1a696a6a970e20f7c4216200fcc42" + "reference": "8520451a140d3f46ac33042715115e290cf5785f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/f92996c4d5c1a696a6a970e20f7c4216200fcc42", - "reference": "f92996c4d5c1a696a6a970e20f7c4216200fcc42", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", + "reference": "8520451a140d3f46ac33042715115e290cf5785f", "shasum": "" }, "require": { @@ -15427,7 +15427,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.1.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" }, "funding": [ { @@ -15435,7 +15435,7 @@ "type": "github" } ], - "time": "2024-02-07T09:43:46+00:00" + "time": "2024-08-06T10:04:20+00:00" }, { "name": "filp/whoops", @@ -16277,16 +16277,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.9.1", + "version": "5.10.0", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "169a9f11f1957ea36607c9b29eac1b48679f1ecc" + "reference": "91d980ab76c3f152481e367f62b921adc38af451" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/169a9f11f1957ea36607c9b29eac1b48679f1ecc", - "reference": "169a9f11f1957ea36607c9b29eac1b48679f1ecc", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/91d980ab76c3f152481e367f62b921adc38af451", + "reference": "91d980ab76c3f152481e367f62b921adc38af451", "shasum": "" }, "require": { @@ -16360,20 +16360,20 @@ "type": "other" } ], - "time": "2024-08-13T19:01:01+00:00" + "time": "2024-08-29T20:56:34+00:00" }, { "name": "phpstan/phpstan", - "version": "1.11.11", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3" + "reference": "384af967d35b2162f69526c7276acadce534d0e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3", - "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/384af967d35b2162f69526c7276acadce534d0e1", + "reference": "384af967d35b2162f69526c7276acadce534d0e1", "shasum": "" }, "require": { @@ -16418,36 +16418,36 @@ "type": "github" } ], - "time": "2024-08-19T14:37:29+00:00" + "time": "2024-08-27T09:18:05+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.15", + "version": "10.1.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae" + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", - "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.18 || ^5.0", + "nikic/php-parser": "^4.19.1 || ^5.1.0", "php": ">=8.1", - "phpunit/php-file-iterator": "^4.0", - "phpunit/php-text-template": "^3.0", - "sebastian/code-unit-reverse-lookup": "^3.0", - "sebastian/complexity": "^3.0", - "sebastian/environment": "^6.0", - "sebastian/lines-of-code": "^2.0", - "sebastian/version": "^4.0", - "theseer/tokenizer": "^1.2.0" + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { "phpunit/phpunit": "^10.1" @@ -16459,7 +16459,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1-dev" + "dev-main": "10.1.x-dev" } }, "autoload": { @@ -16488,7 +16488,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.15" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" }, "funding": [ { @@ -16496,7 +16496,7 @@ "type": "github" } ], - "time": "2024-06-29T08:25:15+00:00" + "time": "2024-08-22T04:31:57+00:00" }, { "name": "phpunit/php-file-iterator", @@ -18257,16 +18257,16 @@ }, { "name": "symfony/yaml", - "version": "v7.1.1", + "version": "v7.1.4", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "fa34c77015aa6720469db7003567b9f772492bf2" + "reference": "92e080b851c1c655c786a2da77f188f2dccd0f4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/fa34c77015aa6720469db7003567b9f772492bf2", - "reference": "fa34c77015aa6720469db7003567b9f772492bf2", + "url": "https://api.github.com/repos/symfony/yaml/zipball/92e080b851c1c655c786a2da77f188f2dccd0f4b", + "reference": "92e080b851c1c655c786a2da77f188f2dccd0f4b", "shasum": "" }, "require": { @@ -18308,7 +18308,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.1.1" + "source": "https://github.com/symfony/yaml/tree/v7.1.4" }, "funding": [ { @@ -18324,7 +18324,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-08-12T09:59:40+00:00" }, { "name": "theseer/tokenizer", diff --git a/database/factories/ForumTopicCommentFactory.php b/database/factories/ForumTopicCommentFactory.php index f81ee0fe8a..28cccd049a 100644 --- a/database/factories/ForumTopicCommentFactory.php +++ b/database/factories/ForumTopicCommentFactory.php @@ -7,6 +7,7 @@ use App\Models\ForumTopicComment; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; /** * @extends Factory @@ -24,7 +25,9 @@ public function definition(): array return [ 'Payload' => ucwords(fake()->words(2, true)), - 'user_id' => $user->ID, + 'author_id' => $user->ID, + 'authorized_at' => Carbon::now(), + 'Authorised' => 1, ]; } } diff --git a/database/migrations/community/2024_06_23_000000_update_useraccounts_table.php b/database/migrations/community/2024_06_23_000000_update_useraccounts_table.php new file mode 100644 index 0000000000..2ba05b1848 --- /dev/null +++ b/database/migrations/community/2024_06_23_000000_update_useraccounts_table.php @@ -0,0 +1,23 @@ +unique('display_name'); + }); + } + + public function down(): void + { + Schema::table('UserAccounts', function (Blueprint $table) { + $table->dropUnique(['display_name']); + }); + } +}; diff --git a/database/seeders/ForumTopicSeeder.php b/database/seeders/ForumTopicSeeder.php index 1576c5d165..3713ae18cf 100644 --- a/database/seeders/ForumTopicSeeder.php +++ b/database/seeders/ForumTopicSeeder.php @@ -4,8 +4,11 @@ namespace Database\Seeders; +use App\Models\Forum; use App\Models\ForumTopic; +use App\Models\ForumTopicComment; use Illuminate\Database\Seeder; +use Illuminate\Support\Collection; class ForumTopicSeeder extends Seeder { @@ -15,6 +18,24 @@ public function run(): void return; } - ForumTopic::factory()->count(15)->create(); + Forum::all()->each(function (Forum $forum) { + $forum->topics()->saveMany(ForumTopic::factory()->count(random_int(0, 10))->create([ + 'ForumID' => $forum->ID, + ])); + }); + + ForumTopic::all()->each(function (ForumTopic $forumTopic) { + /** @var Collection $forumTopicComments */ + $forumTopicComments = $forumTopic->comments()->saveMany(ForumTopicComment::factory()->count(random_int(1, 10))->create([ + 'ForumTopicID' => $forumTopic->ID, + ])); + + $firstComment = $forumTopicComments->first(); + $firstComment->author_id = $forumTopic->author_id; + $firstComment->save(); + + $forumTopic->LatestCommentID = $forumTopicComments->last()?->ID; + $forumTopic->save(); + }); } } diff --git a/resources/css/nav.css b/resources/css/nav.css index 0e56a4d24e..8bfe84a4bd 100644 --- a/resources/css/nav.css +++ b/resources/css/nav.css @@ -1,12 +1,3 @@ -nav { - /*padding-left: 1em; - padding-right: 1em;*/ - position: relative; - /*overflow:hidden; this breaks the dropdown! :S*/ - /*border-radius: 4px;*/ - background-color: var(--embed-color); -} - .nav-item { position: relative; line-height: 20px; diff --git a/resources/css/utilities.css b/resources/css/utilities.css index f01b7ec074..6d18c410a8 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -11,6 +11,13 @@ @layer utilities { } +/** Properly show the red background on barryvdh/laravel-debugbar query timing measurements. **/ +div.phpdebugbar-widgets-sqlqueries div.phpdebugbar-widgets-bg-measure div.phpdebugbar-widgets-value { + height: 100% !important; + opacity: 0.2 !important; + background: red !important; +} + .bg-body { background-color: var(--bg-color); } diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 13d534454d..0666bf4598 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -2,6 +2,8 @@ import { createInertiaApp } from '@inertiajs/react'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { createRoot, hydrateRoot } from 'react-dom/client'; +import { AppProviders } from './common/components/AppProviders'; + const appName = import.meta.env.APP_NAME || 'RetroAchievements'; createInertiaApp({ @@ -12,12 +14,21 @@ createInertiaApp({ setup({ el, App, props }) { if (import.meta.env.DEV) { - createRoot(el).render(); + createRoot(el).render( + + + , + ); return; } - hydrateRoot(el, ); + hydrateRoot( + el, + + + , + ); }, progress: { diff --git a/resources/js/common/components/+vendor/BaseAlert.tsx b/resources/js/common/components/+vendor/BaseAlert.tsx new file mode 100644 index 0000000000..20b7dcce13 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseAlert.tsx @@ -0,0 +1,55 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const baseAlertVariants = cva( + 'relative w-full rounded border light:border-neutral-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-3.5 [&>svg]:top-4 light:[&>svg]:text-neutral-950 border-neutral-800 [&>svg]:text-neutral-50', + { + variants: { + variant: { + default: 'light:bg-white light:text-neutral-950 bg-neutral-950 text-neutral-50', + destructive: + 'light:bg-red-50 bg-red-900/10 light:border-red-500/50 text-red-500 border-red-500 light:[&>svg]:text-red-500 text-red-500 [&>svg]:text-red-500', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const BaseAlert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +BaseAlert.displayName = 'BaseAlert'; + +const BaseAlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +BaseAlertTitle.displayName = 'BaseAlertTitle'; + +const BaseAlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +BaseAlertDescription.displayName = 'BaseAlertDescription'; + +export { BaseAlert, BaseAlertDescription, BaseAlertTitle }; diff --git a/resources/js/common/components/+vendor/BaseBreadcrumb.tsx b/resources/js/common/components/+vendor/BaseBreadcrumb.tsx index 56959d2e72..a760c7b9a5 100644 --- a/resources/js/common/components/+vendor/BaseBreadcrumb.tsx +++ b/resources/js/common/components/+vendor/BaseBreadcrumb.tsx @@ -51,7 +51,7 @@ const BaseBreadcrumbLink = forwardRef< return ( ); @@ -65,7 +65,7 @@ const BaseBreadcrumbPage = forwardRef ), diff --git a/resources/js/common/components/+vendor/BaseButton.tsx b/resources/js/common/components/+vendor/BaseButton.tsx index 9884875d46..1948d227b1 100644 --- a/resources/js/common/components/+vendor/BaseButton.tsx +++ b/resources/js/common/components/+vendor/BaseButton.tsx @@ -8,17 +8,18 @@ import { cn } from '@/utils/cn'; const baseButtonVariants = cva( [ - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium light:ring-offset-white', + 'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium light:ring-offset-white', 'focus-visible:outline-none focus-visible:ring-2 light:focus-visible:ring-neutral-950 focus-visible:ring-offset-2', - 'disabled:pointer-events-none disabled:opacity-50 ring-offset-neutral-950 focus-visible:ring-neutral-300', + 'disabled:pointer-events-none disabled:opacity-50', + 'ring-offset-neutral-950 focus-visible:ring-neutral-300', + 'lg:active:translate-y-[1px] lg:active:scale-[0.98] lg:transition-transform lg:duration-100', ], { variants: { variant: { default: - 'light:bg-neutral-900 light:text-neutral-50 light:hover:bg-neutral-900/90 bg-neutral-50 text-neutral-900 hover:bg-neutral-50/90', - destructive: - 'light:bg-red-500 light:text-neutral-50 light:hover:bg-red-500/90 bg-red-900 text-neutral-50 hover:bg-red-900/90', + 'bg-embed text-link border border-neutral-700 hover:bg-embed-highlight hover:text-link-hover hover:border-menu-link light:bg-white light:border-link light:text-link light:hover:bg-neutral-100', + destructive: 'bg-embed border btn-danger hover:text-link-hover hover:border-menu-link', outline: 'border light:border-neutral-200 light:bg-white light:hover:bg-neutral-100 light:hover:text-neutral-900 border-neutral-800 bg-neutral-950 hover:bg-neutral-800 hover:text-neutral-50', secondary: @@ -28,8 +29,8 @@ const baseButtonVariants = cva( link: 'light:text-neutral-900 underline-offset-4 hover:underline text-neutral-50', }, size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', + default: 'h-9 px-4 py-2', + sm: 'h-[30px] rounded-md px-3 !text-[13px]', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', }, diff --git a/resources/js/common/components/+vendor/BaseCard.tsx b/resources/js/common/components/+vendor/BaseCard.tsx new file mode 100644 index 0000000000..6d1ba5d377 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseCard.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const BaseCard = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +BaseCard.displayName = 'BaseCard'; + +const BaseCardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +BaseCardHeader.displayName = 'CardHeader'; + +const baseCardTitleClassNames = + 'mb-0 border-b-0 text-2xl font-semibold leading-none tracking-tight'; +const BaseCardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +BaseCardTitle.displayName = 'BaseCardTitle'; + +const BaseCardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +BaseCardDescription.displayName = 'BaseCardDescription'; + +const BaseCardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +BaseCardContent.displayName = 'BaseCardContent'; + +const BaseCardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +BaseCardFooter.displayName = 'BaseCardFooter'; + +export { + BaseCard, + BaseCardContent, + BaseCardDescription, + BaseCardFooter, + BaseCardHeader, + BaseCardTitle, + baseCardTitleClassNames, +}; diff --git a/resources/js/common/components/+vendor/BaseCheckbox.tsx b/resources/js/common/components/+vendor/BaseCheckbox.tsx new file mode 100644 index 0000000000..f5ff49d1ec --- /dev/null +++ b/resources/js/common/components/+vendor/BaseCheckbox.tsx @@ -0,0 +1,33 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import * as React from 'react'; +import { HiOutlineCheck } from 'react-icons/hi'; + +import { cn } from '@/utils/cn'; + +const BaseCheckbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +BaseCheckbox.displayName = 'BaseCheckbox'; + +export { BaseCheckbox }; diff --git a/resources/js/common/components/+vendor/BaseDropdownMenu.tsx b/resources/js/common/components/+vendor/BaseDropdownMenu.tsx new file mode 100644 index 0000000000..acfec10dae --- /dev/null +++ b/resources/js/common/components/+vendor/BaseDropdownMenu.tsx @@ -0,0 +1,190 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import * as React from 'react'; +import { LuCheck, LuChevronRight, LuCircle } from 'react-icons/lu'; + +import { cn } from '@/utils/cn'; + +const BaseDropdownMenu = DropdownMenuPrimitive.Root; + +const BaseDropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const BaseDropdownMenuGroup = DropdownMenuPrimitive.Group; + +const BaseDropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const BaseDropdownMenuSub = DropdownMenuPrimitive.Sub; + +const BaseDropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const BaseDropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +BaseDropdownMenuSubTrigger.displayName = 'BaseDropdownMenuSubTrigger'; + +const BaseDropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseDropdownMenuSubContent.displayName = 'BaseDropdownMenuSubContent'; + +const BaseDropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +BaseDropdownMenuContent.displayName = 'BaseDropdownMenuContent'; + +const BaseDropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +BaseDropdownMenuItem.displayName = 'BaseDropdownMenuItem'; + +const BaseDropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +BaseDropdownMenuCheckboxItem.displayName = 'BaseDropdownMenuCheckboxItem'; + +const BaseDropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +BaseDropdownMenuRadioItem.displayName = 'BaseDropdownMenuRadioItem'; + +const BaseDropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +BaseDropdownMenuLabel.displayName = 'BaseDropdownMenuLabel'; + +const BaseDropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseDropdownMenuSeparator.displayName = 'BaseDropdownMenuSeparator'; + +const BaseDropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +BaseDropdownMenuShortcut.displayName = 'BaseDropdownMenuShortcut'; + +export { + BaseDropdownMenu, + BaseDropdownMenuCheckboxItem, + BaseDropdownMenuContent, + BaseDropdownMenuGroup, + BaseDropdownMenuItem, + BaseDropdownMenuLabel, + BaseDropdownMenuPortal, + BaseDropdownMenuRadioGroup, + BaseDropdownMenuRadioItem, + BaseDropdownMenuSeparator, + BaseDropdownMenuShortcut, + BaseDropdownMenuSub, + BaseDropdownMenuSubContent, + BaseDropdownMenuSubTrigger, + BaseDropdownMenuTrigger, +}; diff --git a/resources/js/common/components/+vendor/BaseForm.tsx b/resources/js/common/components/+vendor/BaseForm.tsx new file mode 100644 index 0000000000..baf21f0b6b --- /dev/null +++ b/resources/js/common/components/+vendor/BaseForm.tsx @@ -0,0 +1,168 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import type * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; +import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'; +import { Controller, FormProvider, useFormContext } from 'react-hook-form'; + +import { cn } from '@/utils/cn'; + +import { BaseLabel } from './BaseLabel'; + +const BaseFormProvider = FormProvider; + +type BaseFormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const BaseFormFieldContext = React.createContext( + {} as BaseFormFieldContextValue, +); + +const BaseFormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useBaseFormField = () => { + const fieldContext = React.useContext(BaseFormFieldContext); + const itemContext = React.useContext(BaseFormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type BaseFormItemContextValue = { + id: string; +}; + +const BaseFormItemContext = React.createContext( + {} as BaseFormItemContextValue, +); + +const BaseFormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); + }, +); +BaseFormItem.displayName = 'BaseFormItem'; + +const BaseFormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useBaseFormField(); + + return ( + + ); +}); +BaseFormLabel.displayName = 'BaseFormLabel'; + +const BaseFormControl = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useBaseFormField(); + + return ( + + ); +}); +BaseFormControl.displayName = 'BaseFormControl'; + +const BaseFormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useBaseFormField(); + + return ( +

+ ); +}); +BaseFormDescription.displayName = 'BaseFormDescription'; + +const BaseFormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useBaseFormField(); + const body = error ? String(error?.message) : children; + + if (!body) { + return null; + } + + return ( +

+ {body} +

+ ); +}); +BaseFormMessage.displayName = 'BaseFormMessage'; + +export { + BaseFormControl, + BaseFormDescription, + BaseFormField, + BaseFormItem, + BaseFormLabel, + BaseFormMessage, + BaseFormProvider, + useBaseFormField, +}; diff --git a/resources/js/common/components/+vendor/BaseInput.tsx b/resources/js/common/components/+vendor/BaseInput.tsx new file mode 100644 index 0000000000..9caa0933f1 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseInput.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +export type BaseInputProps = React.InputHTMLAttributes; + +const BaseInput = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +BaseInput.displayName = 'BaseInput'; + +export { BaseInput }; diff --git a/resources/js/common/components/+vendor/BaseLabel.tsx b/resources/js/common/components/+vendor/BaseLabel.tsx new file mode 100644 index 0000000000..c975c5eca4 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseLabel.tsx @@ -0,0 +1,22 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const baseLabelVariants = cva( + 'font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', +); + +const BaseLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +BaseLabel.displayName = 'BaseLabel'; + +export { BaseLabel }; diff --git a/resources/js/common/components/+vendor/BaseSelect.tsx b/resources/js/common/components/+vendor/BaseSelect.tsx new file mode 100644 index 0000000000..7cb07febb6 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseSelect.tsx @@ -0,0 +1,167 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as SelectPrimitive from '@radix-ui/react-select'; +import * as React from 'react'; +import { LuCheck, LuChevronDown, LuChevronUp } from 'react-icons/lu'; + +import { cn } from '@/utils/cn'; + +const BaseSelect = SelectPrimitive.Root; + +const BaseSelectGroup = SelectPrimitive.Group; + +const BaseSelectValue = SelectPrimitive.Value; + +const BaseSelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + 'border-neutral-800 bg-neutral-950 text-menu-link ring-offset-neutral-950 placeholder:text-neutral-400', + 'focus:ring-neutral-300', + className, + )} + {...props} + > + {children} + + + + +)); +BaseSelectTrigger.displayName = 'BaseSelectTrigger'; + +const BaseSelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +BaseSelectScrollUpButton.displayName = 'BaseSelectScrollUpButton'; + +const BaseSelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +BaseSelectScrollDownButton.displayName = 'BaseSelectScrollDownButton'; + +const BaseSelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +BaseSelectContent.displayName = 'BaseSelectContent'; + +const BaseSelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseSelectLabel.displayName = 'BaseSelectLabel'; + +const BaseSelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +BaseSelectItem.displayName = 'BaseSelectItem'; + +const BaseSelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseSelectSeparator.displayName = 'BaseSelectSeparator'; + +export { + BaseSelect, + BaseSelectContent, + BaseSelectGroup, + BaseSelectItem, + BaseSelectLabel, + BaseSelectScrollDownButton, + BaseSelectScrollUpButton, + BaseSelectSeparator, + BaseSelectTrigger, + BaseSelectValue, +}; diff --git a/resources/js/common/components/+vendor/BaseSwitch.tsx b/resources/js/common/components/+vendor/BaseSwitch.tsx new file mode 100644 index 0000000000..f8c2346986 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseSwitch.tsx @@ -0,0 +1,37 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as SwitchPrimitives from '@radix-ui/react-switch'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const BaseSwitch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +BaseSwitch.displayName = 'BaseSwitch'; + +export { BaseSwitch }; diff --git a/resources/js/common/components/+vendor/BaseToaster.tsx b/resources/js/common/components/+vendor/BaseToaster.tsx new file mode 100644 index 0000000000..323b686f30 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseToaster.tsx @@ -0,0 +1,47 @@ +/* eslint-disable no-restricted-imports -- base components can import from sonner */ + +import type { ComponentProps } from 'react'; +import { toast, Toaster as Sonner } from 'sonner'; + +import { cn } from '@/utils/cn'; + +type BaseToasterProps = ComponentProps; + +const BaseToaster = ({ ...props }: BaseToasterProps) => { + const theme = 'dark'; // TODO + + return ( + + ); +}; + +// Rename toast, otherwise IDEs will always try to auto-import from sonner instead of our own. +const toastMessage = toast; + +export { BaseToaster, toastMessage }; diff --git a/resources/js/common/components/+vendor/BaseTooltip.tsx b/resources/js/common/components/+vendor/BaseTooltip.tsx new file mode 100644 index 0000000000..d8b2a7c5c7 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseTooltip.tsx @@ -0,0 +1,33 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const BaseTooltipProvider = TooltipPrimitive.Provider; + +const BaseTooltip = TooltipPrimitive.Root; + +const BaseTooltipTrigger = TooltipPrimitive.Trigger; + +const BaseTooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +BaseTooltipContent.displayName = 'BaseTooltipContent'; + +export { BaseTooltip, BaseTooltipContent, BaseTooltipProvider, BaseTooltipTrigger }; diff --git a/resources/js/common/components/AppProviders/AppProviders.test.tsx b/resources/js/common/components/AppProviders/AppProviders.test.tsx new file mode 100644 index 0000000000..c82dadbf43 --- /dev/null +++ b/resources/js/common/components/AppProviders/AppProviders.test.tsx @@ -0,0 +1,13 @@ +import { render } from '@/test'; + +import { AppProviders } from './AppProviders'; + +describe('Component: AppProviders', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(content, { wrapper: () => <> }); + + // ASSERT + expect(container).toBeTruthy(); + }); +}); diff --git a/resources/js/common/components/AppProviders/AppProviders.tsx b/resources/js/common/components/AppProviders/AppProviders.tsx new file mode 100644 index 0000000000..538cbc702f --- /dev/null +++ b/resources/js/common/components/AppProviders/AppProviders.tsx @@ -0,0 +1,23 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { FC, ReactNode } from 'react'; + +import { BaseToaster } from '../+vendor/BaseToaster'; +import { BaseTooltipProvider } from '../+vendor/BaseTooltip'; + +const queryClient = new QueryClient(); + +interface AppProvidersProps { + children: ReactNode; +} + +export const AppProviders: FC = ({ children }) => { + return ( + + + {children} + + + + + ); +}; diff --git a/resources/js/common/components/AppProviders/index.ts b/resources/js/common/components/AppProviders/index.ts new file mode 100644 index 0000000000..4dfaf720df --- /dev/null +++ b/resources/js/common/components/AppProviders/index.ts @@ -0,0 +1 @@ +export * from './AppProviders'; diff --git a/resources/js/common/components/Embed/Embed.test.tsx b/resources/js/common/components/Embed/Embed.test.tsx new file mode 100644 index 0000000000..3add999f75 --- /dev/null +++ b/resources/js/common/components/Embed/Embed.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@/test'; + +import { Embed } from './Embed'; + +describe('Component: Embed', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(stuff); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('renders children', () => { + // ARRANGE + render(stuff); + + // ASSERT + expect(screen.getByText(/stuff/i)).toBeVisible(); + }); +}); diff --git a/resources/js/common/components/Embed/Embed.tsx b/resources/js/common/components/Embed/Embed.tsx new file mode 100644 index 0000000000..d3ad40c7ab --- /dev/null +++ b/resources/js/common/components/Embed/Embed.tsx @@ -0,0 +1,15 @@ +import type { FC, HTMLAttributes, ReactNode } from 'react'; + +interface EmbedProps extends HTMLAttributes { + children: ReactNode; + + className?: string; +} + +export const Embed: FC = ({ children, className, ...rest }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/resources/js/common/components/Embed/index.ts b/resources/js/common/components/Embed/index.ts new file mode 100644 index 0000000000..8113a2f96c --- /dev/null +++ b/resources/js/common/components/Embed/index.ts @@ -0,0 +1 @@ +export * from './Embed'; diff --git a/resources/js/common/components/GameAvatar/GameAvatar.test.tsx b/resources/js/common/components/GameAvatar/GameAvatar.test.tsx new file mode 100644 index 0000000000..d367d29198 --- /dev/null +++ b/resources/js/common/components/GameAvatar/GameAvatar.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@/test'; +import { createGame } from '@/test/factories'; + +import { GameAvatar } from './GameAvatar'; + +describe('Component: GameAvatar', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given a game title, shows the game title on the screen', () => { + // ARRANGE + const game = createGame(); + + render(); + + // ASSERT + expect(screen.getByText(game.title)).toBeVisible(); + }); + + it('given there is no title, still renders successfully', () => { + // ARRANGE + const game = createGame({ title: undefined }); + + render(); + + // ASSERT + expect(screen.getByRole('img', { name: /game/i })).toBeVisible(); + }); + + it('applies the correct size to the image', () => { + // ARRANGE + const game = createGame(); + + render(); + + // ASSERT + const imgEl = screen.getByRole('img'); + + expect(imgEl).toHaveAttribute('width', '8'); + expect(imgEl).toHaveAttribute('height', '8'); + }); + + it('adds card tooltip props by default', () => { + // ARRANGE + const game = createGame({ id: 1 }); + + render(); + + // ASSERT + const anchorEl = screen.getByRole('link'); + + expect(anchorEl).toHaveAttribute( + 'x-data', + "tooltipComponent($el, {dynamicType: 'game', dynamicId: '1', dynamicContext: 'undefined'})", + ); + expect(anchorEl).toHaveAttribute('x-on:mouseover', 'showTooltip($event)'); + expect(anchorEl).toHaveAttribute('x-on:mouseleave', 'hideTooltip'); + expect(anchorEl).toHaveAttribute('x-on:mousemove', 'trackMouseMovement($event)'); + }); + + it('does not add card tooltip props when `hasTooltip` is false', () => { + // ARRANGE + const game = createGame({ id: 1 }); + + render(); + + // ASSERT + const anchorEl = screen.getByRole('link'); + + expect(anchorEl).not.toHaveAttribute('x-data'); + expect(anchorEl).not.toHaveAttribute('x-on:mouseover'); + expect(anchorEl).not.toHaveAttribute('x-on:mouseleave'); + expect(anchorEl).not.toHaveAccessibleDescription('x-on:mousemove'); + }); +}); diff --git a/resources/js/common/components/GameAvatar/GameAvatar.tsx b/resources/js/common/components/GameAvatar/GameAvatar.tsx new file mode 100644 index 0000000000..54208125cf --- /dev/null +++ b/resources/js/common/components/GameAvatar/GameAvatar.tsx @@ -0,0 +1,49 @@ +import type { FC } from 'react'; + +import { useCardTooltip } from '@/common/hooks/useCardTooltip'; +import type { AvatarSize } from '@/common/models'; + +interface GameAvatarProps { + id: number; + + badgeUrl?: string; + hasTooltip?: boolean; + showBadge?: boolean; + showTitle?: boolean; + size?: AvatarSize; + title?: string; +} + +export const GameAvatar: FC = ({ + id, + badgeUrl, + showBadge, + showTitle, + title, + size = 32, + hasTooltip = true, +}) => { + const { cardTooltipProps } = useCardTooltip({ dynamicType: 'game', dynamicId: id }); + + return ( + + {showBadge !== false ? ( + + ) : null} + + {title && showTitle !== false ? {title} : null} + + ); +}; diff --git a/resources/js/common/components/GameAvatar/index.ts b/resources/js/common/components/GameAvatar/index.ts new file mode 100644 index 0000000000..e2a42ee035 --- /dev/null +++ b/resources/js/common/components/GameAvatar/index.ts @@ -0,0 +1 @@ +export * from './GameAvatar'; diff --git a/resources/js/common/components/SimpleTooltip/SimpleTooltip.test.tsx b/resources/js/common/components/SimpleTooltip/SimpleTooltip.test.tsx new file mode 100644 index 0000000000..4ddcdd7768 --- /dev/null +++ b/resources/js/common/components/SimpleTooltip/SimpleTooltip.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@/test'; + +import { SimpleTooltip } from './SimpleTooltip'; + +describe('Component: SimpleTooltip', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + content, + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('renders children', () => { + // ARRANGE + render(content); + + // ASSERT + expect(screen.getByText(/content/i)).toBeVisible(); + }); +}); diff --git a/resources/js/common/components/SimpleTooltip/SimpleTooltip.tsx b/resources/js/common/components/SimpleTooltip/SimpleTooltip.tsx new file mode 100644 index 0000000000..51ccfa144c --- /dev/null +++ b/resources/js/common/components/SimpleTooltip/SimpleTooltip.tsx @@ -0,0 +1,33 @@ +import type { FC, ReactNode } from 'react'; + +import { BaseTooltip, BaseTooltipContent, BaseTooltipTrigger } from '../+vendor/BaseTooltip'; + +/** + * 👉 If you find yourself reaching to add more props, use BaseTooltip instead. + */ + +interface SimpleTooltipProps { + children: ReactNode; + tooltipContent: string; + + /** Use this to conditionally control whether the tooltip is visible. */ + isOpen?: boolean; + + /** If the tooltip is being added to something like a button, this needs to be truthy. */ + isWrappingTapTarget?: boolean; +} + +export const SimpleTooltip: FC = ({ + children, + isWrappingTapTarget, + tooltipContent, + isOpen, +}) => { + return ( + + {children} + + {tooltipContent} + + ); +}; diff --git a/resources/js/common/components/SimpleTooltip/index.ts b/resources/js/common/components/SimpleTooltip/index.ts new file mode 100644 index 0000000000..f35c08fc21 --- /dev/null +++ b/resources/js/common/components/SimpleTooltip/index.ts @@ -0,0 +1 @@ +export * from './SimpleTooltip'; diff --git a/resources/js/common/components/UserAvatar/UserAvatar.tsx b/resources/js/common/components/UserAvatar/UserAvatar.tsx index ffdfac06e4..5821935519 100644 --- a/resources/js/common/components/UserAvatar/UserAvatar.tsx +++ b/resources/js/common/components/UserAvatar/UserAvatar.tsx @@ -1,14 +1,13 @@ import type { FC } from 'react'; import { useCardTooltip } from '@/common/hooks/useCardTooltip'; +import type { AvatarSize } from '@/common/models'; interface UserAvatarProps { displayName: string | null; hasTooltip?: boolean; - // This is strongly typed so we don't wind up with 100 different possible sizes. - // If possible, use one of these sane defaults. Only add another one if necessary. - size?: 8 | 16 | 24 | 32 | 64 | 128; + size?: AvatarSize; } export const UserAvatar: FC = ({ displayName, size = 32, hasTooltip = true }) => { diff --git a/resources/js/common/layouts/AppLayout/AppLayout.tsx b/resources/js/common/layouts/AppLayout/AppLayout.tsx index 8bcbe83ae0..daa3ccd19c 100644 --- a/resources/js/common/layouts/AppLayout/AppLayout.tsx +++ b/resources/js/common/layouts/AppLayout/AppLayout.tsx @@ -20,7 +20,7 @@ interface AppLayoutMainProps { } const AppLayoutMain: FC = ({ children }) => { - return
{children}
; + return
{children}
; }; interface AppLayoutSidebarProps { diff --git a/resources/js/common/models/app-global-props.model.ts b/resources/js/common/models/app-global-props.model.ts index 09bf10ce4f..2be8c75ac7 100644 --- a/resources/js/common/models/app-global-props.model.ts +++ b/resources/js/common/models/app-global-props.model.ts @@ -1,11 +1,31 @@ import type { PageProps } from '@inertiajs/core'; import type { SetRequired } from 'type-fest'; +import { createFactory } from '@/test/createFactory'; + type AuthenticatedUser = SetRequired< App.Data.User, - 'legacyPermissions' | 'preferences' | 'roles' | 'unreadMessageCount' + 'id' | 'legacyPermissions' | 'preferences' | 'roles' | 'unreadMessageCount' | 'websitePrefs' >; export interface AppGlobalProps extends PageProps { auth: { user: AuthenticatedUser } | null; } + +export const createAuthenticatedUser = createFactory((faker) => ({ + avatarUrl: faker.internet.url(), + displayName: faker.internet.displayName(), + id: faker.number.int({ min: 1, max: 99999 }), + isMuted: false, + legacyPermissions: 8447, + preferences: { + prefersAbsoluteDates: false, + }, + roles: [], + unreadMessageCount: 0, + websitePrefs: 63, // The default when a new account is created. +})); + +export const createAppGlobalProps = createFactory(() => ({ + auth: { user: createAuthenticatedUser() }, +})); diff --git a/resources/js/common/models/avatar-size.model.ts b/resources/js/common/models/avatar-size.model.ts new file mode 100644 index 0000000000..232b2fcc05 --- /dev/null +++ b/resources/js/common/models/avatar-size.model.ts @@ -0,0 +1,3 @@ +// This is strongly typed so we don't wind up with 100 different possible sizes. +// If possible, use one of these sane defaults. Only add another one if necessary. +export type AvatarSize = 8 | 16 | 24 | 32 | 48 | 64 | 128; diff --git a/resources/js/common/models/index.ts b/resources/js/common/models/index.ts index a9cac77932..cc83033849 100644 --- a/resources/js/common/models/index.ts +++ b/resources/js/common/models/index.ts @@ -1,3 +1,5 @@ export * from './app-global-props.model'; export * from './app-page.model'; +export * from './avatar-size.model'; +export * from './laravel-validation-error.model'; export * from './paginated-data.model'; diff --git a/resources/js/common/models/laravel-validation-error.model.ts b/resources/js/common/models/laravel-validation-error.model.ts new file mode 100644 index 0000000000..2a5563fcf8 --- /dev/null +++ b/resources/js/common/models/laravel-validation-error.model.ts @@ -0,0 +1,13 @@ +import type { AxiosError, AxiosResponse } from 'axios'; + +interface LaravelValidationResponse extends AxiosResponse { + status: 422; + data: { + message: string; + errors: Record; + }; +} + +export interface LaravelValidationError extends AxiosError { + response: LaravelValidationResponse; +} diff --git a/resources/js/common/utils/buildTrackingClassNames.test.ts b/resources/js/common/utils/buildTrackingClassNames.test.ts new file mode 100644 index 0000000000..639acc6eee --- /dev/null +++ b/resources/js/common/utils/buildTrackingClassNames.test.ts @@ -0,0 +1,51 @@ +import { buildTrackingClassNames } from './buildTrackingClassNames'; + +// It isn't necessary log the warning for an empty customEventName. +global.console.warn = vi.fn(); + +describe('Util: buildTrackingClassNames', () => { + it('is defined', () => { + // ASSERT + expect(buildTrackingClassNames).toBeDefined(); + }); + + it('given only an event name is provided, returns a single classname', () => { + // ACT + const result = buildTrackingClassNames('Download Patch File Click'); + + // ASSERT + expect(result).toEqual('plausible-event-name=Download+Patch+File+Click'); + }); + + it('given an event name and a property are provided, returns multiple classnames', () => { + // ACT + const result = buildTrackingClassNames('Download Patch File Click', { md5: 'abc123' }); + + // ASSERT + expect(result).toEqual( + 'plausible-event-name=Download+Patch+File+Click plausible-event-md5=abc123', + ); + }); + + it('correctly handles multiple properties with different types', () => { + // ACT + const result = buildTrackingClassNames('Submit Form', { + id: 123, + success: true, + description: 'User Submitted Form', + }); + + // ASSERT + expect(result).toEqual( + 'plausible-event-name=Submit+Form plausible-event-id=123 plausible-event-success=true plausible-event-description=User+Submitted+Form', + ); + }); + + it('returns an empty string when neither event name nor properties are provided', () => { + // ACT + const result = buildTrackingClassNames(''); + + // ASSERT + expect(result).toEqual(''); + }); +}); diff --git a/resources/js/common/utils/buildTrackingClassNames.ts b/resources/js/common/utils/buildTrackingClassNames.ts new file mode 100644 index 0000000000..80354d9eaa --- /dev/null +++ b/resources/js/common/utils/buildTrackingClassNames.ts @@ -0,0 +1,36 @@ +/** + * Track a custom Plausible event on click. Optionally accepts custom properties to decorate + * the event with more specificity (see example). + * + * @example + * className={buildTrackingClassNames('Download Patch File', { md5: hash.md5 })} + * className={`px-3 py-4 ${buildTrackingClassNames('Download Patch File', { md5: hash.md5 })}`} + */ +export function buildTrackingClassNames( + customEventName: string, + customProperties?: Record, +) { + // Something has gone wrong. Bail. + if (customEventName.trim() === '') { + console.warn('buildTrackingClassNames() was called with an empty customEventName.'); + + return ''; + } + + const classNames: string[] = []; + + // Format the custom event name how Plausible expects. + // "My Custom Event" --> "My+Custom+Event" + const formattedEventName = `plausible-event-name=${customEventName.replace(/\s+/g, '+')}`; + classNames.push(formattedEventName); + + // Add each custom property. Spaces here must be replaced with plus signs, too. + if (customProperties) { + for (const [key, value] of Object.entries(customProperties)) { + const formattedValue = `${value}`.replace(/\s+/g, '+'); + classNames.push(`plausible-event-${key}=${formattedValue}`); + } + } + + return classNames.join(' '); +} diff --git a/resources/js/common/utils/convertObjectToWebsitePrefs.ts b/resources/js/common/utils/convertObjectToWebsitePrefs.ts new file mode 100644 index 0000000000..9b8a20c60f --- /dev/null +++ b/resources/js/common/utils/convertObjectToWebsitePrefs.ts @@ -0,0 +1,11 @@ +export function convertObjectToWebsitePrefs(preferences: Record): number { + let websitePrefs = 0; + + for (let i = 0; i <= 17; i++) { + if (preferences[i]) { + websitePrefs += 1 << i; + } + } + + return websitePrefs; +} diff --git a/resources/js/common/utils/convertWebsitePrefsToObject.ts b/resources/js/common/utils/convertWebsitePrefsToObject.ts new file mode 100644 index 0000000000..3249b56170 --- /dev/null +++ b/resources/js/common/utils/convertWebsitePrefsToObject.ts @@ -0,0 +1,9 @@ +export function convertWebsitePrefsToObject(websitePrefs: number): Record { + const preferences: Record = {}; + + for (let i = 0; i <= 17; i++) { + preferences[i] = (websitePrefs & (1 << i)) !== 0; + } + + return preferences; +} diff --git a/resources/js/common/utils/generatedAppConstants.ts b/resources/js/common/utils/generatedAppConstants.ts index 07be1c374f..2940938a69 100644 --- a/resources/js/common/utils/generatedAppConstants.ts +++ b/resources/js/common/utils/generatedAppConstants.ts @@ -1,14 +1,46 @@ /* eslint-disable */ /* generated with `composer types` */ -export const AchievementFlag = { - OfficialCore: 3, - Unofficial: 5, +export const UserPreference = { + EmailOn_ActivityComment: 0, + EmailOn_AchievementComment: 1, + EmailOn_UserWallComment: 2, + EmailOn_ForumReply: 3, + EmailOn_Followed: 4, + EmailOn_PrivateMessage: 5, + EmailOn_Newsletter: 6, + Site_SuppressMatureContentWarning: 7, + SiteMsgOn_ActivityComment: 8, + SiteMsgOn_AchievementComment: 9, + SiteMsgOn_UserWallComment: 10, + SiteMsgOn_ForumReply: 11, + SiteMsgOn_Followed: 12, + SiteMsgOn_PrivateMessage: 13, + SiteMsgOn_Newsletter: 14, + Forum_ShowAbsoluteDates: 15, + Game_HideMissableIndicators: 16, + User_OnlyContactFromFollowing: 17, } as const; -export const StringifiedAchievementFlag = { - OfficialCore: '3', - Unofficial: '5', +export const StringifiedUserPreference = { + EmailOn_ActivityComment: '0', + EmailOn_AchievementComment: '1', + EmailOn_UserWallComment: '2', + EmailOn_ForumReply: '3', + EmailOn_Followed: '4', + EmailOn_PrivateMessage: '5', + EmailOn_Newsletter: '6', + Site_SuppressMatureContentWarning: '7', + SiteMsgOn_ActivityComment: '8', + SiteMsgOn_AchievementComment: '9', + SiteMsgOn_UserWallComment: '10', + SiteMsgOn_ForumReply: '11', + SiteMsgOn_Followed: '12', + SiteMsgOn_PrivateMessage: '13', + SiteMsgOn_Newsletter: '14', + Forum_ShowAbsoluteDates: '15', + Game_HideMissableIndicators: '16', + User_OnlyContactFromFollowing: '17', } as const; diff --git a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx new file mode 100644 index 0000000000..8aac7a7fb8 --- /dev/null +++ b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@/test'; +import { createGame, createSystem } from '@/test/factories'; + +import { GameBreadcrumbs } from './GameBreadcrumbs'; + +describe('Component: GameBreadcrumbs', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('has a link to the All Games list', () => { + // ARRANGE + render(); + + // ASSERT + const allGamesLinkEl = screen.getByRole('link', { name: /all games/i }); + expect(allGamesLinkEl).toBeVisible(); + expect(allGamesLinkEl).toHaveAttribute('href', '/gameList.php'); + }); + + it('given a system, has a link to the system games list', () => { + // ARRANGE + const system = createSystem({ name: 'Nintendo 64' }); + const game = createGame({ system }); + + render(); + + // ASSERT + const systemGamesLinkEl = screen.getByRole('link', { name: /nintendo 64/i }); + expect(systemGamesLinkEl).toBeVisible(); + expect(systemGamesLinkEl).toHaveAttribute('href', `system.game.index,${system.id}`); + }); + + it('given a game, has a link to the game page', () => { + // ARRANGE + const system = createSystem({ name: 'Nintendo 64' }); + const game = createGame({ system }); + + render(); + + // ASSERT + const gameLinkEl = screen.getByRole('link', { name: game.title }); + expect(gameLinkEl).toBeVisible(); + expect(gameLinkEl).toHaveAttribute('href', `game.show,${{ game: game.id }}`); + }); +}); diff --git a/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx new file mode 100644 index 0000000000..3062571a0b --- /dev/null +++ b/resources/js/features/games/components/GameBreadcrumbs/GameBreadcrumbs.tsx @@ -0,0 +1,60 @@ +import type { FC } from 'react'; + +import { + BaseBreadcrumb, + BaseBreadcrumbItem, + BaseBreadcrumbLink, + BaseBreadcrumbList, + BaseBreadcrumbPage, + BaseBreadcrumbSeparator, +} from '@/common/components/+vendor/BaseBreadcrumb'; + +interface GameBreadcrumbsProps { + currentPageLabel: string; + + game?: App.Platform.Data.Game; + system?: App.Platform.Data.System; +} + +export const GameBreadcrumbs: FC = ({ currentPageLabel, game, system }) => { + return ( +
+ + + + All Games + + + {system ? ( + <> + + + + + {system.name} + + + + ) : null} + + {game ? ( + <> + + + + {game.title} + + + + ) : null} + + + + + {currentPageLabel} + + + +
+ ); +}; diff --git a/resources/js/features/games/components/GameBreadcrumbs/index.ts b/resources/js/features/games/components/GameBreadcrumbs/index.ts new file mode 100644 index 0000000000..b9bde9c324 --- /dev/null +++ b/resources/js/features/games/components/GameBreadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './GameBreadcrumbs'; diff --git a/resources/js/features/games/components/GameHeading/GameHeading.test.tsx b/resources/js/features/games/components/GameHeading/GameHeading.test.tsx new file mode 100644 index 0000000000..aa80ee8429 --- /dev/null +++ b/resources/js/features/games/components/GameHeading/GameHeading.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@/test'; +import { createGame } from '@/test/factories'; + +import { GameHeading } from './GameHeading'; + +describe('Component: GameHeading', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(Hello, World); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('displays a clickable avatar of the given game', () => { + // ARRANGE + const game = createGame(); + + render(Hello, World); + + // ASSERT + const linkEl = screen.getByRole('link'); + expect(linkEl).toBeVisible(); + expect(linkEl).toHaveAttribute('href', `game.show,${{ game: game.id }}`); + + expect(screen.getByRole('img', { name: game.title })).toBeVisible(); + }); + + it('displays an accessible header from `children`', () => { + // ARRANGE + const game = createGame(); + + render(Hello, World); + + // ASSERT + expect(screen.getByRole('heading', { name: /hello, world/i })).toBeVisible(); + }); +}); diff --git a/resources/js/features/games/components/GameHeading/GameHeading.tsx b/resources/js/features/games/components/GameHeading/GameHeading.tsx new file mode 100644 index 0000000000..5d21f438a9 --- /dev/null +++ b/resources/js/features/games/components/GameHeading/GameHeading.tsx @@ -0,0 +1,20 @@ +import type { FC, ReactNode } from 'react'; + +import { GameAvatar } from '@/common/components/GameAvatar'; + +interface GameHeadingProps { + children: ReactNode; + game: App.Platform.Data.Game; +} + +export const GameHeading: FC = ({ children, game }) => { + return ( +
+
+ +
+ +

{children}

+
+ ); +}; diff --git a/resources/js/features/games/components/GameHeading/index.ts b/resources/js/features/games/components/GameHeading/index.ts new file mode 100644 index 0000000000..9c378538df --- /dev/null +++ b/resources/js/features/games/components/GameHeading/index.ts @@ -0,0 +1 @@ +export * from './GameHeading'; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx new file mode 100644 index 0000000000..814851f289 --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.test.tsx @@ -0,0 +1,78 @@ +import { faker } from '@faker-js/faker'; + +import { render, screen } from '@/test'; +import { createGameHash } from '@/test/factories'; + +import { HashesList, hashesListContainerTestId } from './HashesList'; + +describe('Component: HashesList', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + hashes: [createGameHash()], + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given there are no hashes, renders nothing', () => { + // ARRANGE + render(, { + pageProps: { + hashes: [], + }, + }); + + // ASSERT + expect(screen.queryByTestId(hashesListContainerTestId)).not.toBeInTheDocument(); + }); + + it('renders both named and unnamed hashes', () => { + // ARRANGE + const hashes = [ + // Named + createGameHash({ name: faker.word.words(3) }), + createGameHash({ name: faker.word.words(3) }), + + // Unnamed + createGameHash({ name: null }), + ]; + + render(, { + pageProps: { hashes }, + }); + + // ASSERT + expect(screen.getAllByRole('listitem').length).toEqual(3); + }); + + it('displays the hash name and md5', () => { + // ARRANGE + const hash = createGameHash({ name: faker.word.words(3) }); + + render(, { + pageProps: { hashes: [hash] }, + }); + + // ASSERT + expect(screen.getByText(hash.name ?? '')).toBeVisible(); + expect(screen.getByText(hash.md5)).toBeVisible(); + }); + + it('given the hash has a patch URL, adds a link to it', () => { + // ARRANGE + const hash = createGameHash({ patchUrl: faker.internet.url() }); + + render(, { + pageProps: { hashes: [hash] }, + }); + + // ASSERT + const linkEl = screen.getByRole('link', { name: /download patch file/i }); + expect(linkEl).toBeVisible(); + expect(linkEl).toHaveAttribute('href', hash.patchUrl); + }); +}); diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx new file mode 100644 index 0000000000..040951bcf1 --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesList.tsx @@ -0,0 +1,43 @@ +import { usePage } from '@inertiajs/react'; +import type { FC } from 'react'; + +import { Embed } from '@/common/components/Embed/Embed'; + +import { HashesListItem } from './HashesListItem'; + +export const hashesListContainerTestId = 'hashes-list'; + +export const HashesList: FC = () => { + const { + props: { hashes }, + } = usePage(); + + if (!hashes.length) { + return null; + } + + const namedHashes = hashes.filter((hash) => !!hash.name?.trim()); + const unnamedHashes = hashes.filter((hash) => !hash.name?.trim()); + + return ( + + {namedHashes.length ? ( +
    + {namedHashes.map((labeledHash) => ( + + ))} +
+ ) : null} + + {namedHashes.length && unnamedHashes.length ?
: null} + + {unnamedHashes.length ? ( +
    + {unnamedHashes.map((unlabeledHash) => ( + + ))} +
+ ) : null} + + ); +}; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx new file mode 100644 index 0000000000..5722526ca8 --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/HashesListItem.tsx @@ -0,0 +1,52 @@ +import type { FC } from 'react'; + +import { buildTrackingClassNames } from '@/common/utils/buildTrackingClassNames'; + +interface HashListingProps { + hash: App.Platform.Data.GameHash; +} + +export const HashesListItem: FC = ({ hash }) => { + return ( +
  • +

    + {hash.name ? {hash.name} : null} + + {hash.labels.length ? ( + <> + {hash.labels.map((hashLabel) => ( + + ))} + + ) : null} +

    + +
    +

    {hash.md5}

    + + {hash.patchUrl ? ( + + Download Patch File + + ) : null} +
    +
  • + ); +}; + +interface HashLabelProps { + hashLabel: App.Platform.Data.GameHashLabel; +} + +export const HashLabel: FC = ({ hashLabel }) => { + const { imgSrc, label } = hashLabel; + + if (!imgSrc) { + return [{label}]; + } + + return ; +}; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesList/index.ts b/resources/js/features/games/components/HashesMainRoot/HashesList/index.ts new file mode 100644 index 0000000000..05ef9e589e --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesList/index.ts @@ -0,0 +1 @@ +export * from './HashesList'; diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx new file mode 100644 index 0000000000..9a3bb322f0 --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@/test'; +import { createGame } from '@/test/factories'; + +import { HashesMainRoot } from './HashesMainRoot'; + +describe('Component: HashesMainRoot', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + can: { manageGameHashes: false }, + game: createGame(), + hashes: [], + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user can manage hashes, shows a manage link', () => { + // ARRANGE + render(, { + pageProps: { + can: { manageGameHashes: true }, + game: createGame(), + hashes: [], + }, + }); + + // ASSERT + expect(screen.getByRole('link', { name: /manage hashes/i })).toBeVisible(); + }); +}); diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx new file mode 100644 index 0000000000..d5a150aeea --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx @@ -0,0 +1,74 @@ +import { usePage } from '@inertiajs/react'; +import type { FC } from 'react'; +import { LuSave } from 'react-icons/lu'; + +import { baseButtonVariants } from '@/common/components/+vendor/BaseButton'; +import { Embed } from '@/common/components/Embed/Embed'; + +import { GameBreadcrumbs } from '../GameBreadcrumbs'; +import { GameHeading } from '../GameHeading/GameHeading'; +import { HashesList } from './HashesList'; + +export const HashesMainRoot: FC = () => { + const { + props: { can, game, hashes }, + } = usePage(); + + return ( +
    + + Supported Game Files + +
    + {can.manageGameHashes ? ( + + + Manage Hashes + + ) : null} + + +

    + This page shows you what ROM hashes are compatible with this game's achievements. +

    + +

    + {game.forumTopicId ? ( + <> + Additional information for these hashes may be listed on{' '} + + the game's official forum topic + + . + + ) : null}{' '} + Details on how the hash is generated for each system can be found{' '} + + here + + .{' '} +

    + + +
    +

    + There {hashes.length === 1 ? 'is' : 'are'} currently{' '} + {hashes.length} supported game file{' '} + {hashes.length === 1 ? 'hash' : 'hashes'} registered for this game. +

    + + +
    +
    +
    + ); +}; diff --git a/resources/js/features/games/components/HashesMainRoot/index.ts b/resources/js/features/games/components/HashesMainRoot/index.ts new file mode 100644 index 0000000000..da529098fd --- /dev/null +++ b/resources/js/features/games/components/HashesMainRoot/index.ts @@ -0,0 +1 @@ +export * from './HashesMainRoot'; diff --git a/resources/js/features/user/settings/components/+root/SettingsRoot.tsx b/resources/js/features/user/settings/components/+root/SettingsRoot.tsx new file mode 100644 index 0000000000..0122b615ed --- /dev/null +++ b/resources/js/features/user/settings/components/+root/SettingsRoot.tsx @@ -0,0 +1,47 @@ +import { type FC, useState } from 'react'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { ChangeEmailAddressSectionCard } from '../ChangeEmailAddressSectionCard'; +import { ChangePasswordSectionCard } from '../ChangePasswordSectionCard'; +import { DeleteAccountSectionCard } from '../DeleteAccountSectionCard'; +import { KeysSectionCard } from '../KeysSectionCard'; +import { NotificationsSectionCard } from '../NotificationsSectionCard'; +import { PreferencesSectionCard } from '../PreferencesSectionCard'; +import { ProfileSectionCard } from '../ProfileSectionCard'; +import { ResetGameProgressSectionCard } from '../ResetGameProgressSectionCard'; + +export const SettingsRoot: FC = () => { + const { auth } = usePageProps(); + + const [currentWebsitePrefs, setCurrentWebsitePrefs] = useState(auth?.user.websitePrefs ?? 0); + + const handleUpdateWebsitePrefs = (newWebsitePrefs: number) => { + setCurrentWebsitePrefs(newWebsitePrefs); + }; + + return ( +
    +

    Settings

    + +
    + + + {/* Make sure the shared websitePrefs values don't accidentally override each other. */} + + + + + + + + +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/+root/index.ts b/resources/js/features/user/settings/components/+root/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/js/features/user/settings/components/+sidebar/SettingsSidebar.test.tsx b/resources/js/features/user/settings/components/+sidebar/SettingsSidebar.test.tsx new file mode 100644 index 0000000000..16c3a4c57f --- /dev/null +++ b/resources/js/features/user/settings/components/+sidebar/SettingsSidebar.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@/test'; + +import { SettingsSidebar } from './SettingsSidebar'; + +describe('Component: SettingsSidebar', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { can: { updateAvatar: true }, auth: { user: {} } }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user is not muted, renders the avatar section', () => { + // ARRANGE + render(, { + pageProps: { can: { updateAvatar: true }, auth: { user: { isMuted: false } } }, + }); + + // ASSERT + expect(screen.getByRole('heading', { name: /avatar/i })).toBeVisible(); + }); + + it('given the user is muted, does not render the avatar section', () => { + // ARRANGE + render(, { + pageProps: { can: { updateAvatar: true }, auth: { user: { isMuted: true } } }, + }); + + // ASSERT + expect(screen.queryByRole('heading', { name: /avatar/i })).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/features/user/settings/components/+sidebar/SettingsSidebar.tsx b/resources/js/features/user/settings/components/+sidebar/SettingsSidebar.tsx new file mode 100644 index 0000000000..4b5bdc13e6 --- /dev/null +++ b/resources/js/features/user/settings/components/+sidebar/SettingsSidebar.tsx @@ -0,0 +1,28 @@ +import type { FC } from 'react'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { AvatarSection } from '../AvatarSection'; +import { SiteAwardsSection } from '../SiteAwardsSection'; + +export const SettingsSidebar: FC = () => { + const { auth } = usePageProps(); + + // Just to improve type safety. + if (!auth?.user) { + return null; + } + + return ( +
    + + + {auth.user.isMuted ? null : ( + <> +
    + + + + )} +
    + ); +}; diff --git a/resources/js/features/user/settings/components/+sidebar/index.ts b/resources/js/features/user/settings/components/+sidebar/index.ts new file mode 100644 index 0000000000..875eeabf9d --- /dev/null +++ b/resources/js/features/user/settings/components/+sidebar/index.ts @@ -0,0 +1 @@ +export * from './SettingsSidebar'; diff --git a/resources/js/features/user/settings/components/AvatarSection/AvatarSection.test.tsx b/resources/js/features/user/settings/components/AvatarSection/AvatarSection.test.tsx new file mode 100644 index 0000000000..e3ee2acd6f --- /dev/null +++ b/resources/js/features/user/settings/components/AvatarSection/AvatarSection.test.tsx @@ -0,0 +1,71 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { render, screen, waitFor } from '@/test'; + +import { AvatarSection } from './AvatarSection'; + +describe('Component: AvatarSection', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { pageProps: { can: {} } }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user does not have permission to upload an avatar, tells them', () => { + // ARRANGE + render(, { pageProps: { can: { updateAvatar: false } } }); + + // ASSERT + expect(screen.queryByLabelText(/new image/i)).not.toBeInTheDocument(); + expect(screen.getByText(/earn 250 points or wait/i)).toBeVisible(); + }); + + it('given the user has permission to upload an avatar, shows the file input field', () => { + // ARRANGE + render(, { pageProps: { can: { updateAvatar: true } } }); + + // ASSERT + expect(screen.getByLabelText(/new image/i)).toBeVisible(); + }); + + it('given the user tries to submit a new avatar image, attempts to upload it to the server', async () => { + // ARRANGE + const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({ success: true }); + + const file = new File(['file content'], 'myfile.png', { type: 'image/png' }); + + render(, { pageProps: { can: { updateAvatar: true } } }); + + // ACT + const fileInputEl = screen.getByLabelText(/new image/i); + + await userEvent.upload(fileInputEl, file); + await userEvent.click(screen.getByRole('button', { name: /upload/i })); + + // ASSERT + await waitFor(() => { + expect(postSpy).toHaveBeenCalledWith( + route('user.avatar.store'), + expect.anything(), + expect.anything(), + ); + }); + }); + + it('given the user tries to reset their avatar to the default, makes the request to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + + render(, { pageProps: { can: { updateAvatar: true } } }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /reset avatar to default/i })); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledWith(route('user.avatar.destroy')); + }); +}); diff --git a/resources/js/features/user/settings/components/AvatarSection/AvatarSection.tsx b/resources/js/features/user/settings/components/AvatarSection/AvatarSection.tsx new file mode 100644 index 0000000000..a02e06fdd6 --- /dev/null +++ b/resources/js/features/user/settings/components/AvatarSection/AvatarSection.tsx @@ -0,0 +1,121 @@ +import type { FC } from 'react'; +import { LuAlertCircle } from 'react-icons/lu'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { baseCardTitleClassNames } from '@/common/components/+vendor/BaseCard'; +import { + BaseFormControl, + BaseFormField, + BaseFormItem, + BaseFormLabel, + BaseFormMessage, + BaseFormProvider, +} from '@/common/components/+vendor/BaseForm'; +import { BaseInput } from '@/common/components/+vendor/BaseInput'; +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { useResetNavbarUserPic } from '../../hooks/useResetNavbarUserPic'; +import { useAvatarSectionForm } from './useAvatarSectionForm'; +import { useResetAvatarMutation } from './useResetAvatarMutation'; + +export const AvatarSection: FC = () => { + const { can } = usePageProps(); + + const { form, mutation: formMutation, onSubmit } = useAvatarSectionForm(); + + const resetAvatarMutation = useResetAvatarMutation(); + + const { resetNavbarUserPic } = useResetNavbarUserPic(); + + const handleResetAvatarClick = () => { + if ( + !confirm( + 'Are you sure you want to reset your avatar to the default? This cannot be reversed.', + ) + ) { + return; + } + + toastMessage.promise(resetAvatarMutation.mutateAsync(), { + loading: 'Resetting...', + success: () => { + resetNavbarUserPic(); + + return 'Reset avatar!'; + }, + error: 'Something went wrong.', + }); + }; + + const [imageData] = form.watch(['imageData']); + + return ( +
    +

    Avatar

    + + {can.updateAvatar ? ( + <> +

    Only png, jpeg, and gif files are supported.

    + + +
    +
    + ( + + New Image + + + { + if (event.target.files) { + field.onChange(event.target.files[0]); + } + }} + /> + + + + + )} + /> +
    + +
    + + Upload + +
    +
    +
    + +

    + After uploading, press Ctrl + F5. This refreshes your browser cache making the new image + visible. +

    + + + + Reset Avatar to Default + + + ) : ( +

    + To upload an avatar, earn 250 points or wait until your account is at least 14 days old. +

    + )} +
    + ); +}; diff --git a/resources/js/features/user/settings/components/AvatarSection/index.ts b/resources/js/features/user/settings/components/AvatarSection/index.ts new file mode 100644 index 0000000000..9c9ed9b938 --- /dev/null +++ b/resources/js/features/user/settings/components/AvatarSection/index.ts @@ -0,0 +1 @@ +export * from './AvatarSection'; diff --git a/resources/js/features/user/settings/components/AvatarSection/useAvatarSectionForm.ts b/resources/js/features/user/settings/components/AvatarSection/useAvatarSectionForm.ts new file mode 100644 index 0000000000..6a6efbf33c --- /dev/null +++ b/resources/js/features/user/settings/components/AvatarSection/useAvatarSectionForm.ts @@ -0,0 +1,51 @@ +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useForm } from 'react-hook-form'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +import { useResetNavbarUserPic } from '../../hooks/useResetNavbarUserPic'; + +interface FormValues { + imageData: File; +} + +export function useAvatarSectionForm() { + const form = useForm(); + + const mutation = useMutation({ + mutationFn: async (formValues: FormValues) => { + const base64ImageData = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + reader.readAsDataURL(formValues.imageData); + }); + + const formData = new FormData(); + formData.append('imageData', base64ImageData); + + return axios.post(route('user.avatar.store'), formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, + }); + + const { resetNavbarUserPic } = useResetNavbarUserPic(); + + const onSubmit = (formValues: FormValues) => { + toastMessage.promise(mutation.mutateAsync(formValues), { + loading: 'Uploading new avatar...', + success: () => { + resetNavbarUserPic(); + + return 'Uploaded!'; + }, + error: 'Something went wrong.', + }); + }; + + return { form, mutation, onSubmit }; +} diff --git a/resources/js/features/user/settings/components/AvatarSection/useResetAvatarMutation.ts b/resources/js/features/user/settings/components/AvatarSection/useResetAvatarMutation.ts new file mode 100644 index 0000000000..f0949a8ed4 --- /dev/null +++ b/resources/js/features/user/settings/components/AvatarSection/useResetAvatarMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; + +export function useResetAvatarMutation() { + return useMutation({ + mutationFn: () => axios.delete(route('user.avatar.destroy')), + }); +} diff --git a/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/ChangeEmailAddressSectionCard.test.tsx b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/ChangeEmailAddressSectionCard.test.tsx new file mode 100644 index 0000000000..3f01ced017 --- /dev/null +++ b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/ChangeEmailAddressSectionCard.test.tsx @@ -0,0 +1,99 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { render, screen } from '@/test'; + +import { ChangeEmailAddressSectionCard } from './ChangeEmailAddressSectionCard'; + +describe('Component: ChangeEmailAddressSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + userSettings: { emailAddress: 'foo@bar.com' }, + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it("displays the user's current email address on the screen", () => { + // ARRANGE + render(, { + pageProps: { + userSettings: { emailAddress: 'foo@bar.com' }, + }, + }); + + // ASSERT + expect(screen.getByLabelText(/current email address/i)).toHaveTextContent('foo@bar.com'); + }); + + it('given the user attempts to submit without a matching email and confirm email, does not submit', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const putSpy = vi.spyOn(axios, 'put'); + + render(, { + pageProps: { + userSettings: { emailAddress: 'foo@bar.com' }, + }, + }); + + // ACT + await userEvent.type(screen.getByLabelText('New Email Address'), 'bar@baz.com'); + await userEvent.type(screen.getByLabelText('Confirm New Email Address'), 'aaaaa@bbbbb.com'); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).not.toHaveBeenCalled(); + }); + + it('given the user attempts to submit without a valid email, does not submit', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const putSpy = vi.spyOn(axios, 'put'); + + render(, { + pageProps: { + userSettings: { emailAddress: 'foo@bar.com' }, + }, + }); + + // ACT + await userEvent.type(screen.getByLabelText('New Email Address'), 'asdfasdfasdf'); + await userEvent.type(screen.getByLabelText('Confirm New Email Address'), 'asdfasdfasdf'); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).not.toHaveBeenCalled(); + }); + + it('given the user attempts to submit with valid form input, submits the data to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const putSpy = vi.spyOn(axios, 'put').mockResolvedValueOnce({ success: true }); + + render(, { + pageProps: { + userSettings: { emailAddress: 'foo@bar.com' }, + }, + }); + + // ACT + await userEvent.type(screen.getByLabelText('New Email Address'), 'valid@email.com'); + await userEvent.type(screen.getByLabelText('Confirm New Email Address'), 'valid@email.com'); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).toHaveBeenCalledWith(route('settings.email.update'), { + newEmail: 'valid@email.com', + // this is sent to the server out of convenience, but the API layer doesn't actually use it + confirmEmail: 'valid@email.com', + }); + }); +}); diff --git a/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/ChangeEmailAddressSectionCard.tsx b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/ChangeEmailAddressSectionCard.tsx new file mode 100644 index 0000000000..2faaac15f4 --- /dev/null +++ b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/ChangeEmailAddressSectionCard.tsx @@ -0,0 +1,96 @@ +import { type FC, useId, useState } from 'react'; + +import { + BaseFormControl, + BaseFormField, + BaseFormItem, + BaseFormLabel, + BaseFormMessage, +} from '@/common/components/+vendor/BaseForm'; +import { BaseInput } from '@/common/components/+vendor/BaseInput'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { SectionFormCard } from '../SectionFormCard'; +import { useChangeEmailAddressForm } from './useChangeEmailAddressForm'; + +export const ChangeEmailAddressSectionCard: FC = () => { + const { userSettings } = usePageProps(); + + const [currentEmailAddress, setCurrentEmailAddress] = useState(userSettings.emailAddress ?? ''); + + const { form, mutation, onSubmit } = useChangeEmailAddressForm({ setCurrentEmailAddress }); + + const visibleEmailFieldId = useId(); + + return ( + +
    +
    +
    + +

    {currentEmailAddress}

    +
    + +
    + ( + + + New Email Address + + +
    + + + + + +
    +
    + )} + /> + + ( + + + Confirm New Email Address + + +
    + + + + + +
    +
    + )} + /> +
    +
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/index.ts b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/index.ts new file mode 100644 index 0000000000..8b3b11c97f --- /dev/null +++ b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/index.ts @@ -0,0 +1 @@ +export * from './ChangeEmailAddressSectionCard'; diff --git a/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/useChangeEmailAddressForm.ts b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/useChangeEmailAddressForm.ts new file mode 100644 index 0000000000..2055dd898a --- /dev/null +++ b/resources/js/features/user/settings/components/ChangeEmailAddressSectionCard/useChangeEmailAddressForm.ts @@ -0,0 +1,62 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +import { usePageProps } from '../../hooks/usePageProps'; + +const changeEmailAddressFormSchema = z + .object({ + newEmail: z.string().email(), + confirmEmail: z.string().email(), + }) + .refine((data) => data.newEmail === data.confirmEmail, { + message: 'Email addresses must match.', + path: ['confirmEmail'], + }); + +type FormValues = z.infer; + +export function useChangeEmailAddressForm(props: { + setCurrentEmailAddress: React.Dispatch>; +}) { + const { auth } = usePageProps(); + + const form = useForm({ + resolver: zodResolver(changeEmailAddressFormSchema), + defaultValues: { + newEmail: '', + confirmEmail: '', + }, + }); + + const mutation = useMutation({ + mutationFn: (formValues: FormValues) => { + return axios.put(route('settings.email.update'), formValues); + }, + onSuccess: () => { + props.setCurrentEmailAddress(form.getValues().newEmail); + }, + }); + + const onSubmit = (formValues: FormValues) => { + const confirmationMessage = auth?.user.roles.length + ? 'Changing your email address will revoke your privileges and you will need to have them restored by staff. Are you sure you want to do this?' + : 'Are you sure you want to change your email address?'; + + if (!confirm(confirmationMessage)) { + return; + } + + toastMessage.promise(mutation.mutateAsync(formValues), { + loading: 'Changing email address...', + success: 'Changed email address!', + error: 'Something went wrong.', + }); + }; + + return { form, mutation, onSubmit }; +} diff --git a/resources/js/features/user/settings/components/ChangePasswordSectionCard/ChangePasswordSectionCard.test.tsx b/resources/js/features/user/settings/components/ChangePasswordSectionCard/ChangePasswordSectionCard.test.tsx new file mode 100644 index 0000000000..8ee0720722 --- /dev/null +++ b/resources/js/features/user/settings/components/ChangePasswordSectionCard/ChangePasswordSectionCard.test.tsx @@ -0,0 +1,60 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { render, screen } from '@/test'; + +import { ChangePasswordSectionCard } from './ChangePasswordSectionCard'; + +describe('Component: ChangePasswordSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: {}, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user attempts to submit without a matching password and confirm password, does not submit', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const putSpy = vi.spyOn(axios, 'put'); + + render(, { + pageProps: {}, + }); + + // ACT + await userEvent.type(screen.getByLabelText(/current password/i), '12345678'); + await userEvent.type(screen.getByLabelText(/new password/i), '87654321'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'aaaaaaaa'); + + // ASSERT + expect(putSpy).not.toHaveBeenCalled(); + }); + + it('given the user attempts to make a valid form submission, submits the request to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const putSpy = vi.spyOn(axios, 'put').mockResolvedValueOnce({ success: true }); + + render(, { + pageProps: {}, + }); + + // ACT + await userEvent.type(screen.getByLabelText(/current password/i), '12345678'); + await userEvent.type(screen.getByLabelText(/new password/i), '87654321'); + await userEvent.type(screen.getByLabelText(/confirm password/i), '87654321'); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).toHaveBeenCalledWith(route('settings.password.update'), { + currentPassword: '12345678', + newPassword: '87654321', + confirmPassword: '87654321', + }); + }); +}); diff --git a/resources/js/features/user/settings/components/ChangePasswordSectionCard/ChangePasswordSectionCard.tsx b/resources/js/features/user/settings/components/ChangePasswordSectionCard/ChangePasswordSectionCard.tsx new file mode 100644 index 0000000000..8d5e84805b --- /dev/null +++ b/resources/js/features/user/settings/components/ChangePasswordSectionCard/ChangePasswordSectionCard.tsx @@ -0,0 +1,121 @@ +import type { FC } from 'react'; + +import { + BaseFormControl, + BaseFormDescription, + BaseFormField, + BaseFormItem, + BaseFormLabel, + BaseFormMessage, +} from '@/common/components/+vendor/BaseForm'; +import { BaseInput } from '@/common/components/+vendor/BaseInput'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { SectionFormCard } from '../SectionFormCard'; +import { useChangePasswordForm } from './useChangePasswordForm'; + +export const ChangePasswordSectionCard: FC = () => { + const { form, mutation, onSubmit } = useChangePasswordForm(); + + const { auth } = usePageProps(); + + return ( + +
    +
    + {/* + Included for a11y. This helps some password managers suggest new passwords. + @see https://www.chromium.org/developers/design-documents/create-amazing-password-forms/#use-hidden-fields-for-implicit-information + */} + + + ( + + Current Password + +
    + + + + + +
    +
    + )} + /> + + ( + + New Password + +
    + + + + + Must be at least 8 characters. + + +
    +
    + )} + /> + + ( + + Confirm Password + +
    + + + + + +
    +
    + )} + /> +
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/ChangePasswordSectionCard/index.ts b/resources/js/features/user/settings/components/ChangePasswordSectionCard/index.ts new file mode 100644 index 0000000000..26cef3b2ee --- /dev/null +++ b/resources/js/features/user/settings/components/ChangePasswordSectionCard/index.ts @@ -0,0 +1 @@ +export * from './ChangePasswordSectionCard'; diff --git a/resources/js/features/user/settings/components/ChangePasswordSectionCard/useChangePasswordForm.ts b/resources/js/features/user/settings/components/ChangePasswordSectionCard/useChangePasswordForm.ts new file mode 100644 index 0000000000..9b75c86023 --- /dev/null +++ b/resources/js/features/user/settings/components/ChangePasswordSectionCard/useChangePasswordForm.ts @@ -0,0 +1,54 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; +import type { LaravelValidationError } from '@/common/models'; + +const changePasswordFormSchema = z + .object({ + currentPassword: z.string().min(1, { message: 'Required' }), + newPassword: z.string().min(8, { message: 'Must be at least 8 characters' }), + confirmPassword: z.string().min(8, { message: 'Must be at least 8 characters' }), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: 'Passwords must match.', + path: ['confirmPassword'], + }); + +type FormValues = z.infer; + +export function useChangePasswordForm() { + const form = useForm({ + resolver: zodResolver(changePasswordFormSchema), + defaultValues: { + confirmPassword: '', + currentPassword: '', + newPassword: '', + }, + }); + + const mutation = useMutation({ + mutationFn: (formValues: FormValues) => { + return axios.put(route('settings.password.update'), formValues); + }, + }); + + const onSubmit = (formValues: FormValues) => { + toastMessage.promise(mutation.mutateAsync(formValues), { + loading: 'Changing password...', + success: () => { + window.location.href = route('login'); + + return ''; + }, + error: ({ response }: LaravelValidationError) => { + return response.data.message; + }, + }); + }; + + return { form, mutation, onSubmit }; +} diff --git a/resources/js/features/user/settings/components/DeleteAccountSectionCard/DeleteAccountSectionCard.test.tsx b/resources/js/features/user/settings/components/DeleteAccountSectionCard/DeleteAccountSectionCard.test.tsx new file mode 100644 index 0000000000..b2639d5364 --- /dev/null +++ b/resources/js/features/user/settings/components/DeleteAccountSectionCard/DeleteAccountSectionCard.test.tsx @@ -0,0 +1,82 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { render, screen } from '@/test'; + +import { DeleteAccountSectionCard } from './DeleteAccountSectionCard'; + +describe('Component: DeleteAccountSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { userSettings: { deleteRequested: null } }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user has not yet requested deletion, does not show a notice indicating they have', () => { + // ARRANGE + render(, { + pageProps: { userSettings: { deleteRequested: null } }, + }); + + // ASSERT + expect(screen.queryByText(/you've requested account deletion/i)).not.toBeInTheDocument(); + + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeVisible(); + }); + + it('given the user has requested deletion, shows a notice and a cancel button', () => { + // ARRANGE + render(, { + pageProps: { userSettings: { deleteRequested: new Date('2024-09-01').toISOString() } }, + }); + + // ASSERT + expect(screen.getByText(/you've requested account deletion/i)).toBeVisible(); + expect(screen.getByText(/will be permanently deleted on/i)).toBeVisible(); + + expect( + screen.queryByRole('button', { name: /request account deletion/i }), + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel account deletion request/i })).toBeVisible(); + }); + + it('given the user requests account deletion, correctly sends the deletion request to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + + const postSpy = vi.spyOn(axios, 'post').mockResolvedValue({ success: true }); + + render(, { + pageProps: { userSettings: { deleteRequested: null } }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /request account deletion/i })); + + // ASSERT + expect(postSpy).toHaveBeenCalledWith(route('user.delete-request.store')); + expect(screen.getByText(/you've requested account deletion/i)).toBeVisible(); + }); + + it('given the user requests to cancel account deletion, correctly sends the cancellation request to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + + render(, { + pageProps: { userSettings: { deleteRequested: new Date('2024-09-01').toISOString() } }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /cancel account deletion request/i })); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledWith(route('user.delete-request.destroy')); + expect(screen.queryByText(/you've requested account deletion/i)).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/features/user/settings/components/DeleteAccountSectionCard/DeleteAccountSectionCard.tsx b/resources/js/features/user/settings/components/DeleteAccountSectionCard/DeleteAccountSectionCard.tsx new file mode 100644 index 0000000000..8ab4d7a622 --- /dev/null +++ b/resources/js/features/user/settings/components/DeleteAccountSectionCard/DeleteAccountSectionCard.tsx @@ -0,0 +1,92 @@ +import dayjs from 'dayjs'; +import { type FC, useState } from 'react'; +import { LuAlertCircle } from 'react-icons/lu'; + +import { + BaseAlert, + BaseAlertDescription, + BaseAlertTitle, +} from '@/common/components/+vendor/BaseAlert'; +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { SectionStandardCard } from '../SectionStandardCard'; +import { useManageAccountDeletion } from './useManageAccountDeletion'; + +export const DeleteAccountSectionCard: FC = () => { + const { userSettings } = usePageProps(); + + const [isDeleteAlreadyRequested, setIsDeleteAlreadyRequested] = useState( + !!userSettings.deleteRequested, + ); + + const { cancelDeleteMutation, requestDeleteMutation } = useManageAccountDeletion(); + + const handleClick = () => { + const toggleMessage = isDeleteAlreadyRequested + ? 'Are you sure you want to cancel your request for account deletion?' + : 'Are you sure you want to request account deletion?'; + + if (!confirm(toggleMessage)) { + return; + } + + if (isDeleteAlreadyRequested) { + toastMessage.promise(cancelDeleteMutation.mutateAsync(), { + loading: 'Loading...', + success: () => { + setIsDeleteAlreadyRequested((prev) => !prev); + + return 'Cancelled account deletion.'; + }, + error: 'Something went wrong.', + }); + } else { + toastMessage.promise(requestDeleteMutation.mutateAsync(), { + loading: 'Loading...', + success: () => { + setIsDeleteAlreadyRequested((prev) => !prev); + + return 'Requested account deletion.'; + }, + error: 'Something went wrong.', + }); + } + }; + + const deletionDate = userSettings.deleteRequested + ? dayjs(userSettings.deleteRequested).add(2, 'weeks') + : dayjs().add(2, 'weeks'); + + return ( + +
    + {isDeleteAlreadyRequested ? ( + + + You've requested account deletion. + + Your account will be permanently deleted on {deletionDate.format('MMMM D')}. + + + ) : null} + +
    +

    After requesting account deletion you may cancel your request within 14 days.

    +

    Your account's username will NOT be available after the deletion.

    +

    Your account's personal data will be cleared from the database permanently.

    +

    Content you wrote in forums, comments, etc. will NOT be removed.

    +
    + +
    + + {isDeleteAlreadyRequested + ? 'Cancel Account Deletion Request' + : 'Request Account Deletion'} + +
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/DeleteAccountSectionCard/index.ts b/resources/js/features/user/settings/components/DeleteAccountSectionCard/index.ts new file mode 100644 index 0000000000..8b4a53b532 --- /dev/null +++ b/resources/js/features/user/settings/components/DeleteAccountSectionCard/index.ts @@ -0,0 +1 @@ +export * from './DeleteAccountSectionCard'; diff --git a/resources/js/features/user/settings/components/DeleteAccountSectionCard/useManageAccountDeletion.ts b/resources/js/features/user/settings/components/DeleteAccountSectionCard/useManageAccountDeletion.ts new file mode 100644 index 0000000000..94abf43e38 --- /dev/null +++ b/resources/js/features/user/settings/components/DeleteAccountSectionCard/useManageAccountDeletion.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; + +export function useManageAccountDeletion() { + const cancelDeleteMutation = useMutation({ + mutationFn: () => axios.delete(route('user.delete-request.destroy')), + }); + + const requestDeleteMutation = useMutation({ + mutationFn: () => axios.post(route('user.delete-request.store')), + }); + + return { cancelDeleteMutation, requestDeleteMutation }; +} diff --git a/resources/js/features/user/settings/components/KeysSectionCard/KeysSectionCard.test.tsx b/resources/js/features/user/settings/components/KeysSectionCard/KeysSectionCard.test.tsx new file mode 100644 index 0000000000..a350e6f174 --- /dev/null +++ b/resources/js/features/user/settings/components/KeysSectionCard/KeysSectionCard.test.tsx @@ -0,0 +1,135 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { render, screen } from '@/test'; +import { createUser } from '@/test/factories'; + +import { KeysSectionCard } from './KeysSectionCard'; + +vi.mock('react-use', async (importOriginal) => { + const original: object = await importOriginal(); + + return { + ...original, + useMedia: vi.fn(), + }; +}); + +describe('Component: KeysSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + can: {}, + userSettings: createUser(), + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it("given the user doesn't have permission to manipulate their API keys, doesn't render", () => { + // ARRANGE + render(, { + pageProps: { + can: { manipulateApiKeys: false }, + userSettings: createUser({ apiKey: 'mockApiKey' }), + }, + }); + + // ASSERT + expect(screen.queryByRole('heading', { name: /keys/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('has a link to the RetroAchievements API documentation', () => { + // ARRANGE + render(, { + pageProps: { + can: { manipulateApiKeys: true }, + userSettings: createUser(), + }, + }); + + // ASSERT + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + 'https://api-docs.retroachievements.org', + ); + }); + + it("shows an obfuscated version of the user's web API key", () => { + // ARRANGE + const mockApiKey = 'AAAAAAxxxxxxxxxxBBBBBB'; + + render(, { + pageProps: { + can: { manipulateApiKeys: true }, + userSettings: createUser({ apiKey: mockApiKey }), + }, + }); + + // ASSERT + expect(screen.queryByText(mockApiKey)).not.toBeInTheDocument(); + expect(screen.getByText('AAAAAA...BBBBBB')).toBeVisible(); + }); + + it('given the user presses the reset web API key button, sends a DELETE call to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + + render(, { + pageProps: { + can: { manipulateApiKeys: true }, + userSettings: createUser(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /reset web api key/i })); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledWith(route('settings.keys.web.destroy')); + }); + + it('given the user resets their web API key, shows their new obfuscated key in the UI', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + vi.spyOn(axios, 'delete').mockResolvedValueOnce({ data: { newKey: 'BBBBBBxxxxxxxCCCCCC' } }); + const mockApiKey = 'AAAAAAxxxxxxxxxxBBBBBB'; + + render(, { + pageProps: { + can: { manipulateApiKeys: true }, + userSettings: createUser({ apiKey: mockApiKey }), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /reset web api key/i })); + + // ASSERT + expect(screen.getByText('BBBBBB...CCCCCC')).toBeVisible(); + }); + + it('given the user presses the reset Connect API key button, sends a DELETE call to the server', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + + render(, { + pageProps: { + can: { manipulateApiKeys: true }, + userSettings: createUser(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /reset connect api key/i })); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledWith(route('settings.keys.connect.destroy')); + }); +}); diff --git a/resources/js/features/user/settings/components/KeysSectionCard/KeysSectionCard.tsx b/resources/js/features/user/settings/components/KeysSectionCard/KeysSectionCard.tsx new file mode 100644 index 0000000000..9b04011dc7 --- /dev/null +++ b/resources/js/features/user/settings/components/KeysSectionCard/KeysSectionCard.tsx @@ -0,0 +1,23 @@ +import { type FC } from 'react'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { SectionStandardCard } from '../SectionStandardCard'; +import { ManageConnectApiKey } from './ManageConnectApiKey'; +import { ManageWebApiKey } from './ManageWebApiKey'; + +export const KeysSectionCard: FC = () => { + const { can } = usePageProps(); + + if (!can.manipulateApiKeys) { + return null; + } + + return ( + +
    + + +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/KeysSectionCard/ManageConnectApiKey.tsx b/resources/js/features/user/settings/components/KeysSectionCard/ManageConnectApiKey.tsx new file mode 100644 index 0000000000..af9d6a4280 --- /dev/null +++ b/resources/js/features/user/settings/components/KeysSectionCard/ManageConnectApiKey.tsx @@ -0,0 +1,56 @@ +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { type FC } from 'react'; +import { LuAlertCircle } from 'react-icons/lu'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +export const ManageConnectApiKey: FC = () => { + const mutation = useMutation({ + mutationFn: () => { + return axios.delete(route('settings.keys.connect.destroy')); + }, + }); + + const handleResetApiKeyClick = () => { + if ( + !confirm( + 'Are you sure you want to reset your Connect API key? This will log you out of all emulators.', + ) + ) { + return; + } + + toastMessage.promise(mutation.mutateAsync(), { + loading: 'Resetting...', + success: 'Your Connect API key has been reset.', + error: 'Something went wrong.', + }); + }; + + return ( +
    +
    +

    Connect API Key

    + +
    +

    + Your Connect API key is used by emulators to keep you logged in. Resetting the key will + log you out of all emulators. +

    + + + + Reset Connect API Key + +
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/KeysSectionCard/ManageWebApiKey.tsx b/resources/js/features/user/settings/components/KeysSectionCard/ManageWebApiKey.tsx new file mode 100644 index 0000000000..2fef71008c --- /dev/null +++ b/resources/js/features/user/settings/components/KeysSectionCard/ManageWebApiKey.tsx @@ -0,0 +1,113 @@ +import { useMutation } from '@tanstack/react-query'; +import type { AxiosResponse } from 'axios'; +import axios from 'axios'; +import { type FC, useState } from 'react'; +import { LuAlertCircle, LuCopy } from 'react-icons/lu'; +import { useCopyToClipboard, useMedia } from 'react-use'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; +import { SimpleTooltip } from '@/common/components/SimpleTooltip'; + +import { usePageProps } from '../../hooks/usePageProps'; + +export const ManageWebApiKey: FC = () => { + const { userSettings } = usePageProps(); + + const [, copyToClipboard] = useCopyToClipboard(); + + // Hide the copy button's tooltip on mobile. + const isXs = useMedia('(max-width: 640px)', true); + + const [currentWebApiKey, setCurrentWebApiKey] = useState(userSettings.apiKey ?? ''); + + const mutation = useMutation({ + mutationFn: () => { + return axios.delete>( + route('settings.keys.web.destroy'), + ); + }, + onSuccess: ({ data }) => { + setCurrentWebApiKey(data.newKey); + }, + }); + + const handleCopyApiKeyClick = () => { + copyToClipboard(currentWebApiKey); + toastMessage.success('Copied your web API key!'); + }; + + const handleResetApiKeyClick = () => { + if (!confirm('Are you sure you want to reset your web API key? This cannot be reversed.')) { + return; + } + + toastMessage.promise(mutation.mutateAsync(), { + loading: 'Resetting...', + success: 'Your web API key has been reset.', + error: 'Something went wrong.', + }); + }; + + return ( +
    +
    +

    Web API Key

    + +
    + + + + {safeFormatApiKey(currentWebApiKey)} + + + +
    +

    + This is your personal web API key. Handle it with + care. +

    +

    + The RetroAchievements API documentation can be found{' '} + + here + + . +

    +
    + + + + Reset Web API Key + +
    +
    +
    + ); +}; + +/** + * If someone is sharing their screen, we don't want them + * to accidentally leak their web API key. + */ +function safeFormatApiKey(apiKey: string): string { + // For safety, but this should never happen. + if (apiKey.length <= 12) { + return apiKey; + } + + // "AAAAAA...123456" + return `${apiKey.slice(0, 6)}...${apiKey.slice(-6)}`; +} diff --git a/resources/js/features/user/settings/components/KeysSectionCard/index.ts b/resources/js/features/user/settings/components/KeysSectionCard/index.ts new file mode 100644 index 0000000000..54eb74a8a7 --- /dev/null +++ b/resources/js/features/user/settings/components/KeysSectionCard/index.ts @@ -0,0 +1 @@ +export * from './KeysSectionCard'; diff --git a/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSectionCard.test.tsx b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSectionCard.test.tsx new file mode 100644 index 0000000000..55f6f96ffa --- /dev/null +++ b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSectionCard.test.tsx @@ -0,0 +1,90 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { createAuthenticatedUser } from '@/common/models'; +import { convertObjectToWebsitePrefs } from '@/common/utils/convertObjectToWebsitePrefs'; +import { UserPreference } from '@/common/utils/generatedAppConstants'; +import { render, screen } from '@/test'; +import { createUser } from '@/test/factories'; + +import { NotificationsSectionCard } from './NotificationsSectionCard'; + +describe('Component: NotificationsSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const userSettings = createUser(); + + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('can correctly initially check the right checkboxes based on the user preferences bit value', () => { + // ARRANGE + const mappedPreferences = { + [UserPreference.EmailOn_ActivityComment]: true, + [UserPreference.SiteMsgOn_ActivityComment]: true, + [UserPreference.Site_SuppressMatureContentWarning]: true, + }; + + const mockWebsitePrefs = convertObjectToWebsitePrefs(mappedPreferences); + + render( + , + { + pageProps: { + can: {}, + userSettings: createUser(), + auth: { user: createAuthenticatedUser({ websitePrefs: mockWebsitePrefs }) }, + }, + }, + ); + + // ASSERT + expect(screen.getByTestId(`email-checkbox-comments-on-my-activity`)).toBeChecked(); + expect(screen.getByTestId(`site-checkbox-comments-on-my-activity`)).toBeChecked(); + }); + + it('given the user submits the form, sends the correct updated websitePrefs bit value to the server', async () => { + // ARRANGE + const putSpy = vi.spyOn(axios, 'put').mockResolvedValueOnce({ success: true }); + + const mappedPreferences = { + [UserPreference.EmailOn_ActivityComment]: true, + [UserPreference.SiteMsgOn_ActivityComment]: true, + [UserPreference.Site_SuppressMatureContentWarning]: true, + }; + + const mockWebsitePrefs = convertObjectToWebsitePrefs(mappedPreferences); + + render( + , + { + pageProps: { + auth: { user: createAuthenticatedUser({ websitePrefs: mockWebsitePrefs }) }, + }, + }, + ); + + // ACT + await userEvent.click(screen.getByTestId(`email-checkbox-someone-follows-me`)); + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).toHaveBeenCalledWith(route('settings.preferences.update'), { + websitePrefs: 401, + }); + }); +}); diff --git a/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSectionCard.tsx b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSectionCard.tsx new file mode 100644 index 0000000000..db1de70dc6 --- /dev/null +++ b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSectionCard.tsx @@ -0,0 +1,98 @@ +import type { FC } from 'react'; + +import { StringifiedUserPreference } from '@/common/utils/generatedAppConstants'; + +import { SectionFormCard } from '../SectionFormCard'; +import { NotificationsSmallRow } from './NotificationsSmallRow'; +import { NotificationsTableRow } from './NotificationsTableRow'; +import { useNotificationsSectionForm } from './useNotificationsSectionForm'; + +const notificationSettings = [ + { + label: 'Comments on my activity', + emailFieldName: StringifiedUserPreference.EmailOn_ActivityComment, + siteFieldName: StringifiedUserPreference.SiteMsgOn_ActivityComment, + }, + { + label: 'Comments on an achievement I created', + emailFieldName: StringifiedUserPreference.EmailOn_AchievementComment, + siteFieldName: StringifiedUserPreference.SiteMsgOn_AchievementComment, + }, + { + label: 'Comments on my user wall', + emailFieldName: StringifiedUserPreference.EmailOn_UserWallComment, + siteFieldName: StringifiedUserPreference.SiteMsgOn_UserWallComment, + }, + { + label: "Comments on a forum topic I'm involved in", + emailFieldName: StringifiedUserPreference.EmailOn_ForumReply, + siteFieldName: StringifiedUserPreference.SiteMsgOn_ForumReply, + }, + { + label: 'Someone follows me', + emailFieldName: StringifiedUserPreference.EmailOn_Followed, + siteFieldName: StringifiedUserPreference.SiteMsgOn_Followed, + }, + { + label: 'I receive a private message', + emailFieldName: StringifiedUserPreference.EmailOn_PrivateMessage, + }, +]; + +interface NotificationsSectionCardProps { + currentWebsitePrefs: number; + onUpdateWebsitePrefs: (newWebsitePrefs: number) => unknown; +} + +export const NotificationsSectionCard: FC = ({ + currentWebsitePrefs, + onUpdateWebsitePrefs, +}) => { + const { form, mutation, onSubmit } = useNotificationsSectionForm( + currentWebsitePrefs, + onUpdateWebsitePrefs, + ); + + return ( + +
    +
    + {notificationSettings.map((setting) => ( + + ))} +
    + + + + + + + + + + + + {notificationSettings.map((setting) => ( + + ))} + +
    Notification typeEmail notificationsSite notifications
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSmallRow.tsx b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSmallRow.tsx new file mode 100644 index 0000000000..301a166984 --- /dev/null +++ b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsSmallRow.tsx @@ -0,0 +1,78 @@ +import { type FC, useId } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { BaseCheckbox } from '@/common/components/+vendor/BaseCheckbox'; +import { BaseFormControl, BaseFormField } from '@/common/components/+vendor/BaseForm'; +import { BaseLabel } from '@/common/components/+vendor/BaseLabel'; +import type { StringifiedUserPreference } from '@/common/utils/generatedAppConstants'; + +import type { FormValues as NotificationsSectionFormValues } from './useNotificationsSectionForm'; + +type UserPreferenceValue = + (typeof StringifiedUserPreference)[keyof typeof StringifiedUserPreference]; + +interface NotificationsTableRowProps { + label: string; + + emailFieldName?: UserPreferenceValue; + siteFieldName?: UserPreferenceValue; +} + +export const NotificationsSmallRow: FC = ({ + label, + emailFieldName, + siteFieldName, +}) => { + const { control } = useFormContext(); + + const emailId = useId(); + const siteId = useId(); + + return ( +
    +

    {label}

    + +
    + {emailFieldName ? ( + ( +
    + + + + + Email me +
    + )} + /> + ) : null} + + {siteFieldName ? ( + ( +
    + + + + + Notify me on the site +
    + )} + /> + ) : null} +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsTableRow.tsx b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsTableRow.tsx new file mode 100644 index 0000000000..72855e2589 --- /dev/null +++ b/resources/js/features/user/settings/components/NotificationsSectionCard/NotificationsTableRow.tsx @@ -0,0 +1,88 @@ +import { type FC, useId } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { BaseCheckbox } from '@/common/components/+vendor/BaseCheckbox'; +import { BaseFormControl, BaseFormField } from '@/common/components/+vendor/BaseForm'; +import { BaseLabel } from '@/common/components/+vendor/BaseLabel'; +import type { StringifiedUserPreference } from '@/common/utils/generatedAppConstants'; + +import type { FormValues as NotificationsSectionFormValues } from './useNotificationsSectionForm'; + +type UserPreferenceValue = + (typeof StringifiedUserPreference)[keyof typeof StringifiedUserPreference]; + +interface NotificationsTableRowProps { + label: string; + + emailFieldName?: UserPreferenceValue; + siteFieldName?: UserPreferenceValue; +} + +export const NotificationsTableRow: FC = ({ + label, + emailFieldName, + siteFieldName, +}) => { + const { control } = useFormContext(); + + const emailId = useId(); + const siteId = useId(); + + return ( + + + {label} + + + +
    + {emailFieldName ? ( + ( + <> + + + + + Email me + + )} + /> + ) : null} +
    + + + +
    + {siteFieldName ? ( + ( + <> + + + + + Notify me on the site + + )} + /> + ) : null} +
    + + + ); +}; diff --git a/resources/js/features/user/settings/components/NotificationsSectionCard/index.ts b/resources/js/features/user/settings/components/NotificationsSectionCard/index.ts new file mode 100644 index 0000000000..0cc73c3171 --- /dev/null +++ b/resources/js/features/user/settings/components/NotificationsSectionCard/index.ts @@ -0,0 +1 @@ +export * from './NotificationsSectionCard'; diff --git a/resources/js/features/user/settings/components/NotificationsSectionCard/useNotificationsSectionForm.ts b/resources/js/features/user/settings/components/NotificationsSectionCard/useNotificationsSectionForm.ts new file mode 100644 index 0000000000..ec1e8fca5e --- /dev/null +++ b/resources/js/features/user/settings/components/NotificationsSectionCard/useNotificationsSectionForm.ts @@ -0,0 +1,53 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; +import { convertObjectToWebsitePrefs } from '@/common/utils/convertObjectToWebsitePrefs'; +import { convertWebsitePrefsToObject } from '@/common/utils/convertWebsitePrefsToObject'; + +import { websitePrefsFormSchema } from '../../utils/websitePrefsFormSchema'; + +export type FormValues = z.infer; + +export function useNotificationsSectionForm( + websitePrefs: number, + onUpdateWebsitePrefs: (newWebsitePrefs: number) => unknown, +) { + const form = useForm({ + resolver: zodResolver(websitePrefsFormSchema), + defaultValues: convertWebsitePrefsToObject(websitePrefs), + }); + + useEffect(() => { + const prefsAsObject = convertWebsitePrefsToObject(websitePrefs); + for (const [key, value] of Object.entries(prefsAsObject)) { + form.setValue(key as keyof FormValues, value); + } + }, [form, websitePrefs]); + + const mutation = useMutation({ + mutationFn: (websitePrefs: number) => { + return axios.put(route('settings.preferences.update'), { websitePrefs }); + }, + }); + + const onSubmit = (formValues: FormValues) => { + const newWebsitePrefs = convertObjectToWebsitePrefs(formValues); + + toastMessage.promise(mutation.mutateAsync(newWebsitePrefs), { + loading: 'Updating...', + success: () => { + onUpdateWebsitePrefs(newWebsitePrefs); + + return 'Updated.'; + }, + error: 'Something went wrong.', + }); + }; + + return { form, mutation, onSubmit }; +} diff --git a/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSectionCard.test.tsx b/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSectionCard.test.tsx new file mode 100644 index 0000000000..b5c0eb26f6 --- /dev/null +++ b/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSectionCard.test.tsx @@ -0,0 +1,46 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { render, screen } from '@/test'; + +import { PreferencesSectionCard } from './PreferencesSectionCard'; + +describe('Component: PreferencesSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('correctly sets the initial form values', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.getByRole('switch', { name: /suppress mature content warnings/i })).toBeChecked(); + expect(screen.getByRole('switch', { name: /show absolute dates/i })).not.toBeChecked(); + expect(screen.getByRole('switch', { name: /hide missable/i })).not.toBeChecked(); + expect(screen.getByRole('switch', { name: /only people i follow/i })).toBeChecked(); + }); + + it('given the user submits the form, makes the correct request to the server', async () => { + // ARRANGE + const putSpy = vi.spyOn(axios, 'put').mockResolvedValueOnce({ success: true }); + + render(); + + // ACT + await userEvent.click(screen.getByRole('switch', { name: /only people i follow/i })); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).toHaveBeenCalledWith(route('settings.preferences.update'), { + websitePrefs: 8399, + }); + }); +}); diff --git a/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSectionCard.tsx b/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSectionCard.tsx new file mode 100644 index 0000000000..5b70e96c24 --- /dev/null +++ b/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSectionCard.tsx @@ -0,0 +1,57 @@ +import type { FC } from 'react'; + +import { StringifiedUserPreference } from '@/common/utils/generatedAppConstants'; + +import { SectionFormCard } from '../SectionFormCard'; +import { PreferencesSwitchField } from './PreferencesSwitchField'; +import { usePreferencesSectionForm } from './usePreferencesSectionForm'; + +interface PreferencesSectionCardProps { + currentWebsitePrefs: number; + onUpdateWebsitePrefs: (newWebsitePrefs: number) => unknown; +} + +export const PreferencesSectionCard: FC = ({ + currentWebsitePrefs, + onUpdateWebsitePrefs, +}) => { + const { form, mutation, onSubmit } = usePreferencesSectionForm( + currentWebsitePrefs, + onUpdateWebsitePrefs, + ); + + return ( + +
    + + + + + + + +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSwitchField.tsx b/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSwitchField.tsx new file mode 100644 index 0000000000..f5c852e2a0 --- /dev/null +++ b/resources/js/features/user/settings/components/PreferencesSectionCard/PreferencesSwitchField.tsx @@ -0,0 +1,44 @@ +import { type FC } from 'react'; +import type { Control } from 'react-hook-form'; + +import { + BaseFormControl, + BaseFormField, + BaseFormItem, + BaseFormLabel, +} from '@/common/components/+vendor/BaseForm'; +import { BaseSwitch } from '@/common/components/+vendor/BaseSwitch'; +import type { StringifiedUserPreference } from '@/common/utils/generatedAppConstants'; + +import type { FormValues as PreferencesSectionFormValues } from './usePreferencesSectionForm'; + +type UserPreferenceValue = + (typeof StringifiedUserPreference)[keyof typeof StringifiedUserPreference]; + +interface PreferencesTableRowProps { + label: string; + fieldName: UserPreferenceValue; + control: Control; +} + +export const PreferencesSwitchField: FC = ({ + label, + fieldName, + control, +}) => { + return ( + ( + + {label} + + + + + + )} + /> + ); +}; diff --git a/resources/js/features/user/settings/components/PreferencesSectionCard/index.ts b/resources/js/features/user/settings/components/PreferencesSectionCard/index.ts new file mode 100644 index 0000000000..7eb5fd7820 --- /dev/null +++ b/resources/js/features/user/settings/components/PreferencesSectionCard/index.ts @@ -0,0 +1 @@ +export * from './PreferencesSectionCard'; diff --git a/resources/js/features/user/settings/components/PreferencesSectionCard/usePreferencesSectionForm.ts b/resources/js/features/user/settings/components/PreferencesSectionCard/usePreferencesSectionForm.ts new file mode 100644 index 0000000000..fde2c78b6d --- /dev/null +++ b/resources/js/features/user/settings/components/PreferencesSectionCard/usePreferencesSectionForm.ts @@ -0,0 +1,53 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; +import { convertObjectToWebsitePrefs } from '@/common/utils/convertObjectToWebsitePrefs'; +import { convertWebsitePrefsToObject } from '@/common/utils/convertWebsitePrefsToObject'; + +import { websitePrefsFormSchema } from '../../utils/websitePrefsFormSchema'; + +export type FormValues = z.infer; + +export function usePreferencesSectionForm( + websitePrefs: number, + onUpdateWebsitePrefs: (newWebsitePrefs: number) => unknown, +) { + const form = useForm({ + resolver: zodResolver(websitePrefsFormSchema), + defaultValues: convertWebsitePrefsToObject(websitePrefs), + }); + + useEffect(() => { + const prefsAsObject = convertWebsitePrefsToObject(websitePrefs); + for (const [key, value] of Object.entries(prefsAsObject)) { + form.setValue(key as keyof FormValues, value); + } + }, [form, websitePrefs]); + + const mutation = useMutation({ + mutationFn: (websitePrefs: number) => { + return axios.put(route('settings.preferences.update'), { websitePrefs }); + }, + }); + + const onSubmit = (formValues: FormValues) => { + const newWebsitePrefs = convertObjectToWebsitePrefs(formValues); + + toastMessage.promise(mutation.mutateAsync(newWebsitePrefs), { + loading: 'Updating...', + success: () => { + onUpdateWebsitePrefs(newWebsitePrefs); + + return 'Updated.'; + }, + error: 'Something went wrong.', + }); + }; + + return { form, mutation, onSubmit }; +} diff --git a/resources/js/features/user/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx b/resources/js/features/user/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx new file mode 100644 index 0000000000..b3d060d43e --- /dev/null +++ b/resources/js/features/user/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx @@ -0,0 +1,152 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { createAuthenticatedUser } from '@/common/models'; +import { render, screen } from '@/test'; +import { createUser } from '@/test/factories'; + +import { ProfileSectionCard } from './ProfileSectionCard'; + +describe('Component: ProfileSectionCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + can: {}, + userSettings: createUser(), + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user has no visible role, tells them', () => { + // ARRANGE + render(, { + pageProps: { + can: {}, + userSettings: createUser({ visibleRole: null }), + }, + }); + + // ASSERT + expect(screen.getByLabelText(/visible role/i)).toHaveTextContent(/none/i); + }); + + it('given the user has a visible role, tells them', () => { + // ARRANGE + render(, { + pageProps: { + can: {}, + userSettings: createUser({ visibleRole: 'Some Role' }), + }, + }); + + // ASSERT + expect(screen.getByLabelText(/visible role/i)).toHaveTextContent(/some role/i); + }); + + it('given the user is unable to change their motto, tells them', () => { + // ARRANGE + render(, { + pageProps: { + can: { + updateMotto: false, + }, + userSettings: createUser({ visibleRole: 'Some Role' }), + }, + }); + + // ASSERT + expect(screen.getByLabelText(/motto/i)).toBeDisabled(); + expect(screen.getByText(/verify your email to update your motto/i)).toBeVisible(); + }); + + it('given the user tries to delete all comments from their wall, makes a call to the server with the request', async () => { + // ARRANGE + vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + + const deleteSpy = vi.spyOn(axios, 'delete').mockResolvedValueOnce({ success: true }); + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ username: 'Scott', id: 1 }), + }, + can: {}, + userSettings: createUser({ visibleRole: null }), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /delete all comments on/i })); + + // ASSERT + expect(deleteSpy).toHaveBeenCalledTimes(1); + }); + + it("correctly prepopulates the user's motto and wall preference", () => { + // ARRANGE + const mockMotto = 'my motto'; + const mockUserWallActive = true; + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ username: 'Scott' }), + }, + can: {}, + userSettings: createUser({ + visibleRole: null, + motto: mockMotto, + userWallActive: mockUserWallActive, + }), + }, + }); + + // ASSERT + expect(screen.getByLabelText(/motto/i)).toHaveValue(mockMotto); + expect(screen.getByLabelText(/allow comments/i)).toBeChecked(); + }); + + it('given the user tries to submit new profile settings, makes a call to the server with the request', async () => { + // ARRANGE + const putSpy = vi.spyOn(axios, 'put').mockResolvedValueOnce({ success: true }); + + const mockMotto = 'my motto'; + const mockUserWallActive = true; + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ username: 'Scott' }), + }, + can: { + updateMotto: true, + }, + userSettings: createUser({ + visibleRole: null, + motto: mockMotto, + userWallActive: mockUserWallActive, + }), + }, + }); + + // ACT + const mottoField = screen.getByLabelText(/motto/i); + const userWallActiveField = screen.getByLabelText(/allow comments/i); + + await userEvent.clear(mottoField); + await userEvent.type(mottoField, 'https://www.youtube.com/watch?v=YYOKMUTTDdA'); + await userEvent.click(userWallActiveField); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).toHaveBeenCalledWith(route('settings.profile.update'), { + motto: 'https://www.youtube.com/watch?v=YYOKMUTTDdA', + userWallActive: false, + }); + }); +}); diff --git a/resources/js/features/user/settings/components/ProfileSectionCard/ProfileSectionCard.tsx b/resources/js/features/user/settings/components/ProfileSectionCard/ProfileSectionCard.tsx new file mode 100644 index 0000000000..219fc8f51d --- /dev/null +++ b/resources/js/features/user/settings/components/ProfileSectionCard/ProfileSectionCard.tsx @@ -0,0 +1,139 @@ +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import type { FC } from 'react'; +import { useId } from 'react'; +import { LuAlertCircle } from 'react-icons/lu'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { + BaseFormControl, + BaseFormDescription, + BaseFormField, + BaseFormItem, + BaseFormLabel, +} from '@/common/components/+vendor/BaseForm'; +import { BaseInput } from '@/common/components/+vendor/BaseInput'; +import { BaseSwitch } from '@/common/components/+vendor/BaseSwitch'; +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +import { usePageProps } from '../../hooks/usePageProps'; +import { SectionFormCard } from '../SectionFormCard'; +import { useProfileSectionForm } from './useProfileSectionForm'; + +export const ProfileSectionCard: FC = () => { + const { auth, can, userSettings } = usePageProps(); + + const { + form, + mutation: formMutation, + onSubmit, + } = useProfileSectionForm({ + motto: userSettings.motto ?? '', + userWallActive: userSettings.userWallActive ?? false, + }); + + const deleteAllCommentsMutation = useMutation({ + mutationFn: async () => { + // This should never happen, but is here for type safety. + if (!auth?.user.id) { + toastMessage.error('Something went wrong.'); + + return; + } + + return axios.delete(route('user.comment.destroyAll', auth?.user.id)); + }, + }); + + const visibleRoleFieldId = useId(); + + const handleDeleteAllCommentsClick = () => { + if (!confirm('Are you sure you want to permanently delete all comments on your wall?')) { + return; + } + + toastMessage.promise(deleteAllCommentsMutation.mutateAsync(), { + loading: 'Deleting...', + success: 'Successfully deleted all comments on your wall.', + error: 'Something went wrong.', + }); + }; + + return ( + +
    +
    + +

    + {userSettings.visibleRole ? ( + `${userSettings.visibleRole}` + ) : ( + none + )} +

    +
    + + ( + + User Motto + +
    + + + + + + {can.updateMotto ? ( + <> + No profanity. + {field.value.length}/50 + + ) : ( + Verify your email to update your motto. + )} + +
    +
    + )} + /> + + ( + + + Allow Comments on My User Wall + + + + + + + )} + /> + + + Delete All Comments on My User Wall + +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/ProfileSectionCard/index.ts b/resources/js/features/user/settings/components/ProfileSectionCard/index.ts new file mode 100644 index 0000000000..4012338224 --- /dev/null +++ b/resources/js/features/user/settings/components/ProfileSectionCard/index.ts @@ -0,0 +1 @@ +export * from './ProfileSectionCard'; diff --git a/resources/js/features/user/settings/components/ProfileSectionCard/useProfileSectionForm.ts b/resources/js/features/user/settings/components/ProfileSectionCard/useProfileSectionForm.ts new file mode 100644 index 0000000000..b84d871c06 --- /dev/null +++ b/resources/js/features/user/settings/components/ProfileSectionCard/useProfileSectionForm.ts @@ -0,0 +1,37 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +const profileFormSchema = z.object({ + motto: z.string().max(50), + userWallActive: z.boolean(), +}); + +type FormValues = z.infer; + +export function useProfileSectionForm(initialValues: FormValues) { + const form = useForm({ + resolver: zodResolver(profileFormSchema), + defaultValues: initialValues, + }); + + const mutation = useMutation({ + mutationFn: (formValues: FormValues) => { + return axios.put(route('settings.profile.update'), formValues); + }, + }); + + const onSubmit = (formValues: FormValues) => { + toastMessage.promise(mutation.mutateAsync(formValues), { + loading: 'Updating...', + success: 'Updated.', + error: 'Something went wrong.', + }); + }; + + return { form, mutation, onSubmit }; +} diff --git a/resources/js/features/user/settings/components/ResetGameProgressSectionCard/ResetGameProgressSectionCard.test.tsx b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/ResetGameProgressSectionCard.test.tsx new file mode 100644 index 0000000000..e49bcdf1e6 --- /dev/null +++ b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/ResetGameProgressSectionCard.test.tsx @@ -0,0 +1,63 @@ +import axios from 'axios'; +import { + mockAllIsIntersecting, + resetIntersectionMocking, +} from 'react-intersection-observer/test-utils'; + +import { render } from '@/test'; +import { createPlayerResettableGame } from '@/test/factories'; + +import { ResetGameProgressSectionCard } from './ResetGameProgressSectionCard'; + +describe('Component: ResetGameProgressSectionCard', () => { + afterEach(() => { + resetIntersectionMocking(); + }); + + it('renders without crashing', () => { + // ARRANGE + const { container } = render(); + + mockAllIsIntersecting(false); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it("given the component has never been on-screen, does not make an API call to fetch the player's resettable games", () => { + // ARRANGE + const getSpy = vi.spyOn(axios, 'get'); + + render(); + + mockAllIsIntersecting(false); + + // ASSERT + expect(getSpy).not.toHaveBeenCalled(); + }); + + it("given the component is visible, fetches the player's resettable games", () => { + // ARRANGE + const getSpy = vi + .spyOn(axios, 'get') + .mockResolvedValueOnce({ results: [createPlayerResettableGame()] }); + + render(); + + // ACT + mockAllIsIntersecting(true); + + // ASSERT + expect(getSpy).toHaveBeenCalledWith(route('player.games.resettable')); + }); + + it.todo('given the user selects a game, fetches the resettable achievements'); + it.todo( + 'given the user wants to reset all achievements, sends the correct request to the server', + ); + it.todo( + 'given the user wants to reset an individual achievement, sends the correct request to the server', + ); + it.todo('after resetting a game, removes it from the list of selectable games'); + it.todo('after resetting an achievement, removes it from the list of selectable achievements'); +}); diff --git a/resources/js/features/user/settings/components/ResetGameProgressSectionCard/ResetGameProgressSectionCard.tsx b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/ResetGameProgressSectionCard.tsx new file mode 100644 index 0000000000..0b88155e0a --- /dev/null +++ b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/ResetGameProgressSectionCard.tsx @@ -0,0 +1,151 @@ +import { type FC } from 'react'; +import { useInView } from 'react-intersection-observer'; + +import { + BaseFormControl, + BaseFormField, + BaseFormItem, + BaseFormLabel, +} from '@/common/components/+vendor/BaseForm'; +import { + BaseSelect, + BaseSelectContent, + BaseSelectItem, + BaseSelectTrigger, + BaseSelectValue, +} from '@/common/components/+vendor/BaseSelect'; + +import { SectionFormCard } from '../SectionFormCard'; +import { useResetGameProgressForm } from './useResetGameProgressForm'; + +export const ResetGameProgressSectionCard: FC = () => { + const { + filteredAchievements, + filteredGames, + form, + mutation, + onSubmit, + resettableGameAchievementsQuery, + resettableGamesQuery, + setIsResettableGamesQueryEnabled, + } = useResetGameProgressForm(); + + // Only fetch the list of resettable games when the control is visible. + const { ref: inViewRef } = useInView({ + triggerOnce: true, + initialInView: false, + onChange: (inView) => { + if (inView) { + setIsResettableGamesQueryEnabled(true); + } + }, + }); + + const [selectedGameId] = form.watch(['gameId']); + + return ( + +
    +
    + ( + + Game + +
    + + + + + + + + + {resettableGamesQuery.isFetched ? ( + <> + {filteredGames.map((game) => ( + + {game.title} ({game.consoleName}) ({game.numAwarded} /{' '} + {game.numPossible} won) + + ))} + + ) : ( + + Loading... + + )} + + +
    +
    + )} + /> + + ( + + Achievement + +
    + + + + + + + + + + All won achievements for this game + + + {resettableGameAchievementsQuery.isFetched ? ( + <> + {filteredAchievements.map((achievement) => ( + + {achievement.title} ({achievement.points} points){' '} + {achievement.isHardcore ? '(Hardcore)' : null} + + ))} + + ) : ( + + Loading... + + )} + + +
    +
    + )} + /> +
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/ResetGameProgressSectionCard/index.ts b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/index.ts new file mode 100644 index 0000000000..7f75531ce1 --- /dev/null +++ b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/index.ts @@ -0,0 +1 @@ +export * from './ResetGameProgressSectionCard'; diff --git a/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResetGameProgressForm.ts b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResetGameProgressForm.ts new file mode 100644 index 0000000000..80ad1003da --- /dev/null +++ b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResetGameProgressForm.ts @@ -0,0 +1,150 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { toastMessage } from '@/common/components/+vendor/BaseToaster'; + +import { useResettableGameAchievementsQuery } from './useResettableGameAchievementsQuery'; +import { useResettableGamesQuery } from './useResettableGamesQuery'; + +const resetGameProgressFormSchema = z.object({ + gameId: z.string().min(1), + achievementId: z.string().optional(), +}); + +export type FormValues = z.infer; + +export function useResetGameProgressForm() { + const form = useForm({ + resolver: zodResolver(resetGameProgressFormSchema), + }); + + // When the user selects a game, instantly choose the "all won achievements" + // option from the Achievement select field. This should reduce an extra click + // for users looking to quickly wipe out a bunch of stuff. + const [selectedGameId] = form.watch(['gameId']); + useEffect(() => { + if (selectedGameId) { + form.setValue('achievementId', 'all'); + } + }, [form, selectedGameId]); + + // The form component itself will control when this query is enabled. + // Ideally, the query fires off when the form control is visible, not on mount. + const [isResettableGamesQueryEnabled, setIsResettableGamesQueryEnabled] = useState(false); + const resettableGamesQuery = useResettableGamesQuery(isResettableGamesQueryEnabled); + + const resettableGameAchievementsQuery = useResettableGameAchievementsQuery(selectedGameId); + + const [alreadyResetGameIds, setAlreadyResetGameIds] = useState([]); + const [alreadyResetAchievementIds, setAlreadyResetAchievementIds] = useState([]); + const [filteredGames, setFilteredGames] = useState([]); + const [filteredAchievements, setFilteredAchievements] = useState< + App.Platform.Data.PlayerResettableGameAchievement[] + >([]); + + useEffect(() => { + if (resettableGamesQuery.data) { + setFilteredGames( + resettableGamesQuery.data.filter((game) => !alreadyResetGameIds.includes(game.id)), + ); + } + }, [alreadyResetGameIds, resettableGamesQuery.data]); + + useEffect(() => { + if (resettableGameAchievementsQuery.data) { + setFilteredAchievements( + resettableGameAchievementsQuery.data.filter( + (achievement) => !alreadyResetAchievementIds.includes(achievement.id), + ), + ); + } + }, [alreadyResetAchievementIds, resettableGameAchievementsQuery.data]); + + const mutation = useMutation({ + mutationFn: (payload: Partial) => { + let url = ''; + if (payload.gameId) { + url = route('user.game.destroy', payload.gameId); + } else if (payload.achievementId) { + url = route('user.achievement.destroy', payload.achievementId); + } + + if (!url.length) { + throw new Error('Nothing to reset.'); + } + + return axios.delete(url); + }, + onSuccess: (_, variables) => { + // After performing the mutation, store IDs for whatever we've wiped + // out so the front-end knows it can stop rendering those things + // as available for user selection. + if (!variables.achievementId) { + // Filter the cleared game IDs client-side. The request to actually + // purge the game ID from the user's account progress is tied to + // an async job that may take some time to finish, so we can't just + // requery for the new list of games with progress. + setAlreadyResetGameIds((prev) => [...prev, Number(variables.gameId)]); + + form.setValue('gameId', ''); + form.setValue('achievementId', ''); + } else { + // Filter the cleared achievement IDs client-side. The request to actually + // purge the achievement ID from the user's account progress is tied to + // an async job that may take some time to finish, so we can't just + // requery for the new list of achievements with progress associated to the playerGame. + setAlreadyResetAchievementIds((prev) => [...prev, Number(variables.achievementId)]); + + // If the game has no achievements left, we need to consider the game as being reset + // and ensure it's unselected from the form. + const remainingAchievements = resettableGameAchievementsQuery.data?.filter( + (achievement) => + !alreadyResetAchievementIds.includes(achievement.id) && + achievement.id !== Number(variables.achievementId), + ); + if (!remainingAchievements || remainingAchievements.length === 0) { + setAlreadyResetGameIds((prev) => [...prev, Number(variables.gameId)]); + form.setValue('gameId', ''); + } + + form.setValue('achievementId', ''); + } + }, + }); + + const onSubmit = (formValues: FormValues) => { + if (!confirm('Are you sure you want to reset this progress? This cannot be reversed.')) { + return; + } + + // We'll either send a game ID or an achievement ID to the server. + // If we only send a game ID, the user wants to reset their progress for the whole game. + const payload: Partial = + formValues.achievementId === 'all' + ? { gameId: formValues.gameId } + : { achievementId: formValues.achievementId }; + + toastMessage.promise(mutation.mutateAsync(payload), { + loading: 'Resetting progress...', + success: 'Progress was reset successfully.', + error: 'Something went wrong.', + }); + }; + + return { + alreadyResetAchievementIds, + alreadyResetGameIds, + filteredAchievements, + filteredGames, + form, + mutation, + onSubmit, + resettableGameAchievementsQuery, + resettableGamesQuery, + setIsResettableGamesQueryEnabled, + }; +} diff --git a/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResettableGameAchievementsQuery.ts b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResettableGameAchievementsQuery.ts new file mode 100644 index 0000000000..20232ce931 --- /dev/null +++ b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResettableGameAchievementsQuery.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export function useResettableGameAchievementsQuery(gameId: string | null) { + return useQuery({ + queryKey: ['resettable-game-achievements', gameId], + queryFn: async () => { + const response = await axios.get<{ + results: App.Platform.Data.PlayerResettableGameAchievement[]; + }>(route('player.game.achievements.resettable', gameId ?? 0)); + + return response.data.results; + }, + enabled: !!gameId, + refetchInterval: false, + staleTime: 60 * 1000, // one minute + }); +} diff --git a/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResettableGamesQuery.ts b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResettableGamesQuery.ts new file mode 100644 index 0000000000..729d55755e --- /dev/null +++ b/resources/js/features/user/settings/components/ResetGameProgressSectionCard/useResettableGamesQuery.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export function useResettableGamesQuery(isEnabled: boolean) { + return useQuery({ + queryKey: ['resettable-games'], + queryFn: async () => { + const response = await axios.get<{ results: App.Platform.Data.PlayerResettableGame[] }>( + route('player.games.resettable'), + ); + + return response.data.results; + }, + enabled: isEnabled, + refetchInterval: false, + staleTime: 60 * 1000, // one minute + }); +} diff --git a/resources/js/features/user/settings/components/SectionFormCard/SectionFormCard.test.tsx b/resources/js/features/user/settings/components/SectionFormCard/SectionFormCard.test.tsx new file mode 100644 index 0000000000..92b9d16f36 --- /dev/null +++ b/resources/js/features/user/settings/components/SectionFormCard/SectionFormCard.test.tsx @@ -0,0 +1,74 @@ +import { useMutation } from '@tanstack/react-query'; +import userEvent from '@testing-library/user-event'; +import type { FC } from 'react'; +import { useForm } from 'react-hook-form'; + +import { render, screen } from '@/test'; + +import { SectionFormCard, type SectionFormCardProps } from './SectionFormCard'; + +const TestHarness: FC> = (props) => { + const formMethods = useForm(); + const mutation = useMutation({}); + + return ; +}; + +describe('Component: SectionFormCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + + children + , + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('renders an accessible heading label', () => { + // ARRANGE + render( + + children + , + ); + + // ASSERT + expect(screen.getByRole('heading', { name: /hello/i })).toBeVisible(); + }); + + it('renders an interactive form', async () => { + // ARRANGE + const mockOnSubmit = vi.fn(); + + render( + + children + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(mockOnSubmit).toHaveBeenCalledTimes(1); + }); + + it('can change its submit button props', () => { + // ARRANGE + render( + + children + , + ); + + // ASSERT + expect(screen.getByRole('button', { name: /some different label/i })).toBeVisible(); + }); +}); diff --git a/resources/js/features/user/settings/components/SectionFormCard/SectionFormCard.tsx b/resources/js/features/user/settings/components/SectionFormCard/SectionFormCard.tsx new file mode 100644 index 0000000000..1693cc3df8 --- /dev/null +++ b/resources/js/features/user/settings/components/SectionFormCard/SectionFormCard.tsx @@ -0,0 +1,56 @@ +import type { FC, ReactNode } from 'react'; +import type { UseFormReturn } from 'react-hook-form'; + +import type { BaseButtonProps } from '@/common/components/+vendor/BaseButton'; +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { + BaseCard, + BaseCardContent, + BaseCardFooter, + BaseCardHeader, + BaseCardTitle, +} from '@/common/components/+vendor/BaseCard'; +import { BaseFormProvider } from '@/common/components/+vendor/BaseForm'; + +export interface SectionFormCardProps { + headingLabel: string; + children: ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- any is valid + formMethods: UseFormReturn; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- any is valid + onSubmit: (formValues: any) => void; + isSubmitting: boolean; + + buttonProps?: BaseButtonProps; +} + +export const SectionFormCard: FC = ({ + headingLabel, + children, + formMethods, + onSubmit, + isSubmitting, + buttonProps, +}) => { + return ( + + + {headingLabel} + + + +
    + {children} + + +
    + + {buttonProps?.children ?? 'Update'} + +
    +
    +
    +
    +
    + ); +}; diff --git a/resources/js/features/user/settings/components/SectionFormCard/index.ts b/resources/js/features/user/settings/components/SectionFormCard/index.ts new file mode 100644 index 0000000000..13c73f24cc --- /dev/null +++ b/resources/js/features/user/settings/components/SectionFormCard/index.ts @@ -0,0 +1 @@ +export * from './SectionFormCard'; diff --git a/resources/js/features/user/settings/components/SectionStandardCard/SectionStandardCard.test.tsx b/resources/js/features/user/settings/components/SectionStandardCard/SectionStandardCard.test.tsx new file mode 100644 index 0000000000..b69eb80680 --- /dev/null +++ b/resources/js/features/user/settings/components/SectionStandardCard/SectionStandardCard.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@/test'; + +import { SectionStandardCard } from './SectionStandardCard'; + +describe('Component: SectionStandardCard', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + children, + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('renders an accessible heading element', () => { + // ARRANGE + render(children); + + // ASSERT + expect(screen.getByRole('heading', { name: /hello/i })).toBeVisible(); + }); + + it('renders children', () => { + // ARRANGE + render(children); + + // ASSERT + expect(screen.getByText(/children/i)).toBeVisible(); + }); +}); diff --git a/resources/js/features/user/settings/components/SectionStandardCard/SectionStandardCard.tsx b/resources/js/features/user/settings/components/SectionStandardCard/SectionStandardCard.tsx new file mode 100644 index 0000000000..644c7e65a2 --- /dev/null +++ b/resources/js/features/user/settings/components/SectionStandardCard/SectionStandardCard.tsx @@ -0,0 +1,25 @@ +import type { FC, ReactNode } from 'react'; + +import { + BaseCard, + BaseCardContent, + BaseCardHeader, + BaseCardTitle, +} from '@/common/components/+vendor/BaseCard'; + +interface SectionStandardCardProps { + headingLabel: string; + children: ReactNode; +} + +export const SectionStandardCard: FC = ({ headingLabel, children }) => { + return ( + + + {headingLabel} + + + {children} + + ); +}; diff --git a/resources/js/features/user/settings/components/SectionStandardCard/index.ts b/resources/js/features/user/settings/components/SectionStandardCard/index.ts new file mode 100644 index 0000000000..1ae64b93d0 --- /dev/null +++ b/resources/js/features/user/settings/components/SectionStandardCard/index.ts @@ -0,0 +1 @@ +export * from './SectionStandardCard'; diff --git a/resources/js/features/user/settings/components/SiteAwardsSection/SiteAwardsSection.test.tsx b/resources/js/features/user/settings/components/SiteAwardsSection/SiteAwardsSection.test.tsx new file mode 100644 index 0000000000..b64cef6276 --- /dev/null +++ b/resources/js/features/user/settings/components/SiteAwardsSection/SiteAwardsSection.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@/test'; + +import { SiteAwardsSection } from './SiteAwardsSection'; + +describe('Component: SiteAwardsSection', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('has a visible link to the reorder site awards page', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.getByRole('link', { name: /reorder site awards/i })).toBeVisible(); + }); +}); diff --git a/resources/js/features/user/settings/components/SiteAwardsSection/SiteAwardsSection.tsx b/resources/js/features/user/settings/components/SiteAwardsSection/SiteAwardsSection.tsx new file mode 100644 index 0000000000..409b3faa88 --- /dev/null +++ b/resources/js/features/user/settings/components/SiteAwardsSection/SiteAwardsSection.tsx @@ -0,0 +1,18 @@ +import type { FC } from 'react'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { baseCardTitleClassNames } from '@/common/components/+vendor/BaseCard'; +import { cn } from '@/utils/cn'; + +export const SiteAwardsSection: FC = () => { + return ( +
    +

    Site Awards

    +

    You can manually reorder how badges appear on your user profile.

    + + + Reorder Site Awards + +
    + ); +}; diff --git a/resources/js/features/user/settings/components/SiteAwardsSection/index.ts b/resources/js/features/user/settings/components/SiteAwardsSection/index.ts new file mode 100644 index 0000000000..0ff89db4b0 --- /dev/null +++ b/resources/js/features/user/settings/components/SiteAwardsSection/index.ts @@ -0,0 +1 @@ +export * from './SiteAwardsSection'; diff --git a/resources/js/features/user/settings/hooks/usePageProps.ts b/resources/js/features/user/settings/hooks/usePageProps.ts new file mode 100644 index 0000000000..ce783a2e5b --- /dev/null +++ b/resources/js/features/user/settings/hooks/usePageProps.ts @@ -0,0 +1,9 @@ +import { usePage } from '@inertiajs/react'; + +import type { AppGlobalProps } from '@/common/models'; + +export function usePageProps() { + const { props } = usePage(); + + return props; +} diff --git a/resources/js/features/user/settings/hooks/useResetNavbarUserPic.ts b/resources/js/features/user/settings/hooks/useResetNavbarUserPic.ts new file mode 100644 index 0000000000..29c084d39f --- /dev/null +++ b/resources/js/features/user/settings/hooks/useResetNavbarUserPic.ts @@ -0,0 +1,25 @@ +import { usePage } from '@inertiajs/react'; + +import type { AppGlobalProps } from '@/common/models'; +import { asset } from '@/utils/helpers'; + +export function useResetNavbarUserPic() { + const { + props: { auth }, + } = usePage(); + + const resetNavbarUserPic = () => { + // Using document functions to mutate the DOM is very bad. + // We only do this because the app shell is still a Blade template. + + const userDisplayName = auth?.user.displayName ?? ''; + const fileName = `/UserPic/${userDisplayName}.png`; + + for (const element of document.querySelectorAll('.userpic')) { + const now = new Date(); // use a query param to ignore the browser cache + element.src = `${asset(fileName)}` + '?' + now.getTime(); + } + }; + + return { resetNavbarUserPic }; +} diff --git a/resources/js/features/user/settings/utils/websitePrefsFormSchema.ts b/resources/js/features/user/settings/utils/websitePrefsFormSchema.ts new file mode 100644 index 0000000000..3e1b45e701 --- /dev/null +++ b/resources/js/features/user/settings/utils/websitePrefsFormSchema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { StringifiedUserPreference } from '@/common/utils/generatedAppConstants'; + +export const websitePrefsFormSchema = z.object({ + [StringifiedUserPreference.EmailOn_ActivityComment]: z.boolean(), + [StringifiedUserPreference.EmailOn_AchievementComment]: z.boolean(), + [StringifiedUserPreference.EmailOn_UserWallComment]: z.boolean(), + [StringifiedUserPreference.EmailOn_ForumReply]: z.boolean(), + [StringifiedUserPreference.EmailOn_Followed]: z.boolean(), + [StringifiedUserPreference.EmailOn_PrivateMessage]: z.boolean(), + [StringifiedUserPreference.EmailOn_Newsletter]: z.boolean(), + [StringifiedUserPreference.Site_SuppressMatureContentWarning]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_ActivityComment]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_AchievementComment]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_UserWallComment]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_ForumReply]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_Followed]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_PrivateMessage]: z.boolean(), + [StringifiedUserPreference.SiteMsgOn_Newsletter]: z.boolean(), + [StringifiedUserPreference.Forum_ShowAbsoluteDates]: z.boolean(), + [StringifiedUserPreference.Game_HideMissableIndicators]: z.boolean(), + [StringifiedUserPreference.User_OnlyContactFromFollowing]: z.boolean(), +}); diff --git a/resources/js/pages/game/[game]/hashes.tsx b/resources/js/pages/game/[game]/hashes.tsx new file mode 100644 index 0000000000..7d70a674c5 --- /dev/null +++ b/resources/js/pages/game/[game]/hashes.tsx @@ -0,0 +1,26 @@ +import { Head } from '@inertiajs/react'; + +import { AppLayout } from '@/common/layouts/AppLayout'; +import type { AppPage } from '@/common/models'; +import { HashesMainRoot } from '@/features/games/components/HashesMainRoot'; + +const Hashes: AppPage = ({ game, hashes }) => { + return ( + <> + + + + + + + + + ); +}; + +Hashes.layout = (page) => {page}; + +export default Hashes; diff --git a/resources/js/pages/settings.tsx b/resources/js/pages/settings.tsx new file mode 100644 index 0000000000..72654001e7 --- /dev/null +++ b/resources/js/pages/settings.tsx @@ -0,0 +1,30 @@ +import { Head } from '@inertiajs/react'; + +import { AppLayout } from '@/common/layouts/AppLayout'; +import type { AppPage } from '@/common/models'; +import { SettingsRoot } from '@/features/user/settings/components/+root/SettingsRoot'; +import { SettingsSidebar } from '@/features/user/settings/components/+sidebar'; + +const Settings: AppPage = () => { + return ( + <> + + + + +
    + + + +
    + + + + + + ); +}; + +Settings.layout = (page) => {page}; + +export default Settings; diff --git a/resources/js/setupTests.ts b/resources/js/setupTests.ts index cfdf9ac1c5..5aaad3d977 100644 --- a/resources/js/setupTests.ts +++ b/resources/js/setupTests.ts @@ -12,6 +12,23 @@ beforeAll(async () => { await loadFaker(); }); +beforeAll(() => { + /** + * ResizeObserver is unavailable in NodeJS. + */ + global.ResizeObserver = class ResizeObserver { + observe() { + // do nothing + } + unobserve() { + // do nothing + } + disconnect() { + // do nothing + } + }; +}); + beforeEach(() => { // We'll directly dump all arguments given to Ziggy's route() function. diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx index d245677e70..83b1bdc76c 100644 --- a/resources/js/ssr.tsx +++ b/resources/js/ssr.tsx @@ -7,6 +7,7 @@ import ReactDOMServer from 'react-dom/server'; import type { RouteName, RouteParams } from 'ziggy-js'; import { route } from '../../vendor/tightenco/ziggy'; +import { AppProviders } from './common/components/AppProviders'; const appName = import.meta.env.APP_NAME || 'RetroAchievements'; @@ -30,7 +31,11 @@ createServer((page) => location: new URL(page.props.ziggy.location), }); - return ; + return ( + + + + ); }, }), ); diff --git a/resources/js/test/factories/createGame.ts b/resources/js/test/factories/createGame.ts new file mode 100644 index 0000000000..921e698398 --- /dev/null +++ b/resources/js/test/factories/createGame.ts @@ -0,0 +1,12 @@ +import { createFactory } from '../createFactory'; +import { createSystem } from './createSystem'; + +export const createGame = createFactory((faker) => { + return { + id: faker.number.int({ min: 1, max: 99999 }), + title: faker.word.words(3), + badgeUrl: faker.internet.url(), + forumTopicId: faker.number.int({ min: 1, max: 99999 }), + system: createSystem(), + }; +}); diff --git a/resources/js/test/factories/createGameHash.ts b/resources/js/test/factories/createGameHash.ts new file mode 100644 index 0000000000..dedba16e34 --- /dev/null +++ b/resources/js/test/factories/createGameHash.ts @@ -0,0 +1,19 @@ +import { createFactory } from '../createFactory'; +import { createGameHashLabel } from './createGameHashLabel'; + +export const createGameHash = createFactory((faker) => { + const labelsCount = faker.number.int({ min: 0, max: 2 }); + + const labels: App.Platform.Data.GameHashLabel[] = []; + for (let i = 0; i < labelsCount; i += 1) { + labels.push(createGameHashLabel()); + } + + return { + labels, + id: faker.number.int({ min: 1, max: 99999 }), + md5: faker.string.alphanumeric(32), + name: faker.word.words(3), + patchUrl: faker.internet.url(), + }; +}); diff --git a/resources/js/test/factories/createGameHashLabel.ts b/resources/js/test/factories/createGameHashLabel.ts new file mode 100644 index 0000000000..88239323ad --- /dev/null +++ b/resources/js/test/factories/createGameHashLabel.ts @@ -0,0 +1,17 @@ +import { createFactory } from '../createFactory'; + +export const createGameHashLabel = createFactory((faker) => ({ + imgSrc: faker.internet.url(), + label: faker.helpers.arrayElement([ + 'nointro', + 'rapatches', + 'fbneo', + 'goodtools', + 'redump', + 'mamesl', + 'tosec', + 'itchio', + 'msu1', + 'lostlevel', + ]), +})); diff --git a/resources/js/test/factories/createPlayerResettableGame.ts b/resources/js/test/factories/createPlayerResettableGame.ts new file mode 100644 index 0000000000..86d278ba1d --- /dev/null +++ b/resources/js/test/factories/createPlayerResettableGame.ts @@ -0,0 +1,11 @@ +import { createFactory } from '../createFactory'; + +export const createPlayerResettableGame = createFactory( + (faker) => ({ + id: faker.number.int({ min: 1, max: 99999 }), + title: faker.word.words(2), + consoleName: faker.word.words(2), + numAwarded: faker.number.int({ min: 1, max: 50 }), + numPossible: faker.number.int({ min: 1, max: 50 }), + }), +); diff --git a/resources/js/test/factories/createPlayerResettableGameAchievement.ts b/resources/js/test/factories/createPlayerResettableGameAchievement.ts new file mode 100644 index 0000000000..7c38526748 --- /dev/null +++ b/resources/js/test/factories/createPlayerResettableGameAchievement.ts @@ -0,0 +1,9 @@ +import { createFactory } from '../createFactory'; + +export const createPlayerResettableGameAchievement = + createFactory((faker) => ({ + id: faker.number.int({ min: 1, max: 99999 }), + title: faker.word.words(2), + points: faker.number.int({ min: 1, max: 100 }), + isHardcore: faker.datatype.boolean(), + })); diff --git a/resources/js/test/factories/createSystem.ts b/resources/js/test/factories/createSystem.ts new file mode 100644 index 0000000000..849d15477e --- /dev/null +++ b/resources/js/test/factories/createSystem.ts @@ -0,0 +1,10 @@ +import { createFactory } from '../createFactory'; + +export const createSystem = createFactory((faker) => { + return { + id: faker.number.int({ min: 1, max: 150 }), + name: faker.word.words(2), + nameFull: faker.word.words(4), + nameShort: faker.string.alphanumeric(3), + }; +}); diff --git a/resources/js/test/factories/createUser.ts b/resources/js/test/factories/createUser.ts index 3d3411463f..18ce125ae1 100644 --- a/resources/js/test/factories/createUser.ts +++ b/resources/js/test/factories/createUser.ts @@ -6,6 +6,7 @@ export const createUser = createFactory((faker) => { return { displayName, username: displayName, + isMuted: faker.datatype.boolean(), avatarUrl: `http://media.retroachievements.org/UserPic/${displayName}.png`, id: faker.number.int({ min: 1, max: 1000000 }), legacyPermissions: faker.number.int({ min: 0, max: 4 }), diff --git a/resources/js/test/factories/index.ts b/resources/js/test/factories/index.ts index 97c8eec043..d6052d526b 100644 --- a/resources/js/test/factories/index.ts +++ b/resources/js/test/factories/index.ts @@ -1 +1,7 @@ +export * from './createForumTopicComment'; +export * from './createGame'; +export * from './createGameHash'; +export * from './createGameHashLabel'; +export * from './createPlayerResettableGame'; +export * from './createSystem'; export * from './createUser'; diff --git a/resources/js/test/setup.tsx b/resources/js/test/setup.tsx index 4ac3a8e289..543e1aebc3 100644 --- a/resources/js/test/setup.tsx +++ b/resources/js/test/setup.tsx @@ -4,6 +4,9 @@ import * as InertiajsReactModule from '@inertiajs/react'; import { render as defaultRender } from '@testing-library/react'; import type { ReactNode } from 'react'; +import { AppProviders } from '@/common/components/AppProviders'; +import type { AppGlobalProps } from '@/common/models'; + export * from '@testing-library/react'; vi.mock('@inertiajs/react', () => ({ @@ -24,14 +27,23 @@ vi.mock('@inertiajs/react', () => ({ type DefaultParams = Parameters; type RenderUI = DefaultParams[0]; -type RenderOptions = DefaultParams[1] & { pageProps?: Record }; // augment this as necessary +type RenderOptions> = DefaultParams[1] & { + pageProps?: Partial; +}; interface WrapperProps { children: ReactNode; } -export function render(ui: RenderUI, { wrapper, pageProps = {}, ...options }: RenderOptions = {}) { - vi.spyOn(InertiajsReactModule, 'usePage').mockImplementationOnce(() => ({ +export function render>( + ui: RenderUI, + { + wrapper, + pageProps = {} as Partial, + ...options + }: RenderOptions = {}, +) { + vi.spyOn(InertiajsReactModule, 'usePage').mockImplementation(() => ({ component: '', props: pageProps as any, rememberedState: {}, @@ -41,7 +53,7 @@ export function render(ui: RenderUI, { wrapper, pageProps = {}, ...options }: Re })); if (!wrapper) { - wrapper = ({ children }: WrapperProps) => <>{children}; + wrapper = ({ children }: WrapperProps) => {children}; } return { diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index e4dd0b8264..8aa48fb1c2 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -1,3 +1,9 @@ +declare namespace App.Community.Data { + export type UserSettingsPageProps = { + userSettings: App.Data.User; + can: App.Data.UserPermissions; + }; +} declare namespace App.Data { export type ForumTopicComment = { id: number; @@ -12,12 +18,12 @@ declare namespace App.Data { id: number; title: string; createdAt: string; - user: App.Data.User | null; latestComment?: App.Data.ForumTopicComment; commentCount24h?: number; oldestComment24hId?: number; commentCount7d?: number; oldestComment7dId?: number; + user: App.Data.User | null; }; export type __UNSAFE_PaginatedData = { currentPage: number; @@ -35,13 +41,48 @@ declare namespace App.Data { export type User = { displayName: string; avatarUrl: string; + isMuted: boolean; id?: number; username?: string | null; + motto?: string; legacyPermissions?: number | null; preferences?: { prefersAbsoluteDates: boolean }; roles?: App.Models.UserRole[]; + apiKey?: string | null; + deleteRequested?: string | null; + emailAddress?: string | null; unreadMessageCount?: number | null; + userWallActive?: boolean | null; + visibleRole?: string | null; + websitePrefs?: number | null; }; + export type UserPermissions = { + manageGameHashes?: boolean; + manipulateApiKeys?: boolean; + updateAvatar?: boolean; + updateMotto?: boolean; + }; +} +declare namespace App.Enums { + export type UserPreference = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17; } declare namespace App.Models { export type UserRole = @@ -70,6 +111,29 @@ declare namespace App.Models { | 'developer-veteran'; } declare namespace App.Platform.Data { + export type Game = { + id: number; + title: string; + badgeUrl?: string; + forumTopicId?: number; + system?: App.Platform.Data.System; + }; + export type GameHash = { + id: number; + md5: string; + name: string | null; + labels: Array; + patchUrl: string | null; + }; + export type GameHashLabel = { + label: string; + imgSrc: string | null; + }; + export type GameHashesPageProps = { + game: App.Platform.Data.Game; + hashes: Array; + can: App.Data.UserPermissions; + }; export type PlayerResettableGameAchievement = { id: number; title: string; @@ -83,7 +147,13 @@ declare namespace App.Platform.Data { numAwarded: number; numPossible: number; }; + export type System = { + id: number; + name: string; + nameFull?: string; + nameShort?: string; + }; } declare namespace App.Platform.Enums { - export type AchievementFlag = 3 | 5; + export type GameSetType = 'hub' | 'similar-games'; } diff --git a/resources/js/ziggy.d.ts b/resources/js/ziggy.d.ts index 4ad6a4de79..9845d0868b 100644 --- a/resources/js/ziggy.d.ts +++ b/resources/js/ziggy.d.ts @@ -158,14 +158,6 @@ declare module 'ziggy-js' { "binding": "ID" } ], - "game.random": [], - "game.hash": [ - { - "name": "game", - "required": true, - "binding": "ID" - } - ], "game.hash.manage": [ { "name": "game", @@ -263,6 +255,14 @@ declare module 'ziggy-js' { "claims.expiring": [], "claims.completed": [], "claims.active": [], + "game.hashes.index": [ + { + "name": "game", + "required": true, + "binding": "ID" + } + ], + "game.random": [], "game-hash.update": [ { "name": "gameHash", @@ -299,6 +299,7 @@ declare module 'ziggy-js' { "binding": "ID" } ], + "settings.show": [], "forum.recent-posts": [], "user.comment.destroyAll": [ { diff --git a/resources/views/components/game/link-buttons/index.blade.php b/resources/views/components/game/link-buttons/index.blade.php index f9c5c9cc1c..b2267c43d7 100644 --- a/resources/views/components/game/link-buttons/index.blade.php +++ b/resources/views/components/game/link-buttons/index.blade.php @@ -67,7 +67,7 @@ @can('viewAny', App\Models\GameHash::class) Supported Game Files diff --git a/resources/views/components/game/top-achievers/most-points.blade.php b/resources/views/components/game/top-achievers/most-points.blade.php index 10da9c4b12..743846c420 100644 --- a/resources/views/components/game/top-achievers/most-points.blade.php +++ b/resources/views/components/game/top-achievers/most-points.blade.php @@ -39,9 +39,20 @@ $user = User::find($playerGame['user_id']) @endphp @if ($isEvent) - + @else - + @endif @php $rank++; diff --git a/resources/views/components/game/top-achievers/score-row.blade.php b/resources/views/components/game/top-achievers/score-row.blade.php index 0c71ef044a..a34642d96d 100644 --- a/resources/views/components/game/top-achievers/score-row.blade.php +++ b/resources/views/components/game/top-achievers/score-row.blade.php @@ -3,6 +3,7 @@ 'user' => null, // User 'score' => 0, 'maxScore' => -1, + 'beatenAt' => 0, ]) @if (request()->user() && $user->id === request()->user()->id) @@ -19,6 +20,8 @@
    @if ($score === $maxScore)
    + @elseif ($beatenAt !== 0) +
    @endif

    {{ localized_number($score) }}

    diff --git a/resources/views/components/head-analytics.blade.php b/resources/views/components/head-analytics.blade.php index aeff42a600..cbcbb7c8b6 100644 --- a/resources/views/components/head-analytics.blade.php +++ b/resources/views/components/head-analytics.blade.php @@ -35,6 +35,8 @@ $props = [ 'isAuthenticated' => auth()->check(), + 'scheme' => request()->cookie('scheme') ?: 'dark', + 'theme' => request()->cookie('theme') ?: 'default', ]; // Define regex patterns to extract props from the URL. diff --git a/resources/views/components/menu/account.blade.php b/resources/views/components/menu/account.blade.php index 464c3c9b0d..0961da3b4f 100644 --- a/resources/views/components/menu/account.blade.php +++ b/resources/views/components/menu/account.blade.php @@ -88,7 +88,7 @@ Reorder Site Awards {{--{{ __res('setting') }}--}} - Settings + Settings {{----}} diff --git a/resources/views/components/supported-game-files/hash-listing.blade.php b/resources/views/components/supported-game-files/hash-listing.blade.php deleted file mode 100644 index 99a0dbfe6f..0000000000 --- a/resources/views/components/supported-game-files/hash-listing.blade.php +++ /dev/null @@ -1,40 +0,0 @@ -@props([ - 'hash' => null, // GameHash -]) - -
  • -

    - @if ($hash->name) - {{ $hash->name }} - @endif - - @if (!empty($hash->labels)) - @foreach (explode(',', $hash->labels) as $label) - @if (empty($label)) - @continue; - @endif - - @php - $image = "/assets/images/labels/" . $label . '.png'; - $publicPath = public_path($image); - @endphp - - @if (file_exists($publicPath)) - - @else - [{{ $label }}] - @endif - @endforeach - @endif -

    - -
    -

    - {{ $hash->md5 }} -

    - - @if ($hash->patch_url) - Download Patch File - @endif -
    -
  • diff --git a/resources/views/pages/game/[game]/hashes/index.blade.php b/resources/views/pages/game/[game]/hashes/index.blade.php deleted file mode 100644 index d1fbe5155c..0000000000 --- a/resources/views/pages/game/[game]/hashes/index.blade.php +++ /dev/null @@ -1,105 +0,0 @@ -hashes()->with('user')->orderBy('name')->orderBy('md5')->get(); - $numHashes = $hashes->count(); - - $unlabeledHashes = $hashes->filter(function ($hash) { - return empty($hash->name); - }); - $labeledHashes = $hashes->reject(function ($hash) { - return empty($hash->name); - }); - - return $view->with([ - 'labeledHashes' => $labeledHashes, - 'numHashes' => $numHashes, - 'unlabeledHashes' => $unlabeledHashes, - ]); -}); - -?> - -@props([ - 'labeledHashes' => null, // Collection - 'numHashes' => 0, - 'unlabeledHashes' => null, // Collection -]) - - - - -
    - {!! gameAvatar($game->toArray(), label: false, iconSize: 48, iconClass: 'rounded-sm') !!} -

    Supported Game Files

    -
    - - @can('manage', App\Models\GameHash::class, ['game' => $game]) -
    - -
    - @endcan - -
    -

    - - RetroAchievements requires that the game files you use with your emulator - are the same as, or compatible with, those used to create the game's achievements. - - This page shows you what ROM hashes are compatible with the game's achievements. -

    - -

    - Details on how the hash is generated for each system can be found - here. - - @if ($game->ForumTopicID > 0) - Additional information for these hashes may be listed on the - official forum topic. - @endif -

    -
    - -

    - There {{ $numHashes === 1 ? 'is' : 'are' }} currently {{ $numHashes }} - supported game file {{ strtolower(__res('game-hash', $numHashes)) }} registered for this game. -

    - -
    -
      - @foreach ($labeledHashes as $hash) - - @endforeach -
    - - @if (!$labeledHashes->isEmpty() && !$unlabeledHashes->isEmpty()) -
    - @endif - - @if (!$unlabeledHashes->isEmpty()) -

    Unlabeled Game File Hashes

    - -
      - @foreach ($unlabeledHashes as $hash) - - @endforeach -
    - @endif -
    -
    diff --git a/resources/views/pages/game/random.blade.php b/resources/views/pages/game/random.blade.php deleted file mode 100644 index 14f4fa3d41..0000000000 --- a/resources/views/pages/game/random.blade.php +++ /dev/null @@ -1,23 +0,0 @@ -where('achievements_published', '>=', 6) - ->inRandomOrder() - ->firstOrFail(); - - return redirect(route('game.show', ['game' => $randomGameWithAchievements])); -}); - -?>