From 873a877f2d13b290f8fc1488b82672d89a5a5448 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 5 Jan 2025 17:11:29 -0500 Subject: [PATCH 1/5] feat: migrate forum post Edit page to React --- .../FetchDynamicShortcodeContentAction.php | 100 +++++++++ .../Api/ForumTopicCommentApiController.php | 57 ++++++ .../ForumTopicCommentController.php | 22 +- app/Community/Enums/TicketState.php | 3 + .../Requests/PreviewForumPostRequest.php | 24 +++ .../UpdateForumTopicCommentRequest.php | 23 +++ app/Community/RouteServiceProvider.php | 5 + .../EditForumTopicCommentPagePropsData.php | 17 ++ app/Data/ForumCategoryData.php | 32 +++ app/Data/ForumData.php | 34 ++++ app/Data/ForumTopicCommentData.php | 20 +- app/Data/ForumTopicData.php | 34 +++- app/Models/Ticket.php | 1 + app/Platform/Data/TicketData.php | 33 +++ app/Platform/Enums/TicketableType.php | 15 ++ app/Policies/ForumTopicCommentPolicy.php | 5 +- lang/en_US.json | 15 +- package.json | 4 + pnpm-lock.yaml | 120 +++++++++++ .../+vendor/BaseAutosizeTextarea.tsx | 113 +++++++++++ .../components/+vendor/BaseCollapsible.tsx | 11 + .../AchievementAvatar.test.tsx | 99 +++++++-- .../AchievementAvatar/AchievementAvatar.tsx | 95 ++++++--- .../ForumBreadcrumbs.test.tsx | 37 ++++ .../ForumBreadcrumbs/ForumBreadcrumbs.tsx | 45 ++++- .../components/GameAvatar/GameAvatar.test.tsx | 36 +++- .../components/GameAvatar/GameAvatar.tsx | 23 ++- .../RecentPostsCards.test.tsx | 2 +- .../RecentPostsTable.test.tsx | 2 +- .../utils/+vendor/bbobLineBreakPlugin.ts | 130 ++++++++++++ .../js/common/utils/generatedAppConstants.ts | 9 + .../EditPostForm/EditPostForm.test.tsx | 162 +++++++++++++++ .../EditPostForm/EditPostForm.tsx | 80 ++++++++ .../EditPostMainRoot/EditPostForm/index.ts | 1 + .../EditPostForm/useEditPostForm.ts | 54 +++++ .../EditPostMainRoot.test.tsx | 89 ++++++++ .../EditPostMainRoot/EditPostMainRoot.tsx | 36 ++++ .../components/EditPostMainRoot/index.ts | 1 + .../ForumPostCard/ForumPostCard.test.tsx | 26 +++ .../ForumPostCard/ForumPostCard.tsx | 34 ++++ .../forums/components/ForumPostCard/index.ts | 1 + .../ShortcodePanel/ShortcodePanel.test.tsx | 124 ++++++++++++ .../ShortcodePanel/ShortcodePanel.tsx | 41 ++++ .../forums/components/ShortcodePanel/index.ts | 1 + .../ShortcodeAch/ShortcodeAch.test.tsx | 43 ++++ .../ShortcodeAch/ShortcodeAch.tsx | 33 +++ .../ShortcodeRenderer/ShortcodeAch/index.ts | 1 + .../ShortcodeGame/ShortcodeGame.test.tsx | 42 ++++ .../ShortcodeGame/ShortcodeGame.tsx | 25 +++ .../ShortcodeRenderer/ShortcodeGame/index.ts | 1 + .../ShortcodeImg/ShortcodeImg.test.tsx | 32 +++ .../ShortcodeImg/ShortcodeImg.tsx | 9 + .../ShortcodeRenderer/ShortcodeImg/index.ts | 1 + .../ShortcodeRenderer.test.tsx | 168 ++++++++++++++++ .../ShortcodeRenderer/ShortcodeRenderer.tsx | 130 ++++++++++++ .../ShortcodeSpoiler.test.tsx | 34 ++++ .../ShortcodeSpoiler/ShortcodeSpoiler.tsx | 29 +++ .../ShortcodeSpoiler/index.ts | 1 + .../ShortcodeTicket/ShortcodeTicket.test.tsx | 190 ++++++++++++++++++ .../ShortcodeTicket/ShortcodeTicket.tsx | 70 +++++++ .../ShortcodeTicket/index.ts | 1 + .../ShortcodeUrl/ShortcodeUrl.test.tsx | 69 +++++++ .../ShortcodeUrl/ShortcodeUrl.tsx | 41 ++++ .../ShortcodeRenderer/ShortcodeUrl/index.ts | 1 + .../ShortcodeUser/ShortcodeUser.test.tsx | 41 ++++ .../ShortcodeUser/ShortcodeUser.tsx | 25 +++ .../ShortcodeRenderer/ShortcodeUser/index.ts | 1 + .../ShortcodeVideo/ShortcodeVideo.test.tsx | 78 +++++++ .../ShortcodeVideo/ShortcodeVideo.tsx | 65 ++++++ .../ShortcodeRenderer/ShortcodeVideo/index.ts | 1 + .../components/ShortcodeRenderer/index.ts | 1 + .../forums/hooks/useForumPostPreview.test.ts | 110 ++++++++++ .../forums/hooks/useForumPostPreview.ts | 85 ++++++++ .../hooks/useForumPostPreviewMutation.ts | 21 ++ .../hooks/useShortcodeInjection.test.tsx | 55 +++++ .../forums/hooks/useShortcodeInjection.ts | 49 +++++ .../forums/hooks/useShortcodesList.test.ts | 57 ++++++ .../forums/hooks/useShortcodesList.ts | 36 ++++ .../dynamic-shortcode-entities.model.ts | 6 + resources/js/features/forums/models/index.ts | 4 + .../forums/models/processed-video.model.ts | 7 + .../features/forums/models/shortcode.model.ts | 10 + .../forums/models/video-type.model.ts | 1 + .../js/features/forums/state/forum.atoms.ts | 9 + .../forums/utils/convertYouTubeTime.test.ts | 56 ++++++ .../forums/utils/convertYouTubeTime.ts | 19 ++ .../extractDynamicEntitiesFromBody.test.ts | 79 ++++++++ .../utils/extractDynamicEntitiesFromBody.ts | 47 +++++ .../utils/postProcessShortcodesInBody.test.ts | 183 +++++++++++++++++ .../utils/postProcessShortcodesInBody.ts | 68 +++++++ .../utils/preProcessShortcodesInBody.test.ts | 86 ++++++++ .../utils/preProcessShortcodesInBody.ts | 47 +++++ .../forums/utils/processAllVideoUrls.test.ts | 102 ++++++++++ .../forums/utils/processAllVideoUrls.ts | 19 ++ .../forums/utils/processTwitchUrl.test.ts | 131 ++++++++++++ .../features/forums/utils/processTwitchUrl.ts | 66 ++++++ .../forums/utils/processVideoUrl.test.ts | 76 +++++++ .../features/forums/utils/processVideoUrl.ts | 19 ++ .../forums/utils/processYouTubeUrl.test.ts | 156 ++++++++++++++ .../forums/utils/processYouTubeUrl.ts | 54 +++++ .../js/pages/forums/post/[comment]/edit.tsx | 24 +++ resources/js/test/factories/createForum.ts | 8 + .../js/test/factories/createForumCategory.ts | 8 + .../js/test/factories/createForumTopic.ts | 11 + resources/js/test/factories/createTicket.ts | 11 + resources/js/test/factories/index.ts | 4 + resources/js/test/setup.tsx | 10 +- resources/js/types/generated.d.ts | 31 ++- resources/js/ziggy.d.ts | 15 ++ vite.config.ts | 1 + 110 files changed, 4665 insertions(+), 65 deletions(-) create mode 100644 app/Community/Actions/FetchDynamicShortcodeContentAction.php create mode 100644 app/Community/Controllers/Api/ForumTopicCommentApiController.php create mode 100644 app/Community/Requests/PreviewForumPostRequest.php create mode 100644 app/Community/Requests/UpdateForumTopicCommentRequest.php create mode 100644 app/Data/EditForumTopicCommentPagePropsData.php create mode 100644 app/Data/ForumCategoryData.php create mode 100644 app/Data/ForumData.php create mode 100644 app/Platform/Data/TicketData.php create mode 100644 app/Platform/Enums/TicketableType.php create mode 100644 resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx create mode 100644 resources/js/common/components/+vendor/BaseCollapsible.tsx create mode 100644 resources/js/common/utils/+vendor/bbobLineBreakPlugin.ts create mode 100644 resources/js/features/forums/components/EditPostMainRoot/EditPostForm/EditPostForm.test.tsx create mode 100644 resources/js/features/forums/components/EditPostMainRoot/EditPostForm/EditPostForm.tsx create mode 100644 resources/js/features/forums/components/EditPostMainRoot/EditPostForm/index.ts create mode 100644 resources/js/features/forums/components/EditPostMainRoot/EditPostForm/useEditPostForm.ts create mode 100644 resources/js/features/forums/components/EditPostMainRoot/EditPostMainRoot.test.tsx create mode 100644 resources/js/features/forums/components/EditPostMainRoot/EditPostMainRoot.tsx create mode 100644 resources/js/features/forums/components/EditPostMainRoot/index.ts create mode 100644 resources/js/features/forums/components/ForumPostCard/ForumPostCard.test.tsx create mode 100644 resources/js/features/forums/components/ForumPostCard/ForumPostCard.tsx create mode 100644 resources/js/features/forums/components/ForumPostCard/index.ts create mode 100644 resources/js/features/forums/components/ShortcodePanel/ShortcodePanel.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodePanel/ShortcodePanel.tsx create mode 100644 resources/js/features/forums/components/ShortcodePanel/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/ShortcodeAch.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/ShortcodeAch.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeAch/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/ShortcodeGame.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/ShortcodeGame.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeGame/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeImg/ShortcodeImg.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeImg/ShortcodeImg.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeImg/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeRenderer.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeSpoiler/ShortcodeSpoiler.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeSpoiler/ShortcodeSpoiler.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeSpoiler/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/ShortcodeTicket.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeTicket/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUrl/ShortcodeUrl.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUrl/ShortcodeUrl.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUrl/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/ShortcodeUser.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/ShortcodeUser.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeUser/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeVideo/ShortcodeVideo.test.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeVideo/ShortcodeVideo.tsx create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/ShortcodeVideo/index.ts create mode 100644 resources/js/features/forums/components/ShortcodeRenderer/index.ts create mode 100644 resources/js/features/forums/hooks/useForumPostPreview.test.ts create mode 100644 resources/js/features/forums/hooks/useForumPostPreview.ts create mode 100644 resources/js/features/forums/hooks/useForumPostPreviewMutation.ts create mode 100644 resources/js/features/forums/hooks/useShortcodeInjection.test.tsx create mode 100644 resources/js/features/forums/hooks/useShortcodeInjection.ts create mode 100644 resources/js/features/forums/hooks/useShortcodesList.test.ts create mode 100644 resources/js/features/forums/hooks/useShortcodesList.ts create mode 100644 resources/js/features/forums/models/dynamic-shortcode-entities.model.ts create mode 100644 resources/js/features/forums/models/index.ts create mode 100644 resources/js/features/forums/models/processed-video.model.ts create mode 100644 resources/js/features/forums/models/shortcode.model.ts create mode 100644 resources/js/features/forums/models/video-type.model.ts create mode 100644 resources/js/features/forums/state/forum.atoms.ts create mode 100644 resources/js/features/forums/utils/convertYouTubeTime.test.ts create mode 100644 resources/js/features/forums/utils/convertYouTubeTime.ts create mode 100644 resources/js/features/forums/utils/extractDynamicEntitiesFromBody.test.ts create mode 100644 resources/js/features/forums/utils/extractDynamicEntitiesFromBody.ts create mode 100644 resources/js/features/forums/utils/postProcessShortcodesInBody.test.ts create mode 100644 resources/js/features/forums/utils/postProcessShortcodesInBody.ts create mode 100644 resources/js/features/forums/utils/preProcessShortcodesInBody.test.ts create mode 100644 resources/js/features/forums/utils/preProcessShortcodesInBody.ts create mode 100644 resources/js/features/forums/utils/processAllVideoUrls.test.ts create mode 100644 resources/js/features/forums/utils/processAllVideoUrls.ts create mode 100644 resources/js/features/forums/utils/processTwitchUrl.test.ts create mode 100644 resources/js/features/forums/utils/processTwitchUrl.ts create mode 100644 resources/js/features/forums/utils/processVideoUrl.test.ts create mode 100644 resources/js/features/forums/utils/processVideoUrl.ts create mode 100644 resources/js/features/forums/utils/processYouTubeUrl.test.ts create mode 100644 resources/js/features/forums/utils/processYouTubeUrl.ts create mode 100644 resources/js/pages/forums/post/[comment]/edit.tsx create mode 100644 resources/js/test/factories/createForum.ts create mode 100644 resources/js/test/factories/createForumCategory.ts create mode 100644 resources/js/test/factories/createForumTopic.ts create mode 100644 resources/js/test/factories/createTicket.ts diff --git a/app/Community/Actions/FetchDynamicShortcodeContentAction.php b/app/Community/Actions/FetchDynamicShortcodeContentAction.php new file mode 100644 index 0000000000..16bedf7a2c --- /dev/null +++ b/app/Community/Actions/FetchDynamicShortcodeContentAction.php @@ -0,0 +1,100 @@ + $this->fetchUsers($usernames), + 'tickets' => $this->fetchTickets($ticketIds), + 'achievements' => $this->fetchAchievements($achievementIds), + 'games' => $this->fetchGames($gameIds), + ]); + + return $results->toArray(); + } + + /** + * @return Collection + */ + private function fetchUsers(array $usernames): Collection + { + if (empty($usernames)) { + return collect(); + } + + $users = User::query() + ->where(function ($query) use ($usernames) { + $query->whereIn('User', $usernames) + ->orWhereIn('display_name', $usernames); + }) + ->get(); + + return $users->map(fn (User $user) => UserData::fromUser($user)); + } + + /** + * @return Collection + */ + private function fetchTickets(array $ticketIds): Collection + { + if (empty($ticketIds)) { + return collect(); + } + + return Ticket::with('achievement') + ->whereIn('ID', $ticketIds) + ->get() + ->map(fn (Ticket $ticket) => TicketData::fromTicket($ticket)->include('state', 'ticketable')); + } + + /** + * @return Collection + */ + private function fetchAchievements(array $achievementIds): Collection + { + if (empty($achievementIds)) { + return collect(); + } + + return Achievement::whereIn('ID', $achievementIds) + ->get() + ->map(fn (Achievement $achievement) => AchievementData::fromAchievement($achievement)->include( + 'badgeUnlockedUrl', + 'points' + )); + } + + /** + * @return Collection + */ + private function fetchGames(array $gameIds): Collection + { + if (empty($gameIds)) { + return collect(); + } + + return Game::with('system') + ->whereIn('ID', $gameIds) + ->get() + ->map(fn (Game $game) => GameData::fromGame($game)->include('badgeUrl', 'system.name')); + } +} diff --git a/app/Community/Controllers/Api/ForumTopicCommentApiController.php b/app/Community/Controllers/Api/ForumTopicCommentApiController.php new file mode 100644 index 0000000000..e25c7fcd32 --- /dev/null +++ b/app/Community/Controllers/Api/ForumTopicCommentApiController.php @@ -0,0 +1,57 @@ +authorize('update', $comment); + + // Take any RA links and convert them to relevant shortcodes. + // eg: "https://retroachievements.org/game/1" --> "[game=1]" + $newPayload = normalize_shortcodes($request->input('body')); + + // Convert [user=$user->username] to [user=$user->id]. + $newPayload = Shortcode::convertUserShortcodesToUseIds($newPayload); + + $comment->body = $newPayload; + $comment->save(); + + return response()->json(['success' => true]); + } + + public function destroy(): void + { + } + + public function preview( + PreviewForumPostRequest $request, + FetchDynamicShortcodeContentAction $action + ): JsonResponse { + $entities = $action->execute( + usernames: $request->input('usernames'), + ticketIds: $request->input('ticketIds'), + achievementIds: $request->input('achievementIds'), + gameIds: $request->input('gameIds'), + ); + + return response()->json($entities); + } +} diff --git a/app/Community/Controllers/ForumTopicCommentController.php b/app/Community/Controllers/ForumTopicCommentController.php index 7033da7f9c..3f35838643 100644 --- a/app/Community/Controllers/ForumTopicCommentController.php +++ b/app/Community/Controllers/ForumTopicCommentController.php @@ -6,11 +6,15 @@ use App\Community\Actions\AddCommentAction; use App\Community\Actions\GetUrlToCommentDestinationAction; +use App\Community\Actions\ReplaceUserShortcodesWithUsernamesAction; use App\Community\Requests\ForumTopicCommentRequest; +use App\Data\EditForumTopicCommentPagePropsData; +use App\Data\ForumTopicCommentData; use App\Models\ForumTopic; use App\Models\ForumTopicComment; -use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; class ForumTopicCommentController extends CommentController { @@ -44,12 +48,22 @@ public function store( // ->with('success', $this->resourceActionSuccessMessage('comment', 'create')); } - public function edit(ForumTopicComment $comment): View + public function edit(ForumTopicComment $comment): InertiaResponse { $this->authorize('update', $comment); - return view('forum-topic-comment.edit') - ->with('comment', $comment); + // "[user=1]" -> "[user=Scott]" + $comment->body = (new ReplaceUserShortcodesWithUsernamesAction())->execute($comment->body); + + $props = new EditForumTopicCommentPagePropsData( + forumTopicComment: ForumTopicCommentData::from($comment)->include( + 'forumTopic', + 'forumTopic.forum', + 'forumTopic.forum.category', + ), + ); + + return Inertia::render('forums/post/[comment]/edit', $props); } protected function update( diff --git a/app/Community/Enums/TicketState.php b/app/Community/Enums/TicketState.php index 07f16246c5..c13910ba2e 100644 --- a/app/Community/Enums/TicketState.php +++ b/app/Community/Enums/TicketState.php @@ -4,6 +4,9 @@ namespace App\Community\Enums; +use Spatie\TypeScriptTransformer\Attributes\TypeScript; + +#[TypeScript] abstract class TicketState { public const Closed = 0; diff --git a/app/Community/Requests/PreviewForumPostRequest.php b/app/Community/Requests/PreviewForumPostRequest.php new file mode 100644 index 0000000000..5b56475114 --- /dev/null +++ b/app/Community/Requests/PreviewForumPostRequest.php @@ -0,0 +1,24 @@ + 'present|array', + 'usernames.*' => 'string', + 'ticketIds' => 'present|array', + 'ticketIds.*' => 'integer', + 'achievementIds' => 'present|array', + 'achievementIds.*' => 'integer', + 'gameIds' => 'present|array', + 'gameIds.*' => 'integer', + ]; + } +} diff --git a/app/Community/Requests/UpdateForumTopicCommentRequest.php b/app/Community/Requests/UpdateForumTopicCommentRequest.php new file mode 100644 index 0000000000..b5e4b97a5b --- /dev/null +++ b/app/Community/Requests/UpdateForumTopicCommentRequest.php @@ -0,0 +1,23 @@ + [ + 'required', + 'string', + 'max:60000', + new ContainsRegularCharacter(), + ], + ]; + } +} diff --git a/app/Community/RouteServiceProvider.php b/app/Community/RouteServiceProvider.php index e15ae84c70..3d198bb953 100755 --- a/app/Community/RouteServiceProvider.php +++ b/app/Community/RouteServiceProvider.php @@ -9,6 +9,7 @@ use App\Community\Controllers\AchievementSetClaimController; use App\Community\Controllers\Api\AchievementCommentApiController; use App\Community\Controllers\Api\ActivePlayersApiController; +use App\Community\Controllers\Api\ForumTopicCommentApiController; use App\Community\Controllers\Api\GameClaimsCommentApiController; use App\Community\Controllers\Api\GameCommentApiController; use App\Community\Controllers\Api\GameHashesCommentApiController; @@ -67,6 +68,9 @@ protected function mapWebRoutes(): void Route::group(['prefix' => 'internal-api'], function () { Route::post('achievement/{achievement}/comment', [AchievementCommentApiController::class, 'store'])->name('api.achievement.comment.store'); + Route::post('forums/post/preview', [ForumTopicCommentApiController::class, 'preview'])->name('api.forum-topic-comment.preview'); + Route::patch('forums/post/{comment}', [ForumTopicCommentApiController::class, 'update'])->name('api.forum-topic-comment.update'); + Route::post('game/{game}/claims/comment', [GameClaimsCommentApiController::class, 'store'])->name('api.game.claims.comment.store'); Route::post('game/{game}/comment', [GameCommentApiController::class, 'store'])->name('api.game.comment.store'); Route::post('game/{game}/hashes/comment', [GameHashesCommentApiController::class, 'store'])->name('api.game.hashes.comment.store'); @@ -113,6 +117,7 @@ protected function mapWebRoutes(): void Route::get('user/{user}/moderation-comments', [UserModerationCommentController::class, 'index'])->name('user.moderation-comment.index'); Route::get('forums/recent-posts', [ForumTopicController::class, 'recentPosts'])->name('forum.recent-posts'); + Route::get('forums/post/{comment}/edit2', [ForumTopicCommentController::class, 'edit'])->name('forum-topic-comment.edit'); Route::get('user/{user}/posts', [UserForumTopicCommentController::class, 'index'])->name('user.posts.index'); diff --git a/app/Data/EditForumTopicCommentPagePropsData.php b/app/Data/EditForumTopicCommentPagePropsData.php new file mode 100644 index 0000000000..68a0a5cb16 --- /dev/null +++ b/app/Data/EditForumTopicCommentPagePropsData.php @@ -0,0 +1,17 @@ +id, + title: $category->title, + description: Lazy::create(fn () => $category->description), + orderColumn: Lazy::create(fn () => $category->order_column), + ); + } +} diff --git a/app/Data/ForumData.php b/app/Data/ForumData.php new file mode 100644 index 0000000000..e4fa09a893 --- /dev/null +++ b/app/Data/ForumData.php @@ -0,0 +1,34 @@ +id, + title: $forum->title, + description: Lazy::create(fn () => $forum->description), + orderColumn: Lazy::create(fn () => $forum->order_column), + category: Lazy::create(fn () => ForumCategoryData::fromForumCategory($forum->category)), + ); + } +} diff --git a/app/Data/ForumTopicCommentData.php b/app/Data/ForumTopicCommentData.php index 4def7b0662..72a35534dd 100644 --- a/app/Data/ForumTopicCommentData.php +++ b/app/Data/ForumTopicCommentData.php @@ -4,8 +4,10 @@ namespace App\Data; -use Illuminate\Support\Carbon; +use App\Models\ForumTopicComment; +use Carbon\Carbon; use Spatie\LaravelData\Data; +use Spatie\LaravelData\Lazy; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript('ForumTopicComment')] @@ -18,7 +20,21 @@ public function __construct( public ?Carbon $updatedAt, public ?UserData $user, public bool $isAuthorized, // TODO migrate to $authorizedAt - public ?int $forumTopicId = null, + public ?int $forumTopicId = null, // TODO remove and use $forumTopic instead + public Lazy|ForumTopicData|null $forumTopic = null, ) { } + + public static function fromForumTopicComment(ForumTopicComment $comment): self + { + return new self( + id: $comment->id, + body: $comment->body, + createdAt: $comment->created_at, + updatedAt: $comment->updated_at, + user: UserData::from($comment->user), + isAuthorized: $comment->is_authorized, + forumTopic: Lazy::create(fn () => ForumTopicData::from($comment->forumTopic)), + ); + } } diff --git a/app/Data/ForumTopicData.php b/app/Data/ForumTopicData.php index b6992170fa..5fad8ee75e 100644 --- a/app/Data/ForumTopicData.php +++ b/app/Data/ForumTopicData.php @@ -4,8 +4,9 @@ namespace App\Data; +use App\Models\ForumTopic; use App\Support\Shortcode\Shortcode; -use Illuminate\Support\Carbon; +use Carbon\Carbon; use Spatie\LaravelData\Data; use Spatie\LaravelData\Lazy; use Spatie\TypeScriptTransformer\Attributes\TypeScript; @@ -17,15 +18,33 @@ public function __construct( public int $id, public string $title, public Carbon $createdAt, - public Lazy|ForumTopicCommentData $latestComment, - public Lazy|int|null $commentCount24h, - public Lazy|int|null $oldestComment24hId, - public Lazy|int|null $commentCount7d, - public Lazy|int|null $oldestComment7dId, + public Lazy|ForumData|null $forum, + public Lazy|ForumTopicCommentData|null $latestComment, // TODO move to separate DTO + public Lazy|int|null $commentCount24h, // TODO move to separate DTO + public Lazy|int|null $oldestComment24hId, // TODO move to separate DTO + public Lazy|int|null $commentCount7d, // TODO move to separate DTO + public Lazy|int|null $oldestComment7dId, // TODO move to separate DTO public ?UserData $user = null, ) { } + public static function fromForumTopic(ForumTopic $topic): self + { + return new self( + id: $topic->id, + title: $topic->title, + createdAt: $topic->created_at, + forum: Lazy::create(fn () => ForumData::fromForum($topic->forum)), + user: UserData::from($topic->user), + + latestComment: null, + commentCount24h: null, + oldestComment24hId: null, + commentCount7d: null, + oldestComment7dId: null, + ); + } + public static function fromHomePageQuery(array $comment): self { return new self( @@ -33,6 +52,7 @@ public static function fromHomePageQuery(array $comment): self title: $comment['ForumTopicTitle'], createdAt: Carbon::parse($comment['PostedAt']), + forum: null, user: null, commentCount24h: null, @@ -58,6 +78,7 @@ public static function fromRecentlyActiveTopic(array $topic): self title: $topic['ForumTopicTitle'], createdAt: Carbon::parse($topic['PostedAt']), + forum: null, user: null, commentCount24h: Lazy::create(fn () => $topic['Count_1d']), @@ -87,6 +108,7 @@ public static function fromUserPost(array $userPost): self title: $userPost['ForumTopicTitle'], createdAt: Carbon::parse($userPost['PostedAt']), + forum: null, user: null, commentCount24h: null, diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 9e8e906d84..8533695c1b 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -31,6 +31,7 @@ class Ticket extends BaseModel // TODO rename Updated column to updated_at // TODO drop AchievementID, use ticketable morph instead // TODO drop Hardcore, derived from player_session + // TODO rename ticketable_model to ticketable_type protected $table = 'Ticket'; protected $primaryKey = 'ID'; diff --git a/app/Platform/Data/TicketData.php b/app/Platform/Data/TicketData.php new file mode 100644 index 0000000000..460e5a234e --- /dev/null +++ b/app/Platform/Data/TicketData.php @@ -0,0 +1,33 @@ +id, + ticketableType: TicketableType::Achievement, + state: Lazy::create(fn () => $ticket->state), + ticketable: Lazy::create(fn () => AchievementData::fromAchievement($ticket->achievement)->include('badgeUnlockedUrl')) + ); + } +} diff --git a/app/Platform/Enums/TicketableType.php b/app/Platform/Enums/TicketableType.php new file mode 100644 index 0000000000..0b21b1f3c2 --- /dev/null +++ b/app/Platform/Enums/TicketableType.php @@ -0,0 +1,15 @@ +hasAnyRole([ + Role::ADMINISTRATOR, Role::MODERATOR, Role::FORUM_MANAGER, - ]) - || $user->getAttribute('Permissions') >= Permissions::Moderator; + ]); } public function viewAny(?User $user): bool diff --git a/lang/en_US.json b/lang/en_US.json index d0b0212283..47130ec7a8 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -590,5 +590,18 @@ "See more info": "See more info", "{{user}} has not played <1>{{game}}.": "{{user}} has not played <1>{{game}}.", "Start of reconstructed timeline.": "Start of reconstructed timeline.", - "Manually unlocked by <1>{{user}}.": "Manually unlocked by <1>{{user}}." + "Manually unlocked by <1>{{user}}.": "Manually unlocked by <1>{{user}}.", + "Edit Post": "Edit Post", + "Bold": "Bold", + "Italic": "Italic", + "Underline": "Underline", + "Strikethrough": "Strikethrough", + "Code": "Code", + "Spoiler": "Spoiler", + "Image": "Image", + "Link": "Link", + "Ticket": "Ticket", + "Ticket #{{ticketId}}": "Ticket #{{ticketId}}", + "Body": "Body", + "Preview": "Preview" } \ No newline at end of file diff --git a/package.json b/package.json index 1faf392625..3ee51e3795 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,16 @@ "crowdin:upload": "tsx resources/js/tools/crowdin-upload.ts" }, "dependencies": { + "@bbob/plugin-helper": "^4.2.0", + "@bbob/preset-react": "^4.2.0", + "@bbob/react": "^4.2.0", "@floating-ui/core": "^1.6.8", "@floating-ui/dom": "^1.5.1", "@hookform/resolvers": "^3.9.0", "@inertiajs/core": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aa2ac48e3..cde3f6525f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@bbob/plugin-helper': + specifier: ^4.2.0 + version: 4.2.0 + '@bbob/preset-react': + specifier: ^4.2.0 + version: 4.2.0(react@18.3.1) + '@bbob/react': + specifier: ^4.2.0 + version: 4.2.0(react@18.3.1) '@floating-ui/core': specifier: ^1.6.8 version: 1.6.8 @@ -26,6 +35,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -424,6 +436,37 @@ packages: resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} + '@bbob/core@4.2.0': + resolution: {integrity: sha512-i5VUIO6xx+TCrBE8plQF9KpW1z+0QcCh9GawCCWYG9KcZrEsyRy57my5/kem0a2lFxXjh0o+tgjQY9sCP5b3bw==} + + '@bbob/html@4.2.0': + resolution: {integrity: sha512-mZG7MmE/YluH1/FNCr2Jv/Vz3Mq5stZ5tFAjBmYpVVN6Ndus4+FA84vKoHFdUHDiuU1p/e2/o/VUrwsWwLdoIA==} + + '@bbob/parser@4.2.0': + resolution: {integrity: sha512-l8BppXjdQClrUiv+qIN0Oe6aS/vlH7CEduMreDaxYDadUPC0RMzxj9lXVjO0xTbFEVEIUJCGc53qnpiApLbx2g==} + + '@bbob/plugin-helper@4.2.0': + resolution: {integrity: sha512-Uxs/UJROnkpcq5EJfz/8NCEYAcme8l6oAgMeWLX1nxWeieUhRgpY8BWQ9eQwUOXEKa0tsKVPnlmbUAjFhppVPw==} + + '@bbob/preset-html5@4.2.0': + resolution: {integrity: sha512-X2EIeb2vqTz/n34KWQXEL5IC0SuB6i2/ltRpEaMTyK7IoDEs0XGIWcqVpenb7Z2d4eNA3gTWhkoUUkKlZmfQRQ==} + + '@bbob/preset-react@4.2.0': + resolution: {integrity: sha512-f6VoTaFVxtx4dVWCYjbSkeK2roYgjj/mMc7TJtPOReSjLZqxk0sV0LUcNfbEeNdCcygASjKZAX5FOhDZ4qADng==} + peerDependencies: + react: '> 15.0' + + '@bbob/preset@4.2.0': + resolution: {integrity: sha512-IA+kxlrRcYBXE634B8W6uU2DO7VvrxeOqWUzIDKV2CgzTMpmCG875ihGp+Q3T+QxGwzG5S0L4GdmCIULTbzC4A==} + + '@bbob/react@4.2.0': + resolution: {integrity: sha512-gmcTg3vrN5Qc6Epp91f4pU4rf8GCb9AO8CGaTgdvmLMYH4LWoAyZdTGn4J0m3QKAhTdUWvnZjG9YOkI4zXw94A==} + peerDependencies: + react: '> 15.0' + + '@bbob/types@4.2.0': + resolution: {integrity: sha512-bSCZnNg0VrPosBBUVkjBDYVKNEqwT0jTNuzvAuNrZkuILgUKtLYI9GHuT8rY4lZecCb7UqGDsHsYt0YJO36eJg==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1052,6 +1095,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.2': + resolution: {integrity: sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.0': resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: @@ -4936,6 +4992,54 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@bbob/core@4.2.0': + dependencies: + '@bbob/parser': 4.2.0 + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/html@4.2.0': + dependencies: + '@bbob/core': 4.2.0 + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/parser@4.2.0': + dependencies: + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/plugin-helper@4.2.0': + dependencies: + '@bbob/types': 4.2.0 + + '@bbob/preset-html5@4.2.0': + dependencies: + '@bbob/plugin-helper': 4.2.0 + '@bbob/preset': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/preset-react@4.2.0(react@18.3.1)': + dependencies: + '@bbob/preset-html5': 4.2.0 + '@bbob/types': 4.2.0 + react: 18.3.1 + + '@bbob/preset@4.2.0': + dependencies: + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + + '@bbob/react@4.2.0(react@18.3.1)': + dependencies: + '@bbob/core': 4.2.0 + '@bbob/html': 4.2.0 + '@bbob/plugin-helper': 4.2.0 + '@bbob/types': 4.2.0 + react: 18.3.1 + + '@bbob/types@4.2.0': {} + '@bcoe/v8-coverage@0.2.3': {} '@crowdin/crowdin-api-client@1.39.1': @@ -5364,6 +5468,22 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-collapsible@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) diff --git a/resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx b/resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx new file mode 100644 index 0000000000..d6aa9ba182 --- /dev/null +++ b/resources/js/common/components/+vendor/BaseAutosizeTextarea.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { useImperativeHandle } from 'react'; + +import { cn } from '@/common/utils/cn'; + +interface UseBaseAutosizeTextAreaProps { + textAreaRef: React.MutableRefObject; + minHeight?: number; + maxHeight?: number; + triggerAutoSize: string; +} + +export const useBaseAutosizeTextArea = ({ + textAreaRef, + triggerAutoSize, + maxHeight = Number.MAX_SAFE_INTEGER, + minHeight = 0, +}: UseBaseAutosizeTextAreaProps) => { + const [init, setInit] = React.useState(true); + React.useEffect(() => { + // We need to reset the height momentarily to get the correct scrollHeight for the textarea + const offsetBorder = 6; + const textAreaElement = textAreaRef.current; + if (textAreaElement) { + if (init) { + textAreaElement.style.minHeight = `${minHeight + offsetBorder}px`; + if (maxHeight > minHeight) { + textAreaElement.style.maxHeight = `${maxHeight}px`; + } + setInit(false); + } + textAreaElement.style.height = `${minHeight + offsetBorder}px`; + const scrollHeight = textAreaElement.scrollHeight; + // We then set the height directly, outside of the render loop + // Trying to set this with state or a ref will product an incorrect value. + if (scrollHeight > maxHeight) { + textAreaElement.style.height = `${maxHeight}px`; + } else { + textAreaElement.style.height = `${scrollHeight + offsetBorder}px`; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional + }, [textAreaRef.current, triggerAutoSize]); +}; + +export type BaseAutosizeTextAreaRef = { + textArea: HTMLTextAreaElement; + maxHeight: number; + minHeight: number; +}; + +type BaseAutosizeTextAreaProps = { + maxHeight?: number; + minHeight?: number; +} & React.TextareaHTMLAttributes; + +export const BaseAutosizeTextarea = React.forwardRef< + BaseAutosizeTextAreaRef, + BaseAutosizeTextAreaProps +>( + ( + { + maxHeight = Number.MAX_SAFE_INTEGER, + minHeight = 52, + className, + onChange, + value, + ...props + }: BaseAutosizeTextAreaProps, + ref: React.Ref, + ) => { + const textAreaRef = React.useRef(null); + const [triggerAutoSize, setTriggerAutoSize] = React.useState(''); + + useBaseAutosizeTextArea({ + textAreaRef, + triggerAutoSize, + maxHeight, + minHeight, + }); + + useImperativeHandle(ref, () => ({ + textArea: textAreaRef.current as HTMLTextAreaElement, + focus: () => textAreaRef?.current?.focus(), + maxHeight, + minHeight, + })); + + React.useEffect(() => { + setTriggerAutoSize(value as string); + }, [props?.defaultValue, value]); + + return ( +