From b60d8b29e325b37bfa322f5300b306c76f2f0cdd Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sun, 1 Feb 2026 22:16:40 +0100 Subject: [PATCH] Add release reporting feature --- .../Admin/AdminReleaseReportController.php | 163 +++++++++ app/Http/Controllers/DetailsController.php | 8 + .../Controllers/ReleaseReportController.php | 83 +++++ app/Models/Release.php | 5 + app/Models/ReleaseReport.php | 150 ++++++++ app/Services/MovieBrowseService.php | 3 + .../Releases/ReleaseBrowseService.php | 3 + .../Releases/ReleaseSearchService.php | 3 + ...01_000000_create_release_reports_table.php | 41 +++ resources/js/csp-safe.js | 290 ++++++++++++++++ .../admin/release-reports/index.blade.php | 323 ++++++++++++++++++ resources/views/browse/index.blade.php | 7 + .../views/components/report-button.blade.php | 113 ++++++ resources/views/details/index.blade.php | 22 +- resources/views/movies/index.blade.php | 23 ++ resources/views/partials/admin-menu.blade.php | 3 + resources/views/search/index.blade.php | 14 + routes/web.php | 13 + 18 files changed, 1261 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Admin/AdminReleaseReportController.php create mode 100644 app/Http/Controllers/ReleaseReportController.php create mode 100644 app/Models/ReleaseReport.php create mode 100644 database/migrations/2026_02_01_000000_create_release_reports_table.php create mode 100644 resources/views/admin/release-reports/index.blade.php create mode 100644 resources/views/components/report-button.blade.php diff --git a/app/Http/Controllers/Admin/AdminReleaseReportController.php b/app/Http/Controllers/Admin/AdminReleaseReportController.php new file mode 100644 index 000000000..b3e49a1d6 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminReleaseReportController.php @@ -0,0 +1,163 @@ +setAdminPrefs(); + + $status = $request->input('status', 'pending'); + $reportsList = ReleaseReport::getReportsRange($status, 50); + $statusCounts = ReleaseReport::getCountByStatus(); + + $meta_title = $title = 'Release Reports'; + + return view('admin.release-reports.index', compact( + 'reportsList', + 'statusCounts', + 'status', + 'title', + 'meta_title' + )); + } + + /** + * Update report status. + */ + public function updateStatus(Request $request, int $id): RedirectResponse + { + $request->validate([ + 'status' => 'required|in:pending,reviewed,resolved,dismissed', + ]); + + $report = ReleaseReport::findOrFail($id); + $report->update([ + 'status' => $request->input('status'), + 'reviewed_by' => Auth::id(), + 'reviewed_at' => now(), + ]); + + return redirect()->back()->with('success', 'Report status updated successfully.'); + } + + /** + * Delete the reported release and resolve the report. + */ + public function deleteRelease(int $id): RedirectResponse + { + $report = ReleaseReport::with('release')->findOrFail($id); + + if ($report->release) { + $releaseName = $report->release->searchname; + $releaseId = $report->releases_id; + + // Delete the release + Release::where('id', $releaseId)->delete(); + + // Update all reports for this release to resolved + ReleaseReport::where('releases_id', $releaseId) + ->update([ + 'status' => 'resolved', + 'reviewed_by' => Auth::id(), + 'reviewed_at' => now(), + ]); + + return redirect()->back()->with('success', "Release '{$releaseName}' deleted and all related reports resolved."); + } + + return redirect()->back()->with('error', 'Release not found or already deleted.'); + } + + /** + * Dismiss a report. + */ + public function dismiss(int $id): RedirectResponse + { + $report = ReleaseReport::findOrFail($id); + $report->update([ + 'status' => 'dismissed', + 'reviewed_by' => Auth::id(), + 'reviewed_at' => now(), + ]); + + return redirect()->back()->with('success', 'Report dismissed successfully.'); + } + + /** + * Bulk update report statuses. + */ + public function bulkAction(Request $request): RedirectResponse + { + $request->validate([ + 'action' => 'required|in:dismiss,resolve,reviewed,delete', + '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(), + ]); + } + + $count++; + } + + $actionLabel = match ($action) { + 'delete' => 'deleted', + 'dismiss' => 'dismissed', + 'resolve' => 'resolved', + 'reviewed' => 'marked as reviewed', + default => 'processed', + }; + + return redirect()->back()->with('success', "{$count} report(s) {$actionLabel} successfully."); + } +} diff --git a/app/Http/Controllers/DetailsController.php b/app/Http/Controllers/DetailsController.php index 2b4c51251..8ef2edcb3 100644 --- a/app/Http/Controllers/DetailsController.php +++ b/app/Http/Controllers/DetailsController.php @@ -9,6 +9,7 @@ use App\Models\ReleaseComment; use App\Models\ReleaseFile; use App\Models\ReleaseNfo; use App\Models\ReleaseRegex; +use App\Models\ReleaseReport; use App\Models\Settings; use App\Models\UserDownload; use App\Models\Video; @@ -74,6 +75,11 @@ class DetailsController extends BasePageController $comments = ReleaseComment::getComments($data['id']); $similars = $this->releaseSearchService->searchSimilar($data['id'], $data['searchname'], $this->userdata->categoryexclusions); $failed = DnzbFailure::getFailedCount($data['id']); + $reportData = ReleaseReport::where('releases_id', $data['id']) + ->whereIn('status', ['pending', 'reviewed']) + ->get(); + $reportCount = $reportData->count(); + $reportReasons = ReleaseReport::reasonKeysToLabels($reportData->pluck('reason')->unique()->implode(', ')); $downloadedBy = UserDownload::query()->with('user')->where('releases_id', $data['id'])->get(['users_id']); $showInfo = ''; @@ -183,6 +189,8 @@ class DetailsController extends BasePageController 'similars' => $similars !== false ? $similars : [], 'privateprofiles' => config('nntmux_settings.private_profiles'), 'failed' => $failed, + 'reportCount' => $reportCount, + 'reportReasons' => $reportReasons, 'regex' => $releaseRegex, 'downloadedby' => $downloadedBy, 'meta_title' => 'View NZB', diff --git a/app/Http/Controllers/ReleaseReportController.php b/app/Http/Controllers/ReleaseReportController.php new file mode 100644 index 000000000..02d5fc5d1 --- /dev/null +++ b/app/Http/Controllers/ReleaseReportController.php @@ -0,0 +1,83 @@ +validate([ + 'release_id' => 'required|integer', + 'reason' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]); + + $releaseId = $request->input('release_id'); + $userId = Auth::id(); + + // Check if release exists + $release = Release::find($releaseId); + if (! $release) { + return response()->json([ + 'success' => false, + 'message' => 'Release not found.', + ], 404); + } + + // Check if user already reported this release + if (ReleaseReport::hasUserReported($releaseId, $userId)) { + return response()->json([ + 'success' => false, + 'message' => 'You have already reported this release.', + ], 409); + } + + // Create the report + ReleaseReport::create([ + 'releases_id' => $releaseId, + 'users_id' => $userId, + 'reason' => $request->input('reason'), + 'description' => $request->input('description'), + 'status' => 'pending', + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Report submitted successfully. Thank you for helping improve our content.', + ]); + } + + /** + * Get report reasons for the form. + */ + public function getReasons(): JsonResponse + { + return response()->json([ + 'reasons' => ReleaseReport::REASONS, + ]); + } + + /** + * Check if user has already reported a release. + */ + public function checkReported(Request $request): JsonResponse + { + $releaseId = $request->input('release_id'); + $userId = Auth::id(); + + $hasReported = ReleaseReport::hasUserReported($releaseId, $userId); + + return response()->json([ + 'has_reported' => $hasReported, + ]); + } +} diff --git a/app/Models/Release.php b/app/Models/Release.php index b514c1eec..9698f3c56 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -76,6 +76,11 @@ class Release extends Model return $this->hasMany(ReleaseComment::class, 'releases_id'); } + public function reports(): HasMany + { + return $this->hasMany(ReleaseReport::class, 'releases_id'); + } + public function releaseGroup(): HasMany { return $this->hasMany(ReleasesGroups::class, 'releases_id'); diff --git a/app/Models/ReleaseReport.php b/app/Models/ReleaseReport.php new file mode 100644 index 000000000..3c5f103ed --- /dev/null +++ b/app/Models/ReleaseReport.php @@ -0,0 +1,150 @@ + 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * Predefined report reasons + */ + public const REASONS = [ + 'duplicate' => 'Duplicate Release', + 'fake' => 'Fake/Malicious Content', + 'password' => 'Password Protected', + 'incomplete' => 'Incomplete/Corrupted', + 'wrong_category' => 'Wrong Category', + 'spam' => 'Spam/Advertisement', + 'other' => 'Other', + ]; + + /** + * Convert a comma-separated list of reason keys to their labels. + */ + public static function reasonKeysToLabels(?string $reasonKeys): string + { + if (empty($reasonKeys)) { + return 'Unknown'; + } + + $keys = array_map('trim', explode(',', $reasonKeys)); + $labels = []; + + foreach ($keys as $key) { + $labels[] = self::REASONS[$key] ?? ucfirst($key); + } + + return implode(', ', array_unique($labels)); + } + + /** + * Get the release that was reported. + */ + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'releases_id'); + } + + /** + * Get the user who reported the release. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'users_id'); + } + + /** + * Get the admin who reviewed the report. + */ + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } + + /** + * Get a paginated list of reports with optional filters. + */ + public static function getReportsRange( + ?string $status = null, + int $perPage = 50 + ): LengthAwarePaginator { + $query = self::query() + ->with(['release', 'user', 'reviewer']) + ->orderBy('created_at', 'desc'); + + if ($status !== null && $status !== 'all') { + $query->where('status', $status); + } + + return $query->paginate($perPage); + } + + /** + * Get report count by status. + */ + public static function getCountByStatus(): array + { + $counts = self::query() + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + return [ + 'pending' => $counts['pending'] ?? 0, + 'reviewed' => $counts['reviewed'] ?? 0, + 'resolved' => $counts['resolved'] ?? 0, + 'dismissed' => $counts['dismissed'] ?? 0, + 'total' => array_sum($counts), + ]; + } + + /** + * Check if a user has already reported a specific release. + */ + public static function hasUserReported(int $releaseId, int $userId): bool + { + return self::query() + ->where('releases_id', $releaseId) + ->where('users_id', $userId) + ->exists(); + } + + /** + * Get the human-readable reason label. + */ + public function getReasonLabelAttribute(): string + { + return self::REASONS[$this->reason] ?? $this->reason; + } +} diff --git a/app/Services/MovieBrowseService.php b/app/Services/MovieBrowseService.php index 4f64ed756..f267d97c8 100644 --- a/app/Services/MovieBrowseService.php +++ b/app/Services/MovieBrowseService.php @@ -77,11 +77,14 @@ class MovieBrowseService ."GROUP_CONCAT(r.comments ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_comments, " ."GROUP_CONCAT(r.grabs ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_grabs, " ."GROUP_CONCAT(df.failed ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_failed, " + ."GROUP_CONCAT(rr.report_count ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_reports, " + ."GROUP_CONCAT(rr.report_reasons ORDER BY r.postdate DESC SEPARATOR '|' ) AS grp_release_report_reasons, " ."GROUP_CONCAT(cp.title, ' > ', c.title ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_catname, " .'m.*, g.name AS group_name, rn.releases_id AS nfoid FROM releases r ' .'LEFT OUTER JOIN usenet_groups g ON g.id = r.groups_id ' .'LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id ' .'LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id ' + .'LEFT OUTER JOIN (SELECT releases_id, COUNT(*) AS report_count, GROUP_CONCAT(DISTINCT reason SEPARATOR \', \') AS report_reasons FROM release_reports WHERE status IN (\'pending\', \'reviewed\') GROUP BY releases_id) rr ON rr.releases_id = r.id ' .'LEFT OUTER JOIN categories c ON c.id = r.categories_id ' .'LEFT OUTER JOIN root_categories cp ON cp.id = c.root_categories_id ' .'INNER JOIN movieinfo m ON m.imdbid = r.imdbid ' diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php index 4a8aa23ea..5b76cc6c5 100644 --- a/app/Services/Releases/ReleaseBrowseService.php +++ b/app/Services/Releases/ReleaseBrowseService.php @@ -63,6 +63,8 @@ class ReleaseBrowseService CONCAT(cp.title, ' > ', c.title) AS category_name, CONCAT(cp.id, ',', c.id) AS category_ids, df.failed AS failed_count, + rr.report_count AS report_count, + rr.report_reasons AS report_reasons, rn.releases_id AS nfoid, re.releases_id AS reid, v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, @@ -85,6 +87,7 @@ class ReleaseBrowseService LEFT OUTER JOIN video_data re ON re.releases_id = r.id LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id + LEFT OUTER JOIN (SELECT releases_id, COUNT(*) AS report_count, GROUP_CONCAT(DISTINCT reason SEPARATOR ', ') AS report_reasons FROM release_reports WHERE status IN ('pending', 'reviewed') GROUP BY releases_id) rr ON rr.releases_id = r.id GROUP BY r.id ORDER BY %8\$s %9\$s", $this->showPasswords(), diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index d2176c754..e1af88119 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -1121,6 +1121,8 @@ class ReleaseSearchService cp.title AS parent_category, c.title AS sub_category, CONCAT(cp.title, ' > ', c.title) AS category_name, df.failed AS failed_count, + rr.report_count AS report_count, + rr.report_reasons AS report_reasons, g.name AS group_name, rn.releases_id AS nfoid, re.releases_id AS reid, @@ -1136,6 +1138,7 @@ class ReleaseSearchService LEFT JOIN categories c ON c.id = r.categories_id LEFT JOIN root_categories cp ON cp.id = c.root_categories_id LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id + LEFT OUTER JOIN (SELECT releases_id, COUNT(*) AS report_count, GROUP_CONCAT(DISTINCT reason SEPARATOR ', ') AS report_reasons FROM release_reports WHERE status IN ('pending', 'reviewed') GROUP BY releases_id) rr ON rr.releases_id = r.id %s", $whereSql ); diff --git a/database/migrations/2026_02_01_000000_create_release_reports_table.php b/database/migrations/2026_02_01_000000_create_release_reports_table.php new file mode 100644 index 000000000..fdd7ccf53 --- /dev/null +++ b/database/migrations/2026_02_01_000000_create_release_reports_table.php @@ -0,0 +1,41 @@ +id(); + $table->unsignedInteger('releases_id'); + $table->unsignedInteger('users_id'); + $table->string('reason', 255); + $table->text('description')->nullable(); + $table->enum('status', ['pending', 'reviewed', 'resolved', 'dismissed'])->default('pending'); + $table->unsignedInteger('reviewed_by')->nullable(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->foreign('releases_id')->references('id')->on('releases')->onDelete('cascade'); + $table->foreign('users_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('reviewed_by')->references('id')->on('users')->onDelete('set null'); + + $table->index(['releases_id', 'status']); + $table->index(['status', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('release_reports'); + } +}; diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index 09885f841..4878fc7f6 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -5727,3 +5727,293 @@ function initSortDropdowns() { // Initialize sort dropdowns on DOMContentLoaded document.addEventListener('DOMContentLoaded', initSortDropdowns); +// ============================================================================= +// RELEASE REPORT FUNCTIONALITY +// ============================================================================= + +/** + * Initialize release report buttons and modals + * CSP-safe implementation for reporting bad releases + */ +function initReleaseReportButtons() { + // Find all report button containers + var containers = document.querySelectorAll('.report-button-container'); + + containers.forEach(function(container) { + var trigger = container.querySelector('.report-trigger'); + var modal = container.querySelector('.report-modal'); + + if (!trigger || !modal) return; + + // Skip if already initialized + if (trigger.hasAttribute('data-report-initialized')) return; + trigger.setAttribute('data-report-initialized', 'true'); + + var releaseId = trigger.getAttribute('data-report-release-id'); + + var form = modal.querySelector('.report-form'); + var closeButtons = modal.querySelectorAll('.report-modal-close'); + var backdrop = modal.querySelector('.report-modal-backdrop'); + var reasonSelect = modal.querySelector('.report-reason'); + var descriptionTextarea = modal.querySelector('.report-description'); + var charCount = modal.querySelector('.report-char-count'); + var submitButton = modal.querySelector('.report-submit'); + var errorDiv = modal.querySelector('.report-error'); + var successDiv = modal.querySelector('.report-success'); + var reportLabel = trigger.querySelector('.report-label'); + var flagIcon = trigger.querySelector('i'); + + var hasReported = false; + var isSubmitting = false; + + // Open modal + trigger.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + if (hasReported) return; + + modal.classList.remove('hidden'); + // Reset form state + if (errorDiv) errorDiv.classList.add('hidden'); + if (successDiv) successDiv.classList.add('hidden'); + if (reasonSelect) reasonSelect.value = ''; + if (descriptionTextarea) descriptionTextarea.value = ''; + if (charCount) charCount.textContent = '0/1000 characters'; + if (submitButton) submitButton.disabled = true; + }); + + // Close modal function + function closeModal() { + modal.classList.add('hidden'); + if (hasReported) { + trigger.disabled = true; + trigger.classList.add('opacity-50', 'cursor-not-allowed'); + if (flagIcon) flagIcon.classList.add('text-red-500'); + if (reportLabel) reportLabel.textContent = 'Reported'; + } + } + + // Close button handlers + closeButtons.forEach(function(closeBtn) { + closeBtn.addEventListener('click', function(e) { + e.preventDefault(); + closeModal(); + }); + }); + + // Backdrop click to close + if (backdrop) { + backdrop.addEventListener('click', function() { + closeModal(); + }); + } + + // Character count for description + if (descriptionTextarea && charCount) { + descriptionTextarea.addEventListener('input', function() { + charCount.textContent = this.value.length + '/1000 characters'; + }); + } + + // Enable/disable submit based on reason selection + if (reasonSelect && submitButton) { + reasonSelect.addEventListener('change', function() { + submitButton.disabled = !this.value; + }); + } + + // Submit button click handler + if (submitButton) { + submitButton.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + if (isSubmitting || !reasonSelect || !reasonSelect.value) return; + + isSubmitting = true; + submitButton.disabled = true; + submitButton.innerHTML = ' Submitting...'; + + if (errorDiv) errorDiv.classList.add('hidden'); + if (successDiv) successDiv.classList.add('hidden'); + + var csrfToken = document.querySelector('meta[name="csrf-token"]'); + var csrfValue = csrfToken ? csrfToken.getAttribute('content') : ''; + + var requestBody = { + release_id: releaseId, + reason: reasonSelect.value, + description: descriptionTextarea ? descriptionTextarea.value : '' + }; + + fetch('/release-report', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfValue, + 'Accept': 'application/json' + }, + body: JSON.stringify(requestBody) + }) + .then(function(response) { + return response.json().then(function(data) { + return { ok: response.ok, data: data }; + }); + }) + .then(function(result) { + if (result.ok && result.data.success) { + if (successDiv) { + successDiv.querySelector('p').textContent = result.data.message; + successDiv.classList.remove('hidden'); + } + hasReported = true; + setTimeout(closeModal, 2000); + } else { + if (errorDiv) { + errorDiv.querySelector('p').textContent = result.data.message || 'An error occurred. Please try again.'; + errorDiv.classList.remove('hidden'); + } + } + }) + .catch(function(error) { + if (errorDiv) { + errorDiv.querySelector('p').textContent = 'Network error. Please try again.'; + errorDiv.classList.remove('hidden'); + } + }) + .finally(function() { + isSubmitting = false; + if (submitButton) { + submitButton.disabled = !reasonSelect.value; + submitButton.innerHTML = 'Submit Report'; + } + }); + }); + } + }); +} + +// Initialize report buttons on DOMContentLoaded +document.addEventListener('DOMContentLoaded', initReleaseReportButtons); + +// Re-initialize on dynamic content load (for AJAX-loaded content) +document.addEventListener('contentLoaded', initReleaseReportButtons); + +// ============================================================================= +// ADMIN RELEASE REPORTS PAGE FUNCTIONALITY +// ============================================================================= + +/** + * Initialize admin release reports page functionality + * Handles description modal, select all, and bulk actions + */ +function initAdminReleaseReports() { + // Report Description Modal + var descModal = document.getElementById('reportDescriptionModal'); + var descButtons = document.querySelectorAll('.report-description-btn'); + var descContent = document.getElementById('reportDescContent'); + var descReason = document.getElementById('reportDescReason'); + var descReporter = document.getElementById('reportDescReporter'); + var descCloseButtons = document.querySelectorAll('.report-desc-modal-close'); + var descBackdrop = document.querySelector('.report-desc-modal-backdrop'); + + // Open description modal + descButtons.forEach(function(btn) { + if (btn.hasAttribute('data-desc-initialized')) return; + btn.setAttribute('data-desc-initialized', 'true'); + + btn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + var description = this.getAttribute('data-description'); + var reason = this.getAttribute('data-reason'); + var reporter = this.getAttribute('data-reporter'); + + if (descContent) descContent.textContent = description || 'No additional details provided.'; + if (descReason) descReason.textContent = reason || 'Unknown'; + if (descReporter) descReporter.textContent = reporter || 'Unknown'; + + if (descModal) descModal.classList.remove('hidden'); + }); + }); + + // Close description modal + function closeDescModal() { + if (descModal) descModal.classList.add('hidden'); + } + + descCloseButtons.forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.preventDefault(); + closeDescModal(); + }); + }); + + if (descBackdrop) { + descBackdrop.addEventListener('click', closeDescModal); + } + + // Select All functionality + var selectAll = document.getElementById('select-all'); + var checkboxes = document.querySelectorAll('.report-checkbox'); + + if (selectAll && !selectAll.hasAttribute('data-selectall-initialized')) { + selectAll.setAttribute('data-selectall-initialized', 'true'); + + selectAll.addEventListener('change', function() { + checkboxes.forEach(function(checkbox) { + checkbox.checked = selectAll.checked; + }); + }); + } + + // Update select all checkbox when individual checkboxes change + checkboxes.forEach(function(checkbox) { + if (checkbox.hasAttribute('data-checkbox-initialized')) return; + checkbox.setAttribute('data-checkbox-initialized', 'true'); + + checkbox.addEventListener('change', function() { + var allChecked = Array.from(checkboxes).every(function(cb) { return cb.checked; }); + var someChecked = Array.from(checkboxes).some(function(cb) { return cb.checked; }); + if (selectAll) { + selectAll.checked = allChecked; + selectAll.indeterminate = someChecked && !allChecked; + } + }); + }); + + // Bulk action form validation + var bulkForm = document.getElementById('bulk-action-form'); + if (bulkForm && !bulkForm.hasAttribute('data-bulk-initialized')) { + bulkForm.setAttribute('data-bulk-initialized', 'true'); + + bulkForm.addEventListener('submit', function(e) { + var actionSelect = this.querySelector('select[name="action"]'); + var action = actionSelect ? actionSelect.value : ''; + var checkedCount = document.querySelectorAll('.report-checkbox:checked').length; + + if (!action) { + e.preventDefault(); + alert('Please select an action.'); + return; + } + + if (checkedCount === 0) { + e.preventDefault(); + alert('Please select at least one report.'); + return; + } + + if (action === 'delete') { + if (!confirm('Are you sure you want to DELETE ' + checkedCount + ' release(s)? This action cannot be undone.')) { + e.preventDefault(); + } + } + }); + } +} + +// Initialize admin release reports on DOMContentLoaded +document.addEventListener('DOMContentLoaded', initAdminReleaseReports); diff --git a/resources/views/admin/release-reports/index.blade.php b/resources/views/admin/release-reports/index.blade.php new file mode 100644 index 000000000..071d4ba11 --- /dev/null +++ b/resources/views/admin/release-reports/index.blade.php @@ -0,0 +1,323 @@ +@extends('layouts.admin') + +@section('content') +
+ + @if(session('success')) +
+
+ +

{{ session('success') }}

+
+ +
+ @endif + + @if(session('error')) +
+
+ +

{{ session('error') }}

+
+ +
+ @endif + +
+ + + + + @if($reportsList->count() > 0) +
+ @csrf +
+ + + +
+ +
+ + + + + + + + + + + + + + + @foreach($reportsList as $report) + + + + + + + + + + + @endforeach + +
IDReleaseReporterReasonStatusDateActions
+ + + #{{ $report->id }} + +
+ @if($report->release) + + {{ Str::limit($report->release->searchname, 80) }} + +
+ ID: {{ $report->releases_id }} | Size: {{ number_format($report->release->size / 1073741824, 2) }} GB +
+ @else + + Release Deleted + +
+ Original ID: {{ $report->releases_id }} +
+ @endif +
+
+
+ @if($report->user) + {{ $report->user->username }} + @else + Deleted User + @endif +
+
+ + {{ $report->reason_label }} + + @if($report->description) + + @endif + + @php + $statusColors = [ + 'pending' => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + 'reviewed' => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + 'resolved' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + 'dismissed' => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200', + ]; + $statusIcons = [ + 'pending' => 'fa-clock', + 'reviewed' => 'fa-eye', + 'resolved' => 'fa-check', + 'dismissed' => 'fa-ban', + ]; + @endphp + + + {{ ucfirst($report->status) }} + + @if($report->reviewer && $report->reviewed_at) +
+ by {{ $report->reviewer->username }} at {{ $report->reviewed_at->format('M d, Y H:i') }} +
+ @endif +
+ {{ $report->created_at->format('M d, Y') }} +
{{ $report->created_at->format('H:i') }}
+
+
+ @if($report->release && in_array($report->status, ['pending', 'reviewed'])) + + + @csrf + + + @endif + + @if($report->status === 'pending') + +
+ @csrf + + +
+ + +
+ @csrf + +
+ @elseif($report->status === 'reviewed') + +
+ @csrf + + +
+ + +
+ @csrf + +
+ @elseif(!$report->release) + Release already deleted + @else + {{ ucfirst($report->status) }} + @endif + + @if($report->release) + + + + @endif +
+
+
+ + + +
+ {{ $reportsList->appends(['status' => $status])->links() }} +
+ @else +
+ +

No Reports Found

+

+ @if($status !== 'all') + No {{ $status }} reports at this time. + View all reports + @else + No release reports have been submitted yet. + @endif +

+
+ @endif +
+
+ + + +@endsection + diff --git a/resources/views/browse/index.blade.php b/resources/views/browse/index.blade.php index 39af6c3e5..7d8bdaebf 100644 --- a/resources/views/browse/index.blade.php +++ b/resources/views/browse/index.blade.php @@ -141,6 +141,12 @@
{{ $result->searchname }} + @if(!empty($result->report_count) && $result->report_count > 0) + + Reported ({{ $result->report_count }}) + + @endif @if(!empty($result->failed_count) && $result->failed_count > 0) @@ -254,6 +260,7 @@ @endif +
diff --git a/resources/views/components/report-button.blade.php b/resources/views/components/report-button.blade.php new file mode 100644 index 000000000..726569acb --- /dev/null +++ b/resources/views/components/report-button.blade.php @@ -0,0 +1,113 @@ +@props([ + 'releaseId' => null, + 'releaseGuid' => null, + 'size' => 'sm', // sm, md, lg + 'variant' => 'icon', // icon, button, text +]) + +@auth +
+ + @if($variant === 'icon') + + @elseif($variant === 'button') + + @else + + @endif + + + +
+@endauth + diff --git a/resources/views/details/index.blade.php b/resources/views/details/index.blade.php index d2f5472bd..9950bb428 100644 --- a/resources/views/details/index.blade.php +++ b/resources/views/details/index.blade.php @@ -30,12 +30,21 @@

{{ $release->searchname }}

- @if(!empty($failed) && $failed > 0) -
- - {{ $failed }} user{{ $failed > 1 ? 's' : '' }} reported download failure -
- @endif +
+ @if(!empty($reportCount) && $reportCount > 0) +
+ + {{ $reportCount }} report{{ $reportCount > 1 ? 's' : '' }}: {{ $reportReasons ?? 'Unknown' }} +
+ @endif + @if(!empty($failed) && $failed > 0) +
+ + {{ $failed }} user{{ $failed > 1 ? 's' : '' }} reported download failure +
+ @endif +
@@ -49,6 +58,7 @@ View NFO @endif + @auth @if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('Moderator')) diff --git a/resources/views/movies/index.blade.php b/resources/views/movies/index.blade.php index 68bf29da4..f37604bed 100644 --- a/resources/views/movies/index.blade.php +++ b/resources/views/movies/index.blade.php @@ -148,6 +148,24 @@

{{ $result->title }}

@endif + @if(!empty($result->grp_release_reports)) + @php + $reportCounts = array_filter(explode(',', $result->grp_release_reports)); + $totalReports = array_sum($reportCounts); + $reportReasonsRaw = !empty($result->grp_release_report_reasons) + ? implode(',', array_unique(array_filter(preg_split('/[|,]/', $result->grp_release_report_reasons)))) + : ''; + $reportReasons = \App\Models\ReleaseReport::reasonKeysToLabels($reportReasonsRaw); + @endphp + @if($totalReports > 0) +
+ + {{ $totalReports }} report{{ $totalReports > 1 ? 's' : '' }} +
+ @endif + @endif + @if(!empty($result->grp_release_failed)) @php $failedCounts = array_filter(explode(',', $result->grp_release_failed)); @@ -227,6 +245,7 @@ $releaseNames = isset($result->grp_release_name) ? explode('#', $result->grp_release_name) : []; $releaseSizes = isset($result->grp_release_size) ? explode(',', $result->grp_release_size) : []; $releaseGuids = isset($result->grp_release_guid) ? explode(',', $result->grp_release_guid) : []; + $releaseIds = isset($result->grp_release_id) ? explode(',', $result->grp_release_id) : []; $releasePostDates = isset($result->grp_release_postdate) ? explode(',', $result->grp_release_postdate) : []; $releaseAddDates = isset($result->grp_release_adddate) ? explode(',', $result->grp_release_adddate) : []; $releaseHasPreview = isset($result->grp_haspreview) ? explode(',', $result->grp_haspreview) : []; @@ -240,6 +259,7 @@ $releaseNames = array_slice($releaseNames, 0, $maxReleases); $releaseSizes = array_slice($releaseSizes, 0, $maxReleases); $releaseGuids = array_slice($releaseGuids, 0, $maxReleases); + $releaseIds = array_slice($releaseIds, 0, $maxReleases); $releasePostDates = array_slice($releasePostDates, 0, $maxReleases); $releaseAddDates = array_slice($releaseAddDates, 0, $maxReleases); $releaseHasPreview = array_slice($releaseHasPreview, 0, $maxReleases); @@ -327,6 +347,9 @@
Details + @if(isset($releaseIds[$index])) + + @endif
diff --git a/resources/views/partials/admin-menu.blade.php b/resources/views/partials/admin-menu.blade.php index b5ec1747c..7d26daf3a 100644 --- a/resources/views/partials/admin-menu.blade.php +++ b/resources/views/partials/admin-menu.blade.php @@ -67,6 +67,9 @@ diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php index 1c0bc5e37..29b68f738 100644 --- a/resources/views/search/index.blade.php +++ b/resources/views/search/index.blade.php @@ -210,6 +210,12 @@
{{ $result->searchname }} + @if(!empty($result->report_count) && $result->report_count > 0) + + Reported ({{ $result->report_count }}) + + @endif @if(!empty($result->failed_count) && $result->failed_count > 0) @@ -322,6 +328,7 @@ @endif +
@@ -341,6 +348,12 @@ {{ $result->searchname }} + @if(!empty($result->report_count) && $result->report_count > 0) + + Reported ({{ $result->report_count }}) + + @endif @if(!empty($result->failed_count) && $result->failed_count > 0) @@ -429,6 +442,7 @@ @endif +
diff --git a/routes/web.php b/routes/web.php index 4b3c6582a..1792645f9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -154,6 +154,11 @@ Route::middleware('isVerified')->group(function () { Route::post('profile/update-theme', [ProfileController::class, 'updateTheme'])->name('profile.update-theme'); Route::match(['GET', 'POST'], 'search', [SearchController::class, 'search'])->name('search'); + // Release Report routes + Route::post('release-report', [\App\Http\Controllers\ReleaseReportController::class, 'store'])->name('release-report.store'); + Route::get('release-report/reasons', [\App\Http\Controllers\ReleaseReportController::class, 'getReasons'])->name('release-report.reasons'); + Route::get('release-report/check', [\App\Http\Controllers\ReleaseReportController::class, 'checkReported'])->name('release-report.check'); + Route::match(['GET', 'POST'], 'mymovies', [MyMoviesController::class, 'show'])->name('mymovies'); Route::match(['GET', 'POST'], 'myshows', [MyShowsController::class, 'show'])->name('myshows'); Route::match(['GET', 'POST'], 'myshows/browse', [MyShowsController::class, 'browse'])->name('myshows.browse'); @@ -236,6 +241,14 @@ Route::middleware('role:Admin', '2fa')->prefix('admin')->group(function () { 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-delete/{id}', [AdminReleasesController::class, 'destroy'])->name('admin.release-delete'); + + // Release Reports Management + Route::get('release-reports', [\App\Http\Controllers\Admin\AdminReleaseReportController::class, 'index'])->name('admin.release-reports'); + Route::post('release-reports/{id}/status', [\App\Http\Controllers\Admin\AdminReleaseReportController::class, 'updateStatus'])->name('admin.release-reports.update-status'); + Route::post('release-reports/{id}/delete-release', [\App\Http\Controllers\Admin\AdminReleaseReportController::class, 'deleteRelease'])->name('admin.release-reports.delete-release'); + Route::post('release-reports/{id}/dismiss', [\App\Http\Controllers\Admin\AdminReleaseReportController::class, 'dismiss'])->name('admin.release-reports.dismiss'); + Route::post('release-reports/bulk', [\App\Http\Controllers\Admin\AdminReleaseReportController::class, 'bulkAction'])->name('admin.release-reports.bulk'); + Route::get('show-list', [AdminShowsController::class, 'index'])->name('admin.show-list'); Route::match(['GET', 'POST'], 'show-edit', [AdminShowsController::class, 'edit'])->name('admin.show-edit'); Route::get('show-remove/{id}', [AdminShowsController::class, 'destroy'])->name('admin.show-remove');