diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php
index 4fab57765..3b3b90598 100644
--- a/app/Services/Releases/ReleaseBrowseService.php
+++ b/app/Services/Releases/ReleaseBrowseService.php
@@ -252,6 +252,7 @@ class ReleaseBrowseService
'size' => 'size',
'files' => 'totalpart',
'stats' => 'grabs',
+ 'added' => 'adddate',
default => 'postdate',
};
@@ -272,6 +273,8 @@ class ReleaseBrowseService
'cat_desc',
'posted_asc',
'posted_desc',
+ 'added_asc',
+ 'added_desc',
'size_asc',
'size_desc',
'files_asc',
diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php
index eb998990f..a0446c5d4 100644
--- a/app/Services/Releases/ReleaseSearchService.php
+++ b/app/Services/Releases/ReleaseSearchService.php
@@ -1167,6 +1167,7 @@ class ReleaseSearchService
'size' => 'size',
'files' => 'totalpart',
'stats' => 'grabs',
+ 'added' => 'adddate',
default => 'postdate',
};
diff --git a/app/View/Components/SortDropdown.php b/app/View/Components/SortDropdown.php
new file mode 100644
index 000000000..d1eec636c
--- /dev/null
+++ b/app/View/Components/SortDropdown.php
@@ -0,0 +1,94 @@
+currentSort = $currentSort ?? request('ob', 'posted_desc');
+ $this->baseUrl = $baseUrl ?? request()->url();
+ $this->queryParams = $queryParams ?? request()->except(['ob', 'page']);
+
+ // Default sort options for releases
+ $this->sortOptions = $options ?? [
+ 'posted_desc' => ['label' => 'Posted (Newest)', 'icon' => 'fa-calendar-alt'],
+ 'posted_asc' => ['label' => 'Posted (Oldest)', 'icon' => 'fa-calendar-alt'],
+ 'added_desc' => ['label' => 'Added (Newest)', 'icon' => 'fa-clock'],
+ 'added_asc' => ['label' => 'Added (Oldest)', 'icon' => 'fa-clock'],
+ 'name_asc' => ['label' => 'Name (A-Z)', 'icon' => 'fa-font'],
+ 'name_desc' => ['label' => 'Name (Z-A)', 'icon' => 'fa-font'],
+ 'size_desc' => ['label' => 'Size (Largest)', 'icon' => 'fa-hdd'],
+ 'size_asc' => ['label' => 'Size (Smallest)', 'icon' => 'fa-hdd'],
+ 'files_desc' => ['label' => 'Files (Most)', 'icon' => 'fa-file'],
+ 'files_asc' => ['label' => 'Files (Least)', 'icon' => 'fa-file'],
+ 'stats_desc' => ['label' => 'Grabs (Most)', 'icon' => 'fa-download'],
+ 'stats_asc' => ['label' => 'Grabs (Least)', 'icon' => 'fa-download'],
+ ];
+
+ // Pre-compute current label and icon
+ $this->currentLabel = $this->sortOptions[$this->currentSort]['label'] ?? 'Posted (Newest)';
+ $this->currentIcon = $this->sortOptions[$this->currentSort]['icon'] ?? 'fa-calendar-alt';
+
+ // Pre-compute all sort URLs
+ $this->sortUrls = [];
+ foreach ($this->sortOptions as $sortKey => $sortData) {
+ $params = array_merge($this->queryParams, ['ob' => $sortKey]);
+ $this->sortUrls[$sortKey] = $this->baseUrl.'?'.http_build_query($params);
+ }
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ */
+ public function render(): View|Closure|string
+ {
+ return view('components.sort-dropdown');
+ }
+}
diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js
index 8ad1af0f4..09885f841 100644
--- a/resources/js/csp-safe.js
+++ b/resources/js/csp-safe.js
@@ -5667,3 +5667,63 @@ function initAutocompleteInput(inputId, dropdownId, formId, itemClass) {
}
}
+// ============================================================================
+// Sort Dropdown Component
+// ============================================================================
+
+/**
+ * Initialize sort dropdown functionality
+ * Handles the custom dropdown for sorting releases on browse/search pages
+ */
+function initSortDropdowns() {
+ document.querySelectorAll('.sort-dropdown').forEach(function(dropdown) {
+ var toggle = dropdown.querySelector('.sort-dropdown-toggle');
+ var menu = dropdown.querySelector('.sort-dropdown-menu');
+ var chevron = dropdown.querySelector('.sort-dropdown-chevron');
+
+ if (!toggle || !menu) return;
+
+ // Skip if already initialized
+ if (toggle.hasAttribute('data-sort-initialized')) return;
+ toggle.setAttribute('data-sort-initialized', 'true');
+
+ toggle.addEventListener('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var isOpen = !menu.classList.contains('hidden');
+
+ // Close all other dropdowns first
+ document.querySelectorAll('.sort-dropdown-menu').forEach(function(m) {
+ m.classList.add('hidden');
+ });
+ document.querySelectorAll('.sort-dropdown-chevron').forEach(function(c) {
+ c.classList.remove('rotate-180');
+ });
+
+ if (!isOpen) {
+ menu.classList.remove('hidden');
+ if (chevron) chevron.classList.add('rotate-180');
+ }
+ });
+ });
+
+ // Close dropdowns when clicking outside (single global listener)
+ if (!window._sortDropdownOutsideListenerAdded) {
+ window._sortDropdownOutsideListenerAdded = true;
+ document.addEventListener('click', function(e) {
+ var isInsideDropdown = e.target.closest('.sort-dropdown');
+ if (!isInsideDropdown) {
+ document.querySelectorAll('.sort-dropdown-menu').forEach(function(m) {
+ m.classList.add('hidden');
+ });
+ document.querySelectorAll('.sort-dropdown-chevron').forEach(function(c) {
+ c.classList.remove('rotate-180');
+ });
+ }
+ });
+ }
+}
+
+// Initialize sort dropdowns on DOMContentLoaded
+document.addEventListener('DOMContentLoaded', initSortDropdowns);
+
diff --git a/resources/views/browse/index.blade.php b/resources/views/browse/index.blade.php
index 92a64f092..ba701526e 100644
--- a/resources/views/browse/index.blade.php
+++ b/resources/views/browse/index.blade.php
@@ -93,16 +93,7 @@
-
-
-
-
+
diff --git a/resources/views/components/sort-dropdown.blade.php b/resources/views/components/sort-dropdown.blade.php
new file mode 100644
index 000000000..8f8d86210
--- /dev/null
+++ b/resources/views/components/sort-dropdown.blade.php
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php
index 46f628e0a..6635bdf3f 100644
--- a/resources/views/search/index.blade.php
+++ b/resources/views/search/index.blade.php
@@ -157,6 +157,11 @@
+ @if(auth()->check() && auth()->user()->hasRole('Admin'))
+
+ @endif
@@ -173,28 +178,9 @@
-
-
Sort by:
-
- @php
- $currentSort = request('ob', 'posted_desc');
- $sortOptions = [
- 'posted_desc' => ['label' => 'Posted Date ↓', 'icon' => 'fa-calendar'],
- 'posted_asc' => ['label' => 'Posted Date ↑', 'icon' => 'fa-calendar'],
- 'size_desc' => ['label' => 'Size ↓', 'icon' => 'fa-hdd'],
- 'size_asc' => ['label' => 'Size ↑', 'icon' => 'fa-hdd'],
- 'name_asc' => ['label' => 'Name ↑', 'icon' => 'fa-font'],
- 'name_desc' => ['label' => 'Name ↓', 'icon' => 'fa-font'],
- ];
- $queryParams = request()->except('ob');
- @endphp
- @foreach($sortOptions as $sortKey => $sortData)
-
- {{ $sortData['label'] }}
-
- @endforeach
-
+
+ Sort results:
+
@@ -224,6 +210,12 @@
{{ $result->searchname }}
+ @if(!empty($result->failed) && $result->failed > 0)
+
+ Failed ({{ $result->failed }})
+
+ @endif
@if(isset($result->haspreview) && $result->haspreview == 1)
@endif
+ @if(isset($result->reid) && $result->reid != null)
+
+ @endif
@if(isset($result->nfostatus) && $result->nfostatus == 1)
@@ -436,6 +473,46 @@
+
+
+
+
+
+
+
+
+ File List
+
+
+
+
+
+
+
+
+
Loading file list...
+
+
+
+
+
@include('partials.nfo-modal')
diff --git a/tests/Unit/ReleaseBrowseServiceTest.php b/tests/Unit/ReleaseBrowseServiceTest.php
new file mode 100644
index 000000000..81898a116
--- /dev/null
+++ b/tests/Unit/ReleaseBrowseServiceTest.php
@@ -0,0 +1,151 @@
+service = new ReleaseBrowseService;
+ }
+
+ /**
+ * Test that getBrowseOrdering returns all expected ordering options.
+ */
+ public function test_get_browse_ordering_returns_all_options(): void
+ {
+ $ordering = $this->service->getBrowseOrdering();
+
+ $this->assertIsArray($ordering);
+ $this->assertContains('name_asc', $ordering);
+ $this->assertContains('name_desc', $ordering);
+ $this->assertContains('cat_asc', $ordering);
+ $this->assertContains('cat_desc', $ordering);
+ $this->assertContains('posted_asc', $ordering);
+ $this->assertContains('posted_desc', $ordering);
+ $this->assertContains('added_asc', $ordering);
+ $this->assertContains('added_desc', $ordering);
+ $this->assertContains('size_asc', $ordering);
+ $this->assertContains('size_desc', $ordering);
+ $this->assertContains('files_asc', $ordering);
+ $this->assertContains('files_desc', $ordering);
+ $this->assertContains('stats_asc', $ordering);
+ $this->assertContains('stats_desc', $ordering);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'added' to 'adddate' column.
+ */
+ public function test_get_browse_order_maps_added_to_adddate(): void
+ {
+ $result = $this->service->getBrowseOrder('added_desc');
+
+ $this->assertEquals(['adddate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly handles added ascending.
+ */
+ public function test_get_browse_order_handles_added_ascending(): void
+ {
+ $result = $this->service->getBrowseOrder('added_asc');
+
+ $this->assertEquals(['adddate', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'posted' to 'postdate' column.
+ */
+ public function test_get_browse_order_maps_posted_to_postdate(): void
+ {
+ $result = $this->service->getBrowseOrder('posted_desc');
+
+ $this->assertEquals(['postdate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder defaults to postdate desc for empty string.
+ */
+ public function test_get_browse_order_defaults_to_postdate_desc(): void
+ {
+ $result = $this->service->getBrowseOrder('');
+
+ $this->assertEquals(['postdate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'name' to 'searchname' column.
+ */
+ public function test_get_browse_order_maps_name_to_searchname(): void
+ {
+ $result = $this->service->getBrowseOrder('name_asc');
+
+ $this->assertEquals(['searchname', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'size' to 'size' column.
+ */
+ public function test_get_browse_order_maps_size_correctly(): void
+ {
+ $result = $this->service->getBrowseOrder('size_desc');
+
+ $this->assertEquals(['size', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'files' to 'totalpart' column.
+ */
+ public function test_get_browse_order_maps_files_to_totalpart(): void
+ {
+ $result = $this->service->getBrowseOrder('files_asc');
+
+ $this->assertEquals(['totalpart', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'stats' to 'grabs' column.
+ */
+ public function test_get_browse_order_maps_stats_to_grabs(): void
+ {
+ $result = $this->service->getBrowseOrder('stats_desc');
+
+ $this->assertEquals(['grabs', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'cat' to 'categories_id' column.
+ */
+ public function test_get_browse_order_maps_cat_to_categories_id(): void
+ {
+ $result = $this->service->getBrowseOrder('cat_asc');
+
+ $this->assertEquals(['categories_id', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder handles unknown order type by defaulting to postdate.
+ */
+ public function test_get_browse_order_defaults_unknown_to_postdate(): void
+ {
+ $result = $this->service->getBrowseOrder('unknown_desc');
+
+ $this->assertEquals(['postdate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder handles invalid direction by defaulting to desc.
+ */
+ public function test_get_browse_order_defaults_invalid_direction_to_desc(): void
+ {
+ $result = $this->service->getBrowseOrder('name_invalid');
+
+ $this->assertEquals(['searchname', 'desc'], $result);
+ }
+}
diff --git a/tests/Unit/ReleaseSearchServiceOrderingTest.php b/tests/Unit/ReleaseSearchServiceOrderingTest.php
new file mode 100644
index 000000000..8a4a54c77
--- /dev/null
+++ b/tests/Unit/ReleaseSearchServiceOrderingTest.php
@@ -0,0 +1,110 @@
+service = $reflection->newInstanceWithoutConstructor();
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'added' to 'adddate' column.
+ */
+ public function test_get_browse_order_maps_added_to_adddate(): void
+ {
+ $result = $this->service->getBrowseOrder('added_desc');
+
+ $this->assertEquals(['adddate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly handles added ascending.
+ */
+ public function test_get_browse_order_handles_added_ascending(): void
+ {
+ $result = $this->service->getBrowseOrder('added_asc');
+
+ $this->assertEquals(['adddate', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'posted' to 'postdate' column.
+ */
+ public function test_get_browse_order_maps_posted_to_postdate(): void
+ {
+ $result = $this->service->getBrowseOrder('posted_desc');
+
+ $this->assertEquals(['postdate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder defaults to postdate desc for empty string.
+ */
+ public function test_get_browse_order_defaults_to_postdate_desc(): void
+ {
+ $result = $this->service->getBrowseOrder('');
+
+ $this->assertEquals(['postdate', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'name' to 'searchname' column.
+ */
+ public function test_get_browse_order_maps_name_to_searchname(): void
+ {
+ $result = $this->service->getBrowseOrder('name_asc');
+
+ $this->assertEquals(['searchname', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'size' to 'size' column.
+ */
+ public function test_get_browse_order_maps_size_correctly(): void
+ {
+ $result = $this->service->getBrowseOrder('size_desc');
+
+ $this->assertEquals(['size', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'files' to 'totalpart' column.
+ */
+ public function test_get_browse_order_maps_files_to_totalpart(): void
+ {
+ $result = $this->service->getBrowseOrder('files_asc');
+
+ $this->assertEquals(['totalpart', 'asc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'stats' to 'grabs' column.
+ */
+ public function test_get_browse_order_maps_stats_to_grabs(): void
+ {
+ $result = $this->service->getBrowseOrder('stats_desc');
+
+ $this->assertEquals(['grabs', 'desc'], $result);
+ }
+
+ /**
+ * Test that getBrowseOrder correctly maps 'cat' to 'categories_id' column.
+ */
+ public function test_get_browse_order_maps_cat_to_categories_id(): void
+ {
+ $result = $this->service->getBrowseOrder('cat_asc');
+
+ $this->assertEquals(['categories_id', 'asc'], $result);
+ }
+}