Mod Listing Page Changes

- Updated visual look of the listing to include all filters at the top of the page
- Updated SPT version filter to be able to filter more than one version at a time (defaults to current minor version)
- Moved location of Livewire components into subfolder
- Moved query building methods for the listing into a `ModFilter` class
- Adds a `isLatestMinor` method on the SptVersion model that checks if the current version number is of the latest minor version release
- Livewire filter properties are now saved to the URL when changed
- Updated the top navigation link to include a "Mods" menu item.

TODO:
- Search codebase for "TODO". I've left notes. :|
This commit is contained in:
Refringe 2024-08-15 17:57:35 -04:00
parent 411514148b
commit 7fe1fad01b
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
18 changed files with 461 additions and 201 deletions

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class InvalidVersionNumberException extends Exception
{
protected $message = 'The version number is an invalid semantic version.';
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Http\Filters;
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Database\Eloquent\Builder;
class ModFilter
{
/**
* The query builder instance for the mod model.
*/
protected Builder $builder;
/**
* The filter that should be applied to the query.
*/
protected array $filters;
public function __construct(array $filters)
{
$this->builder = $this->baseQuery();
$this->filters = $filters;
}
/**
* The base query for the mod listing.
*/
private function baseQuery(): Builder
{
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->whereHas('latestVersion');
}
/**
* Apply the filters to the query.
*/
public function apply(): Builder
{
foreach ($this->filters as $method => $value) {
if (method_exists($this, $method) && ! empty($value)) {
$this->$method($value);
}
}
return $this->builder;
}
/**
* Order the query by the given type.
*/
private function order(string $type): Builder
{
// We order the "recently updated" mods by the ModVersion's updated_at value.
if ($type === 'updated') {
return $this->builder->orderByDesc(
ModVersion::select('updated_at')
->whereColumn('mod_id', 'mods.id')
->orderByDesc('updated_at')
->take(1)
);
}
// By default, we simply order by the column on the mods table/query.
$column = match ($type) {
'downloaded' => 'total_downloads',
default => 'created_at',
};
return $this->builder->orderByDesc($column);
}
/**
* Filter the results by the given search term.
*/
private function query(string $term): Builder
{
return $this->builder->whereLike('name', "%$term%");
}
/**
* Filter the results by the featured status.
*/
private function featured(string $option): Builder
{
return match ($option) {
'exclude' => $this->builder->where('featured', false),
'only' => $this->builder->where('featured', true),
default => $this->builder,
};
}
/**
* Filter the results to a specific SPT version.
*/
private function sptVersion(array $versions): Builder
{
return $this->builder->withWhereHas('latestVersion.sptVersion', function ($query) use ($versions) {
$query->whereIn('version', $versions);
$query->orderByDesc('version');
});
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Mod;
use App\Http\Filters\ModFilter;
use App\Models\SptVersion;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
// TODO: These `Url` properties should be saved to the browser's local storage to persist the filters.
/**
* The search query value.
*/
#[Url]
public string $query = '';
/**
* The sort order value.
*/
#[Url]
public string $order = 'created';
/**
* The SPT version filter value.
*/
#[Url]
public array $sptVersion = [];
/**
* The featured filter value.
*/
#[Url]
public string $featured = 'include';
/**
* The available SPT versions.
*/
public Collection $availableSptVersions;
/**
* The component mount method, run only once when the component is mounted.
*/
public function mount(): void
{
$this->availableSptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get();
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
}
/**
* Get all hotfix versions of the latest minor SPT version.
*/
public function getLatestMinorVersions(): Collection
{
return $this->availableSptVersions->filter(function (SptVersion $sptVersion) {
return $sptVersion->isLatestMinor();
});
}
/**
* The component mount method.
*/
public function render(): View
{
// Fetch the mods using the filters saved to the component properties.
$filters = [
'query' => $this->query,
'featured' => $this->featured,
'order' => $this->order,
'sptVersion' => $this->sptVersion,
];
$mods = (new ModFilter($filters))->apply()->paginate(24);
return view('livewire.mod.index', compact('mods'));
}
/**
* The method to reset the filters.
*/
public function resetFilters(): void
{
$this->query = '';
$this->order = 'created';
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->featured = 'include';
}
}

View File

@ -1,71 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Mod;
use App\Models\SptVersion;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\WithPagination;
class ModIndex extends Component
{
use WithPagination;
public string $modSearch = '';
public string $sectionFilter = 'featured';
public int $versionFilter = -1;
public function render()
{
// 'featured' section is default
$section = 'featured';
switch ($this->sectionFilter) {
case 'new':
$section = 'created_at';
break;
case 'most_downloaded':
$section = 'total_downloads';
break;
case 'recently_updated':
$section = 'updated_at';
break;
case 'top_rated':
// probably use some kind of 'likes' or something
// not implemented yet afaik -waffle
break;
}
$mods = Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with(['users:id,name'])
->where('name', 'like', '%'.Str::trim($this->modSearch).'%');
if ($this->versionFilter === -1) {
$mods = $mods
->with(['latestVersion', 'latestVersion.sptVersion'])
->whereHas('latestVersion.sptVersion');
} else {
$mods = $mods->with(['latestVersion' => function ($query) {
$query->where('spt_version_id', $this->versionFilter);
}, 'latestVersion.sptVersion'])
->whereHas('latestVersion.sptVersion', function ($query) {
$query->where('spt_version_id', $this->versionFilter);
});
}
$mods = $mods->orderByDesc($section)->paginate(12);
$sptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get();
return view('livewire.mod-index', ['mods' => $mods, 'sptVersions' => $sptVersions]);
}
public function changeSection($section): void
{
$this->sectionFilter = $section;
}
}

View File

@ -34,6 +34,7 @@ class Mod extends Model
{
// Apply the global scope to exclude disabled mods.
static::addGlobalScope(new DisabledScope);
// Apply the global scope to exclude non-published mods.
static::addGlobalScope(new PublishedScope);
}

View File

@ -2,10 +2,13 @@
namespace App\Models;
use App\Exceptions\InvalidVersionNumberException;
use App\Services\LatestSptVersionService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
class SptVersion extends Model
{
@ -18,4 +21,49 @@ class SptVersion extends Model
{
return $this->hasMany(ModVersion::class);
}
/**
* Determine if the version is the latest minor version.
*/
public function isLatestMinor(): bool
{
$latestSptVersionService = App::make(LatestSptVersionService::class);
$latestVersion = $latestSptVersionService->getLatestVersion();
if (! $latestVersion) {
return false;
}
try {
$currentMinorVersion = $this->extractMinorVersion($this->version);
$latestMinorVersion = $this->extractMinorVersion($latestVersion->version);
} catch (InvalidVersionNumberException $e) {
// Could not parse a semver version number.
return false;
}
return $currentMinorVersion === $latestMinorVersion;
}
/**
* Extract the minor version from a full version string.
*
* @throws InvalidVersionNumberException
*/
private function extractMinorVersion(string $version): int
{
// Remove everything from the version string except the numbers and dots.
$version = preg_replace('/[^0-9.]/', '', $version);
// Validate that the version string is a valid semver.
if (! preg_match('/^\d+\.\d+\.\d+$/', $version)) {
throw new InvalidVersionNumberException;
}
$parts = explode('.', $version);
// Return the minor version part.
return (int) $parts[1];
}
}

View File

@ -7,6 +7,7 @@ use App\Models\ModVersion;
use App\Models\User;
use App\Observers\ModDependencyObserver;
use App\Observers\ModVersionObserver;
use App\Services\LatestSptVersionService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
@ -19,7 +20,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(LatestSptVersionService::class, function ($app) {
return new LatestSptVersionService;
});
}
/**

View File

@ -0,0 +1,19 @@
<?php
namespace App\Services;
use App\Models\SptVersion;
class LatestSptVersionService
{
protected ?SptVersion $version = null;
public function getLatestVersion(): ?SptVersion
{
if ($this->version === null) {
$this->version = SptVersion::select('version')->orderByDesc('version')->first();
}
return $this->version;
}
}

View File

@ -6,7 +6,7 @@
display: none;
}
main a:not(.mod-list-component):not(.tab) {
main a:not(.mod-list-component):not(.tab):not([role="menuitem"]) {
@apply underline text-gray-800 hover:text-black dark:text-gray-200 dark:hover:text-white;
}

View File

@ -0,0 +1,6 @@
@props(['id', 'name', 'value'])
<div class="flex items-center text-base sm:text-sm">
<input id="{{ $id }}" wire:model.live="{{ $name }}" value="{{ $value }}" type="checkbox" class="cursor-pointer h-4 w-4 flex-shrink-0 rounded border-gray-300 text-gray-600 focus:ring-gray-500">
<label for="{{ $id }}" class="cursor-pointer ml-3 min-w-0 inline-flex text-gray-600 dark:text-gray-300">{{ $slot }}</label>
</div>

View File

@ -0,0 +1,6 @@
@props(['id', 'name', 'value'])
<div class="flex items-center text-base sm:text-sm">
<input id="{{ $id }}" wire:model.live="{{ $name }}" value="{{ $value }}" type="radio" class="h-4 w-4 flex-shrink-0 rounded border-gray-300 text-gray-600 focus:ring-gray-500">
<label for="{{ $id }}" class="cursor-pointer ml-3 min-w-0 inline-flex text-gray-600 dark:text-gray-300">{{ $slot }}</label>
</div>

View File

@ -0,0 +1,8 @@
@props(['order', 'currentOrder'])
<a href="#{{ $order }}"
@click.prevent="$wire.set('order', '{{ $order }}')"
class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white {{ $order === $currentOrder ? "font-bold text-slate-900 dark:text-white" : "" }}"
role="menuitem" tabindex="-1">
{{ $slot }}
</a>

View File

@ -1,5 +1,9 @@
@props(['mod', 'versionScope' => 'latestVersion'])
{{--
// TODO: Should have some way of visially indicating that a mod is featured. Maybe a sideways ribbon over the thumbnail or something?
--}}
<a href="/mod/{{ $mod->id }}/{{ $mod->slug }}" class="mod-list-component mx-auto w-full max-w-md md:max-w-2xl">
<div class="flex flex-col group h-full w-full bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl overflow-hidden hover:shadow-lg hover:bg-gray-50 dark:hover:bg-black hover:shadow-gray-400 dark:hover:shadow-black transition-all duration-200">
<div class="h-auto md:h-full md:flex">

View File

@ -1,6 +1,10 @@
@props(['mods'])
@props(['mods, versionScope, title'])
<div class="mx-auto max-w-7xl px-4 pt-16 sm:px-6 lg:px-8">
{{--
TODO: The button-link should be dynamic based on the versionScope. Eg. Featured `View All` button should take
the user to the mods page with the `featured` query parameter set.
--}}
<x-page-content-title :title="$title" button-text="View All" button-link="/mods" />
<x-mod-list :mods="$mods" :versionScope="$versionScope" />
</div>

View File

@ -1,121 +0,0 @@
<div>
{{-- page links --}}
<div class="m-6">
{{ $mods->links() }}
</div>
{{-- grid layout --}}
<div class="grid gap-6 grid-cols-1 lg:grid-cols-4 m-4">
{{-- column 1 --}}
<div class="col-span-3">
{{-- mods serach bar --}}
<div>
<search class="relative group">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M771-593 630-734l-85 84-85-84 113-114q12-12 27-17.5t30-5.5q16 0 30.5 5.5T686-848l85 85q18 17 26.5 39.5T806-678q0 23-8.5 45T771-593ZM220-409q-18-18-18-42.5t18-42.5l98-99 85 85-99 99q-17 18-41.5 18T220-409Zm-43 297q-11-12-17-26.5t-6-30.5q0-16 5.5-30.5T177-226l283-282-127-128q-18-17-18-41.5t18-42.5q17-18 42-18t43 18l127 127 57-57 112 114q12 12 12 28t-12 28q-12 12-28 12t-28-12L290-112q-12 12-26.5 17.5T234-89q-15 0-30-6t-27-17Z"/></svg>
</div>
<input wire:model.live="modSearch" class="sm:w-1/3 w-full rounded-md border-0 bg-white dark:bg-gray-700 py-1.5 pl-10 pr-3 text-gray-900 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 dark:focus:bg-gray-200 dark:focus:text-black dark:focus:ring-0 sm:text-sm sm:leading-6" placeholder="Search Mods ..." />
</search>
</div>
{{-- mobile section filters --}}
<div class="flex flex-col sm:hidden my-4">
<select wire:model.live="sectionFilter" class="block w-full rounded-md dark:text-white bg-gray-100 dark:bg-gray-950 border-gray-300 dark:border-gray-700 focus:border-grey-500 dark:focus:border-grey-600 focus:ring-grey-500 dark:focus:ring-grey-600">
<option value="featured">{{__('Featured')}}</option>
<option value="new">{{__('New')}}</option>
<option value="recently_updated">{{__('Recently Updated')}}</option>
<option value="most_downloaded">{{__('Most Downloaded')}}</option>
<option value="top_rated">{{__('Top Rated')}}</option>
</select>
</div>
{{-- section filters --}}
<div class="hidden sm:block my-4">
<nav class="isolate flex divide-x divide-gray-200 dark:divide-gray-800 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl" aria-label="Tabs">
<button wire:click="changeSection('featured')" class="tab rounded-l-xl group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>{{ __('Featured') }}</span>
<span aria-hidden="true" class="{{ $sectionFilter === 'featured' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5' }}"></span>
</button>
<button wire:click="changeSection('new')" class="tab group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>{{ __('New') }}</span>
<span aria-hidden="true" class="{{ $sectionFilter === 'new' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5' }}"></span>
</button>
<button wire:click="changeSection('recently_updated')" class="tab group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>{{ __('Recently Updated') }}</span>
<span aria-hidden="true" class="{{ $sectionFilter === 'recently_updated' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5' }}"></span>
</button>
<button wire:click="changeSection('most_downloaded')" class="tab group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>{{ __('Most Downloaded') }}</span>
<span aria-hidden="true" class="{{ $sectionFilter === 'most_downloaded' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5' }}"></span>
</button>
<button wire:click="changeSection('top_rated')" class="tab rounded-r-xl group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>{{ __('Top Rated') }}</span>
<span aria-hidden="true" class="{{ $sectionFilter === 'top_rated' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5' }}"></span>
</button>
</nav>
</div>
{{-- mobile version filters --}}
<div class="flex flex-col lg:hidden my-4">
<select wire:model.live="versionFilter" class="rounded-md dark:text-white bg-gray-100 dark:bg-gray-950 border-gray-300 dark:border-gray-700 focus:border-grey-500 dark:focus:border-grey-600 focus:ring-grey-500 dark:focus:ring-grey-600">
<option value="-1">Any Version</option>
@foreach($sptVersions as $version)
<option value="{{ $version->id }}">{{ $version->version }}</option>
@endforeach
</select>
</div>
{{-- mobile tags filters --}}
<div class="flex flex-col lg:hidden my-4">
<p class="text-gray-700 dark:text-gray-200">tags filters here when ready :)</p>
</div>
{{-- mod cards --}}
<div class="grid gap-6 grid-cols-2">
@foreach($mods as $mod)
<x-mod-card :mod="$mod" />
@endforeach
</div>
</div>
{{-- column 2 --}}
<div class="flex flex-col col-span-1 gap-4 hidden lg:flex">
{{-- spt version filters --}}
<div class="flex flex-col drop-shadow-2xl text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-950 p-4 rounded-xl">
<h2>SPT Version</h2>
<select wire:model.live="versionFilter" class="rounded-md dark:text-white bg-gray-100 dark:bg-gray-950 border-gray-300 dark:border-gray-700 focus:border-grey-500 dark:focus:border-grey-600 focus:ring-grey-500 dark:focus:ring-grey-600">
<option value="-1">Any Version</option>
@foreach($sptVersions as $version)
<option value="{{ $version->id }}">{{ $version->version }}</option>
@endforeach
</select>
</div>
{{-- tag filters --}}
<div class="flex flex-col drop-shadow-2xl text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-950 p-4 rounded-xl gap-2">
<h2>Tags</h2>
<button class="flex justify-between text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 rounded-md p-2">
<span>Placeholder</span>
<span>2501</span>
</button>
<button class="flex justify-between text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 rounded-md p-2">
<span>Stuff</span>
<span>420</span>
</button>
<button class="flex justify-between text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 rounded-md p-2">
<span>Here</span>
<span>69</span>
</button>
</div>
</div>
</div>
{{-- page links --}}
<div class="m-6">
{{ $mods->links() }}
</div>
</div>

View File

@ -0,0 +1,138 @@
{{-- TODO: This container should have the darker background, like the homepage. --}}
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-200">{{ __('Mods') }}</h1>
<p class="mt-4 text-base text-slate-500 dark:text-gray-300">{!! __('Explore an enhanced <abbr title="Single Player Tarkov">SPT</abbr> experience with the mods available below. Check out the featured mods for a tailored solo-survival game with maximum immersion.') !!}</p>
<section x-data="{ isFilterOpen: false }"
@click.away="isFilterOpen = false"
aria-labelledby="filter-heading"
class="my-8 grid items-center border-t border-gray-300 dark:border-gray-700">
<h2 id="filter-heading" class="sr-only">{{ __('Filters') }}</h2>
<div class="relative col-start-1 row-start-1 py-4 border-b border-gray-300 dark:border-gray-700">
<div class="mx-auto flex max-w-7xl space-x-6 divide-x divide-gray-300 dark:divide-gray-700 px-4 text-sm sm:px-6 lg:px-8">
<button type="button" @click="isFilterOpen = !isFilterOpen" class="group flex items-center font-medium text-gray-700 dark:text-gray-300" aria-controls="disclosure-1" aria-expanded="false">
<svg class="mr-2 h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500 dark:text-gray-600" aria-hidden="true" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M2.628 1.601C5.028 1.206 7.49 1 10 1s4.973.206 7.372.601a.75.75 0 01.628.74v2.288a2.25 2.25 0 01-.659 1.59l-4.682 4.683a2.25 2.25 0 00-.659 1.59v3.037c0 .684-.31 1.33-.844 1.757l-1.937 1.55A.75.75 0 018 18.25v-5.757a2.25 2.25 0 00-.659-1.591L2.659 6.22A2.25 2.25 0 012 4.629V2.34a.75.75 0 01.628-.74z" clip-rule="evenodd" />
</svg>
{{ __('Filters') }} {{-- // TODO: Ideally, we should have a count of the number of filters here: "5 Filters" --}}
</button>
<search class="relative group pl-6">
<div class="pointer-events-none absolute inset-y-0 left-8 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-5 w-5 text-gray-400">
<path fill-rule="evenodd" d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z" clip-rule="evenodd" />
</svg>
</div>
<input wire:model.live="query" class="w-full rounded-md border-0 bg-white dark:bg-gray-700 py-1.5 pl-10 pr-3 text-gray-900 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 dark:focus:bg-gray-200 dark:focus:text-black dark:focus:ring-0 sm:text-sm sm:leading-6" placeholder="{{ __('Search Mods') }}" />
</search>
<button @click="$wire.call('resetFilters')" type="button" class="pl-6 text-gray-500 dark:text-gray-300">{{ __('Reset Filters') }}</button>
<div wire:loading.flex>
<p class="pl-6 flex items-center font-medium text-gray-700 dark:text-gray-300">{{ __('Loading...') }}</p>
</div>
</div>
</div>
<div x-cloak
x-show="isFilterOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-10"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-10"
id="disclosure-1"
class="py-10 border-b border-gray-300 dark:border-gray-700">
<div class="mx-auto grid max-w-7xl grid-cols-2 gap-x-4 px-4 text-sm sm:px-6 md:gap-x-6 lg:px-8">
<div class="grid auto-rows-min grid-cols-1 gap-y-10 md:grid-cols-2 md:gap-x-6">
@php
$totalVersions = count($availableSptVersions);
$half = ceil($totalVersions / 2);
@endphp
<fieldset>
<legend class="block font-medium text-gray-900 dark:text-gray-100">{{ __('SPT Versions') }}</legend>
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
@foreach ($availableSptVersions as $index => $version)
@if ($index < $half)
<x-filter-checkbox id="sptVersion-{{ $index }}" name="sptVersion" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
@endif
@endforeach
</div>
</fieldset>
<fieldset>
<legend class="block font-medium text-gray-900 dark:text-gray-100">&nbsp;</legend>
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
@foreach ($availableSptVersions as $index => $version)
@if ($index >= $half)
<x-filter-checkbox id="sptVersion-{{ $index }}" name="sptVersion" value="{{ $version->version }}">{{ $version->version }}</x-filter-checkbox>
@endif
@endforeach
</div>
</fieldset>
</div>
<div class="grid auto-rows-min grid-cols-1 gap-y-10 md:grid-cols-2 md:gap-x-6">
<fieldset>
<legend class="block font-medium text-gray-900 dark:text-gray-100">{{ __('Featured') }}</legend>
<div class="space-y-6 pt-6 sm:space-y-4 sm:pt-4">
<x-filter-radio id="featured-0" name="featured" value="include">{{ __('Include') }}</x-filter-radio>
<x-filter-radio id="featured-1" name="featured" value="exclude">{{ __('Exclude') }}</x-filter-radio>
<x-filter-radio id="featured-2" name="featured" value="only">{{ __('Only') }}</x-filter-radio>
</div>
</fieldset>
</div>
</div>
</div>
<div class="col-start-1 row-start-1 py-4">
<div class="mx-auto flex max-w-7xl justify-end px-4 sm:px-6 lg:px-8">
<div class="relative inline-block" x-data="{ isSortOpen: false }" @click.away="isSortOpen = false">
<div class="flex">
<button type="button" @click="isSortOpen = !isSortOpen" class="group inline-flex justify-center text-sm font-medium text-gray-700 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100" id="menu-button" :aria-expanded="isSortOpen.toString()" aria-haspopup="true">
{{ __('Sort') }}
<svg class="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div x-cloak
x-show="isSortOpen"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute top-7 right-0 z-10 flex w-full min-w-[12rem] flex-col divide-y divide-slate-300 overflow-hidden rounded-xl border border-gray-300 bg-gray-100 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800"
role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
<div class="flex flex-col py-1.5">
<x-filter-sort-menu-item order="created" :currentOrder="$order">{{ __('Newest') }}</x-filter-sort-menu-item>
<x-filter-sort-menu-item order="updated" :currentOrder="$order">{{ __('Recently Updated') }}</x-filter-sort-menu-item>
<x-filter-sort-menu-item order="downloaded" :currentOrder="$order">{{ __('Most Downloaded') }}</x-filter-sort-menu-item>
</div>
</div>
</div>
</div>
</div>
</section>
{{ $mods->onEachSide(1)->links() }}
{{-- Mod Listing --}}
@if ($mods->isNotEmpty())
<div class="my-8 grid gap-6 grid-cols-2">
@foreach ($mods as $mod)
<x-mod-card :mod="$mod" />
@endforeach
</div>
@else
<div class="text-center">
<p>{{ __('There were no mods found with those filters applied. ') }}</p>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mx-auto">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
</div>
@endif
{{ $mods->onEachSide(1)->links() }}
</div>

View File

@ -1,4 +1,3 @@
<x-app-layout>
@livewire('mod-index')
@livewire('mod.index')
</x-app-layout>

View File

@ -9,9 +9,7 @@
</div>
<div class="hidden lg:ml-6 lg:block">
<div class="flex space-x-4">
@auth
<x-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">{{ __('Dashboard') }}</x-nav-link>
@endauth
<x-nav-link href="{{ route('mods') }}" :active="request()->routeIs('mods')">{{ __('Mods') }}</x-nav-link>
{{-- additional menu links here --}}
</div>
</div>
@ -54,7 +52,15 @@
<span class="sr-only">{{ __('Open user menu') }}</span>
<img class="h-8 w-8 rounded-full" src="{{ auth()->user()->profile_photo_url }}" alt="{{ auth()->user()->name }}">
</button>
<div x-cloak x-show="profileDropdownOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard" @click.outside="profileDropdownOpen = false, openedWithKeyboard = false" @keydown.down.prevent="$focus.wrap().next()" @keydown.up.prevent="$focus.wrap().previous()" class="absolute top-11 right-0 z-10 flex w-full min-w-[12rem] flex-col divide-y divide-slate-300 overflow-hidden rounded-xl border border-slate-300 bg-slate-100 dark:divide-slate-700 dark:border-slate-700 dark:bg-slate-800" role="menu">
<div x-cloak
x-show="profileDropdownOpen || openedWithKeyboard"
x-transition
x-trap="openedWithKeyboard"
@click.outside="profileDropdownOpen = false, openedWithKeyboard = false"
@keydown.down.prevent="$focus.wrap().next()"
@keydown.up.prevent="$focus.wrap().previous()"
class="absolute top-11 right-0 z-10 flex w-full min-w-[12rem] flex-col divide-y divide-slate-300 overflow-hidden rounded-xl border border-gray-300 bg-gray-100 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800"
role="menu">
<div class="flex flex-col py-1.5">
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white" role="menuitem">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">