From 09d1d9e281aa49daefb908ef56f10aa1d084fa83 Mon Sep 17 00:00:00 2001 From: William Rose Date: Sat, 28 Jun 2025 18:43:24 -0700 Subject: [PATCH] Support flagging comments using Markable --- app/Enums/LivewireEventEnum.php | 2 + app/Livewire/Comments/CommentComponent.php | 57 +++----- app/Livewire/Flags/FlagComponent.php | 138 +++++++++++------- app/Models/Comment.php | 14 +- app/Models/Post.php | 14 +- app/Traits/CommentComponentTrait.php | 2 + public_html/images/icons/bars-rotate-fade.svg | 1 + resources/sass/modules/_forms.scss | 22 ++- resources/sass/modules/_posts.scss | 17 ++- .../comments/comment-component.blade.php | 5 +- .../comments/comment-show-component.blade.php | 5 +- .../partials/toggle-flagging-button.blade.php | 25 +++- .../livewire/flags/flag-component.blade.php | 80 +++++++--- 13 files changed, 259 insertions(+), 123 deletions(-) create mode 100644 public_html/images/icons/bars-rotate-fade.svg diff --git a/app/Enums/LivewireEventEnum.php b/app/Enums/LivewireEventEnum.php index 2e09c662..7806b95e 100644 --- a/app/Enums/LivewireEventEnum.php +++ b/app/Enums/LivewireEventEnum.php @@ -7,6 +7,7 @@ enum LivewireEventEnum: string { case CommentDeleted = 'comment-deleted'; + case CommentFlagCancelled = 'comment-flag-cancelled'; case CommentFlagDeleted = 'comment-flag-deleted'; case CommentFlagged = 'comment-flagged'; case CommentStored = 'comment-stored'; @@ -19,6 +20,7 @@ enum LivewireEventEnum: string case FlagDeleted = 'flag-deleted'; case HideFlagCommentForm = 'hide-flag-comment-form'; case PostDeleted = 'post-deleted'; + case PostFlagCancelled = 'post-flag-cancelled'; case PostFlagDeleted = 'post-flag-deleted'; case PostFlagged = 'post-flagged'; case PostStored = 'post-stored'; diff --git a/app/Livewire/Comments/CommentComponent.php b/app/Livewire/Comments/CommentComponent.php index cf65ed09..a546d46f 100644 --- a/app/Livewire/Comments/CommentComponent.php +++ b/app/Livewire/Comments/CommentComponent.php @@ -11,7 +11,6 @@ use App\Models\User; use App\Traits\CommentComponentTrait; use Illuminate\Contracts\View\View; -use Illuminate\Support\Facades\DB; use Livewire\Attributes\On; use Livewire\Component; @@ -24,13 +23,12 @@ final class CommentComponent extends Component public string $body = ''; public ?int $parentId = null; public int $flagCount = 0; - public string $flagIconFilename = 'flag'; - public string $flagButtonText = ''; public bool $userFlagged = false; // State public bool $isEditing = false; public bool $isFlagging = false; + public bool $isFlagLoading = false; public bool $isReplying = false; public Comment $comment; @@ -52,10 +50,6 @@ public function mount(Comment $comment, Post $post): void $this->user = auth()->user() ?? null; $this->updateFlagCount(); - $this->hasUserFlagged(); - - $this->flagIconFilename = $this->getFlagIconFilename(); - $this->flagButtonText = $this->getFlagTitleText(); } public function render(): View @@ -63,31 +57,9 @@ public function render(): View return view('livewire.comments.comment-component'); } - private function getFlagIconFilename(): string - { - return $this->userFlagged ? 'flag-fill' : 'flag'; - } - - private function getFlagTitleText(): string - { - return $this->userFlagged ? trans('Remove flag') : trans('Flag this comment'); - } - - private function hasUserFlagged(): void - { - $userFlagCount = DB::table(table: 'markable_flags') - ->where(column: 'user_id', operator: '=', value: auth()->id()) - ->where(column: 'markable_id', operator: '=', value: $this->comment->id) - ->where(column: 'markable_type', operator: 'LIKE', value: '%Comment%') - ->count(); - - $this->userFlagged = $userFlagCount > 0; - } - #[On([ LivewireEventEnum::CommentStored->value, LivewireEventEnum::CommentDeleted->value, - LivewireEventEnum::CommentFlagged->value, LivewireEventEnum::CommentUpdated->value, LivewireEventEnum::EscapeKeyClicked->value, ])] @@ -98,27 +70,36 @@ public function closeForm(): void ]); $this->stopEditing(); - $this->stopFlagging(); $this->stopReplying(); } private function updateFlagCount(): void { - $this->flagCount = Flag::count($this->comment); + $this->flagCount = $this->comment->flagCount(); + $this->userFlagged = $this->comment->userFlagged(); } - #[On('comment-flagged.{comment.id}')] - public function addUserFlag(): void + public function addUserFlag(int $id): void { - \Log::debug('CommentComponent::addUserFlag'); + if ($id !== $this->comment->id) { + return; + } + $this->userFlagged = true; - $this->flagCount++; + // Requery as flag may have just been edited, not added + $this->updateFlagCount(); + $this->stopFlagging(); } - #[On('comment-flag-deleted.{comment.id}')] - public function removeUserFlag(): void + public function removeUserFlag(int $id): void { + if ($id !== $this->comment->id) { + return; + } + $this->userFlagged = false; - $this->flagCount--; + // Requery as technically multiple flags could exist (though they shouldn't) + $this->updateFlagCount(); + $this->stopFlagging(); } } diff --git a/app/Livewire/Flags/FlagComponent.php b/app/Livewire/Flags/FlagComponent.php index 2470d3a4..765b1b70 100644 --- a/app/Livewire/Flags/FlagComponent.php +++ b/app/Livewire/Flags/FlagComponent.php @@ -11,8 +11,8 @@ use App\Traits\AuthStatusTrait; use App\Traits\LoggingTrait; use App\Traits\TypeTrait; -use Exception; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Locked; use Livewire\Component; use Maize\Markable\Exceptions\InvalidMarkValueException; @@ -26,25 +26,42 @@ final class FlagComponent extends Component private const string MODEL_PATH = 'app\\models\\'; public Comment|Post $model; + public Flag|null $userFlag = null; + + #[Locked] + public int $modelId; + #[Locked] + public string $type; + #[Locked] public int $flagCount = 0; + #[Locked] public string $iconFilename = 'flag'; - public string $note = ''; + #[Locked] + public string $titleText; + #[Locked] public array $flagReasons = []; + + // Actual values we interact with + public string $note = ''; public string $selectedReason = ''; - public bool $showForm = false; public bool $showNoteField = false; - public string $titleText; - public string $type; - public bool $userFlagged = false; + public bool $formClosed = false; public function mount( Comment|Post $model, ): void { $configReasons = config('markable.allowed_values.flag', []); - $this->flagReasons = is_array($configReasons) ? $configReasons : []; + $this->flagReasons = is_array($configReasons) ? array_combine( + array_map(fn($reason) => mb_strtolower(preg_replace('/[^\w]+/', '-', $reason)), $configReasons), + $configReasons, + ) : []; + + $this->formClosed = false; $this->model = $model; + $this->modelId = $model->id; $this->type = $this->getType(); + $this->userFlag = $this->getUserFlag(); $this->updateFlagData(); } @@ -53,77 +70,98 @@ public function render(): View return view('livewire.flags.flag-component'); } - public function flagReasonSelected(string $selectedReason): void + public function isNoteVisibleForReason(string $reason): bool { - if ($selectedReason === self::FLAG_WITH_NOTE) { - $this->showNoteField = true; - } else { - $this->showNoteField = false; - $this->reset('note'); - } + return $reason === self::FLAG_WITH_NOTE; + } - $this->selectedReason = $selectedReason; + public function getUserFlag(): Flag | null + { + return Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->model->getKey(), + 'markable_type' => $this->model->getMorphClass(), + ])->first(); } - public function updateFlagData(): void + protected function updateFlagData(): void { + $value = $this->userFlag?->value ?? ''; + $this->selectedReason = in_array($value, $this->flagReasons) ? $value : ''; + $this->note = $this->userFlag?->metadata['note'] ?? ''; + $this->showNoteField = $this->isNoteVisibleForReason($this->selectedReason); $this->setTitleText(); } - public function store(): void + protected function deleteUserFlag(): void { - // $rules = (new StoreFlagRequest())->rules(); + Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->model->getKey(), + 'markable_type' => $this->model->getMorphClass(), + ])->get()->each->delete(); + } - // $this->validate($rules); + public function store(): void + { $metadata = []; $selectedReason = mb_trim($this->selectedReason); + $noteText = mb_trim($this->note); - try { - if ($selectedReason === self::FLAG_WITH_NOTE && mb_strlen($this->note) > 0) { - $metadata = ['note' => $this->note]; - } - - $event = $this->type === 'comment' ? - LivewireEventEnum::CommentFlagged->value : - LivewireEventEnum::PostFlagged->value; - - $this->dispatchEvent($event); + if ($this->isNoteVisibleForReason($selectedReason) && mb_strlen($noteText) > 0) { + $metadata = ['note' => $noteText]; + } - Flag::add($this->model, auth()->user(), $selectedReason, $metadata); + // Just cancel the change if the form is unmodified + if ($selectedReason === ($this->userFlag?->value ?? '') && $metadata === $this->userFlag?->metadata) { + $this->cancel(); + return; + } - $this->showForm = false; + $event = $this->type === 'comment' ? + LivewireEventEnum::CommentFlagged : + LivewireEventEnum::PostFlagged; - $this->userFlagged = false; + // Stop rendering while we are modifying data + $this->formClosed = true; - $this->updateFlagData(); + $this->deleteUserFlag(); + try { + $this->userFlag = Flag::add($this->model, auth()->user(), $selectedReason, $metadata); } catch (InvalidMarkValueException $exception) { $this->logError($exception); } + $this->updateFlagData(); + + $this->dispatchEvent($event); } public function delete(): void { - try { - $event = $this->type === 'comment' ? - LivewireEventEnum::CommentFlagDeleted->value : - LivewireEventEnum::PostFlagDeleted->value; - - $this->dispatchEvent($event); - Flag::remove($this->model, auth()->user()); + // Stop rendering while we are modifying data + $this->formClosed = true; + $event = $this->type === 'comment' ? + LivewireEventEnum::CommentFlagDeleted : + LivewireEventEnum::PostFlagDeleted; - $this->userFlagged = false; + $this->deleteUserFlag(); + $this->userFlag = null; + $this->updateFlagData(); - $this->updateFlagData(); - } catch (Exception $exception) { - $this->logError($exception); - } + $this->dispatchEvent($event); } - public function toggleForm(): void + public function cancel(): void { - $this->showForm = !$this->showForm; + $this->formClosed = true; + + $event = $this->type === 'comment' ? + LivewireEventEnum::CommentFlagCancelled : + LivewireEventEnum::PostFlagCancelled; + + $this->dispatchEvent($event); } private function getType(): string @@ -137,11 +175,11 @@ private function setTitleText(): void { $flagText = 'Flag this ' . $this->type; - $this->titleText = $this->userFlagged ? trans('Remove flag') : trans($flagText); + $this->titleText = $this->userFlag ? trans('Remove or edit flag') : trans($flagText); } - private function dispatchEvent(string $event): void + private function dispatchEvent(LivewireEventEnum $event): void { - $this->dispatch($event, id: $this->model->id); + $this->dispatch($event->value, id: $this->model->id); } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index bfb8c4df..75910c5b 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -97,7 +97,19 @@ public function favoriteCount(): int public function flagCount(): int { - return Flag::count($this); + return Flag::where([ + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->count(); + } + + public function userFlagged(): bool + { + return Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->exists(); } public function post(): BelongsTo diff --git a/app/Models/Post.php b/app/Models/Post.php index f2eae3df..2f76bf97 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -155,7 +155,19 @@ public function favoriteCount(): int public function flagCount(): int { - return Flag::count($this); + return Flag::where([ + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->count(); + } + + public function userFlagged(): bool + { + return Flag::where([ + 'user_id' => auth()->id(), + 'markable_id' => $this->getKey(), + 'markable_type' => $this->getMorphClass(), + ])->exists(); } public function next(): Post|null diff --git a/app/Traits/CommentComponentTrait.php b/app/Traits/CommentComponentTrait.php index 9067c4f4..0f4e7c14 100644 --- a/app/Traits/CommentComponentTrait.php +++ b/app/Traits/CommentComponentTrait.php @@ -48,6 +48,7 @@ public function stopEditing(): void public function startFlagging(): void { $this->isFlagging = true; + $this->isFlagLoading = false; $this->stopEditing(); $this->stopReplying(); } @@ -55,6 +56,7 @@ public function startFlagging(): void public function stopFlagging(): void { $this->isFlagging = false; + $this->isFlagLoading = false; } public function startReplying(): void diff --git a/public_html/images/icons/bars-rotate-fade.svg b/public_html/images/icons/bars-rotate-fade.svg new file mode 100644 index 00000000..bdcd8fcf --- /dev/null +++ b/public_html/images/icons/bars-rotate-fade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/sass/modules/_forms.scss b/resources/sass/modules/_forms.scss index 9890da9d..06f8b489 100644 --- a/resources/sass/modules/_forms.scss +++ b/resources/sass/modules/_forms.scss @@ -19,7 +19,7 @@ main input[type='url'] { } textarea.comment-textarea { - resize: block; + resize: vertical; } input[type='radio'] { @@ -60,6 +60,12 @@ input[type='radio']:focus { min-height: 10rem; } +label.radio-button-label { + display: flex; + align-items: center; + gap: 0.5rem; +} + .radio-button-label:focus-within { color: var(--dark-base-color); } @@ -68,10 +74,22 @@ input[type='radio']:focus { margin: 0 0 1rem 0; } -.fantastic { +.flag-form fieldset legend { margin-bottom: 1rem; } +.flag-form label:has(input[type='radio']) { + margin-bottom: 0.5rem; +} + +.flag-form label:has(input[type='radio'].flag-reason-fantastic) { + margin-bottom: 1rem; +} + +.flag-form textarea.flag-note { + resize: vertical; +} + .input-icon-wrap { display: flex; flex-direction: row; diff --git a/resources/sass/modules/_posts.scss b/resources/sass/modules/_posts.scss index 30e8f2b1..7e62f35e 100644 --- a/resources/sass/modules/_posts.scss +++ b/resources/sass/modules/_posts.scss @@ -1,7 +1,22 @@ - .comment, .post { margin: 0 0 2rem 0; padding: 0.75rem; border-radius: 0.25rem; } + +.button:not(.loading)>span.icon:not(.loading-icon) { + display: inline; +} + +.button:not(.loading)>span.icon.loading-icon { + display: none; +} + +.button.loading>span.icon:not(.loading-icon) { + display: none; +} + +.button.loading>span.icon.loading-icon { + display: inline; +} \ No newline at end of file diff --git a/resources/views/livewire/comments/comment-component.blade.php b/resources/views/livewire/comments/comment-component.blade.php index 32b548ec..7c97d34d 100644 --- a/resources/views/livewire/comments/comment-component.blade.php +++ b/resources/views/livewire/comments/comment-component.blade.php @@ -39,8 +39,11 @@ @if ($isFlagging === true) @endif diff --git a/resources/views/livewire/comments/comment-show-component.blade.php b/resources/views/livewire/comments/comment-show-component.blade.php index da9368e1..cc782831 100644 --- a/resources/views/livewire/comments/comment-show-component.blade.php +++ b/resources/views/livewire/comments/comment-show-component.blade.php @@ -32,7 +32,7 @@ class="button footer-button" @auth - @if ($userFlagged === true) + @if ($userFlag !== null) @endauth @guest - @endguest + +@script + +@endscript \ No newline at end of file diff --git a/resources/views/livewire/flags/flag-component.blade.php b/resources/views/livewire/flags/flag-component.blade.php index 9d9e6c47..6f14a6ec 100644 --- a/resources/views/livewire/flags/flag-component.blade.php +++ b/resources/views/livewire/flags/flag-component.blade.php @@ -1,9 +1,10 @@ -
+
{{ $titleText }} + @if ($userFlag === null) + @endif - @foreach ($flagReasons as $reason) -
-
+
+ @if ($userFlag !== null) + @endif - -
- + + + +
+ +@script + +@endscript \ No newline at end of file