Merge branch 'mod-listing-page' into develop

This commit is contained in:
Refringe 2024-08-16 00:15:02 -04:00
commit d1bfdf5424
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
21 changed files with 581 additions and 40 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

@ -15,7 +15,7 @@ class ModController extends Controller
{
$this->authorize('viewAny', Mod::class);
return ModResource::collection(Mod::all());
return view('mod.index');
}
public function store(ModRequest $request)

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');
});
}
}

113
app/Livewire/Mod/Index.php Normal file
View File

@ -0,0 +1,113 @@
<?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\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
/**
* 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->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->featured = 'include';
// Clear local storage
$this->dispatch('clear-filters');
}
/**
* Compute the count of active filters.
*/
#[Computed]
public function filterCount(): int
{
$count = 0;
if ($this->query !== '') {
$count++;
}
if ($this->featured !== 'include') {
$count++;
}
$count += count($this->sptVersion);
return $count;
}
}

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

@ -10,9 +10,9 @@ class ModPolicy
/**
* Determine whether the user can view multiple models.
*/
public function viewAny(User $user): bool
public function viewAny(?User $user): bool
{
return false;
return true;
}
/**

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;
}
@ -87,3 +87,24 @@ main a:not(.mod-list-component):not(.tab) {
@apply my-2 ml-7 text-gray-800 dark:text-gray-300;
}
}
.ribbon {
font-size: 18px;
font-weight: bold;
color: #fff;
}
.ribbon {
--f: .5em;
position: absolute;
top: 0;
left: 0;
line-height: 1.5;
padding-inline: 1lh;
padding-bottom: var(--f);
border-image: conic-gradient(#0008 0 0) 51%/var(--f);
clip-path: polygon(100% calc(100% - var(--f)), 100% 100%, calc(100% - var(--f)) calc(100% - var(--f)), var(--f) calc(100% - var(--f)), 0 100%, 0 calc(100% - var(--f)), 999px calc(100% - var(--f) - 999px), calc(100% - 999px) calc(100% - var(--f) - 999px));
transform: translate(calc((cos(45deg) - 1) * 100%), -100%) rotate(-45deg);
transform-origin: 100% 100%;
background-color: #0e7490;
}

View File

@ -1,3 +1,12 @@
import "./registerViteAssets";
import "./registerAlpineLivewire";
import "./themeToggle";
document.addEventListener("livewire:init", () => {
Livewire.on("clear-filters", (event) => {
localStorage.removeItem("filter-query");
localStorage.removeItem("filter-order");
localStorage.removeItem("filter-sptVersion");
localStorage.removeItem("filter-featured");
});
});

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

@ -0,0 +1,34 @@
@props(['mod', 'versionScope' => 'latestVersion'])
<a href="/mod/{{ $mod->id }}/{{ $mod->slug }}" class="mod-list-component relative mx-auto w-full max-w-md md:max-w-2xl">
@if ($mod->featured && !request()->routeIs('home'))
<div class="ribbon z-10">{{ __('Featured!') }}</div>
@endif
<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">
<div class="h-auto md:h-full md:shrink-0 overflow-hidden">
@if (empty($mod->thumbnail))
<img src="https://placehold.co/450x450/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
<img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@else
<img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@endif
</div>
<div class="flex flex-col w-full justify-between p-5">
<div class="pb-3">
<div class="flex justify-between items-center space-x-3">
<h3 class="block mt-1 text-lg leading-tight font-medium text-black dark:text-white group-hover:underline">{{ $mod->name }}</h3>
<span class="badge-version {{ $mod->{$versionScope}->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->{$versionScope}->sptVersion->version }}
</span>
</div>
<p class="text-sm italic text-slate-600 dark:text-gray-200">
By {{ $mod->users->pluck('name')->implode(', ') }}
</p>
<p class="mt-2 text-slate-500 dark:text-gray-300">{{ Str::limit($mod->teaser, 100) }}</p>
</div>
<x-mod-list-stats :mod="$mod" :modVersion="$mod->{$versionScope}"/>
</div>
</div>
</div>
</a>

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

@ -3,35 +3,7 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($mods as $mod)
@if ($mod->{$versionScope})
<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">
<div class="h-auto md:h-full md:shrink-0 overflow-hidden">
@if (empty($mod->thumbnail))
<img src="https://placehold.co/450x450/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
<img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@else
<img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@endif
</div>
<div class="flex flex-col w-full justify-between p-5">
<div class="pb-3">
<div class="flex justify-between items-center space-x-3">
<h3 class="block mt-1 text-lg leading-tight font-medium text-black dark:text-white group-hover:underline">{{ $mod->name }}</h3>
<span class="badge-version {{ $mod->{$versionScope}->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->{$versionScope}->sptVersion->version }}
</span>
</div>
<p class="text-sm italic text-slate-600 dark:text-gray-200">
By {{ $mod->users->pluck('name')->implode(', ') }}
</p>
<p class="mt-2 text-slate-500 dark:text-gray-300">{{ Str::limit($mod->teaser, 100) }}</p>
</div>
<x-mod-list-stats :mod="$mod" :modVersion="$mod->{$versionScope}"/>
</div>
</div>
</div>
</a>
<x-mod-card :mod="$mod" :versionScope="$versionScope"/>
@endif
@endforeach
</div>

View File

@ -0,0 +1,169 @@
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"
x-data="{
query: @entangle('query'),
order: @entangle('order'),
sptVersion: @entangle('sptVersion'),
featured: @entangle('featured'),
init() {
this.loadFiltersFromLocalStorage();
$wire.$refresh();
$watch('query', value => this.saveFilterToLocalStorage('query', value));
$watch('order', value => this.saveFilterToLocalStorage('order', value));
$watch('sptVersion', value => this.saveFilterToLocalStorage('sptVersion', value));
$watch('featured', value => this.saveFilterToLocalStorage('featured', value));
},
saveFilterToLocalStorage(key, value) {
localStorage.setItem(`filter-${key}`, JSON.stringify(value));
},
loadFiltersFromLocalStorage() {
const query = localStorage.getItem('filter-query');
if (query) this.query = JSON.parse(query);
const order = localStorage.getItem('filter-order');
if (order) this.order = JSON.parse(order);
const sptVersion = localStorage.getItem('filter-sptVersion');
if (sptVersion) this.sptVersion = JSON.parse(sptVersion);
const featured = localStorage.getItem('filter-featured');
if (featured) this.featured = JSON.parse(featured);
},
}">
<div class="px-4 py-8 sm:px-6 lg:px-8 bg-white dark:bg-gray-900 overflow-hidden shadow-xl dark:shadow-gray-900 rounded-none sm:rounded-lg">
<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>
{{ $this->filterCount }} {{ __('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 grid-cols-1 gap-6 lg: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>
</div>

View File

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

View File

@ -10,7 +10,10 @@
<div class="lg:col-span-2 flex flex-col gap-6">
{{-- Main Mod Details Card --}}
<div class="p-4 sm:p-6 text-center sm:text-left bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<div class="relative p-4 sm:p-6 text-center sm:text-left bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
@if ($mod->featured)
<div class="ribbon z-10">{{ __('Featured!') }}</div>
@endif
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6">
<div class="grow-0 shrink-0 flex justify-center items-center">
@if (empty($mod->thumbnail))

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">