Allow bulk changing release category

This commit is contained in:
DariusIII
2026-05-15 15:45:08 +02:00
parent f62336305a
commit b33bfd2bd8
7 changed files with 390 additions and 22 deletions
@@ -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
*
+93 -13
View File
@@ -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<int, mixed>
*/
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<Release>
*/
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
@@ -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<string> $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<int, int|string>|iterable<int|string> $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<int, mixed>|\Illuminate\Database\Query\Builder[]|Collection<int, mixed>
*/
@@ -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();
},
});
},
}));
+1
View File
@@ -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
+99 -7
View File
@@ -1,13 +1,96 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6">
<div x-data="adminReleaseList" class="space-y-6">
<x-admin.card>
<x-admin.page-header :title="$title" icon="fas fa-list" />
@if(count($releaselist) > 0)
@if(session('success'))
<div class="mx-6 mt-4 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-green-800 dark:text-green-200 text-sm" role="alert">
<i class="fas fa-check-circle mr-2"></i>{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200 text-sm" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>{{ session('error') }}
</div>
@endif
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<form method="GET" action="{{ route('admin.release-list') }}" class="flex flex-col sm:flex-row gap-2">
<div class="relative flex-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-gray-400" aria-hidden="true"></i>
</div>
<input type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search by release name..."
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-white">
</div>
<select name="category_id"
class="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-white sm:min-w-[220px]">
@foreach($catlist as $catId => $catTitle)
<option value="{{ $catId }}" {{ (int) request('category_id', -1) === (int) $catId ? 'selected' : '' }}>
{{ $catTitle }}
</option>
@endforeach
</select>
<button type="submit" class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800">
Apply
</button>
@if(request('search') || (request()->filled('category_id') && (int) request('category_id') !== -1))
<a href="{{ route('admin.release-list') }}" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-center">
Clear
</a>
@endif
</form>
</div>
@if($releaselist->count() > 0)
<form id="bulk-category-form"
method="POST"
action="{{ route('admin.release-bulk-category') }}"
@submit="validateBulkAction($event)">
@csrf
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<button type="button"
@click="selectAll()"
class="px-3 py-1.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-800 transition text-sm font-medium">
<i class="fas fa-check-square mr-1"></i> Select All
</button>
<button type="button"
@click="clearSelection()"
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition text-sm font-medium">
<i class="fas fa-square mr-1"></i> Clear Selection
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
<span x-text="selectedCount"></span> selected
</span>
</div>
<div class="flex flex-wrap items-center gap-3">
<select name="categories_id"
class="text-sm border-gray-300 dark:border-gray-600 rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-200 min-w-[200px]">
@foreach($catlist as $catId => $catTitle)
@if((int) $catId > 0)
<option value="{{ $catId }}">{{ $catTitle }}</option>
@endif
@endforeach
</select>
<button type="submit" class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition">
<i class="fas fa-tags mr-1"></i> Change Category
</button>
</div>
</div>
</form>
<x-admin.data-table>
<x-slot:head>
<x-admin.th class="w-10">
<span class="sr-only">Select</span>
</x-admin.th>
<x-admin.th>ID</x-admin.th>
<x-admin.th>Name</x-admin.th>
<x-admin.th>Category</x-admin.th>
@@ -21,14 +104,24 @@
@foreach($releaselist as $release)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap">
<input type="checkbox"
form="bulk-category-form"
name="guids[]"
value="{{ $release->guid }}"
@change="onCheckboxChange()"
class="release-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700">
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{{ $release->id }}</td>
<td class="px-6 py-4 text-sm">
<div class="text-gray-900 dark:text-gray-100 font-medium max-w-md wrap-break-word break-all" title="{{ $release->searchname }}">
{{ $release->searchname }}
</div>
<div class="text-gray-500 dark:text-gray-400 text-xs mt-1 max-w-md truncate" title="{{ $release->searchname }}">
{{ $release->searchname }}
</div>
@if($release->name && $release->name !== $release->searchname)
<div class="text-gray-500 dark:text-gray-400 text-xs mt-1 max-w-md truncate" title="{{ $release->name }}">
{{ $release->name }}
</div>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300">
@@ -81,10 +174,9 @@
<x-empty-state
icon="fas fa-list"
title="No releases found"
message="No releases are currently available in the system."
message="{{ request('search') || (request()->filled('category_id') && (int) request('category_id') !== -1) ? 'No releases match your filters.' : 'No releases are currently available in the system.' }}"
/>
@endif
</x-admin.card>
</div>
@endsection
+1
View File
@@ -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