diff --git a/app/Http/Controllers/Admin/AdminReleasesController.php b/app/Http/Controllers/Admin/AdminReleasesController.php index 2da0fcf9c..d21c69349 100644 --- a/app/Http/Controllers/Admin/AdminReleasesController.php +++ b/app/Http/Controllers/Admin/AdminReleasesController.php @@ -33,16 +33,40 @@ class AdminReleasesController extends BasePageController $meta_title = $title = 'Release List'; - $page = $request->input('page', 1); - $releaseList = Release::getReleasesRange($page); + $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; + } + + $releaseList = Release::getReleasesRange($page, $search !== '' ? $search : null, $categoryId); + $releaseList->appends($request->only(['search', 'category_id'])); return view('admin.releases.index', [ 'releaselist' => $releaseList, + 'catlist' => Category::getForSelect(true), 'title' => $title, 'meta_title' => $meta_title, ]); } + public function bulkCategory(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'guids' => 'required|array|min:1', + 'guids.*' => 'string', + 'categories_id' => 'required|integer|min:1|exists:categories,id', + ]); + + $count = $this->releaseManagement->bulkUpdateCategory( + $validated['guids'], + (int) $validated['categories_id'], + ); + + return back()->with('success', $count.' release(s) re-categorised.'); + } + /** * @return \Illuminate\Contracts\Foundation\Application|Application|RedirectResponse|Redirector|View * diff --git a/app/Models/Release.php b/app/Models/Release.php index 76bddd0a0..bb5360c78 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Pagination\LengthAwarePaginator as PaginatorLengthAware; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -381,16 +383,99 @@ class Release extends Model * @param int $page The page number to retrieve * @return LengthAwarePaginator|mixed */ - public static function getReleasesRange(int $page = 1): mixed + public static function getReleasesRange(int $page = 1, ?string $search = null, ?int $categoryId = null): mixed { - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); - $cacheKey = md5('releasesRange_'.$page); - $releases = Cache::get($cacheKey); - if ($releases !== null) { - return $releases; + $search = $search !== null && $search !== '' ? $search : null; + $hasFilters = $search !== null || $categoryId !== null; + + if ($search !== null && Search::isAvailable()) { + return self::getReleasesRangeFromSearchIndex($page, $search, $categoryId); } - $releases = self::query() + if (! $hasFilters) { + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); + $cacheKey = md5('releasesRange_'.$page); + $releases = Cache::get($cacheKey); + if ($releases !== null) { + return $releases; + } + } + + $query = self::adminReleaseListQuery(); + + if ($search !== null) { + $query->where('releases.searchname', 'like', '%'.$search.'%'); + } + + if ($categoryId !== null) { + $query->where('releases.categories_id', $categoryId); + } + + $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); + } + + return $releases; + } + + /** + * @return PaginatorLengthAware + */ + private static function getReleasesRangeFromSearchIndex(int $page, string $search, ?int $categoryId): PaginatorLengthAware + { + $perPage = (int) config('nntmux.items_per_page'); + $offset = ($page - 1) * $perPage; + + $criteria = [ + 'phrases' => ['searchname' => $search], + 'category_ids' => $categoryId !== null ? [$categoryId] : null, + 'sort_field' => 'postdate_ts', + 'sort_dir' => 'desc', + 'try_fuzzy' => true, + ]; + + $filtered = Search::searchReleasesFiltered($criteria, $perPage, $offset); + $ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids'] ?? []); + $total = (int) ($filtered['total'] ?? 0); + + if ($ids === []) { + return new PaginatorLengthAware([], 0, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => 'page', + ]); + } + + $idList = implode(',', $ids); + $rows = self::adminReleaseListQuery() + ->whereIn('releases.id', $ids) + ->orderByRaw('FIELD(releases.id, '.$idList.')') + ->get(); + + return new PaginatorLengthAware($rows, $total, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => 'page', + ]); + } + + /** + * Clear cached admin release-list pages after bulk updates. + */ + public static function clearAdminReleasesRangeCache(int $maxPages = 25): void + { + for ($page = 1; $page <= $maxPages; $page++) { + Cache::forget(md5('releasesRange_'.$page)); + } + } + + /** + * @return Builder + */ + private static function adminReleaseListQuery(): Builder + { + return self::query() ->select( [ 'releases.id', @@ -409,12 +494,7 @@ class Release extends Model ) ->leftJoin('categories as c', 'c.id', '=', 'releases.categories_id') ->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id') - ->orderByDesc('releases.postdate') - ->paginate(config('nntmux.items_per_page'), ['*'], 'page', $page); - - Cache::put($cacheKey, $releases, $expiresAt); - - return $releases; + ->orderByDesc('releases.postdate'); } public static function getByGuid(mixed $guid): mixed diff --git a/app/Services/Releases/ReleaseManagementService.php b/app/Services/Releases/ReleaseManagementService.php index 426b4c006..0b3c19ae9 100644 --- a/app/Services/Releases/ReleaseManagementService.php +++ b/app/Services/Releases/ReleaseManagementService.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; /** * Service for managing releases (delete, update, export). @@ -111,6 +112,60 @@ class ReleaseManagementService return $updated; } + /** + * @param list $guids + */ + public function bulkUpdateCategory(array $guids, int $categoryId): int + { + $guids = array_values(array_filter($guids)); + if ($guids === [] || $categoryId <= 0) { + return 0; + } + + $updated = 0; + + DB::transaction(function () use ($guids, $categoryId, &$updated): void { + $releaseIds = Release::query() + ->whereIn('guid', $guids) + ->pluck('id'); + + $updated = Release::query() + ->whereIn('guid', $guids) + ->update(['categories_id' => $categoryId, 'iscategorized' => 1]); + + if ($updated > 0) { + $this->syncReleasesToSearchIndex($releaseIds); + Release::clearAdminReleasesRangeCache(); + } + }); + + return $updated; + } + + /** + * Re-index releases after query-builder updates that bypass {@see ReleaseObserver}. + * + * @param Collection|iterable $releaseIds + */ + private function syncReleasesToSearchIndex(iterable $releaseIds): void + { + foreach ($releaseIds as $releaseId) { + $intId = (int) $releaseId; + if ($intId <= 0) { + continue; + } + + try { + Search::updateRelease($intId); + } catch (\Throwable $e) { + Log::error('ReleaseManagementService: Failed to sync release to search index after category change', [ + 'release_id' => $intId, + 'error' => $e->getMessage(), + ]); + } + } + } + /** * @return Release[]|Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|Collection */ diff --git a/resources/js/alpine/components/admin/release-list.js b/resources/js/alpine/components/admin/release-list.js new file mode 100644 index 000000000..a0e9939b5 --- /dev/null +++ b/resources/js/alpine/components/admin/release-list.js @@ -0,0 +1,115 @@ +/** + * Alpine.data('adminReleaseList') - Admin release list bulk selection and category change + */ +import Alpine from '@alpinejs/csp'; + +Alpine.data('adminReleaseList', () => ({ + allChecked: false, + selectedCount: 0, + rootEl: null, + + init() { + this.rootEl = this.$root; + this.syncSelectionState(); + }, + + componentRoot() { + return this.rootEl || this.$root; + }, + + releaseCheckboxes() { + const root = this.componentRoot(); + + return root ? [...root.querySelectorAll('.release-checkbox')] : []; + }, + + setAllSelection(checked) { + const boxes = this.releaseCheckboxes(); + boxes.forEach(cb => { + cb.checked = checked; + }); + this.allChecked = checked && boxes.length > 0; + this.selectedCount = checked ? boxes.length : 0; + }, + + selectAll() { + this.setAllSelection(true); + }, + + clearSelection() { + this.setAllSelection(false); + }, + + syncSelectionState() { + const boxes = this.releaseCheckboxes(); + const checkedCount = boxes.filter(cb => cb.checked).length; + this.selectedCount = checkedCount; + this.allChecked = boxes.length > 0 && checkedCount === boxes.length; + }, + + onCheckboxChange() { + this.syncSelectionState(); + }, + + showModal(options) { + if (typeof window.showConfirm === 'function') { + window.showConfirm(options); + + return; + } + + if (options.onConfirm) { + options.onConfirm(); + } + }, + + validateBulkAction(e) { + e.preventDefault(); + + const form = e.target; + const root = this.componentRoot(); + const categorySelect = root?.querySelector('select[name="categories_id"]'); + const categoryId = categorySelect?.value; + const categoryName = categorySelect?.selectedOptions?.[0]?.text?.trim() || ''; + const checkedCount = this.releaseCheckboxes().filter(cb => cb.checked).length; + + if (!categoryId || categoryId === '-1') { + this.showModal({ + title: 'Category required', + message: 'Please select a category to assign to the selected releases.', + type: 'warning', + confirmText: 'OK', + cancelText: 'Close', + onConfirm: function() {}, + }); + + return; + } + + if (checkedCount === 0) { + this.showModal({ + title: 'No releases selected', + message: 'Please select at least one release before changing its category.', + type: 'warning', + confirmText: 'OK', + cancelText: 'Close', + onConfirm: function() {}, + }); + + return; + } + + const self = this; + this.showModal({ + title: 'Change category', + message: 'Change category for ' + checkedCount + ' release(s)?', + details: 'Assign to: ' + categoryName + '. The database and search index will be updated.', + type: 'warning', + confirmText: 'Change category', + cancelText: 'Cancel', + onConfirm: function() { + form.submit(); + }, + }); + }, +})); diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js index b839e6c4b..fda871503 100644 --- a/resources/js/alpine/lazy-loader.js +++ b/resources/js/alpine/lazy-loader.js @@ -32,6 +32,7 @@ const lazyComponentMap = { 'contentDelete': () => import('./components/content-toggle.js'), // same file 'releaseReport': () => import('./components/release-report.js'), 'adminReleaseReports': () => import('./components/release-report.js'), // same file + 'adminReleaseList': () => import('./components/admin/release-list.js'), 'profileEdit': () => import('./components/profile-edit.js'), 'profilePage': () => import('./components/profile-edit.js'), // same file 'copyToClipboard': () => import('./components/profile-edit.js'), // same file diff --git a/resources/views/admin/releases/index.blade.php b/resources/views/admin/releases/index.blade.php index 687ad80c6..95c77c458 100644 --- a/resources/views/admin/releases/index.blade.php +++ b/resources/views/admin/releases/index.blade.php @@ -1,13 +1,96 @@ @extends('layouts.admin') @section('content') -
+
- @if(count($releaselist) > 0) + @if(session('success')) + + @endif + + @if(session('error')) + + @endif + +
+
+
+
+ +
+ +
+ + + @if(request('search') || (request()->filled('category_id') && (int) request('category_id') !== -1)) + + Clear + + @endif +
+
+ + @if($releaselist->count() > 0) +
+ @csrf +
+
+ + + + selected + +
+
+ + +
+
+
+ + + Select + ID Name Category @@ -21,14 +104,24 @@ @foreach($releaselist as $release) + + + {{ $release->id }}
{{ $release->searchname }}
-
- {{ $release->searchname }} -
+ @if($release->name && $release->name !== $release->searchname) +
+ {{ $release->name }} +
+ @endif @@ -81,10 +174,9 @@ @endif
@endsection - diff --git a/routes/web.php b/routes/web.php index f6ddb8fab..f798da6a7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -296,6 +296,7 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { Route::post('ajax', [AdminAjaxController::class, 'ajaxAction'])->name('admin.ajax'); Route::match(['GET', 'POST'], 'tmux-edit', [AdminTmuxController::class, 'edit'])->name('admin.tmux-edit'); Route::get('release-list', [AdminReleasesController::class, 'index'])->name('admin.release-list'); + Route::post('release-bulk-category', [AdminReleasesController::class, 'bulkCategory'])->name('admin.release-bulk-category'); Route::post('release-delete/{id}', [AdminReleasesController::class, 'destroy'])->name('admin.release-delete'); // Release Reports Management