Add Livewire comment system

This commit is contained in:
2026-01-10 15:02:14 +01:00
parent 67f5d0db8b
commit 5f575024e2
13 changed files with 456 additions and 33 deletions

90
app/Livewire/Comment.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class Comment extends Component
{
use AuthorizesRequests;
public $comment;
public $isReplying = false;
public $replyState = [
'body' => ''
];
public $isEditing = false;
public $editState = [
'body' => ''
];
protected $listeners = [
'refresh' => '$refresh'
];
protected $validationAttributes = [
'replyState.body' => 'reply'
];
public function updatedIsEditing($isEditing)
{
if (! $isEditing) {
return;
}
$this->editState = [
'body' => $this->comment->body
];
}
public function editComment()
{
$this->authorize('update', $this->comment);
$this->comment->update($this->editState);
$this->isEditing = false;
}
public function deleteComment()
{
$this->authorize('destroy', $this->comment);
$this->comment->delete();
}
public function postReply()
{
if (! $this->comment->depth() < 2) {
return;
}
$this->validate([
'replyState.body' => 'required'
]);
$reply = $this->comment->children()->make($this->replyState);
$reply->user()->associate(auth()->user());
$reply->commentable()->associate($this->comment->commentable);
$reply->save();
$this->replyState = [
'body' => ''
];
$this->isReplying = false;
$this->dispatch('refresh')->self();
}
public function render()
{
return view('livewire.comment');
}
}

56
app/Livewire/Comments.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
class Comments extends Component
{
use WithPagination;
public $model;
public $newCommentState = [
'body' => ''
];
protected $validationAttributes = [
'newCommentState.body' => 'comment'
];
protected $listeners = [
'refresh' => '$refresh'
];
public function postComment()
{
$this->validate([
'newCommentState.body' => 'required'
]);
$comment = $this->model->comments()->make($this->newCommentState);
$comment->user()->associate(auth()->user());
$comment->save();
$this->newCommentState = [
'body' => ''
];
$this->resetPage();
}
public function render()
{
$comments = $this->model
->comments()
->with('user', 'children.user', 'children.children')
->parent()
->latest()
->paginate(3);
return view('livewire.comments', [
'comments' => $comments
]);
}
}

61
app/Models/Comment.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use App\Models\Presenters\CommentPresenter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Comment extends Model
{
use HasFactory, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'body'
];
public function presenter()
{
return new CommentPresenter($this);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeParent(Builder $builder)
{
$builder->whereNull('parent_id');
}
public function children()
{
return $this->hasMany(Comment::class, 'parent_id')->oldest();
}
public function commentable()
{
return $this->morphTo();
}
public function parent()
{
return $this->hasOne(Comment::class, 'id', 'parent_id');
}
// Recursevly calculates how deep the nesting is
public function depth(): int
{
return $this->parent
? $this->parent->depth() + 1
: 0;
}
}

View File

@@ -160,6 +160,11 @@ class Episode extends Model implements Sitemapable
return cache()->remember('episodeComments' . $this->id, 300, fn() => $this->comments->count());
}
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
public function getProblematicTags(): string
{
$problematicTags = ['Gore', 'Scat', 'Horror'];

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\Presenters;
use App\Models\Comment;
use Illuminate\Support\Str;
class CommentPresenter
{
public $comment;
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
public function markdownBody()
{
return Str::of($this->comment->body)->markdown([
'html_input' => 'strip',
]);
}
public function relativeCreatedAt()
{
return $this->comment->created_at->diffForHumans();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Comment;
use Illuminate\Auth\Access\HandlesAuthorization;
class CommentPolicy
{
use HandlesAuthorization;
public function update(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
public function destroy(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Remame old table from laravelista/comments
Schema::rename('comments', 'comments_old');
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete();
$table->morphs('commentable'); // What is being commented on
$table->text('body');
$table->softDeletes();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('comments');
// Revert to old table from laravelista/comments
Schema::rename('comments_old', 'comments');
}
};

View File

@@ -31,7 +31,7 @@
</a>
</div>
<div class="w-[60vw]">
@include('partials.comment', ['comment' => $comment])
{{--@include('partials.comment', ['comment' => $comment])--}}
</div>
</div>
@elseif($comment->commentable_type == 'App\Models\Hentai')
@@ -53,7 +53,7 @@
</a>
</div>
<div class="w-[60vw]">
@include('partials.comment', ['comment' => $comment])
{{--@include('partials.comment', ['comment' => $comment])--}}
</div>
</div>
@endif

View File

@@ -0,0 +1,99 @@
<div>
<div class="flex">
<div class="flex-shrink-0 mr-4">
<img class="h-10 w-10 rounded-full" src="{{ $comment->user->getAvatar() }}" alt="{{ $comment->user->name }}">
</div>
<div class="flex-grow">
<div>
<a href="#" class="font-medium text-gray-900">{{ $comment->user->name }}</a>
</div>
<div class="mt-1 flex-grow w-full">
@if ($isEditing)
<form wire:submit.prevent="editComment">
<div>
<label for="comment" class="sr-only">Comment body</label>
<textarea id="comment" name="comment" rows="3"
class="shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 rounded-md
@error('editState.body') border-red-500 @enderror"
placeholder="Write something" wire:model.defer="editState.body"></textarea>
@error('editState.body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mt-3 flex items-center justify-between">
<button type="submit"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">
Edit
</button>
</div>
</form>
@else
<p class="text-gray-700">{!! $comment->presenter()->markdownBody() !!}</p>
@endif
</div>
<div class="mt-2 space-x-2">
<span class="text-gray-500 font-medium">
{{ $comment->presenter()->relativeCreatedAt() }}
</span>
@auth
{{--@if ($comment->hasParent())--}}
@if ($comment->depth() < 2)
<button wire:click="$toggle('isReplying')" type="button" class="text-gray-900 font-medium">
Reply
</button>
@endif
{{--@endif--}}
@can ('update', $comment)
<button wire:click="$toggle('isEditing')" type="button" class="text-gray-900 font-medium">
Edit
</button>
@endcan
@can ('destroy', $comment)
<button x-data="{
confirmCommentDeletion () {
if (window.confirm('Are you sure you want to delete this comment?')) {
@this.call('deleteComment');
}
}
}"
@click="confirmCommentDeletion"
type="button"
class="text-gray-900 font-medium"
>
Delete
</button>
@endcan
@endauth
</div>
</div>
</div>
<div class="ml-14 mt-6">
@if ($isReplying)
<form wire:submit.prevent="postReply" class="my-4">
<div>
<label for="comment" class="sr-only">Reply body</label>
<textarea id="comment" name="comment" rows="3"
class="shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 rounded-md
@error('replyState.body') border-red-500 @enderror"
placeholder="Write something" wire:model.defer="replyState.body"></textarea>
@error('replyState.body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mt-3 flex items-center justify-between">
<button type="submit"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">
Comment
</button>
</div>
</form>
@endif
@foreach ($comment->children as $child)
<livewire:comment :comment="$child" :key="$child->id"/>
@endforeach
</div>
</div>

View File

@@ -0,0 +1,54 @@
<section>
<div class="bg-white shadow sm:rounded-lg sm:overflow-hidden">
<div class="divide-y divide-gray-200">
<div class="px-4 py-5 sm:px-6">
<h2 class="text-lg font-medium text-gray-900">Comments</h2>
</div>
<div class="px-4 py-6 sm:px-6">
<div class="space-y-8">
@if ($comments->isNotEmpty())
@foreach($comments as $comment)
<livewire:comment :comment="$comment" :key="$comment->id"/>
@endforeach
{{ $comments->links() }}
@else
<p>No comments yet.</p>
@endif
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-6 sm:px-6">
@auth
<div class="flex">
<div class="flex-shrink-0 mr-4">
<img class="h-10 w-10 rounded-full" src="{{ auth()->user()->getAvatar() }}" alt="{{ auth()->user()->name }}">
</div>
<div class="min-w-0 flex-1">
<form wire:submit.prevent="postComment">
<div>
<label for="comment" class="sr-only">Comment body</label>
<textarea id="comment" name="comment" rows="3"
class="shadow-sm block w-full focus:ring-rose-500 focus:border-rose-500 border-gray-300 rounded-md
@error('newCommentState.body') border-red-500 @enderror"
placeholder="Write something" wire:model.defer="newCommentState.body"></textarea>
@error('newCommentState.body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="mt-3 flex items-center justify-between">
<button type="submit"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">
Comment
</button>
</div>
</form>
</div>
</div>
@endauth
@guest
<p>Log in to comment.</p>
@endguest
</div>
</div>
</section>

View File

@@ -1,22 +0,0 @@
@inject('markdown', 'Parsedown')
@php
// TODO: There should be a better place for this.
$markdown->setSafeMode(true);
@endphp
<div id="comment-{{ $comment->id }}" class="flex rounded-lg bg-white p-1 mb-2 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-900">
@php $user = cache()->rememberForever('commentUser'.$comment->commenter_id, fn () => \App\Models\User::where('id', $comment->commenter_id)->first()); @endphp
<div>
<img class="w-16 h-16 rounded-full m-2" src="{{ $user->getAvatar() }}" alt="{{ $user->name }} Avatar">
</div>
<div class="text-gray-800 dark:text-gray-200">
<div>
@if($user->is_patreon)
<h5 class="text-gray-800 dark:text-gray-400">{{ $user->name }} <a data-te-toggle="tooltip" title="Badge of appreciation for the horny people supporting us! :3"><i class="fa-solid fa-hand-holding-heart text-rose-600 animate-pulse"></i></a> <small class="text-muted">- {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}</small></h5>
@else
<h5 class="text-gray-800 dark:text-gray-400">{{ $user->name }} <small class="text-muted">- {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}</small></h5>
@endif
</div>
<div style="white-space: pre-wrap;">{!! $markdown->line($comment->comment) !!}</div>
<br />
</div>
</div>

View File

@@ -26,7 +26,7 @@
<!-- Infos -->
@include('stream.partials.info')
<!-- Comments -->
@include('stream.partials.comments')
<livewire:comments :model="$episode"/>
</div>
<div class="flex flex-col">
@if(! $isMobile)

View File

@@ -1,8 +0,0 @@
<div class="bg-transparent rounded-lg overflow-hidden bg-white dark:bg-neutral-700/40 p-5">
<div id="comments" class="grid grid-cols-1 bg-transparent rounded-lg">
<p class="leading-normal font-bold text-lg text-rose-600">
{{ __('home.latest-comments') }}
</p>
{{--@comments(['model' => $episode])--}}
</div>
</div>