From b5b6841eca4800dd8f201372ad3bd4caf23f8ba6 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 13 Jul 2026 11:36:42 +0200 Subject: [PATCH] Update admin area views and controllers --- .../Admin/AdminGroupController.php | 27 ++-- .../Admin/AdminReleaseReportController.php | 129 ++++++++++-------- .../Admin/AdminReleasesController.php | 13 +- .../Requests/Admin/AdminGroupListRequest.php | 42 ++++++ .../Admin/AdminReleaseListRequest.php | 54 ++++++++ .../AdminReleaseReportBulkActionRequest.php | 42 ++++++ .../Admin/AdminReleaseReportListRequest.php | 33 +++++ app/Models/Release.php | 16 ++- app/Models/ReleaseReport.php | 25 +++- app/Models/UsenetGroup.php | 16 ++- ...000_add_admin_list_performance_indexes.php | 92 +++++++++++++ .../js/alpine/components/admin/features.js | 17 --- .../js/alpine/components/admin/verify-user.js | 36 +++++ resources/js/alpine/lazy-loader.js | 2 +- resources/views/admin/groups/index.blade.php | 123 +++++++---------- resources/views/admin/logs/index.blade.php | 36 ++--- .../admin/release-reports/index.blade.php | 80 +++++------ .../views/admin/releases/index.blade.php | 42 +++--- resources/views/admin/users/index.blade.php | 35 ++--- .../components/admin/action-bar.blade.php | 6 + .../views/components/admin/badge.blade.php | 23 ++++ .../views/components/admin/button.blade.php | 35 +++++ .../views/components/admin/card.blade.php | 3 +- .../components/admin/data-table.blade.php | 19 ++- .../components/admin/empty-state.blade.php | 18 +++ .../components/admin/filter-panel.blade.php | 4 + resources/views/components/admin/th.blade.php | 3 +- .../Feature/AdminListPerformancePlanTest.php | 123 +++++++++++++++++ .../AdminReleaseReportControllerTest.php | 118 +++++++++++++++- 29 files changed, 896 insertions(+), 316 deletions(-) create mode 100644 app/Http/Requests/Admin/AdminGroupListRequest.php create mode 100644 app/Http/Requests/Admin/AdminReleaseListRequest.php create mode 100644 app/Http/Requests/Admin/AdminReleaseReportBulkActionRequest.php create mode 100644 app/Http/Requests/Admin/AdminReleaseReportListRequest.php create mode 100644 database/migrations/2026_07_13_000000_add_admin_list_performance_indexes.php create mode 100644 resources/js/alpine/components/admin/verify-user.js create mode 100644 resources/views/components/admin/action-bar.blade.php create mode 100644 resources/views/components/admin/badge.blade.php create mode 100644 resources/views/components/admin/button.blade.php create mode 100644 resources/views/components/admin/empty-state.blade.php create mode 100644 resources/views/components/admin/filter-panel.blade.php create mode 100644 tests/Feature/AdminListPerformancePlanTest.php diff --git a/app/Http/Controllers/Admin/AdminGroupController.php b/app/Http/Controllers/Admin/AdminGroupController.php index 40ed61af5..4e409a623 100644 --- a/app/Http/Controllers/Admin/AdminGroupController.php +++ b/app/Http/Controllers/Admin/AdminGroupController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; +use App\Http\Requests\Admin\AdminGroupListRequest; use App\Models\UsenetGroup; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -15,9 +16,9 @@ class AdminGroupController extends BasePageController /** * @throws \Exception */ - public function index(Request $request): mixed + public function index(AdminGroupListRequest $request): mixed { - $groupname = $request->input('groupname') ?? ''; + $groupname = $request->groupName(); $grouplist = UsenetGroup::getGroupsRange($groupname); $title = 'Group List'; @@ -106,15 +107,10 @@ class AdminGroupController extends BasePageController /** * @throws \Exception */ - public function active(Request $request): mixed + public function active(AdminGroupListRequest $request): mixed { - $gname = ''; - if (! empty($request->input('groupname'))) { - $gname = $request->input('groupname'); - } - - $groupname = ! empty($request->input('groupname')) ? $request->input('groupname') : ''; - $grouplist = UsenetGroup::getGroupsRange($gname, true); + $groupname = $request->groupName(); + $grouplist = UsenetGroup::getGroupsRange($groupname, true); $title = 'Active Groups'; return view('admin.groups.index', compact('title', 'groupname', 'grouplist')); @@ -123,15 +119,10 @@ class AdminGroupController extends BasePageController /** * @throws \Exception */ - public function inactive(Request $request): mixed + public function inactive(AdminGroupListRequest $request): mixed { - $gname = ''; - if (! empty($request->input('groupname'))) { - $gname = $request->input('groupname'); - } - - $groupname = ! empty($request->input('groupname')) ? $request->input('groupname') : ''; - $grouplist = UsenetGroup::getGroupsRange($gname, false); + $groupname = $request->groupName(); + $grouplist = UsenetGroup::getGroupsRange($groupname, false); $title = 'Inactive Groups'; return view('admin.groups.index', compact('title', 'groupname', 'grouplist')); diff --git a/app/Http/Controllers/Admin/AdminReleaseReportController.php b/app/Http/Controllers/Admin/AdminReleaseReportController.php index ff6856261..e7a40c215 100644 --- a/app/Http/Controllers/Admin/AdminReleaseReportController.php +++ b/app/Http/Controllers/Admin/AdminReleaseReportController.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; +use App\Http\Requests\Admin\AdminReleaseReportBulkActionRequest; +use App\Http\Requests\Admin\AdminReleaseReportListRequest; use App\Models\Release; use App\Models\ReleaseReport; use App\Services\Releases\ReleaseBrowseService; @@ -18,11 +20,11 @@ class AdminReleaseReportController extends BasePageController /** * Display a listing of release reports. */ - public function index(Request $request): View + public function index(AdminReleaseReportListRequest $request): View { $this->setAdminPrefs(); - $status = $request->input('status', 'pending'); + $status = $request->status(); $reportsList = ReleaseReport::getReportsRange($status, 50); $statusCounts = ReleaseReport::getCountByStatus(); @@ -165,62 +167,13 @@ class AdminReleaseReportController extends BasePageController /** * Bulk update report statuses. */ - public function bulkAction(Request $request): RedirectResponse + public function bulkAction(AdminReleaseReportBulkActionRequest $request): RedirectResponse { - $request->validate([ - 'action' => 'required|in:dismiss,resolve,reviewed,delete,revert', - 'report_ids' => 'required|array', - 'report_ids.*' => 'integer', - ]); - - $action = $request->input('action'); - $reportIds = $request->input('report_ids'); - - $count = 0; - - foreach ($reportIds as $reportId) { - $report = ReleaseReport::with('release')->find($reportId); - if (! $report) { - continue; - } - - if ($action === 'delete' && $report->release) { - $releaseId = $report->releases_id; - Release::where('id', $releaseId)->delete(); - ReleaseReport::where('releases_id', $releaseId) - ->update([ - 'status' => 'resolved', - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); - } elseif ($action === 'dismiss') { - $report->update([ - 'status' => 'dismissed', - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); - } elseif ($action === 'resolve') { - $report->update([ - 'status' => 'resolved', - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); - } elseif ($action === 'reviewed') { - $report->update([ - 'status' => 'reviewed', - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); - } elseif ($action === 'revert' && in_array($report->status, ['resolved', 'dismissed'])) { - $report->update([ - 'status' => 'reviewed', - 'reviewed_by' => Auth::id(), - 'reviewed_at' => now(), - ]); - } - - $count++; - } + $action = $request->actionName(); + $reportIds = $request->reportIds(); + $count = $action === 'delete' + ? $this->bulkDeleteReportedReleases($reportIds) + : $this->bulkUpdateReportStatuses($reportIds, $action); if ($count > 0) { ReleaseBrowseService::bumpCacheVersion(); @@ -237,4 +190,66 @@ class AdminReleaseReportController extends BasePageController return redirect()->back()->with('success', "{$count} report(s) {$actionLabel} successfully."); } + + /** + * @param list $reportIds + */ + private function bulkUpdateReportStatuses(array $reportIds, string $action): int + { + $status = match ($action) { + 'dismiss' => 'dismissed', + 'resolve' => 'resolved', + 'reviewed', 'revert' => 'reviewed', + default => null, + }; + + if ($status === null) { + return 0; + } + + $query = ReleaseReport::query() + ->whereIn('id', $reportIds) + ->when($action === 'revert', fn ($query) => $query->whereIn('status', ['resolved', 'dismissed'])); + + $count = (clone $query)->count(); + + $query->update([ + 'status' => $status, + 'reviewed_by' => Auth::id(), + 'reviewed_at' => now(), + ]); + + return $count; + } + + /** + * @param list $reportIds + */ + private function bulkDeleteReportedReleases(array $reportIds): int + { + $reports = ReleaseReport::query() + ->with('release:id') + ->whereIn('id', $reportIds) + ->get(['id', 'releases_id']); + + $count = 0; + + foreach ($reports as $report) { + if (! $report->release) { + continue; + } + + $releaseId = $report->releases_id; + Release::where('id', $releaseId)->delete(); + ReleaseReport::where('releases_id', $releaseId) + ->update([ + 'status' => 'resolved', + 'reviewed_by' => Auth::id(), + 'reviewed_at' => now(), + ]); + $count++; + } + + return $count; + } } diff --git a/app/Http/Controllers/Admin/AdminReleasesController.php b/app/Http/Controllers/Admin/AdminReleasesController.php index d21c69349..aca0174cd 100644 --- a/app/Http/Controllers/Admin/AdminReleasesController.php +++ b/app/Http/Controllers/Admin/AdminReleasesController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; +use App\Http\Requests\Admin\AdminReleaseListRequest; use App\Models\Category; use App\Models\Release; use App\Services\Releases\ReleaseManagementService; @@ -27,20 +28,17 @@ class AdminReleasesController extends BasePageController /** * @throws \Exception */ - public function index(Request $request): mixed + public function index(AdminReleaseListRequest $request): mixed { $this->setAdminPrefs(); $meta_title = $title = 'Release List'; $page = (int) $request->input('page', 1); - $search = trim((string) $request->input('search', '')); - $categoryId = $request->filled('category_id') ? (int) $request->input('category_id') : null; - if ($categoryId === -1) { - $categoryId = null; - } + $search = $request->searchTerm(); + $categoryId = $request->categoryId(); - $releaseList = Release::getReleasesRange($page, $search !== '' ? $search : null, $categoryId); + $releaseList = Release::getReleasesRange($page, $search, $categoryId); $releaseList->appends($request->only(['search', 'category_id'])); return view('admin.releases.index', [ @@ -126,6 +124,7 @@ class AdminReleasesController extends BasePageController try { if ($id) { $this->releaseManagement->deleteMultiple($id); + Release::clearAdminReleasesRangeCache(); // Handle AJAX requests if (request()->wantsJson() || request()->ajax()) { diff --git a/app/Http/Requests/Admin/AdminGroupListRequest.php b/app/Http/Requests/Admin/AdminGroupListRequest.php new file mode 100644 index 000000000..5fc8e8d97 --- /dev/null +++ b/app/Http/Requests/Admin/AdminGroupListRequest.php @@ -0,0 +1,42 @@ +input('groupname'); + + $this->merge([ + 'groupname' => is_string($groupname) ? trim($groupname) : $groupname, + ]); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'groupname' => ['nullable', 'string', 'max:255'], + 'page' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function groupName(): string + { + $value = $this->validated('groupname', ''); + + return is_string($value) ? $value : ''; + } +} diff --git a/app/Http/Requests/Admin/AdminReleaseListRequest.php b/app/Http/Requests/Admin/AdminReleaseListRequest.php new file mode 100644 index 000000000..03154dddf --- /dev/null +++ b/app/Http/Requests/Admin/AdminReleaseListRequest.php @@ -0,0 +1,54 @@ +input('search'); + + $this->merge([ + 'search' => is_string($search) ? trim($search) : $search, + ]); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'search' => ['nullable', 'string', 'max:255'], + 'category_id' => ['nullable', 'integer'], + 'page' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function searchTerm(): ?string + { + $search = $this->validated('search'); + + return is_string($search) && $search !== '' ? $search : null; + } + + public function categoryId(): ?int + { + $categoryId = $this->validated('category_id'); + + if ($categoryId === null || (int) $categoryId === -1) { + return null; + } + + return (int) $categoryId; + } +} diff --git a/app/Http/Requests/Admin/AdminReleaseReportBulkActionRequest.php b/app/Http/Requests/Admin/AdminReleaseReportBulkActionRequest.php new file mode 100644 index 000000000..b7cb3a651 --- /dev/null +++ b/app/Http/Requests/Admin/AdminReleaseReportBulkActionRequest.php @@ -0,0 +1,42 @@ + + */ + public function rules(): array + { + return [ + 'action' => ['required', 'string', 'in:dismiss,resolve,reviewed,delete,revert'], + 'report_ids' => ['required', 'array', 'min:1'], + 'report_ids.*' => ['integer', 'exists:release_reports,id'], + ]; + } + + /** + * @return list + */ + public function reportIds(): array + { + $ids = $this->validated('report_ids', []); + + return array_values(array_unique(array_map('intval', is_array($ids) ? $ids : []))); + } + + public function actionName(): string + { + return (string) $this->validated('action'); + } +} diff --git a/app/Http/Requests/Admin/AdminReleaseReportListRequest.php b/app/Http/Requests/Admin/AdminReleaseReportListRequest.php new file mode 100644 index 000000000..be6b7b5c0 --- /dev/null +++ b/app/Http/Requests/Admin/AdminReleaseReportListRequest.php @@ -0,0 +1,33 @@ +> + */ + public function rules(): array + { + return [ + 'status' => ['nullable', 'string', 'in:pending,reviewed,resolved,dismissed,all'], + 'page' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function status(): string + { + $status = $this->validated('status', 'pending'); + + return is_string($status) && $status !== '' ? $status : 'pending'; + } +} diff --git a/app/Models/Release.php b/app/Models/Release.php index 7fe3eeaed..a50fa6802 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -386,15 +386,18 @@ class Release extends Model public static function getReleasesRange(int $page = 1, ?string $search = null, ?int $categoryId = null): mixed { $search = $search !== null && $search !== '' ? $search : null; - $hasFilters = $search !== null || $categoryId !== null; if ($search !== null && Search::isAvailable()) { return self::getReleasesRangeFromSearchIndex($page, $search, $categoryId); } - if (! $hasFilters) { + $cacheable = $search === null; + $cacheKey = null; + + if ($cacheable) { $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); - $cacheKey = md5('releasesRange_'.$page); + $version = Cache::get('adminReleasesRangeVersion', 1); + $cacheKey = md5('releasesRange_'.$version.'_'.$page.'_'.($categoryId ?? 'all')); $releases = Cache::get($cacheKey); if ($releases !== null) { return $releases; @@ -413,9 +416,8 @@ class Release extends Model $releases = $query->paginate(config('nntmux.items_per_page'), ['*'], 'page', $page); - if (! $hasFilters) { - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); - Cache::put(md5('releasesRange_'.$page), $releases, $expiresAt); + if ($cacheable && $cacheKey !== null) { + Cache::put($cacheKey, $releases, $expiresAt); } return $releases; @@ -465,6 +467,8 @@ class Release extends Model */ public static function clearAdminReleasesRangeCache(int $maxPages = 25): void { + Cache::forever('adminReleasesRangeVersion', ((int) Cache::get('adminReleasesRangeVersion', 1)) + 1); + for ($page = 1; $page <= $maxPages; $page++) { Cache::forget(md5('releasesRange_'.$page)); } diff --git a/app/Models/ReleaseReport.php b/app/Models/ReleaseReport.php index d1868841c..8245cb383 100644 --- a/app/Models/ReleaseReport.php +++ b/app/Models/ReleaseReport.php @@ -126,8 +126,29 @@ class ReleaseReport extends Model int $perPage = 50 ): LengthAwarePaginator { $query = self::query() - ->with(['release', 'user', 'reviewer', 'responder']) - ->orderBy('created_at', 'desc'); + ->select([ + 'id', + 'releases_id', + 'users_id', + 'reason', + 'description', + 'response', + 'status', + 'reviewed_by', + 'reviewed_at', + 'responded_by', + 'responded_at', + 'response_is_public', + 'created_at', + 'updated_at', + ]) + ->with([ + 'release:id,guid,searchname,size', + 'user:id,username', + 'reviewer:id,username', + 'responder:id,username', + ]) + ->orderByDesc('created_at'); if ($status !== null && $status !== 'all') { $query->where('status', $status); diff --git a/app/Models/UsenetGroup.php b/app/Models/UsenetGroup.php index 7023ed45d..bc42f6ff4 100644 --- a/app/Models/UsenetGroup.php +++ b/app/Models/UsenetGroup.php @@ -215,7 +215,21 @@ class UsenetGroup extends Model public static function getGroupsRange(string $groupname = '', mixed $active = null): LengthAwarePaginator // @phpstan-ignore missingType.generics { - $groups = self::query()->groupBy('id')->orderBy('name'); + $groups = self::query() + ->select([ + 'id', + 'name', + 'description', + 'first_record_postdate', + 'last_record_postdate', + 'last_updated', + 'active', + 'backfill', + 'minfilestoformrelease', + 'minsizetoformrelease', + 'backfill_target', + ]) + ->orderBy('name'); if ($groupname !== '') { $groups->where('name', 'like', '%'.$groupname.'%'); diff --git a/database/migrations/2026_07_13_000000_add_admin_list_performance_indexes.php b/database/migrations/2026_07_13_000000_add_admin_list_performance_indexes.php new file mode 100644 index 000000000..59635a7ae --- /dev/null +++ b/database/migrations/2026_07_13_000000_add_admin_list_performance_indexes.php @@ -0,0 +1,92 @@ +> + */ + private array $indexes = [ + 'releases' => [ + 'ix_releases_categories_postdate_admin', + 'ix_releases_postdate_admin', + ], + 'usenet_groups' => [ + 'ix_usenet_groups_active_name_admin', + ], + 'release_reports' => [ + 'ix_release_reports_status_created_admin', + ], + ]; + + public function up(): void + { + if (Schema::hasTable('releases') && ! $this->indexExists('releases', 'ix_releases_categories_postdate_admin')) { + Schema::table('releases', function (Blueprint $table): void { + $table->index(['categories_id', 'postdate'], 'ix_releases_categories_postdate_admin'); + }); + } + + if (Schema::hasTable('releases') && ! $this->indexExists('releases', 'ix_releases_postdate_admin')) { + Schema::table('releases', function (Blueprint $table): void { + $table->index('postdate', 'ix_releases_postdate_admin'); + }); + } + + if (Schema::hasTable('usenet_groups') && ! $this->indexExists('usenet_groups', 'ix_usenet_groups_active_name_admin')) { + Schema::table('usenet_groups', function (Blueprint $table): void { + $table->index(['active', 'name'], 'ix_usenet_groups_active_name_admin'); + }); + } + + if (Schema::hasTable('release_reports') && ! $this->indexExists('release_reports', 'ix_release_reports_status_created_admin')) { + Schema::table('release_reports', function (Blueprint $table): void { + $table->index(['status', 'created_at'], 'ix_release_reports_status_created_admin'); + }); + } + } + + public function down(): void + { + foreach ($this->indexes as $table => $indexNames) { + if (! Schema::hasTable($table)) { + continue; + } + + foreach ($indexNames as $indexName) { + if ($this->indexExists($table, $indexName)) { + Schema::table($table, function (Blueprint $table) use ($indexName): void { + $table->dropIndex($indexName); + }); + } + } + } + } + + private function indexExists(string $table, string $indexName): bool + { + try { + return Schema::hasIndex($table, $indexName); + } catch (Throwable) { + if (DB::getDriverName() === 'sqlite') { + $indexes = DB::select("PRAGMA index_list('{$table}')"); + + foreach ($indexes as $index) { + if (($index->name ?? null) === $indexName) { + return true; + } + } + + return false; + } + } + + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + + return count($indexes) > 0; + } +}; diff --git a/resources/js/alpine/components/admin/features.js b/resources/js/alpine/components/admin/features.js index afd3082b7..918d78743 100644 --- a/resources/js/alpine/components/admin/features.js +++ b/resources/js/alpine/components/admin/features.js @@ -199,23 +199,6 @@ Alpine.data('adminUserEdit', () => ({ } })); -// Verify User Modal -Alpine.data('verifyUser', () => ({ - open: false, - _form: null, - - show(form) { this._form = form; this.open = true; }, - hide() { this.open = false; this._form = null; }, - submit() { if (this._form) this._form.submit(); this.hide(); }, - - init() { - const self = this; - window.showVerifyModal = function(e, form) { e.preventDefault(); self.show(form); }; - window.hideVerifyModal = function() { self.hide(); }; - window.submitVerifyForm = function() { self.submit(); }; - } -})); - // Admin deleted users Alpine.data('adminDeletedUsers', () => ({ allChecked: false, diff --git a/resources/js/alpine/components/admin/verify-user.js b/resources/js/alpine/components/admin/verify-user.js new file mode 100644 index 000000000..e9b9a2da5 --- /dev/null +++ b/resources/js/alpine/components/admin/verify-user.js @@ -0,0 +1,36 @@ +/** + * Alpine.data('verifyUser') - Admin user verification confirmation modal. + */ +import Alpine from '@alpinejs/csp'; + +Alpine.data('verifyUser', () => ({ + open: false, + _form: null, + + show(form) { + this._form = form; + this.open = true; + }, + + hide() { + this.open = false; + this._form = null; + }, + + submit() { + if (this._form) { + this._form.submit(); + } + + this.hide(); + }, + + init() { + window.showVerifyModal = (event, form) => { + event.preventDefault(); + this.show(form); + }; + window.hideVerifyModal = () => this.hide(); + window.submitVerifyForm = () => this.submit(); + }, +})); diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js index 196999500..ff36150f6 100644 --- a/resources/js/alpine/lazy-loader.js +++ b/resources/js/alpine/lazy-loader.js @@ -57,7 +57,7 @@ const lazyComponentMap = { 'adminInvitations': () => import('./components/admin/features.js'), // same file 'adminRegexForm': () => import('./components/admin/features.js'), // same file 'tinyMceEditor': () => import('./components/admin/features.js'), // same file - 'verifyUser': () => import('./components/admin/features.js'), // same file + 'verifyUser': () => import('./components/admin/verify-user.js'), 'tmuxEdit': () => import('./components/admin/features.js'), // same file }; diff --git a/resources/views/admin/groups/index.blade.php b/resources/views/admin/groups/index.blade.php index 44b562e15..6a88da048 100644 --- a/resources/views/admin/groups/index.blade.php +++ b/resources/views/admin/groups/index.blade.php @@ -2,29 +2,15 @@ @section('content')
-
- -
- -
+ + + + Active + Inactive + All + Bulk Add + +
@@ -44,8 +30,7 @@ @endif @if($grouplist && $grouplist->count() > 0) - -
+
@@ -62,9 +47,7 @@ class="pl-10 w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400" placeholder="Search for group...">
- + Go
@@ -81,54 +64,54 @@ - - - + tone="danger" + icon="fas fa-trash"> + Purge All +
- + -
- - - - - - - - - - - - - - - - - - + + Group + First Post + Last Post + Last Updated + Status + Backfill + Releases + Min Files + Min Size + Backfill Days + Actions + @foreach($grouplist as $group) @endforeach - -
+ + + - GroupFirst PostLast PostLast UpdatedStatusBackfillReleasesMin FilesMin SizeBackfill DaysActions
@@ -243,9 +226,7 @@
-
+
@@ -259,16 +240,11 @@
@else -
- -

No groups available

-

No groups have been added yet.

- - Add Groups - -
+ + Add Groups + @endif - +
@endsection - diff --git a/resources/views/admin/logs/index.blade.php b/resources/views/admin/logs/index.blade.php index 0bd5c3f2f..ee710414a 100644 --- a/resources/views/admin/logs/index.blade.php +++ b/resources/views/admin/logs/index.blade.php @@ -5,7 +5,7 @@ - + @php $clearSearchParams = array_filter([ 'file' => $selectedFile !== '' ? $selectedFile : null, @@ -50,31 +50,19 @@
- + Apply @if($selectedFile !== '') - - Clear Search - + Clear Search @endif - - Reset - + Reset
- + @if(empty($availableLogs)) -
- -

No log files found

-

The `storage/logs` directory does not currently contain any readable files.

-
+ @elseif($selectedLog !== null)
@@ -120,11 +108,7 @@ @else -
- -

No search results

-

Try a different search term or clear the search to view the latest lines.

-
+ @endif @elseif($tailView !== null)
@@ -136,11 +120,7 @@
@endif @else -
- -

Select a log file

-

Choose a file from the dropdown above to inspect its contents.

-
+ @endif
diff --git a/resources/views/admin/release-reports/index.blade.php b/resources/views/admin/release-reports/index.blade.php index e5a8bdb80..7b977b7f2 100644 --- a/resources/views/admin/release-reports/index.blade.php +++ b/resources/views/admin/release-reports/index.blade.php @@ -28,18 +28,9 @@
@endif -
- -
-
-
-

- {{ $title }} -

-

Manage release reports submitted by users

-
- - + + + -
-
+ + @if($reportsList->count() > 0)
@csrf -
+
- - + tone="gray" + icon="fas fa-square"> + Clear Selection + selected @@ -95,29 +88,23 @@ - + Apply
-
+
-
- - - - - - - - - - - - - - - + + + Select + ID + Release + Reporter + Reason + Response + Status + Date + Actions + @foreach($reportsList as $report) @endforeach - -
SelectIDReleaseReporterReasonResponseStatusDateActions
@@ -332,9 +319,7 @@
-
+
@@ -354,7 +339,7 @@

@endif -
+ @@ -525,4 +510,3 @@ @endsection - diff --git a/resources/views/admin/releases/index.blade.php b/resources/views/admin/releases/index.blade.php index ee5181170..1e7dfb619 100644 --- a/resources/views/admin/releases/index.blade.php +++ b/resources/views/admin/releases/index.blade.php @@ -17,7 +17,7 @@ @endif -
+
@@ -37,16 +37,16 @@ @endforeach - + @if(request('search') || (request()->filled('category_id') && (int) request('category_id') !== -1)) - + Clear - + @endif -
+ @if($releaselist->count() > 0)
@csrf -
+
- - + tone="gray" + icon="fas fa-square"> + Clear Selection + selected @@ -79,14 +81,12 @@ @endif @endforeach - + Change Category
-
+
- + Select @@ -172,7 +172,7 @@ @else - -
+ - - Add New User - + Add New User - +
@@ -83,15 +81,11 @@
- - - Clear - + Filter + Clear
-
+ @if($errors->any()) @@ -120,11 +114,12 @@ - + class="w-full md:w-auto" + icon="fas fa-check"> + Apply +

Select non-admin active users below. Deleted users are managed from the Deleted Users page.

@@ -400,13 +395,9 @@ {{ $userlist->links() }}
@else -
- -

No users found

-

Try adjusting your search filters or add a new user.

-
+ @endif -
+
diff --git a/resources/views/components/admin/action-bar.blade.php b/resources/views/components/admin/action-bar.blade.php new file mode 100644 index 000000000..ca978285b --- /dev/null +++ b/resources/views/components/admin/action-bar.blade.php @@ -0,0 +1,6 @@ +{{-- Dense action/filter bar for admin list pages --}} +
merge(['class' => 'px-4 sm:px-6 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700']) }}> +
+ {{ $slot }} +
+
diff --git a/resources/views/components/admin/badge.blade.php b/resources/views/components/admin/badge.blade.php new file mode 100644 index 000000000..3e15825a9 --- /dev/null +++ b/resources/views/components/admin/badge.blade.php @@ -0,0 +1,23 @@ +@props([ + 'tone' => 'gray', + 'icon' => null, +]) + +@php + $classes = [ + 'blue' => 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200', + 'green' => 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200', + 'gray' => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200', + 'orange' => 'bg-orange-100 dark:bg-orange-900/40 text-orange-800 dark:text-orange-200', + 'purple' => 'bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-200', + 'red' => 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200', + 'yellow' => 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-200', + ][$tone] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; +@endphp + +merge(['class' => 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold '.$classes]) }}> + @if($icon) + + @endif + {{ $slot }} + diff --git a/resources/views/components/admin/button.blade.php b/resources/views/components/admin/button.blade.php new file mode 100644 index 000000000..f2ac61160 --- /dev/null +++ b/resources/views/components/admin/button.blade.php @@ -0,0 +1,35 @@ +@props([ + 'href' => null, + 'type' => 'button', + 'tone' => 'primary', + 'icon' => null, +]) + +@php + $classes = [ + 'danger' => 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + 'ghost' => 'bg-transparent text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 focus:ring-gray-400', + 'gray' => 'bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 focus:ring-gray-400', + 'primary' => 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', + 'success' => 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500', + 'warning' => 'bg-yellow-600 text-white hover:bg-yellow-700 focus:ring-yellow-500', + ][$tone] ?? 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500'; + + $base = 'inline-flex min-h-9 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 '.$classes; +@endphp + +@if($href) + merge(['class' => $base]) }}> + @if($icon) + + @endif + {{ $slot }} + +@else + +@endif diff --git a/resources/views/components/admin/card.blade.php b/resources/views/components/admin/card.blade.php index ed2c1d657..6ae9d7352 100644 --- a/resources/views/components/admin/card.blade.php +++ b/resources/views/components/admin/card.blade.php @@ -3,7 +3,6 @@ 'noPadding' => false, ]) -
merge(['class' => 'bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700']) }}> +
merge(['class' => 'bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700']) }}> {{ $slot }}
- diff --git a/resources/views/components/admin/data-table.blade.php b/resources/views/components/admin/data-table.blade.php index 867db187f..d36832865 100644 --- a/resources/views/components/admin/data-table.blade.php +++ b/resources/views/components/admin/data-table.blade.php @@ -1,20 +1,29 @@ {{-- Admin data table with consistent thead styling --}} @props([ + 'dense' => true, + 'sticky' => false, 'striped' => false, ]) -
- merge(['class' => 'min-w-full divide-y divide-gray-200 dark:divide-gray-700']) }}> +
+
merge(['class' => 'min-w-full table-auto divide-y divide-gray-200 dark:divide-gray-700']) }}> @if(isset($head)) - + $sticky, + ])> {{ $head }} @endif - + $striped, + '[&_td]:px-4 [&_td]:py-3 [&_td]:text-sm' => $dense, + '[&_td]:px-6 [&_td]:py-4' => ! $dense, + ])> {{ $slot }}
- diff --git a/resources/views/components/admin/empty-state.blade.php b/resources/views/components/admin/empty-state.blade.php new file mode 100644 index 000000000..6b5f00074 --- /dev/null +++ b/resources/views/components/admin/empty-state.blade.php @@ -0,0 +1,18 @@ +@props([ + 'icon' => 'fas fa-circle-info', + 'title', + 'message' => null, +]) + +
merge(['class' => 'px-6 py-12 text-center']) }}> + +

{{ $title }}

+ @if($message) +

{{ $message }}

+ @endif + @if(trim($slot) !== '') +
+ {{ $slot }} +
+ @endif +
diff --git a/resources/views/components/admin/filter-panel.blade.php b/resources/views/components/admin/filter-panel.blade.php new file mode 100644 index 000000000..33697f291 --- /dev/null +++ b/resources/views/components/admin/filter-panel.blade.php @@ -0,0 +1,4 @@ +{{-- Compact filter panel for admin list pages --}} +
merge(['class' => 'px-4 sm:px-6 py-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700']) }}> + {{ $slot }} +
diff --git a/resources/views/components/admin/th.blade.php b/resources/views/components/admin/th.blade.php index 64ce9aa93..2a58a46de 100644 --- a/resources/views/components/admin/th.blade.php +++ b/resources/views/components/admin/th.blade.php @@ -13,7 +13,6 @@ $widthClass = $width ? "w-{$width}" : ''; @endphp -merge(['class' => "px-6 py-3 {$alignClass} text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider {$widthClass}"]) }}> +merge(['class' => "px-4 py-2.5 {$alignClass} text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide {$widthClass}"]) }}> {{ $slot }} - diff --git a/tests/Feature/AdminListPerformancePlanTest.php b/tests/Feature/AdminListPerformancePlanTest.php new file mode 100644 index 000000000..c52ddcfea --- /dev/null +++ b/tests/Feature/AdminListPerformancePlanTest.php @@ -0,0 +1,123 @@ + + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-admin-list-performance-test.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('innerfileblacklist', ''), + ('title', 'NNTmux Test'), + ('home_link', '/')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + + parent::tearDown(); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + + public function test_release_admin_list_uses_versioned_cache_keys(): void + { + $modelPath = app_path('Models/Release.php'); + + $this->assertFileExists($modelPath); + + $content = file_get_contents($modelPath); + + $this->assertStringContainsString('adminReleasesRangeVersion', $content); + $this->assertStringContainsString(".'_'.(\$categoryId ?? 'all')", $content); + $this->assertStringContainsString('Cache::forever(\'adminReleasesRangeVersion\'', $content); + } + + public function test_group_admin_list_no_longer_groups_by_id(): void + { + $modelPath = app_path('Models/UsenetGroup.php'); + + $this->assertFileExists($modelPath); + + $content = file_get_contents($modelPath); + $methodStart = strpos($content, 'public static function getGroupsRange'); + $this->assertIsInt($methodStart); + $methodBody = substr($content, $methodStart, 1200); + + $this->assertStringContainsString("->select([\n 'id',", $methodBody); + $this->assertStringContainsString("->orderBy('name')", $methodBody); + $this->assertStringNotContainsString('groupBy', $methodBody); + } + + public function test_admin_list_index_migration_contains_narrow_indexes(): void + { + $migrationPath = database_path('migrations/2026_07_13_000000_add_admin_list_performance_indexes.php'); + + $this->assertFileExists($migrationPath); + + $content = file_get_contents($migrationPath); + + $this->assertStringContainsString('ix_releases_categories_postdate_admin', $content); + $this->assertStringContainsString('ix_releases_postdate_admin', $content); + $this->assertStringContainsString('ix_usenet_groups_active_name_admin', $content); + $this->assertStringContainsString('ix_release_reports_status_created_admin', $content); + } +} diff --git a/tests/Feature/AdminReleaseReportControllerTest.php b/tests/Feature/AdminReleaseReportControllerTest.php index 63a3e45b0..43767c40a 100644 --- a/tests/Feature/AdminReleaseReportControllerTest.php +++ b/tests/Feature/AdminReleaseReportControllerTest.php @@ -2,10 +2,80 @@ namespace Tests\Feature; +use Illuminate\Contracts\Console\Kernel; +use PDO; use Tests\TestCase; class AdminReleaseReportControllerTest extends TestCase { + private string $databasePath = ''; + + /** + * @var array + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-admin-release-report-test.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('innerfileblacklist', ''), + ('title', 'NNTmux Test'), + ('home_link', '/')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + + parent::tearDown(); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + /** * Test that the controller has the revert method. */ @@ -28,15 +98,21 @@ class AdminReleaseReportControllerTest extends TestCase */ public function test_bulk_action_supports_revert(): void { - $controllerPath = app_path('Http/Controllers/Admin/AdminReleaseReportController.php'); + $requestPath = app_path('Http/Requests/Admin/AdminReleaseReportBulkActionRequest.php'); - $this->assertFileExists($controllerPath); + $this->assertFileExists($requestPath); - $content = file_get_contents($controllerPath); + $content = file_get_contents($requestPath); // Check that bulk action validation includes revert - $this->assertStringContainsString("'action' => 'required|in:dismiss,resolve,reviewed,delete,revert'", $content); - $this->assertStringContainsString("'revert' => 'reverted to reviewed'", $content); + $this->assertStringContainsString("'in:dismiss,resolve,reviewed,delete,revert'", $content); + $this->assertStringContainsString('public function reportIds', $content); + $this->assertStringContainsString('public function actionName', $content); + + $controllerPath = app_path('Http/Controllers/Admin/AdminReleaseReportController.php'); + $controllerContent = file_get_contents($controllerPath); + $this->assertStringContainsString("'revert' => 'reverted to reviewed'", $controllerContent); + $this->assertStringContainsString('bulkUpdateReportStatuses', $controllerContent); } /** @@ -127,6 +203,38 @@ class AdminReleaseReportControllerTest extends TestCase $this->assertStringNotContainsString('.revert-report-btn', $content); } + public function test_verify_user_component_is_split_from_legacy_admin_features_bundle(): void + { + $loaderPath = resource_path('js/alpine/lazy-loader.js'); + $featuresPath = resource_path('js/alpine/components/admin/features.js'); + $verifyPath = resource_path('js/alpine/components/admin/verify-user.js'); + + $this->assertFileExists($loaderPath); + $this->assertFileExists($featuresPath); + $this->assertFileExists($verifyPath); + + $loaderContent = file_get_contents($loaderPath); + $featuresContent = file_get_contents($featuresPath); + $verifyContent = file_get_contents($verifyPath); + + $this->assertStringContainsString("'verifyUser': () => import('./components/admin/verify-user.js')", $loaderContent); + $this->assertStringNotContainsString("Alpine.data('verifyUser'", $featuresContent); + $this->assertStringContainsString("Alpine.data('verifyUser'", $verifyContent); + } + + public function test_release_report_list_uses_column_scoped_eager_loading(): void + { + $modelPath = app_path('Models/ReleaseReport.php'); + + $this->assertFileExists($modelPath); + + $content = file_get_contents($modelPath); + + $this->assertStringContainsString("'release:id,guid,searchname,size'", $content); + $this->assertStringContainsString("'user:id,username'", $content); + $this->assertStringContainsString("->orderByDesc('created_at')", $content); + } + /** * Test that release report bulk-selection JS queries from the component root. */