mirror of
https://github.com/sp-tarkov/forge.git
synced 2025-02-12 12:10:41 -05:00
Merge branch 'mod-listing-page' into develop
This commit is contained in:
commit
d1bfdf5424
10
app/Exceptions/InvalidVersionNumberException.php
Normal file
10
app/Exceptions/InvalidVersionNumberException.php
Normal 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.';
|
||||
}
|
@ -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)
|
||||
|
106
app/Http/Filters/ModFilter.php
Normal file
106
app/Http/Filters/ModFilter.php
Normal 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
113
app/Livewire/Mod/Index.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
19
app/Services/LatestSptVersionService.php
Normal file
19
app/Services/LatestSptVersionService.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
6
resources/views/components/filter-checkbox.blade.php
Normal file
6
resources/views/components/filter-checkbox.blade.php
Normal 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>
|
6
resources/views/components/filter-radio.blade.php
Normal file
6
resources/views/components/filter-radio.blade.php
Normal 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>
|
@ -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>
|
34
resources/views/components/mod-card.blade.php
Normal file
34
resources/views/components/mod-card.blade.php
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
169
resources/views/livewire/mod/index.blade.php
Normal file
169
resources/views/livewire/mod/index.blade.php
Normal 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"> </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>
|
3
resources/views/mod/index.blade.php
Normal file
3
resources/views/mod/index.blade.php
Normal file
@ -0,0 +1,3 @@
|
||||
<x-app-layout>
|
||||
@livewire('mod.index')
|
||||
</x-app-layout>
|
@ -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))
|
||||
|
@ -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">
|
||||
|
Loading…
x
Reference in New Issue
Block a user