Improve admin user & comment moderation page
This commit is contained in:
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
@@ -10,37 +13,147 @@ class AdminCommentSearch extends Component
|
|||||||
{
|
{
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
public $search = '';
|
public $search = '';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
public $userSearch = '';
|
public $userSearch = '';
|
||||||
|
|
||||||
public function updatingSearch(): void
|
#[Url(history: true)]
|
||||||
|
public $sortField = 'created_at';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $sortDirection = 'desc';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $perPage = 20;
|
||||||
|
|
||||||
|
public $selected = [];
|
||||||
|
|
||||||
|
public $selectPage = false;
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'userSearch' => ['except' => ''],
|
||||||
|
'sortField' => ['except' => 'created_at'],
|
||||||
|
'sortDirection' => ['except' => 'desc'],
|
||||||
|
'perPage' => ['except' => 20],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $allowedSortFields = ['id', 'body', 'created_at', 'user_id'];
|
||||||
|
|
||||||
|
protected $allowedPerPages = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
public function updatedPerPage(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatingUserSearch(): void
|
public function updatedPage(): void
|
||||||
{
|
{
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectPage($value): void
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
$this->selected = $this->comments->pluck('id')->map(fn ($id) => (string) $id)->toArray();
|
||||||
|
} else {
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $field): void
|
||||||
|
{
|
||||||
|
if (! in_array($field, $this->allowedSortFields)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sortField === $field) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortField = $field;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters(): void
|
||||||
|
{
|
||||||
|
$this->search = '';
|
||||||
|
$this->userSearch = '';
|
||||||
|
$this->sortField = 'created_at';
|
||||||
|
$this->sortDirection = 'desc';
|
||||||
|
$this->perPage = 20;
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteComment($commentId)
|
public function deleteComment(int $commentId): void
|
||||||
{
|
{
|
||||||
$comment = Comment::where('id', (int) $commentId)->firstOrFail();
|
$comment = Comment::findOrFail($commentId);
|
||||||
$comment->delete();
|
$comment->delete();
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: 'Comment deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkDelete(): void
|
||||||
|
{
|
||||||
|
$count = Comment::whereIn('id', array_map('intval', $this->selected))->delete();
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} comment(s) deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function banCommentAuthor(int $commentId): void
|
||||||
|
{
|
||||||
|
$comment = Comment::findOrFail($commentId);
|
||||||
|
$user = $comment->user;
|
||||||
|
|
||||||
|
if ($user && ! $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
|
||||||
|
} else {
|
||||||
|
$this->dispatch('notify', type: 'error', message: 'User is already banned or not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkBanAuthors(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
$userIds = Comment::whereIn('id', array_map('intval', $this->selected))
|
||||||
|
->pluck('user_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
foreach ($userIds as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user && ! $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache()->flush();
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} comment author(s) banned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCommentsProperty()
|
||||||
|
{
|
||||||
|
return Comment::query()
|
||||||
|
->with('user')
|
||||||
|
->when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%{$this->search}%"))
|
||||||
|
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($q) => $q->where('name', 'LIKE', "%{$this->userSearch}%")))
|
||||||
|
->orderBy($this->sortField, $this->sortDirection)
|
||||||
|
->paginate((int) $this->perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$comments = Comment::when($this->search !== '', fn ($query) => $query->where('body', 'LIKE', "%$this->search%"))
|
|
||||||
->when($this->userSearch !== '', fn ($query) => $query->whereHas('user', fn ($query) => $query->where('name', 'LIKE', "%{$this->userSearch}%")))
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->paginate(12);
|
|
||||||
|
|
||||||
return view('livewire.admin-comment-search', [
|
return view('livewire.admin-comment-search', [
|
||||||
'comments' => $comments,
|
'comments' => $this->comments,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,32 +20,239 @@ class AdminUserSearch extends Component
|
|||||||
public $discordId = '';
|
public $discordId = '';
|
||||||
|
|
||||||
#[Url(history: true)]
|
#[Url(history: true)]
|
||||||
public $patreon = [];
|
public $email = '';
|
||||||
|
|
||||||
#[Url(history: true)]
|
#[Url(history: true)]
|
||||||
public $banned = [];
|
public $roleFilter = [];
|
||||||
|
|
||||||
public function deleteUserComments(int $userID)
|
#[Url(history: true)]
|
||||||
|
public $sortField = 'created_at';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $sortDirection = 'desc';
|
||||||
|
|
||||||
|
#[Url(history: true)]
|
||||||
|
public $perPage = 20;
|
||||||
|
|
||||||
|
public $selected = [];
|
||||||
|
|
||||||
|
public $selectAll = false;
|
||||||
|
|
||||||
|
public $selectPage = false;
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
public $showUserModal = false;
|
||||||
|
|
||||||
|
public $modalUser = null;
|
||||||
|
|
||||||
|
public $modalUserComments = [];
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'discordId' => ['except' => ''],
|
||||||
|
'email' => ['except' => ''],
|
||||||
|
'roleFilter' => ['except' => []],
|
||||||
|
'sortField' => ['except' => 'created_at'],
|
||||||
|
'sortDirection' => ['except' => 'desc'],
|
||||||
|
'perPage' => ['except' => 20],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $allowedSortFields = ['id', 'name', 'email', 'discord_id', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
protected $allowedPerPages = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
public function updatedPerPage(): void
|
||||||
{
|
{
|
||||||
$user = User::where('id', $userID)
|
$this->resetPage();
|
||||||
->firstOrFail();
|
}
|
||||||
|
|
||||||
Comment::where('user_id', $user->id)
|
public function updatedPage(): void
|
||||||
->delete();
|
{
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSelectPage($value): void
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
$this->selected = $this->users->pluck('id')->map(fn ($id) => (string) $id)->toArray();
|
||||||
|
} else {
|
||||||
|
$this->selected = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $field): void
|
||||||
|
{
|
||||||
|
if (! in_array($field, $this->allowedSortFields)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sortField === $field) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortField = $field;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters(): void
|
||||||
|
{
|
||||||
|
$this->search = '';
|
||||||
|
$this->discordId = '';
|
||||||
|
$this->email = '';
|
||||||
|
$this->roleFilter = [];
|
||||||
|
$this->sortField = 'created_at';
|
||||||
|
$this->sortDirection = 'desc';
|
||||||
|
$this->perPage = 20;
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewUser(int $userId): void
|
||||||
|
{
|
||||||
|
$this->modalUser = User::find($userId);
|
||||||
|
|
||||||
|
if ($this->modalUser) {
|
||||||
|
$this->modalUserComments = $this->modalUser->comments()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
$this->showUserModal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeModal(): void
|
||||||
|
{
|
||||||
|
$this->showUserModal = false;
|
||||||
|
$this->modalUser = null;
|
||||||
|
$this->modalUserComments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function banUser(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
cache()->flush();
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been banned.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unbanUser(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->removeRole(UserRole::BANNED);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been unbanned.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grantModerator(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->addRole(UserRole::MODERATOR);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$user->name} has been granted Moderator role.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeModerator(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
$user->removeRole(UserRole::MODERATOR);
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "Moderator role revoked from {$user->name}.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUser->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteUserComments(int $userId): void
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($userId);
|
||||||
|
Comment::where('user_id', $user->id)->delete();
|
||||||
|
cache()->flush();
|
||||||
|
$this->dispatch('notify', type: 'success', message: "All comments from {$user->name} have been deleted.");
|
||||||
|
|
||||||
|
if ($this->showUserModal && $this->modalUser?->id === $userId) {
|
||||||
|
$this->modalUserComments = collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkBan(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->selected as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user && ! $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->addRole(UserRole::BANNED);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been banned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkUnban(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->selected as $userId) {
|
||||||
|
$user = User::find($userId);
|
||||||
|
if ($user && $user->hasRole(UserRole::BANNED)) {
|
||||||
|
$user->removeRole(UserRole::BANNED);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "{$count} user(s) have been unbanned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkDeleteComments(): void
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->selected as $userId) {
|
||||||
|
$deleted = Comment::where('user_id', (int) $userId)->delete();
|
||||||
|
if ($deleted > 0) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->flush();
|
||||||
|
$this->selected = [];
|
||||||
|
$this->selectPage = false;
|
||||||
|
$this->dispatch('notify', type: 'success', message: "Deleted comments from {$count} user(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsersProperty()
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
|
||||||
|
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
|
||||||
|
->when($this->email !== '', fn ($query) => $query->where('email', 'like', '%'.$this->email.'%'))
|
||||||
|
->when(! empty($this->roleFilter), function ($query) {
|
||||||
|
foreach ($this->roleFilter as $role) {
|
||||||
|
$query->whereJsonContains('roles', $role);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy($this->sortField, $this->sortDirection)
|
||||||
|
->paginate((int) $this->perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$users = User::when($this->patreon !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::SUPPORTER->value))
|
|
||||||
->when($this->banned !== [], fn ($query) => $query->whereJsonContains('roles', UserRole::BANNED->value))
|
|
||||||
->when($this->search !== '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
|
|
||||||
->when($this->discordId !== '', fn ($query) => $query->where('discord_id', '=', $this->discordId))
|
|
||||||
->paginate(20);
|
|
||||||
|
|
||||||
return view('livewire.admin-user-search', [
|
return view('livewire.admin-user-search', [
|
||||||
'users' => $users,
|
'users' => $this->users,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,291 @@
|
|||||||
|
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
|
||||||
|
x-data="{
|
||||||
|
showConfirm: false,
|
||||||
|
confirmTitle: '',
|
||||||
|
confirmMessage: '',
|
||||||
|
confirmButtonClass: '',
|
||||||
|
confirmCallback: null,
|
||||||
|
openConfirm(title, message, buttonClass, callback) {
|
||||||
|
this.confirmTitle = title;
|
||||||
|
this.confirmMessage = message;
|
||||||
|
this.confirmButtonClass = buttonClass;
|
||||||
|
this.confirmCallback = callback;
|
||||||
|
this.showConfirm = true;
|
||||||
|
},
|
||||||
|
executeConfirm() {
|
||||||
|
if (this.confirmCallback) this.confirmCallback();
|
||||||
|
this.showConfirm = false;
|
||||||
|
},
|
||||||
|
expandedComment: null,
|
||||||
|
toggleExpand(id) {
|
||||||
|
this.expandedComment = this.expandedComment === id ? null : id;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-on:notify.window="
|
||||||
|
$dispatch('toast', { type: $event.detail.type, message: $event.detail.message })
|
||||||
|
">
|
||||||
|
|
||||||
|
{{-- Notifications --}}
|
||||||
|
<div x-data="{ toasts: [] }"
|
||||||
|
x-on:toast.window="
|
||||||
|
const id = Date.now();
|
||||||
|
toasts.push({ id, type: $event.detail.type, message: $event.detail.message });
|
||||||
|
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);
|
||||||
|
"
|
||||||
|
class="fixed top-4 right-4 z-[100] space-y-2 w-80"
|
||||||
|
>
|
||||||
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
|
<div x-show="true"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="translate-x-full opacity-0"
|
||||||
|
x-transition:enter-end="translate-x-0 opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="translate-x-0 opacity-100"
|
||||||
|
x-transition:leave-end="translate-x-full opacity-0"
|
||||||
|
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
|
||||||
|
class="rounded-lg px-4 py-3 text-white text-sm shadow-lg flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span x-text="toast.message"></span>
|
||||||
|
<button @click="toasts = toasts.filter(t => t.id !== toast.id)" class="ml-2 text-white/80 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Confirmation Modal --}}
|
||||||
|
<div x-show="showConfirm"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/60"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
<div x-show="showConfirm"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="scale-95 opacity-0"
|
||||||
|
x-transition:enter-end="scale-100 opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="scale-100 opacity-100"
|
||||||
|
x-transition:leave-end="scale-95 opacity-0"
|
||||||
|
@click.away="showConfirm = false"
|
||||||
|
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="confirmTitle"></h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300" x-text="confirmMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="showConfirm = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button @click="executeConfirm()"
|
||||||
|
:class="confirmButtonClass || 'bg-red-600 hover:bg-red-700'"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white rounded-lg transition">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Main Content --}}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-full xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||||
|
{{-- Filter Bar --}}
|
||||||
|
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Comment Text</label>
|
||||||
<div class="flex items-center justify-center">
|
<input wire:model.live.debounce.400ms="search" type="search"
|
||||||
<div class="relative overflow-x-auto rounded-lg w-3/6">
|
class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
|
||||||
|
placeholder="Search comment body...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Author Username</label>
|
||||||
|
<input wire:model.live.debounce.400ms="userSearch" type="search"
|
||||||
|
class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
|
||||||
|
placeholder="Search by username...">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<button wire:click="clearFilters"
|
||||||
|
class="h-9 px-3 text-xs font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Per-page and bulk actions bar --}}
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Show</label>
|
||||||
|
<select wire:model.live="perPage"
|
||||||
|
class="h-8 text-xs border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-2">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bulk Actions --}}
|
||||||
|
@if(count($selected) > 0)
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ count($selected) }} selected</span>
|
||||||
|
<button @click="openConfirm('Delete Selected Comments', 'Are you sure you want to delete ' + {{ count($selected) }} + ' comment(s)? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.bulkDelete())"
|
||||||
|
class="h-8 px-3 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition">
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
<button @click="openConfirm('Ban Comment Authors', 'Are you sure you want to ban the authors of ' + {{ count($selected) }} + ' comment(s)?', 'bg-rose-600 hover:bg-rose-700', () => $wire.bulkBanAuthors())"
|
||||||
|
class="h-8 px-3 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition">
|
||||||
|
Ban Authors
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Table --}}
|
||||||
|
<div class="relative overflow-x-auto rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white">
|
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white">
|
||||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200">
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="px-6 py-3 text-center">
|
<th scope="col" class="px-4 py-3 w-10">
|
||||||
User
|
<input type="checkbox" wire:model.live="selectPage"
|
||||||
<input
|
class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
|
||||||
wire:model.live.debounce.600ms="userSearch"
|
|
||||||
type="search"
|
|
||||||
id="live-search"
|
|
||||||
class="w-32 h-7 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
|
|
||||||
placeholder="Search..."
|
|
||||||
>
|
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="px-6 py-3">
|
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||||
|
wire:click="sortBy('user_id')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Author
|
||||||
|
@if($sortField === 'user_id')
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
@if($sortDirection === 'asc')
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||||
|
@else
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
@endif
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||||
|
wire:click="sortBy('body')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
Comment
|
Comment
|
||||||
<input
|
@if($sortField === 'body')
|
||||||
wire:model.live.debounce.600ms="search"
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="search"
|
@if($sortDirection === 'asc')
|
||||||
id="live-search"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||||
class="ml-2 w-32 h-7 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
|
@else
|
||||||
placeholder="Search..."
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
>
|
@endif
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="px-6 py-3">
|
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||||
</th>
|
wire:click="sortBy('created_at')">
|
||||||
<th scope="col" class="px-6 py-3">
|
<div class="flex items-center gap-1">
|
||||||
Actions
|
Date
|
||||||
|
@if($sortField === 'created_at')
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
@if($sortDirection === 'asc')
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||||
|
@else
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
@endif
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach($comments as $comment)
|
@forelse($comments as $comment)
|
||||||
<tr wire:key="comment-{{ $comment->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700">
|
<tr wire:key="comment-{{ $comment->id }}"
|
||||||
<td class="px-6 py-4">
|
class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
|
||||||
{{ $comment->user->name }}
|
<td class="px-4 py-3">
|
||||||
|
<input type="checkbox" wire:model.live="selected" value="{{ $comment->id }}"
|
||||||
|
class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
|
||||||
</td>
|
</td>
|
||||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if($comment->user)
|
||||||
|
<img src="{{ $comment->user->getAvatar() }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ $comment->user->name }}</span>
|
||||||
|
@if($comment->user->hasRole(\App\Enums\UserRole::BANNED))
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 ml-1 rounded text-[10px] font-medium bg-red-600 text-white">Banned</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Unknown</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 max-w-lg">
|
||||||
|
<div x-data="{ expanded: false }" class="relative">
|
||||||
|
<p x-show="!expanded" class="text-gray-900 dark:text-white line-clamp-2 whitespace-pre-wrap break-words">
|
||||||
{{ $comment->body }}
|
{{ $comment->body }}
|
||||||
</th>
|
</p>
|
||||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
|
<p x-show="expanded" class="text-gray-900 dark:text-white whitespace-pre-wrap break-words">
|
||||||
{{ $comment->created_at }}
|
{{ $comment->body }}
|
||||||
</th>
|
</p>
|
||||||
<td class="px-6 py-4">
|
@if(strlen($comment->body) > 150)
|
||||||
<button wire:click="deleteComment({{$comment->id}})" type="button" class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150 mt-2">
|
<button @click="expanded = !expanded"
|
||||||
|
class="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1">
|
||||||
|
<span x-text="expanded ? 'Show less' : 'Read more'"></span>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs whitespace-nowrap">
|
||||||
|
<span title="{{ $comment->created_at->format('Y-m-d H:i:s') }}" class="cursor-help">
|
||||||
|
{{ $comment->created_at->diffForHumans() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button @click="openConfirm('Delete Comment', 'Are you sure you want to delete this comment? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.deleteComment({{ $comment->id }}))"
|
||||||
|
class="inline-block rounded bg-red-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-red-700 transition">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
@if($comment->user && !$comment->user->hasRole(\App\Enums\UserRole::BANNED))
|
||||||
|
<button @click="openConfirm('Ban Author', 'Are you sure you want to ban {{ addslashes($comment->user->name) }}?', 'bg-rose-600 hover:bg-rose-700', () => $wire.banCommentAuthor({{ $comment->id }}))"
|
||||||
|
class="inline-block rounded bg-rose-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-rose-700 transition">
|
||||||
|
Ban Author
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@empty
|
||||||
|
<tr class="bg-white dark:bg-neutral-800">
|
||||||
|
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
|
<p class="text-sm">No comments found matching your filters.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Pagination --}}
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
@if($comments->total() > 0)
|
||||||
|
Showing {{ $comments->firstItem() }} to {{ $comments->lastItem() }} of {{ $comments->total() }} comments
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
{{ $comments->links('pagination::tailwind') }}
|
{{ $comments->links('pagination::tailwind') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,105 +1,435 @@
|
|||||||
|
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]"
|
||||||
|
x-data="{
|
||||||
|
showConfirm: false,
|
||||||
|
confirmAction: null,
|
||||||
|
confirmTitle: '',
|
||||||
|
confirmMessage: '',
|
||||||
|
confirmButtonClass: '',
|
||||||
|
confirmCallback: null,
|
||||||
|
openConfirm(title, message, buttonClass, callback) {
|
||||||
|
this.confirmTitle = title;
|
||||||
|
this.confirmMessage = message;
|
||||||
|
this.confirmButtonClass = buttonClass;
|
||||||
|
this.confirmCallback = callback;
|
||||||
|
this.showConfirm = true;
|
||||||
|
},
|
||||||
|
executeConfirm() {
|
||||||
|
if (this.confirmCallback) this.confirmCallback();
|
||||||
|
this.showConfirm = false;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-on:notify.window="
|
||||||
|
$dispatch('toast', { type: $event.detail.type, message: $event.detail.message })
|
||||||
|
">
|
||||||
|
|
||||||
|
{{-- Notifications --}}
|
||||||
|
<div x-data="{ toasts: [] }"
|
||||||
|
x-on:toast.window="
|
||||||
|
const id = Date.now();
|
||||||
|
toasts.push({ id, type: $event.detail.type, message: $event.detail.message });
|
||||||
|
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);
|
||||||
|
"
|
||||||
|
class="fixed top-4 right-4 z-[100] space-y-2 w-80"
|
||||||
|
>
|
||||||
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
|
<div x-show="true"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="translate-x-full opacity-0"
|
||||||
|
x-transition:enter-end="translate-x-0 opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="translate-x-0 opacity-100"
|
||||||
|
x-transition:leave-end="translate-x-full opacity-0"
|
||||||
|
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
|
||||||
|
class="rounded-lg px-4 py-3 text-white text-sm shadow-lg flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span x-text="toast.message"></span>
|
||||||
|
<button @click="toasts = toasts.filter(t => t.id !== toast.id)" class="ml-2 text-white/80 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Confirmation Modal --}}
|
||||||
|
<div x-show="showConfirm"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/60"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
<div x-show="showConfirm"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="scale-95 opacity-0"
|
||||||
|
x-transition:enter-end="scale-100 opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="scale-100 opacity-100"
|
||||||
|
x-transition:leave-end="scale-95 opacity-0"
|
||||||
|
@click.away="showConfirm = false"
|
||||||
|
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="confirmTitle"></h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300" x-text="confirmMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="showConfirm = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button @click="executeConfirm()"
|
||||||
|
:class="confirmButtonClass || 'bg-red-600 hover:bg-red-700'"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white rounded-lg transition">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- User Detail Modal --}}
|
||||||
|
@if($showUserModal && $modalUser)
|
||||||
|
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-black/60"
|
||||||
|
x-data="{}"
|
||||||
|
wire:key="user-modal-{{ $modalUser->id }}"
|
||||||
|
>
|
||||||
|
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto border border-gray-200 dark:border-neutral-700"
|
||||||
|
@click.away="$wire.closeModal()"
|
||||||
|
>
|
||||||
|
{{-- Modal Header --}}
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-neutral-800 border-b border-gray-200 dark:border-neutral-700 px-6 py-4 flex items-center justify-between z-10 rounded-t-xl">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<img src="{{ $modalUser->getAvatar() }}" alt="{{ $modalUser->name }}" class="w-10 h-10 rounded-full object-cover">
|
||||||
|
{{ $modalUser->name }}
|
||||||
|
</h2>
|
||||||
|
<button wire:click="closeModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-white transition">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Modal Body --}}
|
||||||
|
<div class="px-6 py-4 space-y-6">
|
||||||
|
{{-- User Info Grid --}}
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User ID</dt>
|
||||||
<div class="flex items-center justify-center">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">#{{ $modalUser->id }}</dd>
|
||||||
<div class="relative overflow-x-auto rounded-lg w-3/6">
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Email</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->email ?? 'n/a' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Discord ID</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">{{ $modalUser->discord_id ?? 'n/a' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Registered</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->created_at->format('Y-m-d H:i') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Updated</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->updated_at->format('Y-m-d H:i') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Comments</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $modalUser->commentCount() }}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Roles --}}
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Roles</dt>
|
||||||
|
<dd class="flex flex-wrap gap-2">
|
||||||
|
@php $roleLabels = [
|
||||||
|
\App\Enums\UserRole::ADMINISTRATOR->value => ['bg-purple-600 text-white', 'Admin'],
|
||||||
|
\App\Enums\UserRole::MODERATOR->value => ['bg-blue-600 text-white', 'Moderator'],
|
||||||
|
\App\Enums\UserRole::SUPPORTER->value => ['bg-pink-600 text-white', 'Patreon'],
|
||||||
|
\App\Enums\UserRole::BANNED->value => ['bg-red-600 text-white', 'Banned'],
|
||||||
|
]; @endphp
|
||||||
|
@foreach($roleLabels as $role => $classes)
|
||||||
|
@if($modalUser->hasRole(\App\Enums\UserRole::from($role)))
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $classes[0] }}">{{ $classes[1] }}</span>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@if(empty($modalUser->roles))
|
||||||
|
<span class="text-sm text-gray-400 dark:text-gray-500">No special roles</span>
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Modal Actions --}}
|
||||||
|
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-200 dark:border-neutral-700">
|
||||||
|
@if($modalUser->hasRole(\App\Enums\UserRole::BANNED))
|
||||||
|
<button wire:click="unbanUser({{ $modalUser->id }})"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
||||||
|
Unban User
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button wire:click="banUser({{ $modalUser->id }})"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition">
|
||||||
|
Ban User
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($modalUser->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||||
|
<button wire:click="revokeModerator({{ $modalUser->id }})"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition">
|
||||||
|
Revoke Moderator
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button wire:click="grantModerator({{ $modalUser->id }})"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||||
|
Grant Moderator
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<button wire:click="deleteUserComments({{ $modalUser->id }})"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition">
|
||||||
|
Delete All Comments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Recent Comments --}}
|
||||||
|
@if($modalUserComments->isNotEmpty())
|
||||||
|
<div class="pt-2 border-t border-gray-200 dark:border-neutral-700">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">Recent Comments (last {{ $modalUserComments->count() }})</h4>
|
||||||
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
@foreach($modalUserComments as $comment)
|
||||||
|
<div class="bg-gray-50 dark:bg-neutral-900 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<p class="line-clamp-2">{{ $comment->body }}</p>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 mt-1 block">{{ $comment->created_at->diffForHumans() }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Modal Footer --}}
|
||||||
|
<div class="sticky bottom-0 bg-gray-50 dark:bg-neutral-900 border-t border-gray-200 dark:border-neutral-700 px-6 py-3 rounded-b-xl flex justify-end">
|
||||||
|
<button wire:click="closeModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Main Table Area --}}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-full xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||||
|
{{-- Filter Bar --}}
|
||||||
|
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Username</label>
|
||||||
|
<input wire:model.live.debounce.400ms="search" type="search"
|
||||||
|
class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
|
||||||
|
placeholder="Search username...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Discord ID</label>
|
||||||
|
<input wire:model.live.debounce.400ms="discordId" type="search"
|
||||||
|
class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
|
||||||
|
placeholder="Discord ID...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Email</label>
|
||||||
|
<input wire:model.live.debounce.400ms="email" type="search"
|
||||||
|
class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-500 dark:focus:border-rose-600 px-3"
|
||||||
|
placeholder="Search email...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Role Filter</label>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
@foreach([
|
||||||
|
'admin' => 'Admin',
|
||||||
|
'moderator' => 'Moderator',
|
||||||
|
'supporter' => 'Supporter',
|
||||||
|
'banned' => 'Banned',
|
||||||
|
] as $val => $label)
|
||||||
|
<label class="inline-flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input type="checkbox" wire:model.live="roleFilter" value="{{ $val }}"
|
||||||
|
class="w-3.5 h-3.5 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ $label }}</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<button wire:click="clearFilters"
|
||||||
|
class="h-9 px-3 text-xs font-medium text-gray-600 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 transition">
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Per-page and bulk actions bar --}}
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Show</label>
|
||||||
|
<select wire:model.live="perPage"
|
||||||
|
class="h-8 text-xs border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-2">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bulk Actions --}}
|
||||||
|
@if(count($selected) > 0)
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ count($selected) }} selected</span>
|
||||||
|
<button @click="openConfirm('Ban Selected Users', 'Are you sure you want to ban ' + {{ count($selected) }} + ' user(s)?', 'bg-rose-600 hover:bg-rose-700', () => $wire.bulkBan())"
|
||||||
|
class="h-8 px-3 text-xs font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition">
|
||||||
|
Ban Selected
|
||||||
|
</button>
|
||||||
|
<button @click="openConfirm('Unban Selected Users', 'Are you sure you want to unban ' + {{ count($selected) }} + ' user(s)?', 'bg-green-600 hover:bg-green-700', () => $wire.bulkUnban())"
|
||||||
|
class="h-8 px-3 text-xs font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
||||||
|
Unban Selected
|
||||||
|
</button>
|
||||||
|
<button @click="openConfirm('Delete Comments', 'Are you sure you want to delete ALL comments from ' + {{ count($selected) }} + ' user(s)? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.bulkDeleteComments())"
|
||||||
|
class="h-8 px-3 text-xs font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition">
|
||||||
|
Delete Selected Comments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Table --}}
|
||||||
|
<div class="relative overflow-x-auto rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white">
|
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-white">
|
||||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200">
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-pink-700 dark:text-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="px-6 py-3">
|
<th scope="col" class="px-4 py-3 w-10">
|
||||||
ID
|
<input type="checkbox" wire:model.live="selectPage"
|
||||||
|
class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="px-6 py-3">
|
@foreach([
|
||||||
Discord ID
|
'id' => 'ID',
|
||||||
<input
|
'discord_id' => 'Discord ID',
|
||||||
wire:model.live.debounce.600ms="discordId"
|
'name' => 'Username',
|
||||||
type="search"
|
'email' => 'Email',
|
||||||
id="discord-search"
|
'created_at' => 'Registered',
|
||||||
class="ml-2 w-32 h-7 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
|
'updated_at' => 'Updated',
|
||||||
placeholder="Search..."
|
] as $field => $label)
|
||||||
>
|
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||||
</th>
|
wire:click="sortBy('{{ $field }}')">
|
||||||
<th scope="col" class="px-6 py-3">
|
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||||
Username
|
{{ $label }}
|
||||||
<input
|
@if($sortField === $field)
|
||||||
wire:model.live.debounce.600ms="search"
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="search"
|
@if($sortDirection === 'asc')
|
||||||
id="live-search"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
||||||
class="ml-2 w-32 h-7 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
|
@else
|
||||||
placeholder="Search..."
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
>
|
@endif
|
||||||
</th>
|
</svg>
|
||||||
<th scope="col" class="px-6 py-3">
|
@endif
|
||||||
Patreon
|
</div>
|
||||||
<input
|
|
||||||
class="w-4 h-4 ml-2 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
||||||
type="checkbox"
|
|
||||||
wire:model.live="patreon"
|
|
||||||
value="true"
|
|
||||||
>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3">
|
|
||||||
Banned
|
|
||||||
<input
|
|
||||||
class="w-4 h-4 ml-2 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
||||||
type="checkbox"
|
|
||||||
wire:model.live="banned"
|
|
||||||
value="true"
|
|
||||||
>
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3">
|
|
||||||
Created at
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3">
|
|
||||||
Updated at
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3">
|
|
||||||
Actions
|
|
||||||
</th>
|
</th>
|
||||||
|
@endforeach
|
||||||
|
<th scope="col" class="px-4 py-3">Roles</th>
|
||||||
|
<th scope="col" class="px-4 py-3">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach($users as $user)
|
@forelse($users as $user)
|
||||||
<tr wire:key="user-{{ $user->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700">
|
<tr wire:key="user-{{ $user->id }}"
|
||||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
|
||||||
{{ $user->id }}
|
<td class="px-4 py-3">
|
||||||
</th>
|
<input type="checkbox" wire:model.live="selected" value="{{ $user->id }}"
|
||||||
<td class="px-6 py-4">
|
class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
|
||||||
{{ $user->discord_id ?? 'n/a' }}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-4 py-3 font-mono text-xs">{{ $user->id }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs">{{ $user->discord_id ?? 'n/a' }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<button wire:click="viewUser({{ $user->id }})"
|
||||||
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-2">
|
||||||
|
<img src="{{ $user->getAvatar() }}" alt="" class="w-6 h-6 rounded-full object-cover">
|
||||||
{{ $user->name }}
|
{{ $user->name }}
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
{{ $user->hasRole(\App\Enums\UserRole::SUPPORTER) ? 'Yes' : 'No' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
{{ $user->hasRole(\App\Enums\UserRole::BANNED) ? 'Yes' : 'No' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
{{ $user->created_at->format('Y-m-d') }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
{{ $user->updated_at->format('Y-m-d') }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 flex flex-col gap-1">
|
|
||||||
<form method="POST" action="{{ route('admin.user.update') }}">
|
|
||||||
@csrf
|
|
||||||
<input type="hidden" value="{{ $user->id }}" name="id">
|
|
||||||
<input type="hidden" value="{{ $user->hasRole(\App\Enums\UserRole::BANNED) ? 'unban' : 'ban' }}" name="action">
|
|
||||||
<button type="submit" class="inline-block w-full rounded bg-rose-600 pl-[4px] pr-[4px] p-[1px] text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
|
|
||||||
{{ $user->hasRole(\App\Enums\UserRole::BANNED) ? 'Unban' : 'Ban' }}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</td>
|
||||||
<button wire:click="deleteUserComments('{{ $user->id }}')" class="inline-block w-full rounded bg-red-600 pl-[4px] pr-[4px] p-[1px] text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600">
|
<td class="px-4 py-3 text-xs">{{ $user->email ?? 'n/a' }}</td>
|
||||||
Delete comments
|
<td class="px-4 py-3 text-xs whitespace-nowrap">{{ $user->created_at->format('Y-m-d') }}</td>
|
||||||
|
<td class="px-4 py-3 text-xs whitespace-nowrap">{{ $user->updated_at->format('Y-m-d') }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
@if($user->hasRole(\App\Enums\UserRole::ADMINISTRATOR))
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-600 text-white">Admin</span>
|
||||||
|
@endif
|
||||||
|
@if($user->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-600 text-white">Mod</span>
|
||||||
|
@endif
|
||||||
|
@if($user->hasRole(\App\Enums\UserRole::SUPPORTER))
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-pink-600 text-white">Supp.</span>
|
||||||
|
@endif
|
||||||
|
@if($user->hasRole(\App\Enums\UserRole::BANNED))
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-600 text-white">Banned</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
@if($user->hasRole(\App\Enums\UserRole::BANNED))
|
||||||
|
<button wire:click="unbanUser({{ $user->id }})"
|
||||||
|
class="inline-block rounded bg-green-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-green-700 transition">
|
||||||
|
Unban
|
||||||
</button>
|
</button>
|
||||||
|
@else
|
||||||
|
<button wire:click="banUser({{ $user->id }})"
|
||||||
|
class="inline-block rounded bg-rose-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-rose-700 transition">
|
||||||
|
Ban
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($user->hasRole(\App\Enums\UserRole::MODERATOR))
|
||||||
|
<button @click="openConfirm('Revoke Moderator', 'Are you sure you want to revoke moderator role from {{ addslashes($user->name) }}?', 'bg-amber-600 hover:bg-amber-700', () => $wire.revokeModerator({{ $user->id }}))"
|
||||||
|
class="inline-block rounded bg-amber-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-amber-700 transition">
|
||||||
|
Revoke Mod
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button wire:click="grantModerator({{ $user->id }})"
|
||||||
|
class="inline-block rounded bg-blue-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-blue-700 transition">
|
||||||
|
Grant Mod
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<button @click="openConfirm('Delete Comments', 'Are you sure you want to delete ALL comments from {{ addslashes($user->name) }}? This cannot be undone.', 'bg-red-600 hover:bg-red-700', () => $wire.deleteUserComments({{ $user->id }}))"
|
||||||
|
class="inline-block rounded bg-red-600 px-2 py-1 text-[10px] font-medium uppercase leading-normal text-white hover:bg-red-700 transition">
|
||||||
|
Del Comments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@empty
|
||||||
|
<tr class="bg-white dark:bg-neutral-800">
|
||||||
|
<td colspan="9" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
|
<p class="text-sm">No users found matching your filters.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Pagination --}}
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
@if($users->total() > 0)
|
||||||
|
Showing {{ $users->firstItem() }} to {{ $users->lastItem() }} of {{ $users->total() }} users
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
{{ $users->links('pagination::tailwind') }}
|
{{ $users->links('pagination::tailwind') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user