Merge branch 'develop'

This commit is contained in:
Refringe 2024-08-09 23:13:52 -04:00
commit 1e8a55fdcd
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
65 changed files with 2405 additions and 938 deletions

View File

@ -21,12 +21,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'cover' => ['nullable', 'mimes:jpg,jpeg,png', 'max:2048'],
])->validateWithBag('updateProfileInformation'); ])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) { if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']); $user->updateProfilePhoto($input['photo']);
} }
if (isset($input['cover'])) {
$user->updateCoverPhoto($input['cover']);
}
if ($input['email'] !== $user->email && if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) { $user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input); $this->updateVerifiedUser($user, $input);

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class CircularDependencyException extends Exception
{
protected $message = 'Circular dependency detected.';
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller;
use Illuminate\Support\Str;
class ApiController extends Controller
{
/**
* Determine if the given relationship should be included in the request. If more than one relationship is provided,
* only one needs to be present in the request for this method to return true.
*/
public static function shouldInclude(string|array $relationships): bool
{
$param = request()->get('include');
if (! $param) {
return false;
}
$includeValues = explode(',', Str::lower($param));
if (is_array($relationships)) {
foreach ($relationships as $relationship) {
if (in_array(Str::lower($relationship), $includeValues)) {
return true;
}
}
return false;
}
return in_array(Str::lower($relationships), $includeValues);
}
}

View File

@ -2,20 +2,20 @@
namespace App\Http\Controllers\Api\V0; namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller; use App\Http\Filters\V1\ModFilter;
use App\Http\Requests\Api\V0\StoreModRequest; use App\Http\Requests\Api\V0\StoreModRequest;
use App\Http\Requests\Api\V0\UpdateModRequest; use App\Http\Requests\Api\V0\UpdateModRequest;
use App\Http\Resources\Api\V0\ModResource; use App\Http\Resources\Api\V0\ModResource;
use App\Models\Mod; use App\Models\Mod;
class ModController extends Controller class ModController extends ApiController
{ {
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index() public function index(ModFilter $filters)
{ {
return ModResource::collection(Mod::paginate()); return ModResource::collection(Mod::filter($filters)->paginate());
} }
/** /**

View File

@ -2,20 +2,20 @@
namespace App\Http\Controllers\Api\V0; namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller; use App\Http\Filters\V1\UserFilter;
use App\Http\Requests\Api\V0\StoreUserRequest; use App\Http\Requests\Api\V0\StoreUserRequest;
use App\Http\Requests\Api\V0\UpdateUserRequest; use App\Http\Requests\Api\V0\UpdateUserRequest;
use App\Http\Resources\Api\V0\UserResource; use App\Http\Resources\Api\V0\UserResource;
use App\Models\User; use App\Models\User;
class UsersController extends Controller class UsersController extends ApiController
{ {
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index() public function index(UserFilter $filters)
{ {
return UserResource::collection(User::paginate()); return UserResource::collection(User::filter($filters)->paginate());
} }
/** /**

View File

@ -27,19 +27,27 @@ class ModController extends Controller
public function show(int $modId, string $slug) public function show(int $modId, string $slug)
{ {
$mod = Mod::select() $mod = Mod::withTotalDownloads()
->withTotalDownloads() ->with([
->with(['latestSptVersion', 'users:id,name']) 'versions',
->with('license:id,name,link') 'versions.sptVersion',
->find($modId); 'versions.dependencies',
'versions.dependencies.resolvedVersion',
'versions.dependencies.resolvedVersion.mod',
'users:id,name',
'license:id,name,link',
])
->findOrFail($modId);
if (! $mod || $mod->slug !== $slug) { if ($mod->slug !== $slug) {
abort(404); abort(404);
} }
$this->authorize('view', $mod); $this->authorize('view', $mod);
return view('mod.show', compact('mod')); $latestVersion = $mod->versions->sortByDesc('version')->first();
return view('mod.show', compact(['mod', 'latestVersion']));
} }
public function update(ModRequest $request, Mod $mod) public function update(ModRequest $request, Mod $mod)

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
class UserController extends Controller
{
use AuthorizesRequests;
public function show(Request $request, User $user, string $username)
{
if ($user->slug() !== $username) {
abort(404);
}
if ($request->user()?->cannot('view', $user)) {
abort(403);
}
return view('user.show', compact('user'));
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class ModFilter extends QueryFilter
{
protected array $sortable = [
'name',
'slug',
'teaser',
'source_code_link',
'featured',
'contains_ads',
'contains_ai_content',
'created_at',
'updated_at',
'published_at',
];
// TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait.
// Also, consider using common filter types and making the field names dynamic.
public function id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('id', $ids);
}
public function hub_id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('hub_id', $ids);
}
public function name(string $value): Builder
{
// The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%).
$like = Str::replace('*', '%', $value);
return $this->builder->where('name', 'like', $like);
}
public function slug(string $value): Builder
{
// The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%).
$like = Str::replace('*', '%', $value);
return $this->builder->where('slug', 'like', $like);
}
public function teaser(string $value): Builder
{
// The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%).
$like = Str::replace('*', '%', $value);
return $this->builder->where('teaser', 'like', $like);
}
public function source_code_link(string $value): Builder
{
// The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%).
$like = Str::replace('*', '%', $value);
return $this->builder->where('source_code_link', 'like', $like);
}
public function created_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('created_at', $dates);
}
return $this->builder->whereDate('created_at', $value);
}
public function updated_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('updated_at', $dates);
}
return $this->builder->whereDate('updated_at', $value);
}
public function published_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('published_at', $dates);
}
return $this->builder->whereDate('published_at', $value);
}
public function featured(string $value): Builder
{
// We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value.
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
// This column is not nullable.
if ($value === null) {
return $this->builder;
}
return $this->builder->where('featured', $value);
}
public function contains_ads(string $value): Builder
{
// We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value.
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
// This column is not nullable.
if ($value === null) {
return $this->builder;
}
return $this->builder->where('contains_ads', $value);
}
public function contains_ai_content(string $value): Builder
{
// We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value.
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
// This column is not nullable.
if ($value === null) {
return $this->builder;
}
return $this->builder->where('contains_ai_content', $value);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
abstract class QueryFilter
{
protected Builder $builder;
protected Request $request;
protected array $sortable = [];
public function __construct(Request $request)
{
$this->request = $request;
}
public function apply(Builder $builder): Builder
{
$this->builder = $builder;
foreach ($this->request->all() as $attribute => $value) {
if (method_exists($this, $attribute)) {
$this->$attribute($value);
}
}
return $this->builder;
}
protected function filter(array $filters): Builder
{
foreach ($filters as $attribute => $value) {
if (method_exists($this, $attribute)) {
$this->$attribute($value);
}
}
return $this->builder;
}
protected function sort(string $values): Builder
{
$sortables = array_map('trim', explode(',', $values));
foreach ($sortables as $sortable) {
$direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc';
$column = Str::of($sortable)->remove('-')->value();
if (in_array($column, $this->sortable)) {
$this->builder->orderBy($column, $direction);
}
}
return $this->builder;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class UserFilter extends QueryFilter
{
protected array $sortable = [
'name',
'created_at',
'updated_at',
];
// TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait.
// Also, consider using common filter types and making the field names dynamic.
public function id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('id', $ids);
}
public function name(string $value): Builder
{
// The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%).
$like = Str::replace('*', '%', $value);
return $this->builder->where('name', 'like', $like);
}
public function created_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('created_at', $dates);
}
return $this->builder->whereDate('created_at', $value);
}
public function updated_at(string $value): Builder
{
// The API allows for a range of dates to be passed as a comma-separated list.
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween('updated_at', $dates);
}
return $this->builder->whereDate('updated_at', $value);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Api\V0;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin License */
class LicenseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'type' => 'license',
'id' => $this->id,
'attributes' => [
'name' => $this->name,
'link' => $this->link,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Resources\Api\V0; namespace App\Http\Resources\Api\V0;
use App\Http\Controllers\Api\V0\ApiController;
use App\Models\Mod; use App\Models\Mod;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -18,6 +19,7 @@ class ModResource extends JsonResource
'type' => 'mod', 'type' => 'mod',
'id' => $this->id, 'id' => $this->id,
'attributes' => [ 'attributes' => [
'hub_id' => $this->hub_id,
'name' => $this->name, 'name' => $this->name,
'slug' => $this->slug, 'slug' => $this->slug,
'teaser' => $this->teaser, 'teaser' => $this->teaser,
@ -32,34 +34,53 @@ class ModResource extends JsonResource
'contains_ads' => $this->contains_ads, 'contains_ads' => $this->contains_ads,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
'published_at' => $this->published_at,
], ],
'relationships' => [ 'relationships' => [
'users' => [ 'users' => $this->users->map(fn ($user) => [
'data' => $this->users->map(fn ($user) => [ 'data' => [
'type' => 'user', 'type' => 'user',
'id' => $user->id, 'id' => $user->id,
])->toArray(),
// TODO: Provide 'links.self' to user profile
//'links' => ['self' => '#'],
], ],
'links' => [
'self' => $user->profileUrl(),
],
])->toArray(),
'versions' => $this->versions->map(fn ($version) => [
'data' => [
'type' => 'version',
'id' => $version->id,
],
// TODO: The download link to the version can be placed here, but I'd like to track the number of
// downloads that are made, so we'll need a new route/feature for that. #35
'links' => [
'self' => $version->link,
],
])->toArray(),
'license' => [ 'license' => [
[
'data' => [ 'data' => [
'type' => 'license', 'type' => 'license',
'id' => $this->license_id, 'id' => $this->license_id,
], ],
], ],
], ],
'included' => $this->users->map(fn ($user) => new UserResource($user)), ],
'includes' => $this->when(
// TODO: Provide 'included' data for attached 'license': ApiController::shouldInclude(['users', 'license', 'versions']),
//new LicenseResource($this->license) fn () => collect([
'users' => $this->users->map(fn ($user) => new UserResource($user)),
'license' => new LicenseResource($this->license),
'versions' => $this->versions->map(fn ($version) => new ModVersionResource($version)),
])
->filter(fn ($value, $key) => ApiController::shouldInclude($key))
->flatten(1)
->values()
),
'links' => [ 'links' => [
'self' => route('mod.show', [ 'self' => $this->detailUrl(),
'mod' => $this->id,
'slug' => $this->slug,
]),
], ],
]; ];
} }

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Resources\Api\V0;
use App\Models\ModVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ModVersion */
class ModVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'type' => 'mod_version',
'id' => $this->id,
'attributes' => [
'hub_id' => $this->hub_id,
'mod_id' => $this->mod_id,
'version' => $this->version,
// TODO: This should only be visible on the mod version show route(?) which doesn't exist.
//'description' => $this->when(
// $request->routeIs('api.v0.modversion.show'),
// $this->description
//),
// TODO: The download link to the version can be placed here, but I'd like to track the number of
// downloads that are made, so we'll need a new route/feature for that. #35
'link' => $this->link,
'spt_version_id' => $this->spt_version_id,
'virus_total_link' => $this->virus_total_link,
'downloads' => $this->downloads,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'published_at' => $this->published_at,
],
'relationships' => [
'spt_version' => [
[
'data' => [
'type' => 'spt_version',
'id' => $this->spt_version_id,
],
],
],
],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Resources\Api\V0; namespace App\Http\Resources\Api\V0;
use App\Http\Controllers\Api\V0\ApiController;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -9,9 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */ /** @mixin User */
class UserResource extends JsonResource class UserResource extends JsonResource
{ {
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
@ -21,6 +19,7 @@ class UserResource extends JsonResource
'name' => $this->name, 'name' => $this->name,
'user_role_id' => $this->user_role_id, 'user_role_id' => $this->user_role_id,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
], ],
'relationships' => [ 'relationships' => [
'user_role' => [ 'user_role' => [
@ -30,11 +29,13 @@ class UserResource extends JsonResource
], ],
], ],
], ],
// TODO: Provide 'included' data for attached 'user_role' 'includes' => $this->when(
//'included' => [new UserRoleResource($this->role)], ApiController::shouldInclude('user_role'),
new UserRoleResource($this->role)
// TODO: Provide 'links.self' to user profile: ),
//'links' => ['self' => '#'], 'links' => [
'self' => $this->profileUrl(),
],
]; ];
} }
} }

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Api\V0;
use App\Models\UserRole;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin UserRole */
class UserRoleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'type' => 'user_role',
'id' => $this->id,
'attributes' => [
'name' => $this->name,
'short_name' => $this->short_name,
'description' => $this->description,
'color_class' => $this->color_class,
],
];
}
}

View File

@ -20,6 +20,7 @@ class ModResource extends JsonResource
'license' => new LicenseResource($this->whenLoaded('license')), 'license' => new LicenseResource($this->whenLoaded('license')),
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
'published_at' => $this->published_at,
]; ];
} }
} }

View File

@ -13,6 +13,7 @@ class ModVersionResource extends JsonResource
return [ return [
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
'published_at' => $this->published_at,
'id' => $this->id, 'id' => $this->id,
'version' => $this->version, 'version' => $this->version,
'description' => $this->description, 'description' => $this->description,

View File

@ -36,6 +36,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
{ {
// Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary // Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary
// tables to store the data to save on memory; we don't want this to be a hog. // tables to store the data to save on memory; we don't want this to be a hog.
$this->bringUserAvatarLocal();
$this->bringFileAuthorsLocal(); $this->bringFileAuthorsLocal();
$this->bringFileOptionsLocal(); $this->bringFileOptionsLocal();
$this->bringFileContentLocal(); $this->bringFileContentLocal();
@ -54,6 +55,36 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
// Re-sync search. // Re-sync search.
Artisan::call('app:search-sync'); Artisan::call('app:search-sync');
Artisan::call('cache:clear');
}
/**
* Bring the user avatar table from the Hub database to the local database temporary table.
*/
protected function bringUserAvatarLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar');
DB::statement('CREATE TEMPORARY TABLE temp_user_avatar (
avatarID INT,
avatarExtension VARCHAR(255),
userID INT,
fileHash VARCHAR(255)
)');
DB::connection('mysql_hub')
->table('wcf1_user_avatar')
->orderBy('avatarID')
->chunk(200, function ($avatars) {
foreach ($avatars as $avatar) {
DB::table('temp_user_avatar')->insert([
'avatarID' => (int) $avatar->avatarID,
'avatarExtension' => $avatar->avatarExtension,
'userID' => (int) $avatar->userID,
'fileHash' => $avatar->fileHash,
]);
}
});
} }
/** /**
@ -62,10 +93,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileAuthorsLocal(): void protected function bringFileAuthorsLocal(): void
{ {
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_author'); DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_author');
DB::statement('CREATE TEMPORARY TABLE temp_file_author ( DB::statement('CREATE TEMPORARY TABLE temp_file_author (fileID INT, userID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
fileID INT,
userID INT
)');
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('filebase1_file_author') ->table('filebase1_file_author')
@ -86,11 +114,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileOptionsLocal(): void protected function bringFileOptionsLocal(): void
{ {
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values ( DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (fileID INT, optionID INT, optionValue VARCHAR(255)) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
fileID INT,
optionID INT,
optionValue VARCHAR(255)
)');
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('filebase1_file_option_value') ->table('filebase1_file_option_value')
@ -112,12 +136,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileContentLocal(): void protected function bringFileContentLocal(): void
{ {
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
DB::statement('CREATE TEMPORARY TABLE temp_file_content ( DB::statement('CREATE TEMPORARY TABLE temp_file_content (fileID INT, subject VARCHAR(255), teaser VARCHAR(255), message LONGTEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
fileID INT,
subject VARCHAR(255),
teaser VARCHAR(255),
message LONGTEXT
)');
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('filebase1_file_content') ->table('filebase1_file_content')
@ -140,10 +159,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileVersionLabelsLocal(): void protected function bringFileVersionLabelsLocal(): void
{ {
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels'); DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels ( DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (labelID INT, objectID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
labelID INT,
objectID INT
)');
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('wcf1_label_object') ->table('wcf1_label_object')
@ -165,10 +181,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileVersionContentLocal(): void protected function bringFileVersionContentLocal(): void
{ {
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content'); DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content ( DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (versionID INT, description TEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
versionID INT,
description TEXT
)');
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('filebase1_file_version_content') ->table('filebase1_file_version_content')
@ -188,15 +201,33 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
*/ */
protected function importUsers(): void protected function importUsers(): void
{ {
// Initialize a cURL handler for downloading mod thumbnails.
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('wcf1_user as u') ->table('wcf1_user as u')
->select('u.userID', 'u.username', 'u.email', 'u.password', 'u.registrationDate', 'u.banned', 'u.banReason', 'u.banExpires', 'u.rankID', 'r.rankTitle') ->select(
'u.userID',
'u.username',
'u.email',
'u.password',
'u.registrationDate',
'u.banned',
'u.banReason',
'u.banExpires',
'u.coverPhotoHash',
'u.coverPhotoExtension',
'u.rankID',
'r.rankTitle',
)
->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID') ->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID')
->chunkById(250, function (Collection $users) { ->chunkById(250, function (Collection $users) use ($curl) {
$userData = $bannedUsers = $userRanks = []; $userData = $bannedUsers = $userRanks = [];
foreach ($users as $user) { foreach ($users as $user) {
$userData[] = $this->collectUserData($user); $userData[] = $this->collectUserData($curl, $user);
$bannedUserData = $this->collectBannedUserData($user); $bannedUserData = $this->collectBannedUserData($user);
if ($bannedUserData) { if ($bannedUserData) {
@ -213,15 +244,20 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$this->handleBannedUsers($bannedUsers); $this->handleBannedUsers($bannedUsers);
$this->handleUserRoles($userRanks); $this->handleUserRoles($userRanks);
}, 'userID'); }, 'userID');
// Close the cURL handler.
curl_close($curl);
} }
protected function collectUserData($user): array protected function collectUserData(CurlHandle $curl, object $user): array
{ {
return [ return [
'hub_id' => (int) $user->userID, 'hub_id' => (int) $user->userID,
'name' => $user->username, 'name' => $user->username,
'email' => mb_convert_case($user->email, MB_CASE_LOWER, 'UTF-8'), 'email' => Str::lower($user->email),
'password' => $this->cleanPasswordHash($user->password), 'password' => $this->cleanPasswordHash($user->password),
'profile_photo_path' => $this->fetchUserAvatar($curl, $user),
'cover_photo_path' => $this->fetchUserCoverPhoto($curl, $user),
'created_at' => $this->cleanRegistrationDate($user->registrationDate), 'created_at' => $this->cleanRegistrationDate($user->registrationDate),
'updated_at' => now('UTC')->toDateTimeString(), 'updated_at' => now('UTC')->toDateTimeString(),
]; ];
@ -240,6 +276,75 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
return str_starts_with($clean, '$2') ? $clean : ''; return str_starts_with($clean, '$2') ? $clean : '';
} }
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserAvatar(CurlHandle $curl, object $user): string
{
// Fetch the user's avatar data from the temporary table.
$avatar = DB::table('temp_user_avatar')->where('userID', $user->userID)->first();
if (! $avatar) {
return '';
}
$hashShort = substr($avatar->fileHash, 0, 2);
$fileName = $avatar->fileHash.'.'.$avatar->avatarExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/avatars/'.$hashShort.'/'.$avatar->avatarID.'-'.$fileName;
$relativePath = 'user-avatars/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}
/**
* Fetch and store an image from the Hub.
*/
protected function fetchAndStoreImage(CurlHandle $curl, string $hubUrl, string $relativePath): string
{
// Determine the disk to use based on the environment.
$disk = match (config('app.env')) {
'production' => 'r2', // Cloudflare R2 Storage
default => 'public', // Local
};
// Check to make sure the image doesn't already exist.
if (Storage::disk($disk)->exists($relativePath)) {
return $relativePath; // Already exists, return the path.
}
// Download the image using the cURL handler.
curl_setopt($curl, CURLOPT_URL, $hubUrl);
$image = curl_exec($curl);
if ($image === false) {
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl));
return '';
}
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
return $relativePath;
}
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserCoverPhoto(CurlHandle $curl, object $user): string
{
if (empty($user->coverPhotoHash) || empty($user->coverPhotoExtension)) {
return '';
}
$hashShort = substr($user->coverPhotoHash, 0, 2);
$fileName = $user->coverPhotoHash.'.'.$user->coverPhotoExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$user->userID.'-'.$fileName;
$relativePath = 'user-covers/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}
/** /**
* Clean the registration date from the Hub database. * Clean the registration date from the Hub database.
*/ */
@ -311,9 +416,6 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
} }
} }
/*
* Build an array of user rank data ready to be inserted into the local database.
*/
protected function collectUserRankData($user): ?array protected function collectUserRankData($user): ?array
{ {
if ($user->rankID && $user->rankTitle) { if ($user->rankID && $user->rankTitle) {
@ -531,7 +633,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
'users' => $modAuthors, 'users' => $modAuthors,
'name' => $modContent?->subject ?? '', 'name' => $modContent?->subject ?? '',
'slug' => Str::slug($modContent?->subject ?? ''), 'slug' => Str::slug($modContent?->subject ?? ''),
'teaser' => Str::limit($modContent?->teaser ?? ''), 'teaser' => Str::limit($modContent?->teaser ?? '', 255),
'description' => $this->cleanHubContent($modContent?->message ?? ''), 'description' => $this->cleanHubContent($modContent?->message ?? ''),
'thumbnail' => $this->fetchModThumbnail($curl, $mod->fileID, $mod->iconHash, $mod->iconExtension), 'thumbnail' => $this->fetchModThumbnail($curl, $mod->fileID, $mod->iconHash, $mod->iconExtension),
'license_id' => License::whereHubId($mod->licenseID)->value('id'), 'license_id' => License::whereHubId($mod->licenseID)->value('id'),
@ -540,6 +642,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
'contains_ai_content' => (bool) $optionContainsAi?->contains_ai, 'contains_ai_content' => (bool) $optionContainsAi?->contains_ai,
'contains_ads' => (bool) $optionContainsAds?->contains_ads, 'contains_ads' => (bool) $optionContainsAds?->contains_ads,
'disabled' => (bool) $mod->isDisabled, 'disabled' => (bool) $mod->isDisabled,
'published_at' => Carbon::parse($mod->time, 'UTC'),
'created_at' => Carbon::parse($mod->time, 'UTC'), 'created_at' => Carbon::parse($mod->time, 'UTC'),
'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'), 'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'),
]; ];
@ -549,7 +652,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
// Remove the user_id from the mod data before upserting. // Remove the user_id from the mod data before upserting.
$insertModData = array_map(fn ($mod) => Arr::except($mod, 'users'), $modData); $insertModData = array_map(fn ($mod) => Arr::except($mod, 'users'), $modData);
Mod::upsert($insertModData, ['hub_id'], [ Mod::withoutGlobalScopes()->upsert($insertModData, ['hub_id'], [
'name', 'name',
'slug', 'slug',
'teaser', 'teaser',
@ -561,6 +664,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
'contains_ai_content', 'contains_ai_content',
'contains_ads', 'contains_ads',
'disabled', 'disabled',
'published_at',
'created_at', 'created_at',
'updated_at', 'updated_at',
]); ]);
@ -582,7 +686,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
{ {
// Alright, hear me out... Shut up. // Alright, hear me out... Shut up.
$converter = new HtmlConverter(); $converter = new HtmlConverter;
$clean = Purify::clean($dirty); $clean = Purify::clean($dirty);
return $converter->convert($clean); return $converter->convert($clean);
@ -604,31 +708,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName; $hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName;
$relativePath = 'mods/'.$fileName; $relativePath = 'mods/'.$fileName;
// Determine the disk to use based on the environment. return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
$disk = match (config('app.env')) {
'production' => 'r2', // Cloudflare R2 Storage
default => 'public', // Local
};
// Check to make sure the image doesn't already exist.
if (Storage::disk($disk)->exists($relativePath)) {
return $relativePath; // Already exists, return the path.
}
// Download the image using the cURL handler.
curl_setopt($curl, CURLOPT_URL, $hubUrl);
$image = curl_exec($curl);
if ($image === false) {
Log::error('There was an error attempting to download a mod thumbnail. cURL error: '.curl_error($curl));
return '';
}
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
return $relativePath;
} }
/** /**
@ -678,13 +758,14 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '', 'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
'downloads' => max((int) $version->downloads, 0), // At least 0. 'downloads' => max((int) $version->downloads, 0), // At least 0.
'disabled' => (bool) $version->isDisabled, 'disabled' => (bool) $version->isDisabled,
'published_at' => Carbon::parse($version->uploadTime, 'UTC'),
'created_at' => Carbon::parse($version->uploadTime, 'UTC'), 'created_at' => Carbon::parse($version->uploadTime, 'UTC'),
'updated_at' => Carbon::parse($version->uploadTime, 'UTC'), 'updated_at' => Carbon::parse($version->uploadTime, 'UTC'),
]; ];
} }
if (! empty($insertData)) { if (! empty($insertData)) {
ModVersion::upsert($insertData, ['hub_id'], [ ModVersion::withoutGlobalScopes()->upsert($insertData, ['hub_id'], [
'mod_id', 'mod_id',
'version', 'version',
'description', 'description',
@ -692,6 +773,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
'spt_version_id', 'spt_version_id',
'virus_total_link', 'virus_total_link',
'downloads', 'downloads',
'published_at',
'created_at', 'created_at',
'updated_at', 'updated_at',
]); ]);
@ -705,6 +787,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
public function failed(Exception $exception): void public function failed(Exception $exception): void
{ {
// Explicitly drop the temporary tables. // Explicitly drop the temporary tables.
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_author'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_author');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content');

View File

@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Profile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm;
use Livewire\Features\SupportRedirects\Redirector;
class UpdateProfileForm extends UpdateProfileInformationForm
{
/**
* The new cover photo for the user.
*
* @var mixed
*/
public $cover;
/**
* When the photo is temporarily uploaded.
*/
public function updatedPhoto(): void
{
$this->validate([
'photo' => 'image|mimes:jpg,jpeg,png|max:1024', // 1MB Max
]);
}
/**
* When the cover is temporarily uploaded.
*/
public function updatedCover(): void
{
$this->validate([
'cover' => 'image|mimes:jpg,jpeg,png|max:2048', // 2MB Max
]);
}
/**
* Update the user's profile information.
*/
public function updateProfileInformation(UpdatesUserProfileInformation $updater): RedirectResponse|Redirector|null
{
$this->resetErrorBag();
$updater->update(
Auth::user(),
$this->photo || $this->cover
? array_merge($this->state, array_filter([
'photo' => $this->photo,
'cover' => $this->cover,
])) : $this->state
);
if (isset($this->photo) || isset($this->cover)) {
return redirect()->route('profile.show');
}
$this->dispatch('saved');
$this->dispatch('refresh-navigation-menu');
return null;
}
/**
* Delete user's profile photo.
*/
public function deleteCoverPhoto(): void
{
Auth::user()->deleteCoverPhoto();
$this->dispatch('refresh-navigation-menu');
}
}

View File

@ -11,6 +11,9 @@ class License extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
/**
* The relationship between a license and mod.
*/
public function mods(): HasMany public function mods(): HasMany
{ {
return $this->hasMany(Mod::class); return $this->hasMany(Mod::class);

View File

@ -2,7 +2,10 @@
namespace App\Models; namespace App\Models;
use App\Http\Filters\V1\QueryFilter;
use App\Models\Scopes\DisabledScope; use App\Models\Scopes\DisabledScope;
use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -16,34 +19,47 @@ use Illuminate\Support\Str;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
/** /**
* @property int $id
* @property string $name
* @property string $slug * @property string $slug
*/ */
class Mod extends Model class Mod extends Model
{ {
use HasFactory, Searchable, SoftDeletes; use HasFactory, Searchable, SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void protected static function booted(): void
{ {
// Apply the global scope to exclude disabled mods. // Apply the global scope to exclude disabled mods.
static::addGlobalScope(new DisabledScope); static::addGlobalScope(new DisabledScope);
// Apply the global scope to exclude non-published mods.
static::addGlobalScope(new PublishedScope);
} }
/** /**
* The users that belong to the mod. * The relationship between a mod and its users.
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
return $this->belongsToMany(User::class); return $this->belongsToMany(User::class);
} }
/**
* The relationship between a mod and its license.
*/
public function license(): BelongsTo public function license(): BelongsTo
{ {
return $this->belongsTo(License::class); return $this->belongsTo(License::class);
} }
/**
* The relationship between a mod and its versions.
*/
public function versions(): HasMany public function versions(): HasMany
{ {
return $this->hasMany(ModVersion::class); return $this->hasMany(ModVersion::class)->orderByDesc('version');
} }
/** /**
@ -57,17 +73,21 @@ class Mod extends Model
]); ]);
} }
/**
* The relationship between a mod and its last updated version.
*/
public function lastUpdatedVersion(): HasOne public function lastUpdatedVersion(): HasOne
{ {
return $this->hasOne(ModVersion::class)->orderByDesc('updated_at')->with('sptVersion'); return $this->hasOne(ModVersion::class)
->orderByDesc('updated_at');
} }
/** /**
* Get the indexable data array for the model. * The data that is searchable by Scout.
*/ */
public function toSearchableArray(): array public function toSearchableArray(): array
{ {
$latestSptVersion = $this->latestSptVersion()->first(); $latestVersion = $this->latestVersion()->with('sptVersion')->first();
return [ return [
'id' => (int) $this->id, 'id' => (int) $this->id,
@ -78,27 +98,25 @@ class Mod extends Model
'featured' => $this->featured, 'featured' => $this->featured,
'created_at' => strtotime($this->created_at), 'created_at' => strtotime($this->created_at),
'updated_at' => strtotime($this->updated_at), 'updated_at' => strtotime($this->updated_at),
'latestSptVersion' => $latestSptVersion?->sptVersion->version, 'published_at' => strtotime($this->published_at),
'latestSptVersionColorClass' => $latestSptVersion?->sptVersion->color_class, 'latestVersion' => $latestVersion?->sptVersion->version,
'latestVersionColorClass' => $latestVersion?->sptVersion->color_class,
]; ];
} }
public function latestSptVersion(): HasOne /**
* The relationship to the latest mod version, dictated by the mod version number.
*/
public function latestVersion(): HasOne
{ {
return $this->hasOne(ModVersion::class) return $this->hasOne(ModVersion::class)
->orderByDesc(
SptVersion::select('version')
->whereColumn('mod_versions.spt_version_id', 'spt_versions.id')
->orderByDesc('version')
->take(1),
)
->with('sptVersion')
->orderByDesc('version') ->orderByDesc('version')
->orderByDesc('updated_at')
->take(1); ->take(1);
} }
/** /**
* Determine if the model should be searchable. * Determine if the model instance should be searchable.
*/ */
public function shouldBeSearchable(): bool public function shouldBeSearchable(): bool
{ {
@ -106,7 +124,7 @@ class Mod extends Model
} }
/** /**
* Get the URL to the thumbnail. * Build the URL to the mod's thumbnail.
*/ */
public function thumbnailUrl(): Attribute public function thumbnailUrl(): Attribute
{ {
@ -118,7 +136,7 @@ class Mod extends Model
} }
/** /**
* Get the disk where the thumbnail is stored. * Get the disk where the thumbnail is stored based on the current environment.
*/ */
protected function thumbnailDisk(): string protected function thumbnailDisk(): string
{ {
@ -128,6 +146,25 @@ class Mod extends Model
}; };
} }
/**
* Scope a query by applying QueryFilter filters.
*/
public function scopeFilter(Builder $builder, QueryFilter $filters): Builder
{
return $filters->apply($builder);
}
/**
* Build the URL to the mod's detail page.
*/
public function detailUrl(): string
{
return route('mod.show', [$this->id, $this->slug]);
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@ -139,7 +176,7 @@ class Mod extends Model
} }
/** /**
* Ensure the slug is always lower case when retrieved and slugified when saved. * Mutate the slug attribute to always be lower case on get and slugified on set.
*/ */
protected function slug(): Attribute protected function slug(): Attribute
{ {

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $mod_version_id
* @property int $dependency_mod_id
* @property string $version_constraint
* @property int|null $resolved_version_id
*/
class ModDependency extends Model
{
use HasFactory;
/**
* The relationship between a mod dependency and mod version.
*/
public function modVersion(): BelongsTo
{
return $this->belongsTo(ModVersion::class);
}
/**
* The relationship between a mod dependency and mod.
*/
public function dependencyMod(): BelongsTo
{
return $this->belongsTo(Mod::class, 'dependency_mod_id');
}
/**
* The relationship between a mod dependency and resolved mod version.
*/
public function resolvedVersion(): BelongsTo
{
return $this->belongsTo(ModVersion::class, 'resolved_version_id');
}
}

View File

@ -3,25 +3,50 @@
namespace App\Models; namespace App\Models;
use App\Models\Scopes\DisabledScope; use App\Models\Scopes\DisabledScope;
use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
* @property int $mod_id
* @property string $version
*/
class ModVersion extends Model class ModVersion extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void protected static function booted(): void
{ {
static::addGlobalScope(new DisabledScope); static::addGlobalScope(new DisabledScope);
static::addGlobalScope(new PublishedScope);
} }
/**
* The relationship between a mod version and mod.
*/
public function mod(): BelongsTo public function mod(): BelongsTo
{ {
return $this->belongsTo(Mod::class); return $this->belongsTo(Mod::class);
} }
/**
* The relationship between a mod version and its dependencies.
*/
public function dependencies(): HasMany
{
return $this->hasMany(ModDependency::class);
}
/**
* The relationship between a mod version and SPT version.
*/
public function sptVersion(): BelongsTo public function sptVersion(): BelongsTo
{ {
return $this->belongsTo(SptVersion::class); return $this->belongsTo(SptVersion::class);

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class PublishedScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->whereNotNull($model->getTable().'.published_at')
->where($model->getTable().'.published_at', '<=', now());
}
}

View File

@ -11,6 +11,9 @@ class SptVersion extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
/**
* The relationship between an SPT version and mod version.
*/
public function modVersions(): HasMany public function modVersions(): HasMany
{ {
return $this->hasMany(ModVersion::class); return $this->hasMany(ModVersion::class);

View File

@ -2,9 +2,12 @@
namespace App\Models; namespace App\Models;
use App\Http\Filters\V1\QueryFilter;
use App\Notifications\ResetPassword; use App\Notifications\ResetPassword;
use App\Notifications\VerifyEmail; use App\Notifications\VerifyEmail;
use App\Traits\HasCoverPhoto;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -21,6 +24,7 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
use Bannable; use Bannable;
use HasApiTokens; use HasApiTokens;
use HasCoverPhoto;
use HasFactory; use HasFactory;
use HasProfilePhoto; use HasProfilePhoto;
use Notifiable; use Notifiable;
@ -38,11 +42,17 @@ class User extends Authenticatable implements MustVerifyEmail
'profile_photo_url', 'profile_photo_url',
]; ];
/**
* The relationship between a user and their mods.
*/
public function mods(): BelongsToMany public function mods(): BelongsToMany
{ {
return $this->belongsToMany(Mod::class); return $this->belongsToMany(Mod::class);
} }
/**
* The data that is searchable by Scout.
*/
public function toSearchableArray(): array public function toSearchableArray(): array
{ {
return [ return [
@ -51,28 +61,25 @@ class User extends Authenticatable implements MustVerifyEmail
]; ];
} }
/**
* Determine if the model instance should be searchable.
*/
public function shouldBeSearchable(): bool public function shouldBeSearchable(): bool
{ {
return ! is_null($this->email_verified_at); return ! is_null($this->email_verified_at);
} }
public function assignRole(UserRole $role): bool /**
{ * Check if the user has the role of a moderator.
$this->role()->associate($role); */
return $this->save();
}
public function role(): BelongsTo
{
return $this->belongsTo(UserRole::class, 'user_role_id');
}
public function isMod(): bool public function isMod(): bool
{ {
return Str::lower($this->role?->name) === 'moderator'; return Str::lower($this->role?->name) === 'moderator';
} }
/**
* Check if the user has the role of an administrator.
*/
public function isAdmin(): bool public function isAdmin(): bool
{ {
return Str::lower($this->role?->name) === 'administrator'; return Str::lower($this->role?->name) === 'administrator';
@ -94,12 +101,49 @@ class User extends Authenticatable implements MustVerifyEmail
$this->notify(new ResetPassword($token)); $this->notify(new ResetPassword($token));
} }
protected function casts(): array /**
* Get the relative URL to the user's profile page.
*/
public function profileUrl(): string
{ {
return [ return route('user.show', [
'email_verified_at' => 'datetime', 'user' => $this->id,
'password' => 'hashed', 'username' => $this->slug(),
]; ]);
}
/**
* Get the slug of the user's name.
*/
public function slug(): string
{
return Str::lower(Str::slug($this->name));
}
/**
* Assign a role to the user.
*/
public function assignRole(UserRole $role): bool
{
$this->role()->associate($role);
return $this->save();
}
/**
* The relationship between a user and their role.
*/
public function role(): BelongsTo
{
return $this->belongsTo(UserRole::class, 'user_role_id');
}
/**
* Scope a query by applying QueryFilter filters.
*/
public function scopeFilter(Builder $builder, QueryFilter $filters): Builder
{
return $filters->apply($builder);
} }
/** /**
@ -107,9 +151,17 @@ class User extends Authenticatable implements MustVerifyEmail
*/ */
protected function profilePhotoDisk(): string protected function profilePhotoDisk(): string
{ {
return match (config('app.env')) { return config('filesystems.asset_upload', 'public');
'production' => 'r2', // Cloudflare R2 Storage }
default => 'public', // Local
}; /**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
} }
} }

View File

@ -10,6 +10,9 @@ class UserRole extends Model
{ {
use HasFactory; use HasFactory;
/**
* The relationship between a user role and users.
*/
public function users(): HasMany public function users(): HasMany
{ {
return $this->hasMany(User::class); return $this->hasMany(User::class);

View File

@ -0,0 +1,33 @@
<?php
namespace App\Observers;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\ModVersionService;
class ModDependencyObserver
{
protected ModVersionService $modVersionService;
public function __construct(ModVersionService $modVersionService)
{
$this->modVersionService = $modVersionService;
}
public function saved(ModDependency $modDependency): void
{
$modVersion = ModVersion::find($modDependency->mod_version_id);
if ($modVersion) {
$this->modVersionService->resolveDependencies($modVersion);
}
}
public function deleted(ModDependency $modDependency): void
{
$modVersion = ModVersion::find($modDependency->mod_version_id);
if ($modVersion) {
$this->modVersionService->resolveDependencies($modVersion);
}
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Observers;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\ModVersionService;
class ModVersionObserver
{
protected ModVersionService $modVersionService;
public function __construct(ModVersionService $modVersionService)
{
$this->modVersionService = $modVersionService;
}
public function saved(ModVersion $modVersion): void
{
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
foreach ($dependencies as $dependency) {
$this->modVersionService->resolveDependencies($dependency->modVersion);
}
}
public function deleted(ModVersion $modVersion): void
{
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
foreach ($dependencies as $dependency) {
$this->modVersionService->resolveDependencies($dependency->modVersion);
}
}
}

View File

@ -8,7 +8,7 @@ use App\Models\User;
class ModPolicy class ModPolicy
{ {
/** /**
* Determine whether the user can view any models. * Determine whether the user can view multiple models.
*/ */
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
@ -16,7 +16,7 @@ class ModPolicy
} }
/** /**
* Determine whether the user can view the model. * Determine whether the user can view a specific model.
*/ */
public function view(?User $user, Mod $mod): bool public function view(?User $user, Mod $mod): bool
{ {

View File

@ -0,0 +1,47 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return false;
}
public function view(User $userCurrent, User $userResource): bool
{
// TODO: check to see if the userResource has blocked the userCurrent.
return true;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, User $model): bool
{
return false;
}
public function delete(User $user, User $model): bool
{
return false;
}
public function restore(User $user, User $model): bool
{
return false;
}
public function forceDelete(User $user, User $model): bool
{
return false;
}
}

View File

@ -2,9 +2,14 @@
namespace App\Providers; namespace App\Providers;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Models\User; use App\Models\User;
use App\Observers\ModDependencyObserver;
use App\Observers\ModVersionObserver;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -25,9 +30,23 @@ class AppServiceProvider extends ServiceProvider
// Allow mass assignment for all models. Be careful! // Allow mass assignment for all models. Be careful!
Model::unguard(); Model::unguard();
// Register observers.
ModVersion::observe(ModVersionObserver::class);
ModDependency::observe(ModDependencyObserver::class);
// This gate determines who can access the Pulse dashboard. // This gate determines who can access the Pulse dashboard.
Gate::define('viewPulse', function (User $user) { Gate::define('viewPulse', function (User $user) {
return $user->isAdmin(); return $user->isAdmin();
}); });
// Register a number macro to format download numbers.
Number::macro('downloads', function (int|float $number) {
return Number::forHumans(
$number,
$number > 1000000 ? 2 : ($number > 1000 ? 1 : 0),
maxPrecision: null,
abbreviate: true
);
});
} }
} }

View File

@ -0,0 +1,99 @@
<?php
namespace App\Services;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Composer\Semver\Semver;
use Illuminate\Database\Eloquent\Collection;
class ModVersionService
{
protected array $visited = [];
protected array $stack = [];
/**
* Resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
public function resolveDependencies(ModVersion $modVersion): array
{
$resolvedVersions = [];
$this->visited = [];
$this->stack = [];
$this->processDependencies($modVersion, $resolvedVersions);
return $resolvedVersions;
}
/**
* Perform a depth-first search to resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void
{
if (in_array($modVersion->id, $this->stack)) {
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
}
if (in_array($modVersion->id, $this->visited)) {
return; // Skip already processed versions
}
$this->visited[] = $modVersion->id;
$this->stack[] = $modVersion->id;
/** @var Collection|ModDependency[] $dependencies */
$dependencies = $this->getDependencies($modVersion);
foreach ($dependencies as $dependency) {
$resolvedVersionId = $this->resolveVersionIdForDependency($dependency);
if ($dependency->resolved_version_id !== $resolvedVersionId) {
$dependency->updateQuietly(['resolved_version_id' => $resolvedVersionId]);
}
$resolvedVersions[$dependency->id] = $resolvedVersionId ? ModVersion::find($resolvedVersionId) : null;
if ($resolvedVersionId) {
$nextModVersion = ModVersion::find($resolvedVersionId);
if ($nextModVersion) {
$this->processDependencies($nextModVersion, $resolvedVersions);
}
}
}
array_pop($this->stack);
}
/**
* Get the dependencies for the given mod version.
*/
protected function getDependencies(ModVersion $modVersion): Collection
{
return $modVersion->dependencies()->with(['dependencyMod.versions'])->get();
}
/**
* Resolve the latest version ID that satisfies the version constraint on given dependency.
*/
protected function resolveVersionIdForDependency(ModDependency $dependency): ?int
{
$mod = $dependency->dependencyMod;
if (! $mod || $mod->versions->isEmpty()) {
return null;
}
$availableVersions = $mod->versions->pluck('id', 'version')->toArray();
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint);
// Versions are sorted in descending order by default. Take the first key (the latest version) using `reset()`.
return $satisfyingVersions ? $availableVersions[reset($satisfyingVersions)] : null;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait HasCoverPhoto
{
/**
* Update the user's cover photo.
*/
public function updateCoverPhoto(UploadedFile $cover, $storagePath = 'cover-photos'): void
{
tap($this->cover_photo_path, function ($previous) use ($cover, $storagePath) {
$this->forceFill([
'cover_photo_path' => $cover->storePublicly(
$storagePath, ['disk' => $this->coverPhotoDisk()]
),
])->save();
if ($previous) {
Storage::disk($this->coverPhotoDisk())->delete($previous);
}
});
}
/**
* Get the disk that cover photos should be stored on.
*/
protected function coverPhotoDisk(): string
{
return config('filesystems.asset_upload', 'public');
}
/**
* Delete the user's cover photo.
*/
public function deleteCoverPhoto(): void
{
if (is_null($this->cover_photo_path)) {
return;
}
Storage::disk($this->coverPhotoDisk())->delete($this->cover_photo_path);
$this->forceFill([
'cover_photo_path' => null,
])->save();
}
/**
* Get the URL to the user's cover photo.
*/
public function coverPhotoUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->cover_photo_path
? Storage::disk($this->coverPhotoDisk())->url($this->cover_photo_path)
: $this->defaultCoverPhotoUrl();
});
}
/**
* Get the default profile photo URL if no profile photo has been uploaded.
*/
protected function defaultCoverPhotoUrl(): string
{
return 'https://picsum.photos/seed/'.urlencode($this->name).'/720/100?blur=2';
}
}

View File

@ -29,7 +29,8 @@ class ModListSection extends Component
return Cache::remember('homepage-featured-mods', now()->addMinutes(5), function () { return Cache::remember('homepage-featured-mods', now()->addMinutes(5), function () {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads() ->withTotalDownloads()
->with(['latestSptVersion', 'users:id,name']) ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->whereHas('latestVersion')
->where('featured', true) ->where('featured', true)
->latest() ->latest()
->limit(6) ->limit(6)
@ -42,7 +43,8 @@ class ModListSection extends Component
return Cache::remember('homepage-latest-mods', now()->addMinutes(5), function () { return Cache::remember('homepage-latest-mods', now()->addMinutes(5), function () {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads() ->withTotalDownloads()
->with(['latestSptVersion', 'users:id,name']) ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->whereHas('latestVersion')
->latest() ->latest()
->limit(6) ->limit(6)
->get(); ->get();
@ -54,7 +56,8 @@ class ModListSection extends Component
return Cache::remember('homepage-updated-mods', now()->addMinutes(5), function () { return Cache::remember('homepage-updated-mods', now()->addMinutes(5), function () {
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads() ->withTotalDownloads()
->with(['lastUpdatedVersion', 'users:id,name']) ->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name'])
->whereHas('lastUpdatedVersion')
->orderByDesc( ->orderByDesc(
ModVersion::select('updated_at') ModVersion::select('updated_at')
->whereColumn('mod_id', 'mods.id') ->whereColumn('mod_id', 'mods.id')
@ -79,12 +82,12 @@ class ModListSection extends Component
[ [
'title' => 'Featured Mods', 'title' => 'Featured Mods',
'mods' => $this->modsFeatured, 'mods' => $this->modsFeatured,
'versionScope' => 'latestSptVersion', 'versionScope' => 'latestVersion',
], ],
[ [
'title' => 'Newest Mods', 'title' => 'Newest Mods',
'mods' => $this->modsLatest, 'mods' => $this->modsLatest,
'versionScope' => 'latestSptVersion', 'versionScope' => 'latestVersion',
], ],
[ [
'title' => 'Recently Updated Mods', 'title' => 'Recently Updated Mods',

View File

@ -9,6 +9,7 @@
"ext-curl": "*", "ext-curl": "*",
"ext-intl": "*", "ext-intl": "*",
"aws/aws-sdk-php": "^3.314", "aws/aws-sdk-php": "^3.314",
"composer/semver": "^3.4",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
"laravel/framework": "^11.11", "laravel/framework": "^11.11",
@ -52,7 +53,7 @@
}, },
"scripts": { "scripts": {
"phpstan": [ "phpstan": [
"./vendor/bin/phpstan analyse -c phpstan.neon --debug --memory-limit=2G" "./vendor/bin/phpstan analyse --configuration phpstan.neon --error-format=table --memory-limit=2G"
], ],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",

888
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,18 @@ return [
'default' => env('FILESYSTEM_DISK', 'local'), 'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Default Asset Upload Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that assets should be
| uploaded to. Typically, this will be either the "public" or "r2" disk.
|
*/
'asset_upload' => env('ASSET_UPLOAD_DISK', 'public'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Filesystem Disks | Filesystem Disks

View File

@ -207,7 +207,7 @@ return [
'maxJobs' => 0, 'maxJobs' => 0,
'memory' => 256, 'memory' => 256,
'tries' => 1, 'tries' => 1,
'timeout' => 900, // 15 Minutes 'timeout' => 1500, // 25 Minutes
'nice' => 0, 'nice' => 0,
], ],
], ],

View File

@ -64,17 +64,17 @@ return [
*/ */
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default' 'disk' => null,
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) 'rules' => ['file', 'max:12288'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'directory' => null,
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'middleware' => 'throttle:5,1',
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a', 'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
], ],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... 'max_upload_time' => 5,
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... 'cleanup' => true,
], ],
/* /*

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class ModDependencyFactory extends Factory
{
protected $model = ModDependency::class;
public function definition(): array
{
return [
'mod_version_id' => ModVersion::factory(),
'dependency_mod_id' => Mod::factory(),
'version_constraint' => '^'.$this->faker->numerify('#.#.#'),
'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
];
}
}

View File

@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\License; use App\Models\License;
use App\Models\Mod; use App\Models\Mod;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Random\RandomException; use Random\RandomException;
@ -30,12 +31,22 @@ class ModFactory extends Factory
'featured' => fake()->boolean(), 'featured' => fake()->boolean(),
'contains_ai_content' => fake()->boolean(), 'contains_ai_content' => fake()->boolean(),
'contains_ads' => fake()->boolean(), 'contains_ads' => fake()->boolean(),
'disabled' => fake()->boolean(), 'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'created_at' => now(), 'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'updated_at' => now(), 'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
]; ];
} }
/**
* Indicate that the mod should be disabled.
*/
public function disabled(): static
{
return $this->state(fn (array $attributes) => [
'disabled' => true,
]);
}
/** /**
* Indicate that the mod should be soft-deleted. * Indicate that the mod should be soft-deleted.
*/ */

View File

@ -16,15 +16,25 @@ class ModVersionFactory extends Factory
{ {
return [ return [
'mod_id' => Mod::factory(), 'mod_id' => Mod::factory(),
'version' => fake()->numerify('1.#.#'), 'version' => fake()->numerify('#.#.#'),
'description' => fake()->text(), 'description' => fake()->text(),
'link' => fake()->url(), 'link' => fake()->url(),
'spt_version_id' => SptVersion::factory(), 'spt_version_id' => SptVersion::factory(),
'virus_total_link' => fake()->url(), 'virus_total_link' => fake()->url(),
'downloads' => fake()->randomNumber(), 'downloads' => fake()->randomNumber(),
'disabled' => fake()->boolean(), 'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'created_at' => Carbon::now(), 'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
]; ];
} }
/**
* Indicate that the mod version should be disabled.
*/
public function disabled(): static
{
return $this->state(fn (array $attributes) => [
'disabled' => true,
]);
}
} }

View File

@ -13,7 +13,7 @@ class SptVersionFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'version' => $this->faker->numerify('1.#.#'), 'version' => $this->faker->numerify('SPT 1.#.#'),
'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']), 'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']),
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now(),

View File

@ -29,7 +29,8 @@ return new class extends Migration
->nullOnDelete() ->nullOnDelete()
->cascadeOnUpdate(); ->cascadeOnUpdate();
$table->rememberToken(); $table->rememberToken();
$table->string('profile_photo_path', 2048)->nullable(); $table->string('profile_photo_path', 2048)->nullable()->default(null);
$table->string('cover_photo_path', 2048)->nullable()->default(null);
$table->timestamps(); $table->timestamps();
}); });

View File

@ -32,6 +32,7 @@ return new class extends Migration
$table->boolean('contains_ads')->default(false); $table->boolean('contains_ads')->default(false);
$table->boolean('disabled')->default(false); $table->boolean('disabled')->default(false);
$table->softDeletes(); $table->softDeletes();
$table->timestamp('published_at')->nullable()->default(null);
$table->timestamps(); $table->timestamps();
$table->index(['deleted_at', 'disabled'], 'mods_show_index'); $table->index(['deleted_at', 'disabled'], 'mods_show_index');

View File

@ -33,6 +33,7 @@ return new class extends Migration
$table->unsignedBigInteger('downloads'); $table->unsignedBigInteger('downloads');
$table->boolean('disabled')->default(false); $table->boolean('disabled')->default(false);
$table->softDeletes(); $table->softDeletes();
$table->timestamp('published_at')->nullable()->default(null);
$table->timestamps(); $table->timestamps();
$table->index(['mod_id', 'deleted_at', 'disabled', 'version'], 'mod_versions_filtering_index'); $table->index(['mod_id', 'deleted_at', 'disabled', 'version'], 'mod_versions_filtering_index');

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mod_dependencies', function (Blueprint $table) {
$table->id();
$table->foreignId('mod_version_id')
->constrained('mod_versions')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->foreignId('dependency_mod_id')
->constrained('mods')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->string('version_constraint'); // e.g., ^1.0.1
$table->foreignId('resolved_version_id')
->nullable()
->constrained('mod_versions')
->nullOnDelete()
->cascadeOnUpdate();
$table->timestamps();
$table->unique(['mod_version_id', 'dependency_mod_id', 'version_constraint'], 'mod_dependencies_unique');
});
}
public function down(): void
{
Schema::dropIfExists('mod_dependencies');
}
};

View File

@ -2,8 +2,10 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Exceptions\CircularDependencyException;
use App\Models\License; use App\Models\License;
use App\Models\Mod; use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion; use App\Models\ModVersion;
use App\Models\SptVersion; use App\Models\SptVersion;
use App\Models\User; use App\Models\User;
@ -46,6 +48,31 @@ class DatabaseSeeder extends Seeder
} }
// Add 1000 mod versions, assigning them to the mods we just created. // Add 1000 mod versions, assigning them to the mods we just created.
ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create(); $modVersions = ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create();
// Add ModDependencies to a subset of ModVersions.
foreach ($modVersions as $modVersion) {
$hasDependencies = rand(0, 100) < 30; // 30% chance to have dependencies
if ($hasDependencies) {
$numDependencies = rand(1, 3); // 1 to 3 dependencies
$dependencyMods = $mods->random($numDependencies);
foreach ($dependencyMods as $dependencyMod) {
try {
ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create([
'version_constraint' => $this->generateVersionConstraint(),
]);
} catch (CircularDependencyException $e) {
continue;
}
}
}
}
}
private function generateVersionConstraint(): string
{
$versionConstraints = ['*', '^1.0.0', '>=2.0.0', '~1.1.0', '>=1.2.0 <2.0.0'];
return $versionConstraints[array_rand($versionConstraints)];
} }
} }

216
package-lock.json generated
View File

@ -556,9 +556,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.2.tgz",
"integrity": "sha512-lncuC4aHicncmbORnx+dUaAgzee9cm/PbIqgWz1PpXuwc+sa1Ct83tnqUDy/GFKleLiN7ZIeytM6KJ4cAn1SxA==", "integrity": "sha512-OHflWINKtoCFSpm/WmuQaWW4jeX+3Qt3XQDepkkiFTsoxFc5BpF3Z5aDxFZgBqRjO6ATP5+b1iilp4kGIZVWlA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -570,9 +570,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.2.tgz",
"integrity": "sha512-F/tkdw0WSs4ojqz5Ovrw5r9odqzFjb5LIgHdHZG65dFI1lWTWRVy32KDJLKRISHgJvqUeUhdIvy43fX41znyDg==", "integrity": "sha512-k0OC/b14rNzMLDOE6QMBCjDRm3fQOHAL8Ldc9bxEWvMo4Ty9RY6rWmGetNTWhPo+/+FNd1lsQYRd0/1OSix36A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -584,9 +584,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.2.tgz",
"integrity": "sha512-vk+ma8iC1ebje/ahpxpnrfVQJibTMyHdWpOGZ3JpQ7Mgn/3QNHmPq7YwjZbIE7km73dH5M1e6MRRsnEBW7v5CQ==", "integrity": "sha512-IIARRgWCNWMTeQH+kr/gFTHJccKzwEaI0YSvtqkEBPj7AshElFq89TyreKNFAGh5frLfDCbodnq+Ye3dqGKPBw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -598,9 +598,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.2.tgz",
"integrity": "sha512-IgpzXKauRe1Tafcej9STjSSuG0Ghu/xGYH+qG6JwsAUxXrnkvNHcq/NL6nz1+jzvWAnQkuAJ4uIwGB48K9OCGA==", "integrity": "sha512-52udDMFDv54BTAdnw+KXNF45QCvcJOcYGl3vQkp4vARyrcdI/cXH8VXTEv/8QWfd6Fru8QQuw1b2uNersXOL0g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -612,9 +612,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.2.tgz",
"integrity": "sha512-P9bSiAUnSSM7EmyRK+e5wgpqai86QOSv8BwvkGjLwYuOpaeomiZWifEos517CwbG+aZl1T4clSE1YqqH2JRs+g==", "integrity": "sha512-r+SI2t8srMPYZeoa1w0o/AfoVt9akI1ihgazGYPQGRilVAkuzMGiTtexNZkrPkQsyFrvqq/ni8f3zOnHw4hUbA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -626,9 +626,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.2.tgz",
"integrity": "sha512-5RnjpACoxtS+aWOI1dURKno11d7krfpGDEn19jI8BuWmSBbUC4ytIADfROM1FZrFhQPSoP+KEa3NlEScznBTyQ==", "integrity": "sha512-+tYiL4QVjtI3KliKBGtUU7yhw0GMcJJuB9mLTCEauHEsqfk49gtUBXGtGP3h1LW8MbaTY6rSFIQV1XOBps1gBA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -640,9 +640,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.2.tgz",
"integrity": "sha512-8mwmGD668m8WaGbthrEYZ9CBmPug2QPGWxhJxh/vCgBjro5o96gL04WLlg5BA233OCWLqERy4YUzX3bJGXaJgQ==", "integrity": "sha512-OR5DcvZiYN75mXDNQQxlQPTv4D+uNCUsmSCSY2FolLf9W5I4DSoJyg7z9Ea3TjKfhPSGgMJiey1aWvlWuBzMtg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -654,9 +654,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.2.tgz",
"integrity": "sha512-dJX9u4r4bqInMGOAQoGYdwDP8lQiisWb9et+T84l2WXk41yEej8v2iGKodmdKimT8cTAYt0jFb+UEBxnPkbXEQ==", "integrity": "sha512-Hw3jSfWdUSauEYFBSFIte6I8m6jOj+3vifLg8EU3lreWulAUpch4JBjDMtlKosrBzkr0kwKgL9iCfjA8L3geoA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -668,9 +668,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.2.tgz",
"integrity": "sha512-V72cXdTl4EI0x6FNmho4D502sy7ed+LuVW6Ym8aI6DRQ9hQZdp5sj0a2usYOlqvFBNKQnLQGwmYnujo2HvjCxQ==", "integrity": "sha512-rhjvoPBhBwVnJRq/+hi2Q3EMiVF538/o9dBuj9TVLclo9DuONqt5xfWSaE6MYiFKpo/lFPJ/iSI72rYWw5Hc7w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -682,9 +682,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.2.tgz",
"integrity": "sha512-f+pJih7sxoKmbjghrM2RkWo2WHUW8UbfxIQiWo5yeCaCM0TveMEuAzKJte4QskBp1TIinpnRcxkquY+4WuY/tg==", "integrity": "sha512-EAz6vjPwHHs2qOCnpQkw4xs14XJq84I81sDRGPEjKPFVPBw7fwvtwhVjcZR6SLydCv8zNK8YGFblKWd/vRmP8g==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -696,9 +696,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.2.tgz",
"integrity": "sha512-qb1hMMT3Fr/Qz1OKovCuUM11MUNLUuHeBC2DPPAWUYYUAOFWaxInaTwTQmc7Fl5La7DShTEpmYwgdt2hG+4TEg==", "integrity": "sha512-IJSUX1xb8k/zN9j2I7B5Re6B0NNJDJ1+soezjNojhT8DEVeDNptq2jgycCOpRhyGj0+xBn7Cq+PK7Q+nd2hxLA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -710,9 +710,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.2.tgz",
"integrity": "sha512-7O5u/p6oKUFYjRbZkL2FLbwsyoJAjyeXHCU3O4ndvzg2OFO2GinFPSJFGbiwFDaCFc+k7gs9CF243PwdPQFh5g==", "integrity": "sha512-OgaToJ8jSxTpgGkZSkwKE+JQGihdcaqnyHEFOSAU45utQ+yLruE1dkonB2SDI8t375wOKgNn8pQvaWY9kPzxDQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -724,9 +724,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.2.tgz",
"integrity": "sha512-pDLkYITdYrH/9Cv/Vlj8HppDuLMDUBmgsM0+N+xLtFd18aXgM9Nyqupb/Uw+HeidhfYg2lD6CXvz6CjoVOaKjQ==", "integrity": "sha512-5V3mPpWkB066XZZBgSd1lwozBk7tmOkKtquyCJ6T4LN3mzKENXyBwWNQn8d0Ci81hvlBw5RoFgleVpL6aScLYg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -738,9 +738,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.2.tgz",
"integrity": "sha512-W2ZNI323O/8pJdBGil1oCauuCzmVd9lDmWBBqxYZcOqWD6aWqJtVBQ1dFrF4dYpZPks6F+xCZHfzG5hYlSHZ6g==", "integrity": "sha512-ayVstadfLeeXI9zUPiKRVT8qF55hm7hKa+0N1V6Vj+OTNFfKSoUxyZvzVvgtBxqSb5URQ8sK6fhwxr9/MLmxdA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -752,9 +752,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.2.tgz",
"integrity": "sha512-ELfEX1/+eGZYMaCIbK4jqLxO1gyTSOIlZr6pbC4SRYFaSIDVKOnZNMdoZ+ON0mrFDp4+H5MhwNC1H/AhE3zQLg==", "integrity": "sha512-Mda7iG4fOLHNsPqjWSjANvNZYoW034yxgrndof0DwCy0D3FvTjeNo+HGE6oGWgvcLZNLlcp0hLEFcRs+UGsMLg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -766,9 +766,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.2.tgz",
"integrity": "sha512-yjk2MAkQmoaPYCSu35RLJ62+dz358nE83VfTePJRp8CG7aMg25mEJYpXFiD+NcevhX8LxD5OP5tktPXnXN7GDw==", "integrity": "sha512-DPi0ubYhSow/00YqmG1jWm3qt1F8aXziHc/UNy8bo9cpCacqhuWu+iSq/fp2SyEQK7iYTZ60fBU9cat3MXTjIQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -915,9 +915,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.2", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -970,9 +970,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.23.2", "version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -990,9 +990,9 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001640", "caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.4.820", "electron-to-chromium": "^1.5.4",
"node-releases": "^2.0.14", "node-releases": "^2.0.18",
"update-browserslist-db": "^1.1.0" "update-browserslist-db": "^1.1.0"
}, },
"bin": { "bin": {
@ -1013,9 +1013,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001642", "version": "1.0.30001646",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001646.tgz",
"integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==", "integrity": "sha512-dRg00gudiBDDTmUhClSdv3hqRfpbOnU28IpI1T6PBTLWa+kOj0681C8uML3PifYfREuBrVjDGhL3adYpBT6spw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1161,9 +1161,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.829", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.829.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.4.tgz",
"integrity": "sha512-5qp1N2POAfW0u1qGAxXEtz6P7bO1m6gpZr5hdf5ve6lxpLM7MpiM4jIPz7xcrNlClQMafbyUDDWjlIQZ1Mw0Rw==", "integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -1696,9 +1696,9 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.17", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==", "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1824,9 +1824,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.39", "version": "8.4.40",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1940,21 +1940,27 @@
} }
}, },
"node_modules/postcss-nested": { "node_modules/postcss-nested": {
"version": "6.0.1", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true, "dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"postcss-selector-parser": "^6.0.11" "postcss-selector-parser": "^6.1.1"
}, },
"engines": { "engines": {
"node": ">=12.0" "node": ">=12.0"
}, },
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
"peerDependencies": { "peerDependencies": {
"postcss": "^8.2.14" "postcss": "^8.2.14"
} }
@ -2166,9 +2172,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.18.1", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.19.2.tgz",
"integrity": "sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==", "integrity": "sha512-6/jgnN1svF9PjNYJ4ya3l+cqutg49vOZ4rVgsDKxdl+5gpGPnByFXWGyfH9YGx9i3nfBwSu1Iyu6vGwFFA0BdQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2182,22 +2188,22 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.18.1", "@rollup/rollup-android-arm-eabi": "4.19.2",
"@rollup/rollup-android-arm64": "4.18.1", "@rollup/rollup-android-arm64": "4.19.2",
"@rollup/rollup-darwin-arm64": "4.18.1", "@rollup/rollup-darwin-arm64": "4.19.2",
"@rollup/rollup-darwin-x64": "4.18.1", "@rollup/rollup-darwin-x64": "4.19.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.18.1", "@rollup/rollup-linux-arm-gnueabihf": "4.19.2",
"@rollup/rollup-linux-arm-musleabihf": "4.18.1", "@rollup/rollup-linux-arm-musleabihf": "4.19.2",
"@rollup/rollup-linux-arm64-gnu": "4.18.1", "@rollup/rollup-linux-arm64-gnu": "4.19.2",
"@rollup/rollup-linux-arm64-musl": "4.18.1", "@rollup/rollup-linux-arm64-musl": "4.19.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.18.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.19.2",
"@rollup/rollup-linux-riscv64-gnu": "4.18.1", "@rollup/rollup-linux-riscv64-gnu": "4.19.2",
"@rollup/rollup-linux-s390x-gnu": "4.18.1", "@rollup/rollup-linux-s390x-gnu": "4.19.2",
"@rollup/rollup-linux-x64-gnu": "4.18.1", "@rollup/rollup-linux-x64-gnu": "4.19.2",
"@rollup/rollup-linux-x64-musl": "4.18.1", "@rollup/rollup-linux-x64-musl": "4.19.2",
"@rollup/rollup-win32-arm64-msvc": "4.18.1", "@rollup/rollup-win32-arm64-msvc": "4.19.2",
"@rollup/rollup-win32-ia32-msvc": "4.18.1", "@rollup/rollup-win32-ia32-msvc": "4.19.2",
"@rollup/rollup-win32-x64-msvc": "4.18.1", "@rollup/rollup-win32-x64-msvc": "4.19.2",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -2418,9 +2424,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.6", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
"integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2564,9 +2570,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.3.4", "version": "5.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz",
"integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2745,9 +2751,9 @@
} }
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.4.5", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
"integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {

View File

@ -1 +1 @@
function n(){return{collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){if(this.isLoading=!0,this.areRecordsSelected(this.getRecordsInGroupOnPage(e))){this.deselectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e));return}this.selectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e)),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){let e=this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[];for(let t of e)t.removeEventListener("click",this.handleCheckboxClick),t.addEventListener("click",s=>this.handleCheckboxClick(s,t))},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let o=s.indexOf(this.lastChecked),r=s.indexOf(t),l=[o,r].sort((i,d)=>i-d),c=[];for(let i=l[0];i<=l[1];i++)s[i].checked=t.checked,c.push(s[i].value);t.checked?this.selectRecords(c):this.deselectRecords(c)}this.lastChecked=t}}}export{n as default}; function n(){return{checkboxClickController:null,collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){if(this.isLoading=!0,this.areRecordsSelected(this.getRecordsInGroupOnPage(e))){this.deselectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e));return}this.selectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e)),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){this.checkboxClickController&&this.checkboxClickController.abort(),this.checkboxClickController=new AbortController;let{signal:e}=this.checkboxClickController;this.$root?.addEventListener("click",t=>t.target?.matches(".fi-ta-record-checkbox")&&this.handleCheckboxClick(t,t.target),{signal:e})},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let o=s.indexOf(this.lastChecked),r=s.indexOf(t),l=[o,r].sort((i,d)=>i-d),c=[];for(let i=l[0];i<=l[1];i++)s[i].checked=t.checked,c.push(s[i].value);t.checked?this.selectRecords(c):this.deselectRecords(c)}this.lastChecked=t}}}export{n as default};

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,18 @@
<div class="xl:grid xl:grid-cols-3 xl:gap-8"> <div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div> <div>
<p class="text-lg italic font-extrabold leading-6 text-white">The Forge</p> <p class="text-lg italic font-extrabold leading-6 text-white">The Forge</p>
<p class="mt-6 flex space-x-4">
<a href="https://discord.com/invite/Xn9msqQZan" title="{{ __('Join Our Discord!') }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36" class="w-6 h-6">
<path fill="#fff" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
</svg>
</a>
<a href="https://www.reddit.com/r/SPTarkov/" title="{{ __('Join Our Subreddit!') }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6">
<path fill="#fff" d="M14.238 15.348c.085.084.085.221 0 .306-.465.462-1.194.687-2.231.687l-.008-.002-.008.002c-1.036 0-1.766-.225-2.231-.688-.085-.084-.085-.221 0-.305.084-.084.222-.084.307 0 .379.377 1.008.561 1.924.561l.008.002.008-.002c.915 0 1.544-.184 1.924-.561.085-.084.223-.084.307 0zm-3.44-2.418c0-.507-.414-.919-.922-.919-.509 0-.923.412-.923.919 0 .506.414.918.923.918.508.001.922-.411.922-.918zm13.202-.93c0 6.627-5.373 12-12 12s-12-5.373-12-12 5.373-12 12-12 12 5.373 12 12zm-5-.129c0-.851-.695-1.543-1.55-1.543-.417 0-.795.167-1.074.435-1.056-.695-2.485-1.137-4.066-1.194l.865-2.724 2.343.549-.003.034c0 .696.569 1.262 1.268 1.262.699 0 1.267-.566 1.267-1.262s-.568-1.262-1.267-1.262c-.537 0-.994.335-1.179.804l-2.525-.592c-.11-.027-.223.037-.257.145l-.965 3.038c-1.656.02-3.155.466-4.258 1.181-.277-.255-.644-.415-1.05-.415-.854.001-1.549.693-1.549 1.544 0 .566.311 1.056.768 1.325-.03.164-.05.331-.05.5 0 2.281 2.805 4.137 6.253 4.137s6.253-1.856 6.253-4.137c0-.16-.017-.317-.044-.472.486-.261.82-.766.82-1.353zm-4.872.141c-.509 0-.922.412-.922.919 0 .506.414.918.922.918s.922-.412.922-.918c0-.507-.413-.919-.922-.919z" />
</svg>
</a>
</p>
</div> </div>
<div class="mt-16 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 xl:col-span-2 xl:mt-0"> <div class="mt-16 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 xl:col-span-2 xl:mt-0">
<div class="sm:order-first"> <div class="sm:order-first">

View File

@ -6,7 +6,7 @@
<img src="{{ Storage::url($result['thumbnail']) }}" alt="{{ $result['name'] }}" class="h-6 w-6 self-center"> <img src="{{ Storage::url($result['thumbnail']) }}" alt="{{ $result['name'] }}" class="h-6 w-6 self-center">
@endif @endif
<p class="flex-grow">{{ $result['name'] }}</p> <p class="flex-grow">{{ $result['name'] }}</p>
<p class="ml-auto self-center badge-version {{ $result['latestSptVersionColorClass'] }} }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> <p class="ml-auto self-center badge-version {{ $result['latestVersionColorClass'] }} }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $result['latestSptVersion'] }} {{ $result['latestVersion'] }}
</p> </p>
</a> </a>

View File

@ -1,5 +1,5 @@
<p {{ $attributes->class(['text-slate-700 dark:text-gray-300 text-sm']) }}> <p {{ $attributes->class(['text-slate-700 dark:text-gray-300 text-sm']) }}>
<span>{{ Number::format($mod->total_downloads) }} downloads</span> <span title="{{ __('Exactly') }} {{ $mod->total_downloads }}">{{ Number::downloads($mod->total_downloads) }} downloads</span>
@if(!is_null($mod->created_at)) @if(!is_null($mod->created_at))
<span> <span>
&mdash; Created &mdash; Created

View File

@ -2,8 +2,8 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($mods as $mod) @foreach ($mods as $mod)
<a href="/mod/{{ $mod->id }}/{{ $mod->slug }}" class="mod-list-component"> <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 max-w-md mx-auto bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl overflow-hidden md:max-w-2xl 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="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:flex">
<div class="h-auto md:h-full md:shrink-0 overflow-hidden"> <div class="h-auto md:h-full md:shrink-0 overflow-hidden">
@if (empty($mod->thumbnail)) @if (empty($mod->thumbnail))
@ -14,15 +14,17 @@
@endif @endif
</div> </div>
<div class="flex flex-col w-full justify-between p-5"> <div class="flex flex-col w-full justify-between p-5">
<div> <div class="pb-3">
<div class="flex justify-between items-center space-x-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> <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"> <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 }} {{ $mod->{$versionScope}->sptVersion->version }}
</span> </span>
</div> </div>
<p class="text-sm italic text-slate-600 dark:text-gray-200">By {{ $mod->users->pluck('name')->implode(', ') }}</p> <p class="text-sm italic text-slate-600 dark:text-gray-200">
<p class="mt-2 text-slate-500 dark:text-gray-300">{{ $mod->teaser }}</p> 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> </div>
<x-mod-list-stats :mod="$mod" :modVersion="$mod->{$versionScope}"/> <x-mod-list-stats :mod="$mod" :modVersion="$mod->{$versionScope}"/>
</div> </div>

View File

@ -6,8 +6,10 @@
</h2> </h2>
</x-slot> </x-slot>
<div class="grid grid-cols-1 lg:grid-cols-3 max-w-7xl mx-auto pb-6 px-4 gap-6 sm:px-6 lg:px-8"> <div class="grid grid-cols-1 lg:grid-cols-3 max-w-7xl mx-auto py-6 px-4 gap-6 sm:px-6 lg:px-8">
<div class="lg:col-span-2 flex flex-col gap-6"> <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="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="flex flex-col sm:flex-row gap-4 sm:gap-6"> <div class="flex flex-col sm:flex-row gap-4 sm:gap-6">
<div class="grow-0 shrink-0 flex justify-center items-center"> <div class="grow-0 shrink-0 flex justify-center items-center">
@ -19,57 +21,136 @@
@endif @endif
</div> </div>
<div class="grow flex flex-col justify-center items-center sm:items-start text-gray-800 dark:text-gray-200"> <div class="grow flex flex-col justify-center items-center sm:items-start text-gray-800 dark:text-gray-200">
<h2 class="pb-1 sm:p-0 text-3xl font-bold text-gray-900 dark:text-white"> <div class="flex justify-between items-center space-x-3">
<h2 class="pb-1 sm:pb-2 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }} {{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400"> <span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $mod->latestSptVersion->version }} {{ $latestVersion->version }}
</span> </span>
</h2> </h2>
<p>{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}</p>
<p>{{ $mod->latestSptVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ $mod->total_downloads }} {{ __('Downloads') }}</p>
</div> </div>
<p>
{{ __('Created by') }}
@foreach ($mod->users as $user)
<a href="{{ $user->profileUrl() }}" class="text-slate-600 dark:text-gray-200 hover:underline">{{ $user->name }}</a>{{ $loop->last ? '' : ',' }}
@endforeach
</p>
<p title="{{ __('Exactly') }} {{ $mod->total_downloads }}">{{ Number::downloads($mod->total_downloads) }} {{ __('Downloads') }}</p>
<p class="mt-2">
<span class="badge-version {{ $latestVersion->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}
</span>
</p>
</div> </div>
</div> </div>
{{-- Mod teaser --}}
@if ($mod->teaser)
<p class="mt-6 pt-3 border-t-2 border-gray-200 dark:border-gray-800 text-gray-800 dark:text-gray-200">{{ $mod->teaser }}</p>
@endif
</div>
{{-- Mobile Download Button --}}
<a href="{{ $latestVersion->link }}" class="block lg:hidden">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button>
</a>
{{-- Tabs --}}
<div x-data="{ selectedTab: window.location.hash ? window.location.hash.substring(1) : 'description' }" x-init="$watch('selectedTab', (tab) => {window.location.hash = tab})" class="lg:col-span-2 flex flex-col gap-6">
<div> <div>
{{-- Mobile Dropdown --}}
<div class="sm:hidden"> <div class="sm:hidden">
<label for="tabs" class="sr-only">Select a tab</label> <label for="tabs" class="sr-only">{{ __('Select a tab') }}</label>
{{-- Use an "onChange" listener to redirect the user to the selected tab URL. --}} <select id="tabs" name="tabs" x-model="selectedTab" 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">
<select id="tabs" name="tabs" 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="description">{{ __('Description') }}</option>
<option selected>Description</option> <option value="versions">{{ __('Versions') }}</option>
<option>Versions</option> <option value="comments">{{ __('Comments') }}</option>
<option>Comments</option>
</select> </select>
</div> </div>
{{-- Desktop Tabs --}}
<div class="hidden sm:block"> <div class="hidden sm:block">
<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"> <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">
<a href="#description" 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-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10" aria-current="page"> <button @click="selectedTab = 'description'" 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" aria-current="page">
<span>Description</span> <span>{{ __('Description') }}</span>
<span aria-hidden="true" class="bg-gray-500 absolute inset-x-0 bottom-0 h-0.5"></span> </a> <span aria-hidden="true" :class="selectedTab === 'description' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5'"></span>
<a href="#versions" 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"> </button>
<span>Versions</span> <button @click="selectedTab = 'versions'" 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 aria-hidden="true" class="bg-transparent absolute inset-x-0 bottom-0 h-0.5"></span> <span>{{ __('Versions') }}</span>
</a> <span aria-hidden="true" :class="selectedTab === 'versions' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5'"></span>
<a href="#comments" 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"> </button>
<span>Comments</span> <button @click="selectedTab = 'comments'" 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 aria-hidden="true" class="bg-transparent absolute inset-x-0 bottom-0 h-0.5"></span> <span>{{ __('Comments') }}</span>
</a> <span aria-hidden="true" :class="selectedTab === 'comments' ? 'bg-gray-500 absolute inset-x-0 bottom-0 h-0.5' : 'bottom-0 h-0.5'"></span>
</button>
</nav> </nav>
</div> </div>
</div> </div>
<div id="description" class="user-markdown p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl"> {{-- Mod Description --}}
<div x-show="selectedTab === 'description'" class="user-markdown p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
{{-- The description below is safe to write directly because it has been run though HTMLPurifier. --}}
{!! Str::markdown($mod->description) !!}
</div>
{{-- Mod Versions --}}
<div x-show="selectedTab === 'versions'">
@foreach ($mod->versions as $version)
<div class="p-4 mb-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<div class="pb-6 border-b-2 border-gray-200 dark:border-gray-800">
<div class="flex items-center justify-between">
<a class="text-2xl font-extrabold" href="{{ $version->link }}">
{{ __('Version') }} {{ $version->version }}
</a>
<p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p>
</div>
<div class="flex items-center justify-between">
<span class="badge-version {{ $version->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $version->sptVersion->version }}
</span>
<a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a>
</div>
<div class="flex items-center justify-between text-gray-600 dark:text-gray-400">
<span>{{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }}</span>
<span>{{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}</span>
</div>
@if ($version->dependencies->isNotEmpty() && $version->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
<div class="text-gray-600 dark:text-gray-400">
{{ __('Dependencies:') }}
@foreach ($version->dependencies as $dependency)
@if ($dependency->resolvedVersion?->mod)
<a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a>@if (!$loop->last), @endif
@endif
@endforeach
</div>
@endif
</div>
<div class="p-3 user-markdown">
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}} {{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}}
<p>{!! Str::markdown($mod->description) !!}</p> {!! Str::markdown($version->description) !!}
</div>
</div>
@endforeach
</div>
{{-- Comments --}}
<div x-show="selectedTab === 'comments'" class="user-markdown p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<p>{{ __('The comments go here.') }}</p>
</div>
</div> </div>
</div> </div>
{{-- Right Column --}}
<div class="col-span-1 flex flex-col gap-6"> <div class="col-span-1 flex flex-col gap-6">
<a href="{{ $mod->latestSptVersion->link }}" class="block">
<button type="button" class="w-full">{{ __('Download Latest Version') }} ({{ $mod->latestSptVersion->version }})</button> {{-- Desktop Download Button --}}
<a href="{{ $latestVersion->link }}" class="hidden lg:block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button>
</a> </a>
{{-- Additional Mod Details --}}
<div class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl"> <div class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ __('Details') }}</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ __('Details') }}</h2>
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-800 text-gray-900 dark:text-gray-100"> <ul role="list" class="divide-y divide-gray-200 dark:divide-gray-800 text-gray-900 dark:text-gray-100">
@ -93,16 +174,28 @@
</p> </p>
</li> </li>
@endif @endif
@if($mod->latestSptVersion->virus_total_link) @if ($latestVersion->virus_total_link)
<li class="px-4 py-4 sm:px-0"> <li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest VirusTotal Result') }}</h3> <h3>{{ __('Latest Version VirusTotal Result') }}</h3>
<p class="truncate"> <p class="truncate">
<a href="{{ $mod->latestSptVersion->virus_total_link }}" title="{{ $mod->latestSptVersion->virus_total_link }}" target="_blank"> <a href="{{ $latestVersion->virus_total_link }}" title="{{ $latestVersion->virus_total_link }}" target="_blank">
{{ $mod->latestSptVersion->virus_total_link }} {{ $latestVersion->virus_total_link }}
</a> </a>
</p> </p>
</li> </li>
@endif @endif
@if ($latestVersion->dependencies->isNotEmpty() && $latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest Version Dependencies') }}</h3>
<p class="truncate">
@foreach ($latestVersion->dependencies as $dependency)
<a href="{{ $dependency->resolvedVersion->mod->detailUrl() }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a><br />
@endforeach
</p>
</li>
@endif
@if ($mod->contains_ads) @if ($mod->contains_ads)
<li class="px-4 py-4 sm:px-0 flex flex-row gap-2 items-center"> <li class="px-4 py-4 sm:px-0 flex flex-row gap-2 items-center">
<svg class="grow-0 w-[16px] h-[16px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"> <svg class="grow-0 w-[16px] h-[16px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">

View File

@ -63,19 +63,25 @@
</svg> </svg>
{{ __('Dashboard') }} {{ __('Dashboard') }}
</a> </a>
</div> <a href="{{ auth()->user()->profileUrl() }}" 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">
<div class="flex flex-col py-1.5">
<a href="{{ route('profile.show') }}" 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" aria-hidden="true" fill="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/>
</svg> </svg>
{{ __('Profile') }} {{ __('Profile') }}
</a> </a>
@if (Laravel\Jetstream\Jetstream::hasApiFeatures()) </div>
<a href="{{ route('api-tokens.index') }}" 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"> <div class="flex flex-col py-1.5">
<a href="{{ route('profile.show') }}" 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" aria-hidden="true" fill="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 00-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 00-2.282.819l-.922 1.597a1.875 1.875 0 00.432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 000 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 00-.432 2.385l.922 1.597a1.875 1.875 0 002.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 002.28-.819l.923-1.597a1.875 1.875 0 00-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 000-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 00-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 00-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 00-1.85-1.567h-1.843zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 00-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 00-2.282.819l-.922 1.597a1.875 1.875 0 00.432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 000 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 00-.432 2.385l.922 1.597a1.875 1.875 0 002.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 002.28-.819l.923-1.597a1.875 1.875 0 00-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 000-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 00-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 00-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 00-1.85-1.567h-1.843zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" clip-rule="evenodd"/>
</svg> </svg>
{{ __('Edit Profile') }}
</a>
@if (Laravel\Jetstream\Jetstream::hasApiFeatures())
<a href="{{ route('api-tokens.index') }}" 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" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
{{ __('API Tokens') }} {{ __('API Tokens') }}
</a> </a>
@endif @endif

View File

@ -8,7 +8,7 @@
<div> <div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
@if (Laravel\Fortify\Features::canUpdateProfileInformation()) @if (Laravel\Fortify\Features::canUpdateProfileInformation())
@livewire('profile.update-profile-information-form') @livewire('profile.update-profile-form')
<x-section-border /> <x-section-border />
@endif @endif

View File

@ -8,7 +8,7 @@
</x-slot> </x-slot>
<x-slot name="form"> <x-slot name="form">
<!-- Profile Photo --> <!-- Profile Picture -->
@if (Laravel\Jetstream\Jetstream::managesProfilePhotos()) @if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
<div x-data="{photoName: null, photoPreview: null}" class="col-span-6 sm:col-span-4"> <div x-data="{photoName: null, photoPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Profile Photo File Input --> <!-- Profile Photo File Input -->
@ -24,7 +24,7 @@
reader.readAsDataURL($refs.photo.files[0]); reader.readAsDataURL($refs.photo.files[0]);
" /> " />
<x-label for="photo" value="{{ __('Photo') }}" /> <x-label for="photo" value="{{ __('Profile Picture') }}" />
<!-- Current Profile Photo --> <!-- Current Profile Photo -->
<div class="mt-2" x-show="! photoPreview"> <div class="mt-2" x-show="! photoPreview">
@ -52,6 +52,48 @@
</div> </div>
@endif @endif
<!-- Cover Picture -->
<div x-data="{coverName: null, coverPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Cover Picture File Input -->
<input type="file" id="cover" class="hidden"
wire:model.live="cover"
x-ref="cover"
x-on:change="
coverName = $refs.cover.files[0].name;
const reader = new FileReader();
reader.onload = (e) => {
coverPreview = e.target.result;
};
reader.readAsDataURL($refs.cover.files[0]);
" />
<x-label for="cover" value="{{ __('Cover Picture') }}" />
<!-- Current Cover Photo -->
<div class="mt-2" x-show="! coverPreview">
<img src="{{ $this->user->cover_photo_url }}" alt="{{ $this->user->name }}" class="rounded-sm h-20 w-60 object-cover">
</div>
<!-- New Cover Photo Preview -->
<div class="mt-2" x-show="coverPreview" style="display: none;">
<span class="block h-20 w-60 bg-cover bg-no-repeat bg-center"
x-bind:style="'background-image: url(\'' + coverPreview + '\');'">
</span>
</div>
<x-secondary-button class="mt-2 me-2" type="button" x-on:click.prevent="$refs.cover.click()">
{{ __('Select A New Cover Photo') }}
</x-secondary-button>
@if ($this->user->cover_photo_path)
<x-secondary-button type="button" class="mt-2" wire:click="deleteCoverPhoto">
{{ __('Remove Cover Photo') }}
</x-secondary-button>
@endif
<x-input-error for="cover" class="mt-2" />
</div>
<!-- Name --> <!-- Name -->
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<x-label for="name" value="{{ __('Name') }}" /> <x-label for="name" value="{{ __('Name') }}" />
@ -88,7 +130,7 @@
{{ __('Saved.') }} {{ __('Saved.') }}
</x-action-message> </x-action-message>
<x-button wire:loading.attr="disabled" wire:target="photo"> <x-button wire:loading.attr="disabled" wire:target="photo,cover">
{{ __('Save') }} {{ __('Save') }}
</x-button> </x-button>
</x-slot> </x-slot>

View File

@ -0,0 +1,35 @@
<x-app-layout>
<div class="sm:-mt-12 dark:bg-gray-800 dark:text-gray-100">
<div>
<img class="h-32 w-full object-cover lg:h-48" src="{{ $user->cover_photo_url }}" alt="{{ $user->name }}">
</div>
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="-mt-12 sm:-mt-16 sm:flex sm:items-end sm:space-x-5">
<div class="flex">
<img class="h-24 w-24 rounded-full ring-4 ring-white dark:ring-gray-800 sm:h-32 sm:w-32" src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" />
</div>
<div class="mt-6 sm:flex sm:min-w-0 sm:flex-1 sm:items-center sm:justify-end sm:space-x-6 sm:pb-1">
<div class="mt-6 min-w-0 flex-1 sm:hidden md:block">
<h1 class="truncate text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $user->name }}</h1>
</div>
{{--
<div class="mt-6 flex flex-col justify-stretch space-y-3 sm:flex-row sm:space-x-4 sm:space-y-0">
<button type="button" class="inline-flex justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600">
<svg class="-ml-0.5 mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-300" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M3 4a2 2 0 00-2 2v1.161l8.441 4.221a1.25 1.25 0 001.118 0L19 7.162V6a2 2 0 00-2-2H3z" />
<path d="M19 8.839l-7.77 3.885a2.75 2.75 0 01-2.46 0L1 8.839V14a2 2 0 002 2h14a2 2 0 002-2V8.839z" />
</svg>
<span>Message</span>
</button>
</div>
--}}
</div>
</div>
<div class="mt-6 hidden min-w-0 flex-1 sm:block md:hidden">
<h1 class="truncate text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $user->name }}</h1>
</div>
</div>
</div>
</x-app-layout>

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\ModController; use App\Http\Controllers\ModController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['auth.banned'])->group(function () { Route::middleware(['auth.banned'])->group(function () {
@ -11,7 +12,11 @@ Route::middleware(['auth.banned'])->group(function () {
Route::controller(ModController::class)->group(function () { Route::controller(ModController::class)->group(function () {
Route::get('/mods', 'index')->name('mods'); Route::get('/mods', 'index')->name('mods');
Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show'); Route::get('/mod/{mod}/{slug}', 'show')->where(['mod' => '[0-9]+'])->name('mod.show');
});
Route::controller(UserController::class)->group(function () {
Route::get('/user/{user}/{username}', 'show')->where(['user' => '[0-9]+'])->name('user.show');
}); });
Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () { Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () {

View File

@ -0,0 +1,238 @@
<?php
use App\Exceptions\CircularDependencyException;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves mod version dependency when mod version is created', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
});
it('resolves mod version dependency when mod version is updated', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
$modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Update the mod B version
$modBv3->update(['version' => '1.1.2']);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.2');
});
it('resolves mod version dependency when mod version is deleted', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
$modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Update the mod B version
$modBv3->delete();
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.0');
});
it('resolves mod version dependency after semantic version constraint is updated', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Update the dependency version constraint
$modDependency->update(['version_constraint' => '^2.0.0']);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('2.0.1');
});
it('resolves mod version dependency with exact semantic version constraint', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '1.1.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.0');
});
it('resolves mod version dependency with complex semantic version constraint', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '>=1.0.0 <2.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.2.1');
$modDependency->update(['version_constraint' => '1.0.0 || >=1.1.0 <1.2.0']);
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
});
it('resolves null when no mod versions are available', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create version for Mod A that has no resolvable dependency
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->toBeNull();
});
it('resolves null when no mod versions match against semantic version constraint', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
// Create version for Mod A that has no resolvable dependency
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '~1.2.0',
]);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->toBeNull();
});
it('resolves multiple dependencies', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
$modC = Mod::factory()->create(['name' => 'Mod C']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '2.0.0']);
// Creating a version for Mod A that depends on Mod B and Mod C
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependencyB = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
$modDependencyC = ModDependency::factory()->recycle([$modAv1, $modC])->create([
'version_constraint' => '^1.0.0',
]);
$modDependencyB->refresh();
expect($modDependencyB->resolvedVersion->version)->toBe('1.1.1');
$modDependencyC->refresh();
expect($modDependencyC->resolvedVersion->version)->toBe('1.1.1');
});
it('throws exception when there is a circular version dependency', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
$modBv1 = ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']);
$modDependencyAtoB = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '1.0.0',
]);
// Create circular dependencies
$modDependencyBtoA = ModDependency::factory()->recycle([$modBv1, $modA])->create([
'version_constraint' => '1.0.0',
]);
})->throws(CircularDependencyException::class);

42
tests/Feature/ModTest.php Normal file
View File

@ -0,0 +1,42 @@
<?php
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows the latest version on the mod detail page', function () {
// Create a mod instance
$mod = Mod::factory()->create();
// Create 5 mod versions with specified versions
$versions = [
'1.0.0',
'1.1.0',
'1.2.0',
'2.0.0',
'2.1.0',
];
// get the highest version in the array
$latestVersion = max($versions);
foreach ($versions as $version) {
ModVersion::factory()->create([
'mod_id' => $mod->id,
'version' => $version,
]);
}
// Make a request to the mod's detail URL
$response = $this->get($mod->detailUrl());
$this->assertEquals('2.1.0', $latestVersion);
// Assert the latest version is next to the mod's name
$response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion"));
// Assert the latest version is in the latest download button
$response->assertSeeText(__('Download Latest Version')." ($latestVersion)");
});

View File

@ -0,0 +1,39 @@
<?php
use App\Models\ModVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
it('includes only published mod versions', function () {
$publishedMod = ModVersion::factory()->create([
'published_at' => Carbon::now()->subDay(),
]);
$unpublishedMod = ModVersion::factory()->create([
'published_at' => Carbon::now()->addDay(),
]);
$noPublishedDateMod = ModVersion::factory()->create([
'published_at' => null,
]);
$all = ModVersion::withoutGlobalScopes()->get();
expect($all)->toHaveCount(3);
$mods = ModVersion::all();
expect($mods)->toHaveCount(1)
->and($mods->contains($publishedMod))->toBeTrue()
->and($mods->contains($unpublishedMod))->toBeFalse()
->and($mods->contains($noPublishedDateMod))->toBeFalse();
});
it('handles null published_at as not published', function () {
$modWithNoPublishDate = ModVersion::factory()->create([
'published_at' => null,
]);
$mods = ModVersion::all();
expect($mods->contains($modWithNoPublishDate))->toBeFalse();
});