Improve admin user & comment moderation page
This commit is contained in:
@@ -1,60 +1,291 @@
|
||||
<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">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-x-auto rounded-lg w-3/6">
|
||||
<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>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Comment Text</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 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">
|
||||
<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>
|
||||
<th scope="col" class="px-6 py-3 text-center">
|
||||
User
|
||||
<input
|
||||
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 scope="col" class="px-4 py-3 w-10">
|
||||
<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 scope="col" class="px-6 py-3">
|
||||
Comment
|
||||
<input
|
||||
wire:model.live.debounce.600ms="search"
|
||||
type="search"
|
||||
id="live-search"
|
||||
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"
|
||||
placeholder="Search..."
|
||||
>
|
||||
<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-6 py-3">
|
||||
<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
|
||||
@if($sortField === 'body')
|
||||
<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-6 py-3">
|
||||
Actions
|
||||
<th scope="col" class="px-4 py-3 cursor-pointer select-none hover:bg-pink-800/50 transition"
|
||||
wire:click="sortBy('created_at')">
|
||||
<div class="flex items-center gap-1">
|
||||
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 scope="col" class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($comments as $comment)
|
||||
<tr wire:key="comment-{{ $comment->id }}" class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700">
|
||||
<td class="px-6 py-4">
|
||||
{{ $comment->user->name }}
|
||||
@forelse($comments as $comment)
|
||||
<tr wire:key="comment-{{ $comment->id }}"
|
||||
class="bg-white border-t dark:bg-neutral-800 dark:border-pink-700 hover:bg-gray-50 dark:hover:bg-neutral-750 transition">
|
||||
<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>
|
||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
|
||||
{{ $comment->body }}
|
||||
</th>
|
||||
<th scope="row" class="px-6 py-4 font-medium text-gray-900 dark:text-white max-w-lg">
|
||||
{{ $comment->created_at }}
|
||||
</th>
|
||||
<td class="px-6 py-4">
|
||||
<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">
|
||||
Delete
|
||||
</button>
|
||||
<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 }}
|
||||
</p>
|
||||
<p x-show="expanded" class="text-gray-900 dark:text-white whitespace-pre-wrap break-words">
|
||||
{{ $comment->body }}
|
||||
</p>
|
||||
@if(strlen($comment->body) > 150)
|
||||
<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
|
||||
</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>
|
||||
</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>
|
||||
</table>
|
||||
</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 class="flex items-center gap-1">
|
||||
{{ $comments->links('pagination::tailwind') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ $comments->links('pagination::tailwind') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user