diff --git a/app/Community/Actions/FetchDynamicShortcodeContentAction.php b/app/Community/Actions/FetchDynamicShortcodeContentAction.php index 16bedf7a2c..7e0094ed56 100644 --- a/app/Community/Actions/FetchDynamicShortcodeContentAction.php +++ b/app/Community/Actions/FetchDynamicShortcodeContentAction.php @@ -7,11 +7,14 @@ use App\Data\UserData; use App\Models\Achievement; use App\Models\Game; +use App\Models\GameSet; use App\Models\Ticket; use App\Models\User; use App\Platform\Data\AchievementData; use App\Platform\Data\GameData; +use App\Platform\Data\GameSetData; use App\Platform\Data\TicketData; +use App\Platform\Enums\GameSetType; use Illuminate\Support\Collection; class FetchDynamicShortcodeContentAction @@ -21,12 +24,14 @@ public function execute( array $ticketIds = [], array $achievementIds = [], array $gameIds = [], + array $hubIds = [], ): array { $results = collect([ 'users' => $this->fetchUsers($usernames), 'tickets' => $this->fetchTickets($ticketIds), 'achievements' => $this->fetchAchievements($achievementIds), 'games' => $this->fetchGames($gameIds), + 'hubs' => $this->fetchHubs($hubIds), ]); return $results->toArray(); @@ -97,4 +102,19 @@ private function fetchGames(array $gameIds): Collection ->get() ->map(fn (Game $game) => GameData::fromGame($game)->include('badgeUrl', 'system.name')); } + + /** + * @return Collection + */ + private function fetchHubs(array $hubIds): Collection + { + if (empty($hubIds)) { + return collect(); + } + + return GameSet::whereIn('id', $hubIds) + ->where('type', GameSetType::Hub) + ->get() + ->map(fn (GameSet $gameSet) => GameSetData::fromGameSetWithCounts($gameSet)->include('gameId')); + } } diff --git a/app/Community/AppServiceProvider.php b/app/Community/AppServiceProvider.php index 4129a94084..7a023e068b 100644 --- a/app/Community/AppServiceProvider.php +++ b/app/Community/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Community; +use App\Community\Commands\ConvertGameShortcodesToHubs; use App\Community\Commands\GenerateAnnualRecap; use App\Community\Commands\SyncComments; use App\Community\Commands\SyncForumCategories; @@ -47,6 +48,8 @@ public function boot(): void { if ($this->app->runningInConsole()) { $this->commands([ + ConvertGameShortcodesToHubs::class, + SyncComments::class, SyncForumCategories::class, SyncForums::class, diff --git a/app/Community/Commands/ConvertGameShortcodesToHubs.php b/app/Community/Commands/ConvertGameShortcodesToHubs.php new file mode 100644 index 0000000000..c85f6bd02d --- /dev/null +++ b/app/Community/Commands/ConvertGameShortcodesToHubs.php @@ -0,0 +1,259 @@ +option('undo')) { + $this->info("\nUndoing the migration of [hub=id] shortcodes back to [game=id]."); + + $this->undoForumMigration(); + $this->undoMessagesMigration(); + } else { + $this->info("\nStarting the migration of legacy hub [game=id] shortcodes to [hub=id]."); + + $this->migrateForumShortcodes(); + $this->migrateMessageShortcodes(); + } + } + + private function migrateForumShortcodes(): void + { + // Get the total count of comments that need to be processed. + $totalComments = ForumTopicComment::where('body', 'like', '%[game=%')->count(); + + $progressBar = $this->output->createProgressBar($totalComments); + ForumTopicComment::where('body', 'like', '%[game=%')->chunkById(1000, function ($forumTopicComments) use ($progressBar) { + // Collect all game IDs to fetch hub mappings in bulk. + $gameIds = []; + + /** @var ForumTopicComment $forumTopicComment */ + foreach ($forumTopicComments as $forumTopicComment) { + preg_match_all('/\[game=(\d+)\]/', $forumTopicComment->body, $matches); + $gameIds = array_merge($gameIds, $matches[1]); + } + + // Remove duplicates and fetch games that are legacy hubs. + $gameIds = array_unique($gameIds); + $legacyHubs = Game::whereIn('ID', $gameIds) + ->where('ConsoleID', 100) + ->get() + ->keyBy('ID'); + + // Fetch corresponding hub IDs for these legacy games. + $hubMappings = GameSet::where('type', GameSetType::Hub) + ->whereIn('game_id', $legacyHubs->pluck('ID')) + ->get() + ->keyBy('game_id'); + + // Process each comment. + /** @var ForumTopicComment $forumTopicComment */ + foreach ($forumTopicComments as $forumTopicComment) { + $originalPayload = $forumTopicComment->body; + $updatedPayload = preg_replace_callback('/\[game=(\d+)\]/', function ($matches) use ($legacyHubs, $hubMappings) { + $gameId = (int) $matches[1]; + + // Only replace if it's a legacy hub and we have a mapping. + if ($legacyHubs->has($gameId) && $hubMappings->has($gameId)) { + return "[hub={$hubMappings->get($gameId)->id}]"; + } + + return $matches[0]; + }, $forumTopicComment->body); + + // Update the comment in the DB only if it has actually changed. + // Use `DB` so we don't change the `updated_at` value. + if ($originalPayload !== $updatedPayload) { + DB::table('forum_topic_comments') + ->where('id', $forumTopicComment->id) + ->update([ + 'body' => $updatedPayload, + 'updated_at' => DB::raw('updated_at'), // don't change the value + ]); + } + } + + $progressBar->advance(count($forumTopicComments)); + }); + $progressBar->finish(); + + $this->info("\nForumTopicComments migration completed successfully."); + } + + private function migrateMessageShortcodes(): void + { + // Get the total count of messages that need to be processed. + $totalMessages = Message::where('body', 'like', '%[game=%')->count(); + + $progressBar = $this->output->createProgressBar($totalMessages); + Message::where('body', 'like', '%[game=%')->chunkById(1000, function ($messages) use ($progressBar) { + // Collect all game IDs to fetch hub mappings in bulk. + $gameIds = []; + + /** @var Message $message */ + foreach ($messages as $message) { + preg_match_all('/\[game=(\d+)\]/', $message->body, $matches); + $gameIds = array_merge($gameIds, $matches[1]); + } + + // Remove duplicates and fetch games that are legacy hubs. + $gameIds = array_unique($gameIds); + $legacyHubs = Game::whereIn('ID', $gameIds) + ->where('ConsoleID', 100) + ->get() + ->keyBy('ID'); + + // Fetch corresponding hub IDs for these legacy games. + $hubMappings = GameSet::where('type', GameSetType::Hub) + ->whereIn('game_id', $legacyHubs->pluck('ID')) + ->get() + ->keyBy('game_id'); + + // Process each message. + /** @var Message $message */ + foreach ($messages as $message) { + $originalBody = $message->body; + $updatedBody = preg_replace_callback('/\[game=(\d+)\]/', function ($matches) use ($legacyHubs, $hubMappings) { + $gameId = (int) $matches[1]; + + // Only replace if it's a legacy hub and we have a mapping. + if ($legacyHubs->has($gameId) && $hubMappings->has($gameId)) { + return "[hub={$hubMappings->get($gameId)->id}]"; + } + + return $matches[0]; + }, $message->body); + + // Update the message in the DB only if it has actually changed. + // Use `DB` so we don't change the `updated_at` value. + if ($originalBody !== $updatedBody) { + $message->body = $updatedBody; + $message->save(); + } + } + + $progressBar->advance(count($messages)); + }); + $progressBar->finish(); + + $this->info("\nMessages migration completed successfully."); + } + + private function undoForumMigration(): void + { + // Get the total count of comments that need to be processed. + $totalComments = ForumTopicComment::where('body', 'like', '%[hub=%')->count(); + + $progressBar = $this->output->createProgressBar($totalComments); + ForumTopicComment::where('body', 'like', '%[hub=%')->chunkById(1000, function ($forumTopicComments) use ($progressBar) { + // Collect all hub IDs to fetch game mappings in bulk. + $hubIds = []; + + /** @var ForumTopicComment $forumTopicComment */ + foreach ($forumTopicComments as $forumTopicComment) { + preg_match_all('/\[hub=(\d+)\]/', $forumTopicComment->body, $matches); + $hubIds = array_merge($hubIds, $matches[1]); + } + + // Remove duplicates and fetch hub mappings. + $hubIds = array_unique($hubIds); + $hubMappings = GameSet::whereIn('id', $hubIds) + ->where('type', GameSetType::Hub) + ->get() + ->keyBy('id'); + + // Process each comment. + /** @var ForumTopicComment $forumTopicComment */ + foreach ($forumTopicComments as $forumTopicComment) { + $originalPayload = $forumTopicComment->body; + $updatedPayload = preg_replace_callback('/\[hub=(\d+)\]/', function ($matches) use ($hubMappings) { + $hubId = (int) $matches[1]; + $hubMapping = $hubMappings->get($hubId); + + return $hubMapping ? "[game={$hubMapping->game_id}]" : $matches[0]; + }, $forumTopicComment->body); + + // Update the comment in the DB only if it has actually changed. + // Use `DB` so we don't change the `updated_at` value. + if ($originalPayload !== $updatedPayload) { + DB::table('forum_topic_comments') + ->where('id', $forumTopicComment->id) + ->update([ + 'body' => $updatedPayload, + 'updated_at' => DB::raw('updated_at'), // don't change the value + ]); + } + } + + $progressBar->advance(count($forumTopicComments)); + }); + $progressBar->finish(); + + $this->info("\nForumTopicComments undo completed successfully."); + } + + private function undoMessagesMigration(): void + { + // Get the total count of messages that need to be processed. + $totalMessages = Message::where('body', 'like', '%[hub=%')->count(); + + $progressBar = $this->output->createProgressBar($totalMessages); + Message::where('body', 'like', '%[hub=%')->chunkById(1000, function ($messages) use ($progressBar) { + // Collect all hub IDs to fetch game mappings in bulk. + $hubIds = []; + + /** @var Message $message */ + foreach ($messages as $message) { + preg_match_all('/\[hub=(\d+)\]/', $message->body, $matches); + $hubIds = array_merge($hubIds, $matches[1]); + } + + // Remove duplicates and fetch hub mappings. + $hubIds = array_unique($hubIds); + $hubMappings = GameSet::whereIn('id', $hubIds) + ->where('type', GameSetType::Hub) + ->get() + ->keyBy('id'); + + // Process each message. + /** @var Message $message */ + foreach ($messages as $message) { + $originalBody = $message->body; + $updatedBody = preg_replace_callback('/\[hub=(\d+)\]/', function ($matches) use ($hubMappings) { + $hubId = (int) $matches[1]; + $hubMapping = $hubMappings->get($hubId); + + return $hubMapping ? "[game={$hubMapping->game_id}]" : $matches[0]; + }, $message->body); + + // Update the message in the DB only if it has actually changed. + // Use `DB` so we don't change the `updated_at` value. + if ($originalBody !== $updatedBody) { + $message->body = $updatedBody; + $message->save(); + } + } + + $progressBar->advance(count($messages)); + }); + $progressBar->finish(); + + $this->info("\nMessages undo completed successfully."); + } +} diff --git a/app/Community/Controllers/Api/ForumTopicCommentApiController.php b/app/Community/Controllers/Api/ForumTopicCommentApiController.php index e25c7fcd32..c5ae893ec1 100644 --- a/app/Community/Controllers/Api/ForumTopicCommentApiController.php +++ b/app/Community/Controllers/Api/ForumTopicCommentApiController.php @@ -50,6 +50,7 @@ public function preview( ticketIds: $request->input('ticketIds'), achievementIds: $request->input('achievementIds'), gameIds: $request->input('gameIds'), + hubIds: $request->input('hubIds'), ); return response()->json($entities); diff --git a/app/Community/Requests/PreviewForumPostRequest.php b/app/Community/Requests/PreviewForumPostRequest.php index 5b56475114..4a22605aa7 100644 --- a/app/Community/Requests/PreviewForumPostRequest.php +++ b/app/Community/Requests/PreviewForumPostRequest.php @@ -19,6 +19,8 @@ public function rules(): array 'achievementIds.*' => 'integer', 'gameIds' => 'present|array', 'gameIds.*' => 'integer', + 'hubIds' => 'present|array', + 'hubIds.*' => 'integer', ]; } } diff --git a/app/Helpers/render/game.php b/app/Helpers/render/game.php index 240bcff101..aa775290bc 100644 --- a/app/Helpers/render/game.php +++ b/app/Helpers/render/game.php @@ -18,6 +18,7 @@ function gameAvatar( bool $tooltip = true, ?string $context = null, ?string $title = null, + ?string $href = null, ): string { $id = $game; @@ -52,7 +53,7 @@ function gameAvatar( resource: 'game', id: $id, label: $label !== false && ($label || !$icon) ? $label : null, - link: route('game.show', $id), + link: $href ?? route('game.show', $id), tooltip: $tooltip, iconUrl: $icon !== false && ($icon || !$label) ? $icon : null, iconSize: $iconSize, diff --git a/app/Helpers/shortcode.php b/app/Helpers/shortcode.php index e78d952a14..72dc590235 100644 --- a/app/Helpers/shortcode.php +++ b/app/Helpers/shortcode.php @@ -85,6 +85,7 @@ function normalize_shortcodes(string $input): string $modifiedInput = normalize_targeted_shortcodes($modifiedInput, 'user'); $modifiedInput = normalize_targeted_shortcodes($modifiedInput, 'game'); + $modifiedInput = normalize_targeted_shortcodes($modifiedInput, 'hub'); $modifiedInput = normalize_targeted_shortcodes($modifiedInput, 'achievement', 'ach'); $modifiedInput = normalize_targeted_shortcodes($modifiedInput, 'ticket'); diff --git a/app/Support/Shortcode/Shortcode.php b/app/Support/Shortcode/Shortcode.php index d5e7351729..5207e4383c 100644 --- a/app/Support/Shortcode/Shortcode.php +++ b/app/Support/Shortcode/Shortcode.php @@ -5,9 +5,11 @@ namespace App\Support\Shortcode; use App\Models\Achievement; +use App\Models\GameSet; use App\Models\System; use App\Models\Ticket; use App\Models\User; +use App\Platform\Enums\GameSetType; use Carbon\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -39,6 +41,7 @@ public function __construct() ->add('spoiler', fn (ShortcodeInterface $s) => $this->renderSpoiler($s)) ->add('ach', fn (ShortcodeInterface $s) => $this->embedAchievement((int) ($s->getBbCode() ?: $s->getContent()))) ->add('game', fn (ShortcodeInterface $s) => $this->embedGame((int) ($s->getBbCode() ?: $s->getContent()))) + ->add('hub', fn (ShortcodeInterface $s) => $this->embedHub((int) ($s->getBbCode() ?: $s->getContent()))) ->add('ticket', fn (ShortcodeInterface $s) => $this->embedTicket((int) ($s->getBbCode() ?: $s->getContent()))) ->add('user', fn (ShortcodeInterface $s) => $this->embedUser($s->getBbCode() ?: $s->getContent())); } @@ -389,6 +392,32 @@ private function embedGame(int $id): string return str_replace("\n", '', gameAvatar($data, iconSize: 24)); } + private function embedHub(int $id): string + { + $data = Cache::store('array')->rememberForever('hub: ' . $id . ':hub-data', function () use ($id) { + $hubGameSet = GameSet::where('type', GameSetType::Hub) + ->where('id', $id) + ->first(); + + if (!$hubGameSet) { + return []; + } + + return [ + ...getGameData($hubGameSet->game_id), + 'HubID' => $hubGameSet->id, + ]; + }); + + if (empty($data)) { + return ''; + } + + $hubHref = route('hub.show', ['gameSet' => $data['HubID']]); + + return str_replace("\n", '', gameAvatar($data, iconSize: 24, href: $hubHref)); + } + private function embedTicket(int $id): string { $ticket = Ticket::find($id); diff --git a/lang/en_US.json b/lang/en_US.json index 47e3d94589..1a7af86f1b 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -656,6 +656,7 @@ "Don't ask for links to copyrighted ROMs. Don't share links to copyrighted ROMs.": "Don't ask for links to copyrighted ROMs. Don't share links to copyrighted ROMs.", "Start new topic": "Start new topic", "enter your new topic's title...": "enter your new topic's title...", + "{{hubTitle}} (Hubs)": "{{hubTitle}} (Hubs)", "New username must be different from current username.": "New username must be different from current username.", "Your username change request is being reviewed.": "Your username change request is being reviewed.", "Your username cannot be changed right now.": "Your username cannot be changed right now.", diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/ShortcodeAch.test.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/ShortcodeAch.test.tsx index 154e64229a..194f33288e 100644 --- a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/ShortcodeAch.test.tsx +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/ShortcodeAch.test.tsx @@ -8,7 +8,10 @@ describe('Component: ShortcodeAch', () => { it('renders without crashing', () => { // ARRANGE const { container } = render(, { - jotaiAtoms: [[persistedAchievementsAtom, []]], + jotaiAtoms: [ + [persistedAchievementsAtom, []], + // + ], }); // ASSERT @@ -20,7 +23,10 @@ describe('Component: ShortcodeAch', () => { const achievement = createAchievement({ id: 1 }); render(, { - jotaiAtoms: [[persistedAchievementsAtom, [achievement]]], + jotaiAtoms: [ + [persistedAchievementsAtom, [achievement]], + // + ], }); // ASSERT @@ -32,7 +38,10 @@ describe('Component: ShortcodeAch', () => { const achievement = createAchievement({ id: 1, title: 'Test Achievement', points: 5 }); render(, { - jotaiAtoms: [[persistedAchievementsAtom, [achievement]]], + jotaiAtoms: [ + [persistedAchievementsAtom, [achievement]], + // + ], }); // ASSERT diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/ShortcodeGame.test.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/ShortcodeGame.test.tsx index 3f0e032ba4..dce42d4ab2 100644 --- a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/ShortcodeGame.test.tsx +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/ShortcodeGame.test.tsx @@ -8,7 +8,10 @@ describe('Component: ShortcodeGame', () => { it('renders without crashing', () => { // ARRANGE const { container } = render(, { - jotaiAtoms: [[persistedGamesAtom, []]], + jotaiAtoms: [ + [persistedGamesAtom, []], + // + ], }); // ASSERT @@ -18,7 +21,10 @@ describe('Component: ShortcodeGame', () => { it('given the game ID is not found in persisted games, renders nothing', () => { // ARRANGE render(, { - jotaiAtoms: [[persistedGamesAtom, [createGame({ id: 1 })]]], + jotaiAtoms: [ + [persistedGamesAtom, [createGame({ id: 1 })]], + // + ], }); // ASSERT @@ -31,10 +37,15 @@ describe('Component: ShortcodeGame', () => { const game = createGame({ system, id: 1, title: 'Sonic the Hedgehog' }); render(, { - jotaiAtoms: [[persistedGamesAtom, [game]]], + jotaiAtoms: [ + [persistedGamesAtom, [game]], + // + ], }); // ASSERT + expect(screen.getByTestId('game-embed')).toBeVisible(); + expect(screen.getByRole('img', { name: /sonic the hedgehog/i })).toBeVisible(); expect(screen.getByText(/sega genesis/i)).toBeVisible(); expect(screen.getByRole('link')).toBeVisible(); diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/ShortcodeHub.test.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/ShortcodeHub.test.tsx new file mode 100644 index 0000000000..0501526d94 --- /dev/null +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/ShortcodeHub.test.tsx @@ -0,0 +1,52 @@ +import { persistedHubsAtom } from '@/features/forums/state/forum.atoms'; +import { render, screen } from '@/test'; +import { createGameSet } from '@/test/factories'; + +import { ShortcodeHub } from './ShortcodeHub'; + +describe('Component: ShortcodeHub', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render(, { + jotaiAtoms: [ + [persistedHubsAtom, []], + // + ], + }); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the hub ID is not found in persisted hubs, renders nothing', () => { + // ARRANGE + render(, { + jotaiAtoms: [ + [persistedHubsAtom, [createGameSet({ id: 1 })]], + // + ], + }); + + // ASSERT + expect(screen.queryByTestId('hub-embed')).not.toBeInTheDocument(); + }); + + it('given the hub ID is found in persisted hubs, renders the hub avatar', () => { + // ARRANGE + const hub = createGameSet({ id: 1, title: '[Central]' }); + + render(, { + jotaiAtoms: [ + [persistedHubsAtom, [hub]], + // + ], + }); + + // ASSERT + expect(screen.getByTestId('hub-embed')).toBeVisible(); + + expect(screen.getByRole('img', { name: /central/i })).toBeVisible(); + expect(screen.getByText(/central/i)).toBeVisible(); + expect(screen.getByRole('link')).toBeVisible(); + }); +}); diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/ShortcodeHub.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/ShortcodeHub.tsx new file mode 100644 index 0000000000..bac9eaf3d3 --- /dev/null +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/ShortcodeHub.tsx @@ -0,0 +1,34 @@ +import { useAtom } from 'jotai'; +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { GameAvatar } from '@/common/components/GameAvatar'; +import { persistedHubsAtom } from '@/features/forums/state/forum.atoms'; + +interface ShortcodeHubProps { + hubId: number; +} + +export const ShortcodeHub: FC = ({ hubId }) => { + const { t } = useTranslation(); + + const [persistedHubs] = useAtom(persistedHubsAtom); + + const foundHub = persistedHubs?.find((hub) => hub.id === hubId); + + if (!foundHub) { + return null; + } + + return ( + + + + ); +}; diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/index.ts b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/index.ts new file mode 100644 index 0000000000..076aae249f --- /dev/null +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeHub/index.ts @@ -0,0 +1 @@ +export * from './ShortcodeHub'; diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.test.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.test.tsx index b4ebba8e04..41794cdf71 100644 --- a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.test.tsx +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.test.tsx @@ -1,6 +1,7 @@ import { persistedAchievementsAtom, persistedGamesAtom, + persistedHubsAtom, persistedTicketsAtom, persistedUsersAtom, } from '@/features/forums/state/forum.atoms'; @@ -8,6 +9,7 @@ import { render, screen } from '@/test'; import { createAchievement, createGame, + createGameSet, createSystem, createTicket, createUser, @@ -99,7 +101,10 @@ describe('Component: ShortcodeRenderer', () => { const body = '[user=Scott]'; render(, { - jotaiAtoms: [[persistedUsersAtom, []]], + jotaiAtoms: [ + [persistedUsersAtom, []], + // + ], }); // ASSERT @@ -111,7 +116,10 @@ describe('Component: ShortcodeRenderer', () => { const body = '[user=Scott]'; render(, { - jotaiAtoms: [[persistedUsersAtom, [createUser({ displayName: 'Scott' })]]], + jotaiAtoms: [ + [persistedUsersAtom, [createUser({ displayName: 'Scott' })]], + // + ], }); // ASSERT @@ -126,7 +134,10 @@ describe('Component: ShortcodeRenderer', () => { const body = '[game=1]'; render(, { - jotaiAtoms: [[persistedGamesAtom, [game]]], + jotaiAtoms: [ + [persistedGamesAtom, [game]], + // + ], }); // ASSERT @@ -135,6 +146,25 @@ describe('Component: ShortcodeRenderer', () => { expect(screen.getByText('Sonic the Hedgehog (Sega Genesis)')).toBeVisible(); }); + it('given a body with a hub tag and a found persisted hub, renders the hub shortcode component', () => { + // ARRANGE + const hub = createGameSet({ id: 1, title: '[Central]' }); + + const body = '[hub=1]'; + + render(, { + jotaiAtoms: [ + [persistedHubsAtom, [hub]], + // + ], + }); + + // ASSERT + expect(screen.getByRole('link')).toBeVisible(); + expect(screen.getByRole('img')).toBeVisible(); + expect(screen.getByText('[Central] (Hubs)')).toBeVisible(); + }); + it('given a body with an achievement tag and a found persisted achievement, renders the ach shortcode component', () => { // ARRANGE const achievement = createAchievement({ title: 'That Was Easy!', points: 5, id: 9 }); @@ -142,7 +172,10 @@ describe('Component: ShortcodeRenderer', () => { const body = '[ach=9]'; render(, { - jotaiAtoms: [[persistedAchievementsAtom, [achievement]]], + jotaiAtoms: [ + [persistedAchievementsAtom, [achievement]], + // + ], }); // ASSERT @@ -161,7 +194,10 @@ describe('Component: ShortcodeRenderer', () => { const body = '[ticket=12345]'; render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.tsx index 588422d20a..6c95181d1b 100644 --- a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.tsx +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.tsx @@ -8,6 +8,7 @@ import { postProcessShortcodesInBody } from '../../utils/postProcessShortcodesIn import { ShortcodeAch } from './ShortcodeAch'; import { ShortcodeCode } from './ShortcodeCode'; import { ShortcodeGame } from './ShortcodeGame'; +import { ShortcodeHub } from './ShortcodeHub'; import { ShortcodeImg } from './ShortcodeImg'; import { ShortcodeQuote } from './ShortcodeQuote'; import { ShortcodeSpoiler } from './ShortcodeSpoiler'; @@ -75,6 +76,13 @@ const retroachievementsPreset = presetReact.extend((tags) => ({ }, }), + hub: (node) => ({ + tag: ShortcodeHub, + attrs: { + hubId: Number((node.content as string[]).join()), + }, + }), + ach: (node) => ({ tag: ShortcodeAch, attrs: { @@ -124,6 +132,7 @@ export const ShortcodeRenderer: FC = ({ body }) => { 'user', 'ach', 'game', + 'hub', 'ticket', 'video', ], diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx index 20a2d27213..a660e98490 100644 --- a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx @@ -16,7 +16,10 @@ describe('Component: ShortcodeTicket', () => { }); const { container } = render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -34,7 +37,10 @@ describe('Component: ShortcodeTicket', () => { // !! using id 999 render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -51,7 +57,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -74,7 +83,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -92,7 +104,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -109,7 +124,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -126,7 +144,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -143,7 +164,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -160,7 +184,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT @@ -179,7 +206,10 @@ describe('Component: ShortcodeTicket', () => { }); render(, { - jotaiAtoms: [[persistedTicketsAtom, [ticket]]], + jotaiAtoms: [ + [persistedTicketsAtom, [ticket]], + // + ], }); // ASSERT diff --git a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/ShortcodeUser.test.tsx b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/ShortcodeUser.test.tsx index d04175a8ae..725d67cb55 100644 --- a/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/ShortcodeUser.test.tsx +++ b/resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/ShortcodeUser.test.tsx @@ -20,7 +20,10 @@ describe('Component: ShortcodeUser', () => { const testUser = createUser({ displayName: 'test-user' }); render(, { - jotaiAtoms: [[persistedUsersAtom, [testUser]]], + jotaiAtoms: [ + [persistedUsersAtom, [testUser]], + // + ], }); // ASSERT @@ -32,7 +35,10 @@ describe('Component: ShortcodeUser', () => { const testUser = createUser({ displayName: 'test-user' }); render(, { - jotaiAtoms: [[persistedUsersAtom, [testUser]]], + jotaiAtoms: [ + [persistedUsersAtom, [testUser]], + // + ], }); // ASSERT diff --git a/resources/js/features/forums/hooks/useForumPostPreview.test.ts b/resources/js/features/forums/hooks/useForumPostPreview.test.ts index 5eba4a9e0e..952fa353b5 100644 --- a/resources/js/features/forums/hooks/useForumPostPreview.test.ts +++ b/resources/js/features/forums/hooks/useForumPostPreview.test.ts @@ -1,7 +1,13 @@ import axios from 'axios'; import { act, renderHook, waitFor } from '@/test'; -import { createAchievement, createGame, createTicket, createUser } from '@/test/factories'; +import { + createAchievement, + createGame, + createGameSet, + createTicket, + createUser, +} from '@/test/factories'; import { persistedGamesAtom } from '../state/forum.atoms'; import { useForumPostPreview } from './useForumPostPreview'; @@ -43,6 +49,7 @@ describe('Hook: useForumPostPreview', () => { data: { achievements: [], games: [createGame({ id: 123, title: 'Test Game' })], + hubs: [], tickets: [], users: [createUser({ displayName: 'username' })], }, @@ -70,6 +77,7 @@ describe('Hook: useForumPostPreview', () => { data: { achievements: [createAchievement({ id: 9 })], games: [createGame({ id: 123, title: 'Test Game' })], + hubs: [createGameSet({ id: 1, title: '[Central]' })], tickets: [createTicket({ id: 12345 })], users: [createUser({ displayName: 'username' })], }, @@ -79,7 +87,10 @@ describe('Hook: useForumPostPreview', () => { const initialGames = [{ id: 456, name: 'Existing Game' }]; const { result } = renderHook(() => useForumPostPreview(), { - jotaiAtoms: [[persistedGamesAtom, initialGames]], + jotaiAtoms: [ + [persistedGamesAtom, initialGames], + // + ], }); // ACT @@ -92,8 +103,13 @@ describe('Hook: useForumPostPreview', () => { expect(result.current.previewContent).toEqual(contentWithEntities); }); - const { persistedGames, persistedAchievements, persistedTickets, persistedUsers } = - result.current.unsafe_getPersistedValues(); + const { + persistedGames, + persistedHubs, + persistedAchievements, + persistedTickets, + persistedUsers, + } = result.current.unsafe_getPersistedValues(); expect(persistedGames).toHaveLength(2); expect(persistedGames).toEqual( @@ -103,6 +119,7 @@ describe('Hook: useForumPostPreview', () => { ]), ); + expect(persistedHubs).toHaveLength(1); expect(persistedAchievements).toHaveLength(1); expect(persistedTickets).toHaveLength(1); expect(persistedUsers).toHaveLength(1); diff --git a/resources/js/features/forums/hooks/useForumPostPreview.ts b/resources/js/features/forums/hooks/useForumPostPreview.ts index dfc24ae441..970bdeee9e 100644 --- a/resources/js/features/forums/hooks/useForumPostPreview.ts +++ b/resources/js/features/forums/hooks/useForumPostPreview.ts @@ -4,6 +4,7 @@ import { useState } from 'react'; import { persistedAchievementsAtom, persistedGamesAtom, + persistedHubsAtom, persistedTicketsAtom, persistedUsersAtom, } from '../state/forum.atoms'; @@ -17,6 +18,7 @@ export function useForumPostPreview() { const [persistedAchievements, setPersistedAchievements] = useAtom(persistedAchievementsAtom); const [persistedGames, setPersistedGames] = useAtom(persistedGamesAtom); + const [persistedHubs, setPersistedHubs] = useAtom(persistedHubsAtom); const [persistedTickets, setPersistedTickets] = useAtom(persistedTicketsAtom); const [persistedUsers, setPersistedUsers] = useAtom(persistedUsersAtom); @@ -27,6 +29,7 @@ export function useForumPostPreview() { mergeEntities(prev, responseData.achievements, (item) => item.id), ); setPersistedGames((prev) => mergeEntities(prev, responseData.games, (item) => item.id)); + setPersistedHubs((prev) => mergeEntities(prev, responseData.hubs, (item) => item.id)); setPersistedTickets((prev) => mergeEntities(prev, responseData.tickets, (item) => item.id)); setPersistedUsers((prev) => mergeEntities(prev, responseData.users, (item) => item.displayName), @@ -60,6 +63,7 @@ export function useForumPostPreview() { return { persistedAchievements, persistedGames, + persistedHubs, persistedTickets, persistedUsers, }; diff --git a/resources/js/features/forums/hooks/useForumPostPreviewMutation.ts b/resources/js/features/forums/hooks/useForumPostPreviewMutation.ts index 6762556e61..c4823cd604 100644 --- a/resources/js/features/forums/hooks/useForumPostPreviewMutation.ts +++ b/resources/js/features/forums/hooks/useForumPostPreviewMutation.ts @@ -6,6 +6,7 @@ import type { DynamicShortcodeEntities } from '../models'; export interface ForumPostPreviewMutationResponse { achievements: App.Platform.Data.Achievement[]; games: App.Platform.Data.Game[]; + hubs: App.Platform.Data.GameSet[]; tickets: App.Platform.Data.Ticket[]; users: App.Data.User[]; } diff --git a/resources/js/features/forums/hooks/useShortcodesList.test.ts b/resources/js/features/forums/hooks/useShortcodesList.test.ts index d2c156e1e6..08a5e894ec 100644 --- a/resources/js/features/forums/hooks/useShortcodesList.test.ts +++ b/resources/js/features/forums/hooks/useShortcodesList.test.ts @@ -49,6 +49,7 @@ describe('Hook: useShortcodesList', () => { '[url=', '[ach=', '[game=', + '[hub=', '[user=', '[ticket=', ]), diff --git a/resources/js/features/forums/hooks/useShortcodesList.ts b/resources/js/features/forums/hooks/useShortcodesList.ts index 5fbf7fd95c..460dd40258 100644 --- a/resources/js/features/forums/hooks/useShortcodesList.ts +++ b/resources/js/features/forums/hooks/useShortcodesList.ts @@ -8,6 +8,7 @@ import { LuEyeOff, LuItalic, LuLink, + LuNetwork, LuQuote, LuStrikethrough, LuUnderline, @@ -30,6 +31,7 @@ export function useShortcodesList() { { icon: LuLink, t_label: t('Link'), start: '[url=', end: ']' }, { icon: ImTrophy, t_label: t('Achievement'), start: '[ach=', end: ']' }, { icon: FaGamepad, t_label: t('Game'), start: '[game=', end: ']' }, + { icon: LuNetwork, t_label: t('Hub'), start: '[hub=', end: ']' }, { icon: FaUser, t_label: t('User'), start: '[user=', end: ']' }, { icon: FaTicketAlt, t_label: t('Ticket'), start: '[ticket=', end: ']' }, ]; diff --git a/resources/js/features/forums/models/dynamic-shortcode-entities.model.ts b/resources/js/features/forums/models/dynamic-shortcode-entities.model.ts index 2569516ef0..22e780e9a9 100644 --- a/resources/js/features/forums/models/dynamic-shortcode-entities.model.ts +++ b/resources/js/features/forums/models/dynamic-shortcode-entities.model.ts @@ -1,6 +1,7 @@ export interface DynamicShortcodeEntities { - usernames: string[]; - ticketIds: number[]; - gameIds: number[]; achievementIds: number[]; + gameIds: number[]; + hubIds: number[]; + ticketIds: number[]; + usernames: string[]; } diff --git a/resources/js/features/forums/state/forum.atoms.ts b/resources/js/features/forums/state/forum.atoms.ts index 189f6bd2d3..b16390336b 100644 --- a/resources/js/features/forums/state/forum.atoms.ts +++ b/resources/js/features/forums/state/forum.atoms.ts @@ -4,6 +4,8 @@ export const persistedUsersAtom = atom(); export const persistedGamesAtom = atom(); +export const persistedHubsAtom = atom(); + export const persistedAchievementsAtom = atom(); export const persistedTicketsAtom = atom(); diff --git a/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.test.ts b/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.test.ts index 066faec155..c1550f5f0f 100644 --- a/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.test.ts +++ b/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.test.ts @@ -50,6 +50,17 @@ describe('Util: extractDynamicEntitiesFromBody', () => { expect(result.gameIds).toEqual([1, 14402]); }); + it('given the input contains hub shortcodes, extracts and dedupes all hub IDs', () => { + // ARRANGE + const input = 'I like to visit [hub=1] and [hub=2] and [hub=1].'; + + // ACT + const result = extractDynamicEntitiesFromBody(input); + + // ASSERT + expect(result.hubIds).toEqual([1, 2]); + }); + it('given the input contains invalid numeric IDs, ignores them', () => { // ARRANGE const input = '[ticket=abc] [ach=def] [game=xyz]'; diff --git a/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.ts b/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.ts index 14c7455041..a8c65d3da0 100644 --- a/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.ts +++ b/resources/js/features/forums/utils/extractDynamicEntitiesFromBody.ts @@ -1,31 +1,22 @@ import type { DynamicShortcodeEntities } from '../models'; const shortcodePatterns = { - user: /\[user=([^\]]+)\]/g, - game: /\[game=([^\]]+)\]/g, achievement: /\[ach=([^\]]+)\]/g, + game: /\[game=([^\]]+)\]/g, + hub: /\[hub=([^\]]+)\]/g, ticket: /\[ticket=([^\]]+)\]/g, + user: /\[user=([^\]]+)\]/g, }; export function extractDynamicEntitiesFromBody(input: string): DynamicShortcodeEntities { const entities: DynamicShortcodeEntities = { - usernames: [], - ticketIds: [], achievementIds: [], gameIds: [], + hubIds: [], + ticketIds: [], + usernames: [], }; - // Extract usernames. - for (const match of input.matchAll(shortcodePatterns.user)) { - entities.usernames.push(match[1]); - } - - // Extract ticket IDs. - for (const match of input.matchAll(shortcodePatterns.ticket)) { - const id = parseInt(match[1], 10); - if (!isNaN(id)) entities.ticketIds.push(id); - } - // Extract achievement IDs. for (const match of input.matchAll(shortcodePatterns.achievement)) { const id = parseInt(match[1], 10); @@ -38,10 +29,28 @@ export function extractDynamicEntitiesFromBody(input: string): DynamicShortcodeE if (!isNaN(id)) entities.gameIds.push(id); } + // Extract hub IDs. + for (const match of input.matchAll(shortcodePatterns.hub)) { + const id = parseInt(match[1], 10); + if (!isNaN(id)) entities.hubIds.push(id); + } + + // Extract ticket IDs. + for (const match of input.matchAll(shortcodePatterns.ticket)) { + const id = parseInt(match[1], 10); + if (!isNaN(id)) entities.ticketIds.push(id); + } + + // Extract usernames. + for (const match of input.matchAll(shortcodePatterns.user)) { + entities.usernames.push(match[1]); + } + return { - usernames: [...new Set(entities.usernames)], - ticketIds: [...new Set(entities.ticketIds)], achievementIds: [...new Set(entities.achievementIds)], gameIds: [...new Set(entities.gameIds)], + hubIds: [...new Set(entities.hubIds)], + ticketIds: [...new Set(entities.ticketIds)], + usernames: [...new Set(entities.usernames)], }; } diff --git a/resources/js/features/forums/utils/preProcessShortcodesInBody.ts b/resources/js/features/forums/utils/preProcessShortcodesInBody.ts index fa79ebc7af..0438f612c2 100644 --- a/resources/js/features/forums/utils/preProcessShortcodesInBody.ts +++ b/resources/js/features/forums/utils/preProcessShortcodesInBody.ts @@ -1,8 +1,9 @@ const shortcodeTypes = [ - { type: 'user', shortcode: 'user' }, - { type: 'game', shortcode: 'game' }, { type: 'achievement', shortcode: 'ach' }, + { type: 'game', shortcode: 'game' }, + { type: 'hub', shortcode: 'hub' }, { type: 'ticket', shortcode: 'ticket' }, + { type: 'user', shortcode: 'user' }, ] as const; const createPatterns = (type: string) => [ diff --git a/resources/js/features/game-list/components/DataTableToolbar/DataTableToolbar.test.tsx b/resources/js/features/game-list/components/DataTableToolbar/DataTableToolbar.test.tsx index 14f7686ed7..7dcbe1c788 100644 --- a/resources/js/features/game-list/components/DataTableToolbar/DataTableToolbar.test.tsx +++ b/resources/js/features/game-list/components/DataTableToolbar/DataTableToolbar.test.tsx @@ -314,7 +314,10 @@ describe('Component: DataTableToolbar', () => { ziggy: createZiggyProps({ device: 'desktop' }), filterableSystemOptions: [createSystem({ name: 'Nintendo 64', nameShort: 'N64' })], }, - jotaiAtoms: [[isCurrentlyPersistingViewAtom, false]], // !! + jotaiAtoms: [ + [isCurrentlyPersistingViewAtom, false], // !! + // + ], }); // ASSERT @@ -331,7 +334,10 @@ describe('Component: DataTableToolbar', () => { ziggy: createZiggyProps({ device: 'desktop' }), filterableSystemOptions: [createSystem({ name: 'Nintendo 64', nameShort: 'N64' })], }, - jotaiAtoms: [[isCurrentlyPersistingViewAtom, true]], // !! + jotaiAtoms: [ + [isCurrentlyPersistingViewAtom, true], // !! + // + ], }); // ASSERT @@ -348,7 +354,10 @@ describe('Component: DataTableToolbar', () => { ziggy: createZiggyProps({ device: 'desktop' }), filterableSystemOptions: [createSystem({ name: 'Nintendo 64', nameShort: 'N64' })], }, - jotaiAtoms: [[isCurrentlyPersistingViewAtom, false]], // !! + jotaiAtoms: [ + [isCurrentlyPersistingViewAtom, false], // !! + // + ], }); // ACT diff --git a/resources/views/components/base/form/textarea-rich-text-controls.blade.php b/resources/views/components/base/form/textarea-rich-text-controls.blade.php index 82302bb31a..ed10ffad41 100644 --- a/resources/views/components/base/form/textarea-rich-text-controls.blade.php +++ b/resources/views/components/base/form/textarea-rich-text-controls.blade.php @@ -54,6 +54,9 @@ {{-- x-tooltip="{{ __res('game', 1) }}" --}} title="{{ __res('game', 1) }}"> +