diff --git a/app/Community/Controllers/UserSettingsController.php b/app/Community/Controllers/UserSettingsController.php index a1cfeab626..550dc9cd63 100644 --- a/app/Community/Controllers/UserSettingsController.php +++ b/app/Community/Controllers/UserSettingsController.php @@ -21,9 +21,11 @@ use App\Data\UserData; use App\Data\UserPermissionsData; use App\Enums\Permissions; +use App\Enums\UserPreference; use App\Http\Controller; use App\Models\User; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response as InertiaResponse; @@ -124,6 +126,21 @@ public function updateLocale(UpdateLocaleRequest $request): JsonResponse return response()->json(['success' => true]); } + // TODO migrate to $user->preferences blob + public function enableSuppressMatureContentWarning(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $currentPreferences = (int) $user->getAttribute('websitePrefs'); + $newPreferences = $currentPreferences | (1 << UserPreference::Site_SuppressMatureContentWarning); + + $user->websitePrefs = $newPreferences; + $user->save(); + + return response()->json(['success' => true]); + } + // TODO migrate to $user->preferences blob public function updatePreferences(UpdateWebsitePrefsRequest $request): JsonResponse { diff --git a/app/Community/RouteServiceProvider.php b/app/Community/RouteServiceProvider.php index 70f6df039f..fc35f371eb 100755 --- a/app/Community/RouteServiceProvider.php +++ b/app/Community/RouteServiceProvider.php @@ -339,6 +339,9 @@ protected function mapWebRoutes(): void 'middleware' => ['auth'], 'prefix' => 'internal-api/settings', ], function () { + Route::patch('/preferences/content-warning', [UserSettingsController::class, 'enableSuppressMatureContentWarning']) + ->name('api.settings.preferences.suppress-mature-content-warning'); + Route::put('profile', [UserSettingsController::class, 'updateProfile'])->name('api.settings.profile.update'); Route::put('locale', [UserSettingsController::class, 'updateLocale'])->name('api.settings.locale.update'); Route::put('preferences', [UserSettingsController::class, 'updatePreferences'])->name('api.settings.preferences.update'); diff --git a/app/Data/UserData.php b/app/Data/UserData.php index 735a3f1fde..78ff7ea65f 100644 --- a/app/Data/UserData.php +++ b/app/Data/UserData.php @@ -42,7 +42,10 @@ public function __construct( public Lazy|string|null $visibleRole = null, public Lazy|int|null $websitePrefs = null, - #[TypeScriptType(['prefersAbsoluteDates' => 'boolean'])] + #[TypeScriptType([ + 'shouldAlwaysBypassContentWarnings' => 'boolean', + 'prefersAbsoluteDates' => 'boolean', + ])] public Lazy|array|null $preferences = [], #[LiteralTypeScriptType('App.Models.UserRole[]')] public Lazy|array|null $roles = [], @@ -82,6 +85,7 @@ public static function fromUser(User $user): self motto: Lazy::create(fn () => $user->Motto), preferences: Lazy::create( fn () => [ + 'shouldAlwaysBypassContentWarnings' => $user->should_always_bypass_content_warnings, 'prefersAbsoluteDates' => $user->prefers_absolute_dates, ] ), diff --git a/app/Filament/Resources/HubResource.php b/app/Filament/Resources/HubResource.php index 523fe6f5f3..130753f012 100644 --- a/app/Filament/Resources/HubResource.php +++ b/app/Filament/Resources/HubResource.php @@ -10,6 +10,7 @@ use App\Filament\Resources\HubResource\RelationManagers\GamesRelationManager; use App\Filament\Resources\HubResource\RelationManagers\ParentHubsRelationManager; use App\Models\GameSet; +use App\Models\User; use App\Platform\Enums\GameSetType; use App\Support\Rules\NoEmoji; use Filament\Forms; @@ -20,6 +21,7 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Auth; class HubResource extends Resource { @@ -57,6 +59,11 @@ public static function infolist(Infolist $infolist): Infolist Infolists\Components\TextEntry::make('title') ->label('Title'), + Infolists\Components\TextEntry::make('has_mature_content') + ->label('Has Mature Content') + ->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No') + ->color(fn (bool $state): string => $state ? 'danger' : ''), + BreadcrumbPreview::make('breadcrumbs') ->label('Breadcrumb Preview') ->columnSpanFull(), @@ -74,6 +81,9 @@ public static function infolist(Infolist $infolist): Infolist public static function form(Form $form): Form { + /** @var User $user */ + $user = Auth::user(); + return $form ->schema([ Forms\Components\Section::make('Primary Details') @@ -85,6 +95,12 @@ public static function form(Form $form): Form ->minLength(2) ->maxLength(80) ->rules([new NoEmoji()]), + + Forms\Components\Toggle::make('has_mature_content') + ->label('Has Mature Content') + ->helperText('CAUTION: If this is enabled, players will get a warning when opening any game in the hub!') + ->default(false) + ->visible(fn ($record) => $user->can('toggleHasMatureContent', $record)), ]), Forms\Components\Section::make('Internal Notes') diff --git a/app/Models/Game.php b/app/Models/Game.php index fba4f611cf..5daef1b14f 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -323,6 +323,11 @@ public function getCanonicalUrlAttribute(): string return route('game.show', [$this, $this->getSlugAttribute()]); } + public function getHasMatureContentAttribute(): bool + { + return $this->gameSets()->where('has_mature_content', true)->exists(); + } + public function getLastUpdatedAttribute(): Carbon { return $this->last_achievement_update ?? $this->Updated; diff --git a/app/Models/GameSet.php b/app/Models/GameSet.php index f1dfb7acb8..e82ced733e 100644 --- a/app/Models/GameSet.php +++ b/app/Models/GameSet.php @@ -36,6 +36,7 @@ class GameSet extends BaseModel 'game_id', 'internal_notes', 'image_asset_path', + 'has_mature_content', 'title', 'type', 'updated_at', @@ -43,6 +44,7 @@ class GameSet extends BaseModel ]; protected $casts = [ + 'has_mature_content' => 'boolean', 'type' => GameSetType::class, ]; @@ -148,9 +150,10 @@ public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logOnly([ - 'title', - 'internal_notes', + 'has_mature_content', 'image_asset_path', + 'internal_notes', + 'title', ]) ->logOnlyDirty() ->dontSubmitEmptyLogs(); diff --git a/app/Models/User.php b/app/Models/User.php index f441cf63e9..1882b768e6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -428,6 +428,11 @@ public function getPermissionsAttribute(): int return $this->attributes['Permissions']; } + public function getShouldAlwaysBypassContentWarningsAttribute(): bool + { + return BitSet($this->getAttribute('websitePrefs'), UserPreference::Site_SuppressMatureContentWarning); + } + public function getPrefersAbsoluteDatesAttribute(): bool { return BitSet($this->getAttribute('websitePrefs'), UserPreference::Forum_ShowAbsoluteDates); diff --git a/app/Platform/Actions/BuildGameSetRelatedHubsAction.php b/app/Platform/Actions/BuildGameSetRelatedHubsAction.php index d11c9f3167..1689129dff 100644 --- a/app/Platform/Actions/BuildGameSetRelatedHubsAction.php +++ b/app/Platform/Actions/BuildGameSetRelatedHubsAction.php @@ -25,6 +25,7 @@ public function execute(GameSet $gameSet): array 'title', 'image_asset_path', 'type', + 'has_mature_content', 'updated_at', ]) ->withCount([ diff --git a/app/Platform/Actions/BuildHubBreadcrumbsAction.php b/app/Platform/Actions/BuildHubBreadcrumbsAction.php index 9e077eda9e..888aaab05b 100644 --- a/app/Platform/Actions/BuildHubBreadcrumbsAction.php +++ b/app/Platform/Actions/BuildHubBreadcrumbsAction.php @@ -7,6 +7,7 @@ use App\Models\GameSet; use App\Platform\Data\GameSetData; use App\Platform\Enums\GameSetType; +use Carbon\Carbon; use Illuminate\Support\Facades\Cache; /** @@ -163,7 +164,8 @@ public function execute(GameSet $gameSet): array badgeUrl: media_asset($data['image_asset_path']), gameCount: 0, linkCount: 0, - updatedAt: new \Carbon\Carbon($data['updated_at']), + updatedAt: new Carbon($data['updated_at']), + hasMatureContent: false, // doesn't matter, this is just a breadcrumb ), $cachedData ?? [], ); diff --git a/app/Platform/Controllers/HubController.php b/app/Platform/Controllers/HubController.php index ad519dadf7..1ea7737d2e 100644 --- a/app/Platform/Controllers/HubController.php +++ b/app/Platform/Controllers/HubController.php @@ -96,7 +96,7 @@ public function show(GameListRequest $request, ?GameSet $gameSet): InertiaRespon $can = UserPermissionsData::fromUser($user)->include('develop', 'manageGameSets'); $props = new HubPagePropsData( - hub: GameSetData::from($gameSet)->include('title', 'badgeUrl', 'updatedAt'), + hub: GameSetData::from($gameSet)->include('title', 'badgeUrl', 'updatedAt', 'hasMatureContent'), relatedHubs: (new BuildGameSetRelatedHubsAction())->execute($gameSet), breadcrumbs: (new BuildHubBreadcrumbsAction())->execute($gameSet), paginatedGameListEntries: $paginatedData, diff --git a/app/Platform/Data/GameSetData.php b/app/Platform/Data/GameSetData.php index f091ac5c04..63ad777c7c 100644 --- a/app/Platform/Data/GameSetData.php +++ b/app/Platform/Data/GameSetData.php @@ -8,6 +8,7 @@ use App\Platform\Enums\GameSetType; use Carbon\Carbon; use Spatie\LaravelData\Data; +use Spatie\LaravelData\Lazy; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript('GameSet')] @@ -21,6 +22,7 @@ public function __construct( public int $gameCount, public int $linkCount, public Carbon $updatedAt, + public Lazy|bool $hasMatureContent, ) { } @@ -37,6 +39,7 @@ public static function fromGameSetWithCounts(GameSet $gameSet): self gameCount: $gameSet->games_count ?? 0, linkCount: $gameSet->link_count ?? 0, updatedAt: $gameSet->updated_at, + hasMatureContent: Lazy::create(fn () => $gameSet->has_mature_content), ); } } diff --git a/app/Policies/GameSetPolicy.php b/app/Policies/GameSetPolicy.php index 46e58c0e90..90f0eaf6b1 100644 --- a/app/Policies/GameSetPolicy.php +++ b/app/Policies/GameSetPolicy.php @@ -67,4 +67,12 @@ public function forceDelete(User $user, GameSet $gameSet): bool { return false; } + + public function toggleHasMatureContent(User $user, GameSet $gameSet): bool + { + return $user->hasAnyRole([ + Role::ADMINISTRATOR, + Role::DEVELOPER_STAFF, + ]); + } } diff --git a/database/migrations/2024_12_18_000000_update_game_sets_tables.php b/database/migrations/2024_12_18_000000_update_game_sets_tables.php new file mode 100644 index 0000000000..9dd971febc --- /dev/null +++ b/database/migrations/2024_12_18_000000_update_game_sets_tables.php @@ -0,0 +1,23 @@ +boolean('has_mature_content')->default(false)->after('definition'); + }); + } + + public function down(): void + { + Schema::table('game_sets', function (Blueprint $table) { + $table->dropColumn('has_mature_content'); + }); + } +}; diff --git a/lang/en_US.json b/lang/en_US.json index b39a056421..c44039c271 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -512,5 +512,10 @@ "Remember my view": "Remember my view", "Moderation Comments - {{user}}": "Moderation Comments - {{user}}", "Moderation Comments": "Moderation Comments", - "Columns": "Columns" + "Mature Content Warning": "Mature Content Warning", + "This page may contain content that is not appropriate for all ages.": "This page may contain content that is not appropriate for all ages.", + "Are you sure you want to view this page?": "Are you sure you want to view this page?", + "Yes, and don't ask again": "Yes, and don't ask again", + "Columns": "Columns", + "Yes, I'm an adult": "Yes, I'm an adult" } \ No newline at end of file diff --git a/package.json b/package.json index 9a46859570..1faf392625 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@floating-ui/dom": "^1.5.1", "@hookform/resolvers": "^3.9.0", "@inertiajs/core": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2406777828..9aa2ac48e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@inertiajs/core': specifier: ^1.2.0 version: 1.2.0 + '@radix-ui/react-alert-dialog': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1007,6 +1010,22 @@ packages: '@radix-ui/primitive@1.1.0': resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + + '@radix-ui/react-alert-dialog@1.1.4': + resolution: {integrity: sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.0': resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==} peerDependencies: @@ -1064,6 +1083,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.0.1': resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} peerDependencies: @@ -1117,6 +1145,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dialog@1.1.4': + resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.0': resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -1152,6 +1193,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.3': + resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dropdown-menu@2.1.2': resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} peerDependencies: @@ -1209,6 +1263,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.1': + resolution: {integrity: sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.0.1': resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} peerDependencies: @@ -1318,6 +1385,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.3': + resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.0.1': resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} peerDependencies: @@ -1344,6 +1424,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@1.0.3': resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} peerDependencies: @@ -1370,6 +1463,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.1': + resolution: {integrity: sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.0': resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==} peerDependencies: @@ -1453,6 +1559,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.1': + resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.1': resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} peerDependencies: @@ -3854,6 +3969,16 @@ packages: '@types/react': optional: true + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-remove-scroll@2.5.5: resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} engines: {node: '>=10'} @@ -3874,6 +3999,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.6.2: + resolution: {integrity: sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-smooth@4.0.1: resolution: {integrity: sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==} peerDependencies: @@ -3890,6 +4025,16 @@ packages: '@types/react': optional: true + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-textarea-autosize@8.5.4: resolution: {integrity: sha512-eSSjVtRLcLfFwFcariT77t9hcbVJHQV76b51QjQGarQIHml2+gM2lms0n3XrhnDmgK5B+/Z7TmQk5OHNzqYm/A==} engines: {node: '>=10'} @@ -4403,6 +4548,16 @@ packages: '@types/react': optional: true + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-composed-ref@1.3.0: resolution: {integrity: sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==} peerDependencies: @@ -5168,6 +5323,22 @@ snapshots: '@radix-ui/primitive@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} + + '@radix-ui/react-alert-dialog@1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5218,6 +5389,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-context@1.0.1(@types/react@18.3.12)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -5282,6 +5459,28 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-dialog@1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.2(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -5315,6 +5514,19 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-dismissable-layer@1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5366,6 +5578,17 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-focus-scope@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-id@1.0.1(@types/react@18.3.12)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -5499,6 +5722,16 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-portal@1.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -5520,6 +5753,16 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-presence@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -5539,6 +5782,15 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-primitive@2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-progress@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -5637,6 +5889,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-slot@1.1.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -8146,6 +8405,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + react-remove-scroll-bar@2.3.8(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@18.3.1) + tslib: 2.8.0 + optionalDependencies: + '@types/react': 18.3.12 + react-remove-scroll@2.5.5(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 @@ -8168,6 +8435,17 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + react-remove-scroll@2.6.2(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.12)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + tslib: 2.8.0 + use-callback-ref: 1.3.3(@types/react@18.3.12)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + react-smooth@4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: fast-equals: 5.0.1 @@ -8185,6 +8463,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + react-style-singleton@2.2.3(@types/react@18.3.12)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.0 + optionalDependencies: + '@types/react': 18.3.12 + react-textarea-autosize@8.5.4(@types/react@18.3.12)(react@18.3.1): dependencies: '@babel/runtime': 7.26.0 @@ -8784,6 +9070,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + use-callback-ref@1.3.3(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.0 + optionalDependencies: + '@types/react': 18.3.12 + use-composed-ref@1.3.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/resources/js/common/components/+vendor/BaseAlertDialog.tsx b/resources/js/common/components/+vendor/BaseAlertDialog.tsx new file mode 100644 index 0000000000..910f598e1e --- /dev/null +++ b/resources/js/common/components/+vendor/BaseAlertDialog.tsx @@ -0,0 +1,128 @@ +/* eslint-disable no-restricted-imports -- base components can import from @radix-ui */ + +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; + +import { cn } from '@/common/utils/cn'; + +import { baseButtonVariants } from './BaseButton'; + +const BaseAlertDialog = AlertDialogPrimitive.Root; +const BaseAlertDialogTrigger = AlertDialogPrimitive.Trigger; +const BaseAlertDialogPortal = AlertDialogPrimitive.Portal; + +const BaseAlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const BaseAlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + shouldBlurBackdrop?: boolean; + } +>(({ className, shouldBlurBackdrop = false, ...props }, ref) => ( + + + + + +)); +BaseAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const BaseAlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +BaseAlertDialogHeader.displayName = 'BaseAlertDialogHeader'; + +const BaseAlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +BaseAlertDialogFooter.displayName = 'BaseAlertDialogFooter'; + +const BaseAlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const BaseAlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseAlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const BaseAlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const BaseAlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +BaseAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + BaseAlertDialog, + BaseAlertDialogAction, + BaseAlertDialogCancel, + BaseAlertDialogContent, + BaseAlertDialogDescription, + BaseAlertDialogFooter, + BaseAlertDialogHeader, + BaseAlertDialogOverlay, + BaseAlertDialogPortal, + BaseAlertDialogTitle, + BaseAlertDialogTrigger, +}; diff --git a/resources/js/common/components/+vendor/BaseDialog.tsx b/resources/js/common/components/+vendor/BaseDialog.tsx index 3fae4e90c3..0869af714f 100644 --- a/resources/js/common/components/+vendor/BaseDialog.tsx +++ b/resources/js/common/components/+vendor/BaseDialog.tsx @@ -33,18 +33,19 @@ BaseDialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const BaseDialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => { + React.ComponentPropsWithoutRef & { shouldBlurBackdrop?: boolean } +>(({ className, children, shouldBlurBackdrop = false, ...props }, ref) => { const { t } = useTranslation(); return ( - + + {children} - - + + + + {t('Close')} diff --git a/resources/js/common/components/CommentList/CommentList.test.tsx b/resources/js/common/components/CommentList/CommentList.test.tsx index 08b3dc38b8..e4f3d77295 100644 --- a/resources/js/common/components/CommentList/CommentList.test.tsx +++ b/resources/js/common/components/CommentList/CommentList.test.tsx @@ -125,7 +125,11 @@ describe('Component: CommentList', () => { />, { pageProps: { - auth: { user: createAuthenticatedUser({ preferences: { prefersAbsoluteDates: true } }) }, + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: true, shouldAlwaysBypassContentWarnings: false }, + }), + }, }, }, ); diff --git a/resources/js/common/components/MatureContentWarningDialog/MatureContentWarningDialog.test.tsx b/resources/js/common/components/MatureContentWarningDialog/MatureContentWarningDialog.test.tsx new file mode 100644 index 0000000000..475948e322 --- /dev/null +++ b/resources/js/common/components/MatureContentWarningDialog/MatureContentWarningDialog.test.tsx @@ -0,0 +1,191 @@ +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; + +import { createAuthenticatedUser } from '@/common/models'; +import { render, screen, waitFor } from '@/test'; + +import { MatureContentWarningDialog } from './MatureContentWarningDialog'; + +describe('Component: ContentWarningDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: false, shouldAlwaysBypassContentWarnings: false }, + }), + }, + }, + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the user has not opted to bypass content warnings, shows the dialog', () => { + // ARRANGE + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { + prefersAbsoluteDates: false, + shouldAlwaysBypassContentWarnings: false, // !! + }, + }), + }, + }, + }); + + // ASSERT + expect(screen.getByRole('alertdialog')).toBeVisible(); + + expect(screen.getByText(/content warning/i)).toBeVisible(); + expect(screen.getByText(/are you sure you want to view this page/i)).toBeVisible(); + }); + + it('given the user has opted to bypass content warnings, does not show the dialog', () => { + // ARRANGE + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { + prefersAbsoluteDates: false, + shouldAlwaysBypassContentWarnings: true, // !! + }, + }), + }, + }, + }); + + // ASSERT + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('given the user clicks "Yes, I\'m an adult", closes the dialog without making any API calls', async () => { + // ARRANGE + const patchSpy = vi.spyOn(axios, 'patch'); + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: false, shouldAlwaysBypassContentWarnings: false }, + }), + }, + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /yes, i'm an adult/i })); + + // ASSERT + expect(patchSpy).not.toHaveBeenCalled(); + expect(screen.queryByText(/content warning/i)).not.toBeInTheDocument(); + }); + + it('given the user clicks "Yes, and don\'t ask again", makes an API call and closes the dialog', async () => { + // ARRANGE + const patchSpy = vi.spyOn(axios, 'patch').mockResolvedValueOnce({}); + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: false, shouldAlwaysBypassContentWarnings: false }, + }), + }, + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /yes, and don't ask again/i })); + + // ASSERT + await waitFor(() => { + expect(patchSpy).toHaveBeenCalledWith( + route('api.settings.preferences.suppress-mature-content-warning'), + ); + }); + + expect(screen.queryByText(/content warning/i)).not.toBeInTheDocument(); + }); + + it('given the user clicks "No", redirects them to the specified URL', async () => { + // ARRANGE + const mockLocationAssign = vi.fn(); + Object.defineProperty(window, 'location', { + value: { assign: mockLocationAssign }, + writable: true, + }); + + const customUrl = '/custom-url'; + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: false, shouldAlwaysBypassContentWarnings: false }, + }), + }, + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /no/i })); + + // ASSERT + expect(mockLocationAssign).toHaveBeenCalledWith(customUrl); + }); + + it('given the user clicks "No" without a specified URL, redirects them to the home page', async () => { + // ARRANGE + const mockLocationAssign = vi.fn(); + Object.defineProperty(window, 'location', { + value: { assign: mockLocationAssign }, + writable: true, + }); + + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: false, shouldAlwaysBypassContentWarnings: false }, + }), + }, + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /no/i })); + + // ASSERT + expect(mockLocationAssign).toHaveBeenCalledWith(route('home')); + }); + + it('given the user presses the escape key, still does not close the dialog', async () => { + // ARRANGE + render(, { + pageProps: { + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: false, shouldAlwaysBypassContentWarnings: false }, + }), + }, + }, + }); + + // ACT + await userEvent.keyboard('{Escape}'); + + // ASSERT + expect(screen.getByRole('alertdialog')).toBeVisible(); + expect(screen.getByText(/content warning/i)).toBeVisible(); + }); +}); diff --git a/resources/js/common/components/MatureContentWarningDialog/MatureContentWarningDialog.tsx b/resources/js/common/components/MatureContentWarningDialog/MatureContentWarningDialog.tsx new file mode 100644 index 0000000000..82f0efea12 --- /dev/null +++ b/resources/js/common/components/MatureContentWarningDialog/MatureContentWarningDialog.tsx @@ -0,0 +1,89 @@ +import { type FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import { + BaseAlertDialog, + BaseAlertDialogAction, + BaseAlertDialogContent, + BaseAlertDialogDescription, + BaseAlertDialogFooter, + BaseAlertDialogHeader, + BaseAlertDialogTitle, +} from '../+vendor/BaseAlertDialog'; +import { BaseButton } from '../+vendor/BaseButton'; +import { useSuppressMatureContentWarningMutation } from './useSuppressMatureContentWarningMutation'; + +interface MatureContentWarningDialogProps { + /** On clicking "No", the user will be redirected to this URL. Defaults to the home page. */ + noHref?: string; +} + +export const MatureContentWarningDialog: FC = ({ + noHref = route('home'), +}) => { + const { auth } = usePageProps(); + + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState( + auth?.user.preferences?.shouldAlwaysBypassContentWarnings ? false : true, + ); + + const mutation = useSuppressMatureContentWarningMutation(); + + const handlePermanentYesClick = () => { + mutation.mutate(); // Fire and forget. + setIsOpen(false); + }; + + const handleYesClick = () => { + setIsOpen(false); + }; + + const handleNoClick = () => { + window.location.assign(noHref); + }; + + return ( + <> + {/* This prevents a flash of the page content appearing before the dialog has rendered on the client. */} + {isOpen ? ( +
+ ) : null} + + + { + event.preventDefault(); // don't allow closing with the escape key + }} + > + + {t('Mature Content Warning')} + + + + {t('This page may contain content that is not appropriate for all ages.')} + + {t('Are you sure you want to view this page?')} + + + + + + {t("Yes, and don't ask again")} + + + + {t("Yes, I'm an adult")} + + + {t('No')} + + + + + ); +}; diff --git a/resources/js/common/components/MatureContentWarningDialog/index.ts b/resources/js/common/components/MatureContentWarningDialog/index.ts new file mode 100644 index 0000000000..5d7166d8ae --- /dev/null +++ b/resources/js/common/components/MatureContentWarningDialog/index.ts @@ -0,0 +1 @@ +export * from './MatureContentWarningDialog'; diff --git a/resources/js/common/components/MatureContentWarningDialog/useSuppressMatureContentWarningMutation.ts b/resources/js/common/components/MatureContentWarningDialog/useSuppressMatureContentWarningMutation.ts new file mode 100644 index 0000000000..8ab3dc5f98 --- /dev/null +++ b/resources/js/common/components/MatureContentWarningDialog/useSuppressMatureContentWarningMutation.ts @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; + +export function useSuppressMatureContentWarningMutation() { + return useMutation({ + mutationFn: () => + axios.patch(route('api.settings.preferences.suppress-mature-content-warning')), + }); +} diff --git a/resources/js/common/components/RecentPostsCards/RecentPostsCards.test.tsx b/resources/js/common/components/RecentPostsCards/RecentPostsCards.test.tsx index 95a22ce604..dc589f97e8 100644 --- a/resources/js/common/components/RecentPostsCards/RecentPostsCards.test.tsx +++ b/resources/js/common/components/RecentPostsCards/RecentPostsCards.test.tsx @@ -111,7 +111,11 @@ describe('Component: RecentPostsCards', () => { render(, { pageProps: { - auth: { user: createAuthenticatedUser({ preferences: { prefersAbsoluteDates: true } }) }, + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: true, shouldAlwaysBypassContentWarnings: false }, + }), + }, }, }); diff --git a/resources/js/common/components/RecentPostsTable/RecentPostsTable.test.tsx b/resources/js/common/components/RecentPostsTable/RecentPostsTable.test.tsx index e301443080..e0abd01214 100644 --- a/resources/js/common/components/RecentPostsTable/RecentPostsTable.test.tsx +++ b/resources/js/common/components/RecentPostsTable/RecentPostsTable.test.tsx @@ -137,7 +137,11 @@ describe('Component: RecentPostsTable', () => { render(, { pageProps: { - auth: { user: createAuthenticatedUser({ preferences: { prefersAbsoluteDates: true } }) }, + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: true, shouldAlwaysBypassContentWarnings: false }, + }), + }, }, }); diff --git a/resources/js/common/models/app-global-props.model.ts b/resources/js/common/models/app-global-props.model.ts index 5751b48022..9c5873d635 100644 --- a/resources/js/common/models/app-global-props.model.ts +++ b/resources/js/common/models/app-global-props.model.ts @@ -44,6 +44,7 @@ export const createAuthenticatedUser = createFactory((faker) pointsSoftcore: faker.number.int({ min: 0, max: 100000 }), preferences: { prefersAbsoluteDates: false, + shouldAlwaysBypassContentWarnings: false, }, roles: [], unreadMessageCount: 0, diff --git a/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.test.tsx b/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.test.tsx index 95144ad050..dc4c11b3e8 100644 --- a/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.test.tsx +++ b/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.test.tsx @@ -870,4 +870,38 @@ describe('Component: HubMainRoot', () => { expect(await screen.findByText(/sonic the hedgehog/i)).toBeVisible(); }); + + it('given the hub has a content warning, displays the content warning dialog', () => { + // ARRANGE + render(, { + pageProps: { + filterableSystemOptions: [], + breadcrumbs: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + hub: createGameSet({ hasMatureContent: true }), + ziggy: createZiggyProps({ device: 'desktop' }), + }, + }); + + // ASSERT + expect(screen.getByRole('alertdialog', { name: /content warning/i })).toBeVisible(); + }); + + it('given the hub does not have a content warning, does not display the content warning dialog', () => { + // ARRANGE + render(, { + pageProps: { + filterableSystemOptions: [], + breadcrumbs: [], + paginatedGameListEntries: createPaginatedData([]), + can: { develop: false }, + hub: createGameSet({ hasMatureContent: false }), + ziggy: createZiggyProps({ device: 'desktop' }), + }, + }); + + // ASSERT + expect(screen.queryByRole('alertdialog', { name: /content warning/i })).not.toBeInTheDocument(); + }); }); diff --git a/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.tsx b/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.tsx index 44f2dcb44d..acf42bcd60 100644 --- a/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.tsx +++ b/resources/js/features/game-list/components/HubMainRoot/HubMainRoot.tsx @@ -2,6 +2,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { useAtom } from 'jotai'; import type { FC } from 'react'; +import { MatureContentWarningDialog } from '@/common/components/MatureContentWarningDialog'; import { usePageProps } from '@/common/hooks/usePageProps'; import { useGameListState } from '../../hooks/useGameListState'; @@ -16,7 +17,7 @@ import { HubHeading } from './HubHeading'; import { RelatedHubs } from './RelatedHubs'; export const HubMainRoot: FC = () => { - const { auth, breadcrumbs, defaultDesktopPageSize, paginatedGameListEntries } = + const { auth, breadcrumbs, defaultDesktopPageSize, hub, paginatedGameListEntries } = usePageProps(); const { @@ -54,6 +55,8 @@ export const HubMainRoot: FC = () => { return (
+ {hub.hasMatureContent ? : null} + diff --git a/resources/js/features/home/components/+root/RecentForumPosts/RecentForumPosts.test.tsx b/resources/js/features/home/components/+root/RecentForumPosts/RecentForumPosts.test.tsx index 4b0a867aec..514e8d3be7 100644 --- a/resources/js/features/home/components/+root/RecentForumPosts/RecentForumPosts.test.tsx +++ b/resources/js/features/home/components/+root/RecentForumPosts/RecentForumPosts.test.tsx @@ -76,7 +76,11 @@ describe('Component: RecentForumPosts', () => { ], }), - auth: { user: createAuthenticatedUser({ preferences: { prefersAbsoluteDates: true } }) }, + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: true, shouldAlwaysBypassContentWarnings: false }, + }), + }, }, }); diff --git a/resources/js/features/home/components/+sidebar/AchievementOfTheWeek/AchievementOfTheWeek.test.tsx b/resources/js/features/home/components/+sidebar/AchievementOfTheWeek/AchievementOfTheWeek.test.tsx index a45c11c64d..eb34e254f0 100644 --- a/resources/js/features/home/components/+sidebar/AchievementOfTheWeek/AchievementOfTheWeek.test.tsx +++ b/resources/js/features/home/components/+sidebar/AchievementOfTheWeek/AchievementOfTheWeek.test.tsx @@ -183,7 +183,11 @@ describe('Component: AchievementOfTheWeek', () => { render(, { pageProps: { - auth: { user: createAuthenticatedUser({ preferences: { prefersAbsoluteDates: true } }) }, + auth: { + user: createAuthenticatedUser({ + preferences: { prefersAbsoluteDates: true, shouldAlwaysBypassContentWarnings: false }, + }), + }, ...createHomePageProps({ achievementOfTheWeek }), }, }); diff --git a/resources/js/test/factories/createGameSet.ts b/resources/js/test/factories/createGameSet.ts index 5e68900ae9..9735229e53 100644 --- a/resources/js/test/factories/createGameSet.ts +++ b/resources/js/test/factories/createGameSet.ts @@ -2,9 +2,10 @@ import { createFactory } from '../createFactory'; export const createGameSet = createFactory((faker) => { return { - id: faker.number.int({ min: 1, max: 99999 }), badgeUrl: faker.internet.url(), gameCount: faker.number.int({ min: 0, max: 200 }), + hasMatureContent: false, + id: faker.number.int({ min: 1, max: 99999 }), linkCount: faker.number.int({ min: 0, max: 200 }), title: faker.word.words(3), type: faker.helpers.arrayElement(['hub', 'similar-games']), diff --git a/resources/js/test/factories/createUser.ts b/resources/js/test/factories/createUser.ts index 6524211018..bea60cd3b7 100644 --- a/resources/js/test/factories/createUser.ts +++ b/resources/js/test/factories/createUser.ts @@ -13,6 +13,7 @@ export const createUser = createFactory((faker) => { legacyPermissions: faker.number.int({ min: 0, max: 4 }), preferences: { prefersAbsoluteDates: faker.datatype.boolean(), + shouldAlwaysBypassContentWarnings: faker.datatype.boolean(), }, playerPreferredMode: 'hardcore', roles: [], diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index e7206be28a..3d3c876234 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -211,7 +211,7 @@ declare namespace App.Data { userWallActive?: boolean | null; visibleRole?: string | null; websitePrefs?: number | null; - preferences?: { prefersAbsoluteDates: boolean }; + preferences?: { shouldAlwaysBypassContentWarnings: boolean; prefersAbsoluteDates: boolean }; roles?: App.Models.UserRole[]; }; export type UserPermissions = { @@ -375,6 +375,7 @@ declare namespace App.Platform.Data { gameCount: number; linkCount: number; updatedAt: string; + hasMatureContent?: boolean; }; export type GameTopAchiever = { rank: number; diff --git a/resources/js/ziggy.d.ts b/resources/js/ziggy.d.ts index e0052605d2..cc43cd4a88 100644 --- a/resources/js/ziggy.d.ts +++ b/resources/js/ziggy.d.ts @@ -565,6 +565,7 @@ declare module 'ziggy-js' { "binding": "id" } ], + "api.settings.preferences.suppress-mature-content-warning": [], "api.settings.profile.update": [], "api.settings.locale.update": [], "api.settings.preferences.update": [], diff --git a/resources/views/pages-legacy/gameInfo.blade.php b/resources/views/pages-legacy/gameInfo.blade.php index ccda6f3a9e..fc8378eeb9 100644 --- a/resources/views/pages-legacy/gameInfo.blade.php +++ b/resources/views/pages-legacy/gameInfo.blade.php @@ -136,17 +136,9 @@ $v = requestInputSanitized('v', 0, 'integer'); $gate = false; if ($v != 1) { - if ($isFullyFeaturedGame) { - foreach ($gameHubs as $hub) { - if ($hub['Title'] == '[Theme - Mature]') { - if ($userDetails && BitSet($userDetails['websitePrefs'], $matureContentPref)) { - break; - } - $gate = true; - } - } - } elseif (str_contains($gameTitle, '[Theme - Mature]')) { - $gate = !$userDetails || !BitSet($userDetails['websitePrefs'], $matureContentPref); + $canBypassGate = $userDetails && BitSet($userDetails['websitePrefs'], $matureContentPref); + if (!$canBypassGate) { + $gate = $gameModel->has_mature_content; } } ?>