mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 23:01:29 +00:00
Allow bulk changing release category
This commit is contained in:
@@ -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
@@ -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();
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user