Merge branch 'profile-data' into develop

This commit is contained in:
Refringe 2024-09-24 00:49:46 -04:00
commit 275760f34a
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
46 changed files with 1593 additions and 934 deletions

View File

@ -11,8 +11,16 @@ class UserController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
public function show(Request $request, User $user, string $username): View public function show(Request $request, int $userId, string $username): View
{ {
$user = User::whereId($userId)
->firstOrFail();
$mods = $user->mods()
->orderByDesc('created_at')
->paginate(10)
->fragment('mods');
if ($user->slug() !== $username) { if ($user->slug() !== $username) {
abort(404); abort(404);
} }
@ -21,6 +29,6 @@ class UserController extends Controller
abort(403); abort(403);
} }
return view('user.show', compact('user')); return view('user.show', compact('user', 'mods'));
} }
} }

View File

@ -2,12 +2,8 @@
namespace App\Http\Filters\V1; namespace App\Http\Filters\V1;
use App\Models\Mod;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
/**
* @extends QueryFilter<Mod>
*/
class ModFilter extends QueryFilter class ModFilter extends QueryFilter
{ {
/** /**

View File

@ -2,12 +2,8 @@
namespace App\Http\Filters\V1; namespace App\Http\Filters\V1;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
/**
* @extends QueryFilter<User>
*/
class UserFilter extends QueryFilter class UserFilter extends QueryFilter
{ {
/** /**

View File

@ -49,6 +49,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
// Begin to import the data into the permanent local database tables. // Begin to import the data into the permanent local database tables.
$this->importUsers(); $this->importUsers();
$this->importUserFollows();
$this->importLicenses(); $this->importLicenses();
$this->importSptVersions(); $this->importSptVersions();
$this->importMods(); $this->importMods();
@ -608,6 +609,37 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
]; ];
} }
protected function importUserFollows(): void
{
$followsGroupedByFollower = [];
DB::connection('mysql_hub')
->table('wcf1_user_follow')
->select(['followID', 'userID', 'followUserID', 'time'])
->chunkById(100, function (Collection $follows) use (&$followsGroupedByFollower) {
foreach ($follows as $follow) {
$followerId = User::whereHubId($follow->userID)->value('id');
$followingId = User::whereHubId($follow->followUserID)->value('id');
if (! $followerId || ! $followingId) {
continue;
}
$followsGroupedByFollower[$followerId][$followingId] = [
'created_at' => Carbon::parse($follow->time, 'UTC'),
'updated_at' => Carbon::parse($follow->time, 'UTC'),
];
}
}, 'followID');
foreach ($followsGroupedByFollower as $followerId => $followings) {
$user = User::find($followerId);
if ($user) {
$user->following()->sync($followings);
}
}
}
/** /**
* Import the licenses from the Hub database to the local database. * Import the licenses from the Hub database to the local database.
*/ */

View File

@ -7,6 +7,7 @@ use App\Models\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\View\View; use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
class GlobalSearch extends Component class GlobalSearch extends Component
@ -17,20 +18,26 @@ class GlobalSearch extends Component
public string $query = ''; public string $query = '';
/** /**
* Whether to show the search result dropdown. * The search results.
*/ */
public bool $showDropdown = false; #[Locked]
public array $result = [];
/** /**
* Whether to show the "no results found" message. * The total number of search results.
*/ */
public bool $noResults = false; #[Locked]
public int $count = 0;
/**
* Render the component.
*/
public function render(): View public function render(): View
{ {
return view('livewire.global-search', [ $this->result = $this->executeSearch($this->query);
'results' => $this->executeSearch($this->query), $this->count = $this->countTotalResults($this->result);
]);
return view('livewire.global-search');
} }
/** /**
@ -39,19 +46,15 @@ class GlobalSearch extends Component
protected function executeSearch(string $query): array protected function executeSearch(string $query): array
{ {
$query = Str::trim($query); $query = Str::trim($query);
$results = ['data' => [], 'total' => 0];
if (Str::length($query) > 0) { if (Str::length($query) > 0) {
$results['data'] = [ return [
'user' => $this->fetchUserResults($query), 'user' => $this->fetchUserResults($query),
'mod' => $this->fetchModResults($query), 'mod' => $this->fetchModResults($query),
]; ];
$results['total'] = $this->countTotalResults($results['data']);
} }
$this->noResults = $results['total'] === 0; return [];
return $results;
} }
/** /**
@ -59,10 +62,7 @@ class GlobalSearch extends Component
*/ */
protected function fetchUserResults(string $query): Collection protected function fetchUserResults(string $query): Collection
{ {
/** @var array<int, array<string, mixed>> $userHits */ return collect(User::search($query)->raw()['hits']);
$userHits = User::search($query)->raw()['hits'];
return collect($userHits);
} }
/** /**
@ -70,10 +70,7 @@ class GlobalSearch extends Component
*/ */
protected function fetchModResults(string $query): Collection protected function fetchModResults(string $query): Collection
{ {
/** @var array<int, array<string, mixed>> $modHits */ return collect(Mod::search($query)->raw()['hits']);
$modHits = Mod::search($query)->raw()['hits'];
return collect($modHits);
} }
/** /**

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\User;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
class FollowButtons extends Component
{
/**
* The ID of the user whose profile is being viewed.
*/
#[Locked]
public int $profileUserId;
/**
* Whether the authenticated user is currently following the profile user.
*/
#[Locked]
public bool $isFollowing;
/**
* Action to follow a user.
*/
public function follow(): void
{
auth()->user()->follow($this->profileUserId);
$this->isFollowing = true;
$this->dispatch('user-follow-change');
}
/**
* Action to unfollow a user.
*/
public function unfollow(): void
{
auth()->user()->unfollow($this->profileUserId);
$this->isFollowing = false;
$this->dispatch('user-follow-change');
}
/**
* Render the component.
*/
public function render(): View
{
return view('livewire.user.follow-buttons');
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Livewire\User;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
class FollowCard extends Component
{
/**
* The ID of the user whose profile is being viewed.
*/
#[Locked]
public int $profileUserId;
/**
* The type of user follow relationship to display.
* Currently, either "followers" or "following".
*/
#[Locked]
public string $relationship;
/**
* The title of the card.
*/
public string $title;
/**
* The message to display when there are no results.
*/
public string $emptyMessage;
/**
* The title of the dialog.
*/
public string $dialogTitle;
/**
* The users to display in the card.
*
* @var Collection<User>
*/
#[Locked]
public Collection $followUsers;
/**
* The maximum number of users to display on the card.
*/
#[Locked]
public int $limit = 5;
/**
* Whether to show all users in a model dialog.
*/
public bool $showFollowDialog = false;
/**
* The user whose profile is being viewed.
*/
#[Locked]
public User $profileUser;
/**
* Called when the component is initialized.
*/
public function mount(): void
{
$this->profileUser = User::select(['id', 'name', 'profile_photo_path', 'cover_photo_path'])
->findOrFail($this->profileUserId);
$this->setTitle();
$this->setEmptyMessage();
$this->setDialogTitle();
}
/**
* Set the title of the card based on the relationship.
*/
private function setTitle(): void
{
$this->title = match ($this->relationship) {
'followers' => __('Followers'),
'following' => __('Following'),
default => __('Users'),
};
}
/**
* Set the empty message based on the relationship.
*/
private function setEmptyMessage(): void
{
$this->emptyMessage = match ($this->relationship) {
'followers' => __('No followers yet.'),
'following' => __('Not yet following anyone.'),
default => __('No users found.'),
};
}
/**
* Set the dialog title based on the relationship.
*/
private function setDialogTitle(): void
{
$this->dialogTitle = match ($this->relationship) {
'followers' => 'User :name has these followers:',
'following' => 'User :name is following:',
default => 'Users:',
};
}
/**
* Render the component.
*/
public function render(): View
{
$this->populateFollowUsers();
return view('livewire.user.follow-card');
}
/**
* Called when the user follows or unfollows a user.
*/
#[On('user-follow-change')]
public function populateFollowUsers(): void
{
// Fetch IDs of all users the authenticated user is following.
$followingIds = auth()->user()->following()->pluck('following_id');
// Load the profile user's followers (or following) and map the follow status.
$this->followUsers = $this->profileUser->{$this->relationship}
->map(function (User $user) use ($followingIds) {
// Add the follow status based on the preloaded IDs.
$user->follows = $followingIds->contains($user->id);
return $user;
});
}
/**
* Toggle showing the follow dialog.
*/
public function toggleFollowDialog(): void
{
$this->showFollowDialog = ! $this->showFollowDialog;
}
}

View File

@ -8,6 +8,7 @@ use App\Notifications\VerifyEmail;
use App\Traits\HasCoverPhoto; use App\Traits\HasCoverPhoto;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
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;
@ -52,6 +53,65 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->belongsToMany(Mod::class); return $this->belongsToMany(Mod::class);
} }
/**
* The relationship between a user and users that follow them.
*
* @return BelongsToMany<User>
*/
public function followers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_follows', 'following_id', 'follower_id')
->withTimestamps();
}
/**
* Follow another user.
*/
public function follow(User|int $user): void
{
$userId = $user instanceof User ? $user->id : $user;
if ($this->id === $userId) {
// Don't allow following yourself.
return;
}
$this->following()->syncWithoutDetaching([$userId]);
}
/**
* The relationship between a user and users they follow.
*
* @return BelongsToMany<User>
*/
public function following(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_follows', 'follower_id', 'following_id')
->withTimestamps();
}
/**
* Unfollow another user.
*/
public function unfollow(User|int $user): void
{
$userId = $user instanceof User ? $user->id : $user;
if ($this->isFollowing($userId)) {
$this->following()->detach($userId);
}
}
/**
* Check if the user is following another user.
*/
public function isFollowing(User|int $user): bool
{
$userId = $user instanceof User ? $user->id : $user;
return $this->following()->where('following_id', $userId)->exists();
}
/** /**
* The data that is searchable by Scout. * The data that is searchable by Scout.
*/ */
@ -68,7 +128,7 @@ class User extends Authenticatable implements MustVerifyEmail
*/ */
public function shouldBeSearchable(): bool public function shouldBeSearchable(): bool
{ {
return ! is_null($this->email_verified_at); return $this->isNotBanned();
} }
/** /**
@ -150,6 +210,24 @@ class User extends Authenticatable implements MustVerifyEmail
return $filters->apply($builder); return $filters->apply($builder);
} }
/**
* Handle the about default value if empty. Thanks MySQL!
*/
protected function about(): Attribute
{
return Attribute::make(
set: function ($value) {
// MySQL will not allow you to set a default value of an empty string for a (LONG)TEXT column. *le sigh*
// NULL is the default. If NULL is saved, we'll swap it out for an empty string.
if (is_null($value)) {
return '';
}
return $value;
},
);
}
/** /**
* Get the disk that profile photos should be stored on. * Get the disk that profile photos should be stored on.
*/ */
@ -164,8 +242,12 @@ class User extends Authenticatable implements MustVerifyEmail
protected function casts(): array protected function casts(): array
{ {
return [ return [
'id' => 'integer',
'hub_id' => 'integer',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'created_at' => 'datetime',
'updated_at' => 'datetime',
]; ];
} }
} }

View File

@ -35,8 +35,8 @@
"laravel/pint": "^1.16", "laravel/pint": "^1.16",
"laravel/sail": "^1.29", "laravel/sail": "^1.29",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.1", "nunomaduro/collision": "^8.4",
"pestphp/pest": "^2.34", "pestphp/pest": "^3.0",
"spatie/laravel-ignition": "^2.8" "spatie/laravel-ignition": "^2.8"
}, },
"autoload": { "autoload": {

1002
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -114,7 +114,7 @@ return [
| |
*/ */
'inject_assets' => true, 'inject_assets' => false,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------

View File

@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Random\RandomException;
/** /**
* @extends Factory<User> * @extends Factory<User>
@ -21,6 +22,8 @@ class UserFactory extends Factory
/** /**
* Define the user's default state. * Define the user's default state.
*
* @throws RandomException
*/ */
public function definition(): array public function definition(): array
{ {
@ -29,6 +32,7 @@ class UserFactory extends Factory
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'about' => fake()->paragraphs(random_int(1, 10), true),
'two_factor_secret' => null, 'two_factor_secret' => null,
'two_factor_recovery_codes' => null, 'two_factor_recovery_codes' => null,
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),

View File

@ -22,6 +22,7 @@ return new class extends Migration
$table->string('email')->unique(); $table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable(); $table->timestamp('email_verified_at')->nullable();
$table->string('password'); $table->string('password');
$table->longText('about')->nullable()->default(null);
$table->foreignIdFor(UserRole::class) $table->foreignIdFor(UserRole::class)
->nullable() ->nullable()
->default(null) ->default(null)

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_follows', function (Blueprint $table) {
$table->id();
$table->foreignId('follower_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('following_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_follows');
}
};

158
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.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
"integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -570,9 +570,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
"integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -584,9 +584,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
"integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -598,9 +598,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
"integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -612,9 +612,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
"integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -626,9 +626,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
"integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -640,9 +640,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
"integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -654,9 +654,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
"integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -668,9 +668,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
"integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -682,9 +682,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
"integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -696,9 +696,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
"integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -710,9 +710,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
"integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -724,9 +724,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
"integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -738,9 +738,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
"integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -752,9 +752,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
"integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -766,9 +766,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
"integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1013,9 +1013,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001660", "version": "1.0.30001663",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001663.tgz",
"integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", "integrity": "sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA==",
"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.5.23", "version": "1.5.27",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.23.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.27.tgz",
"integrity": "sha512-mBhODedOXg4v5QWwl21DjM5amzjmI1zw9EPrPK/5Wx7C8jt33bpZNrC7OhHUG3pxRtbLpr3W2dXT+Ph1SsfRZA==", "integrity": "sha512-o37j1vZqCoEgBuWWXLHQgTN/KDKe7zwpiY5CPeq2RvUqOyJw9xnrULzZAEVQ5p4h+zjMk7hgtOoPdnLxr7m/jw==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -2176,9 +2176,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
"integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2192,22 +2192,22 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.21.3", "@rollup/rollup-android-arm-eabi": "4.22.4",
"@rollup/rollup-android-arm64": "4.21.3", "@rollup/rollup-android-arm64": "4.22.4",
"@rollup/rollup-darwin-arm64": "4.21.3", "@rollup/rollup-darwin-arm64": "4.22.4",
"@rollup/rollup-darwin-x64": "4.21.3", "@rollup/rollup-darwin-x64": "4.22.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.21.3", "@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
"@rollup/rollup-linux-arm-musleabihf": "4.21.3", "@rollup/rollup-linux-arm-musleabihf": "4.22.4",
"@rollup/rollup-linux-arm64-gnu": "4.21.3", "@rollup/rollup-linux-arm64-gnu": "4.22.4",
"@rollup/rollup-linux-arm64-musl": "4.21.3", "@rollup/rollup-linux-arm64-musl": "4.22.4",
"@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
"@rollup/rollup-linux-riscv64-gnu": "4.21.3", "@rollup/rollup-linux-riscv64-gnu": "4.22.4",
"@rollup/rollup-linux-s390x-gnu": "4.21.3", "@rollup/rollup-linux-s390x-gnu": "4.22.4",
"@rollup/rollup-linux-x64-gnu": "4.21.3", "@rollup/rollup-linux-x64-gnu": "4.22.4",
"@rollup/rollup-linux-x64-musl": "4.21.3", "@rollup/rollup-linux-x64-musl": "4.22.4",
"@rollup/rollup-win32-arm64-msvc": "4.21.3", "@rollup/rollup-win32-arm64-msvc": "4.22.4",
"@rollup/rollup-win32-ia32-msvc": "4.21.3", "@rollup/rollup-win32-ia32-msvc": "4.22.4",
"@rollup/rollup-win32-x64-msvc": "4.21.3", "@rollup/rollup-win32-x64-msvc": "4.22.4",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -2428,9 +2428,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.11", "version": "3.4.13",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz",
"integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2574,9 +2574,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.5", "version": "5.4.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz",
"integrity": "sha512-pXqR0qtb2bTwLkev4SE3r4abCNioP3GkjvIDLlzziPpXtHgiJIjuKl+1GN6ESOT3wMjG3JTeARopj2SwYaHTOA==", "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

File diff suppressed because one or more lines are too long

View File

@ -13,7 +13,7 @@ cropperjs/dist/cropper.min.css:
filepond/dist/filepond.min.css: filepond/dist/filepond.min.css:
(*! (*!
* FilePond 4.31.1 * FilePond 4.31.3
* Licensed under MIT, https://opensource.org/licenses/MIT/ * Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details. * Please visit https://pqina.nl/filepond/ for details.
*) *)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -108,3 +108,66 @@ main a:not(.mod-list-component):not(.tab):not([role="menuitem"]) {
transform-origin: 100% 100%; transform-origin: 100% 100%;
background-color: #0e7490; background-color: #0e7490;
} }
.rainbow {
height: 100%;
width: 100%;
left: 0;
right: 0;
top: 0;
bottom: 0;
position: absolute;
background: linear-gradient(124deg, #ff2400, #e81d1d, #e8b71d, #e3e81d, #1de840, #1ddde8, #2b1de8, #dd00f3, #dd00f3);
background-size: 1800% 1800%;
-webkit-animation: rainbow 18s ease infinite;
-o-animation: rainbow 18s ease infinite;
animation: rainbow 18s ease infinite;
}
@-webkit-keyframes rainbow {
0% {
background-position: 0 82%
}
50% {
background-position: 100% 19%
}
100% {
background-position: 0 82%
}
}
@-moz-keyframes rainbow {
0% {
background-position: 0 82%
}
50% {
background-position: 100% 19%
}
100% {
background-position: 0 82%
}
}
@-o-keyframes rainbow {
0% {
background-position: 0 82%
}
50% {
background-position: 100% 19%
}
100% {
background-position: 0 82%
}
}
@keyframes rainbow {
0% {
background-position: 0 82%
}
50% {
background-position: 100% 19%
}
100% {
background-position: 0 82%
}
}

View File

@ -1,10 +1,9 @@
<a href="/mod/{{ $result['id'] }}/{{ $result['slug'] }}" class="{{ $linkClass }}" role="listitem" tabindex="0" class="flex flex-col"> <a href="/mod/{{ $result['id'] }}/{{ $result['slug'] }}" class="{{ $linkClass }}" role="listitem" tabindex="0" class="flex flex-col">
@if(empty($result->thumbnail)) @empty ($result['thumbnail'])
<img src="https://placehold.co/450x450/EEE/31343C?font=source-sans-pro&text={{ $result['name'] }}" alt="{{ $result['name'] }}" class="block dark:hidden h-6 w-6 self-center border border-gray-200 group-hover/global-search-link:border-gray-400"> <img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ urlencode($result['name']) }}" alt="{{ $result['name'] }}" class="h-6 w-6 self-center border border-gray-700 group-hover/global-search-link:border-gray-600">
<img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ $result['name'] }}" alt="{{ $result['name'] }}" class="hidden dark:block h-6 w-6 self-center border border-gray-700 group-hover/global-search-link:border-gray-600">
@else @else
<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 @endempty
<p class="flex-grow">{{ $result['name'] }}</p> <p class="flex-grow">{{ $result['name'] }}</p>
<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"> <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['latestVersion'] }} {{ $result['latestVersion'] }}

View File

@ -1,3 +1,3 @@
<a href="#/{{ Str::slug($result['name']) }}" class="{{ $linkClass }}"> <a href="/user/{{ $result['id'] }}/{{ Str::slug($result['name']) }}" class="{{ $linkClass }}">
<p>{{ $result['name'] }}</p> <p>{{ $result['name'] }}</p>
</a> </a>

View File

@ -1,40 +0,0 @@
<div id="search-results"
x-cloak
x-show="showDropdown && query.length"
x-transition
aria-live="polite"
class="{{ $showDropdown ? 'block' : 'hidden' }} absolute z-10 top-11 w-full mx-auto max-w-2xl transform overflow-hidden rounded-md bg-white dark:bg-gray-900 shadow-2xl border border-gray-300 dark:border-gray-700 transition-all"
>
@if ($showDropdown)
<h2 class="sr-only">{{ __('Search Results') }}</h2>
<div class="max-h-96 scroll-py-2 overflow-y-auto" role="list">
@foreach($results['data'] as $typeName => $typeResults)
@if($typeResults->count())
<h4 class="flex flex-row gap-1.5 py-2.5 px-4 text-[0.6875rem] font-semibold uppercase text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-950">
<span>{{ Str::plural($typeName) }}</span>
<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="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</h4>
<div class="divide-y divide-dashed divide-gray-200 dark:divide-gray-800">
@foreach($typeResults as $result)
@component('components.global-search-result-' . Str::lower($typeName), [
'result' => $result,
'linkClass' => 'group/global-search-link flex flex-row gap-3 py-1.5 px-4 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors duration-200 ease-in-out',
])
@endcomponent
@endforeach
</div>
@endif
@endforeach
</div>
@endif
@if($noResults)
<div class="px-6 py-14 text-center sm:px-14">
<svg class="mx-auto h-6 w-6 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Zm3.75 11.625a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
<p class="mt-4 text-sm text-gray-900 dark:text-gray-200">{{ __("We couldn't find any content with that query. Please try again.") }}</p>
</div>
@endif
</div>

View File

@ -7,20 +7,21 @@
<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="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">
@empty($mod->thumbnail) @if ($mod->thumbnail)
<img src="https://placehold.co/450x450/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
<img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@else
<img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200"> <img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@endempty @else
<img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ urlencode($mod->name) }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@endif
</div> </div>
<div class="flex flex-col w-full justify-between p-5"> <div class="flex flex-col w-full justify-between p-5">
<div class="pb-3"> <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 {{ $version->latestSptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> @if ($version?->latestSptVersion)
{{ $version->latestSptVersion->version_formatted }} <span class="badge-version {{ $version->latestSptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
</span> {{ $version->latestSptVersion->version_formatted }}
</span>
@endif
</div> </div>
<p class="text-sm italic text-slate-600 dark:text-gray-200"> <p class="text-sm italic text-slate-600 dark:text-gray-200">
{{ __('By :authors', ['authors' => $mod->users->pluck('name')->implode(', ')]) }} {{ __('By :authors', ['authors' => $mod->users->pluck('name')->implode(', ')]) }}
@ -31,7 +32,7 @@
</div> </div>
<div class="text-slate-700 dark:text-gray-300 text-sm"> <div class="text-slate-700 dark:text-gray-300 text-sm">
<div class="flex items-end w-full text-sm"> <div class="flex items-end w-full text-sm">
@if ($mod->updated_at || $mod->created_at) @if (($mod->updated_at || $mod->created_at) && $version)
<div class="flex items-end w-full"> <div class="flex items-end w-full">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">

View File

@ -0,0 +1,11 @@
@props(['name'])
<div class="flex flex-col justify-stretch sm:flex-row">
<button {{ $attributes->whereStartsWith('wire:') }} {{ $attributes->merge([
'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',
]) }}>
{{ $icon }}
<span>{{ $slot }}</span>
</button>
</div>

View File

@ -0,0 +1,17 @@
@props(['name'])
<button
@click="selectedTab = '{{ Str::lower($name) }}'"
:aria-selected="selectedTab == '{{ Str::lower($name) }}'"
:class="{
'font-extrabold': selectedTab == '{{ Str::lower($name) }}',
'font-light': selectedTab != '{{ Str::lower($name) }}',
'tab group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm text-gray-800 dark:text-white bg-cyan-500 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 focus:z-10 last:rounded-r-xl first:rounded-l-xl flex items-center justify-center gap-1': true
}"
{{ $attributes }}
>
{{ __($name) }}
<svg x-cloak x-show="selectedTab == '{{ Str::lower($name) }}'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="h-3 h-3">
<path fill-rule="evenodd" d="M7.47 12.78a.75.75 0 0 0 1.06 0l3.25-3.25a.75.75 0 0 0-1.06-1.06L8 11.19 5.28 8.47a.75.75 0 0 0-1.06 1.06l3.25 3.25ZM4.22 4.53l3.25 3.25a.75.75 0 0 0 1.06 0l3.25-3.25a.75.75 0 0 0-1.06-1.06L8 6.19 5.28 3.47a.75.75 0 0 0-1.06 1.06Z" clip-rule="evenodd" />
</svg>
</button>

View File

@ -1,5 +1,5 @@
@props(['datetime']) @props(['datetime'])
<time datetime="{{ $datetime->format('c') }}"> <time datetime="{{ $datetime->format('c') }}" title="{{ $datetime->format('l jS \\of F Y g:i:s A e') }}">
{{ Carbon::dynamicFormat($datetime) }} {{ Carbon::dynamicFormat($datetime) }}
</time> </time>

View File

@ -0,0 +1,8 @@
@props(['profileUserId'])
<div class="flex w-full max-w-sm">
<livewire:user.follow-card relationship="followers" :profile-user-id="$profileUserId" />
</div>
<div class="flex w-full max-w-sm">
<livewire:user.follow-card relationship="following" :profile-user-id="$profileUserId" />
</div>

View File

@ -1,3 +1,4 @@
<div class="items-center justify-between gap-x-6 text-gray-200 bg-gray-900 dark:text-gray-900 dark:bg-gray-100 px-6 py-2.5 sm:pr-3.5 lg:pl-8"> <div class="relative items-center justify-between gap-x-6 text-gray-900 px-6 py-2.5 sm:pr-3.5 lg:pl-8">
<p class="text-center text-sm leading-6">Notice: The Forge is currently under <em class="font-bold">heavy</em> construction. Expect nothing to work. Data is reset every hour.</p> <div class="rainbow z-0"></div>
<p class="relative z-10 text-center text-sm leading-6 font-bold" style="text-shadow: 0 0 15px white;">{!! __('The Forge is currently under <em>heavy</em> construction. Expect nothing to work. Data is reset every hour.') !!}</p>
</div> </div>

View File

@ -0,0 +1 @@
<script defer src="https://umami.refringe.com/script.js" data-website-id="3cf2977d-3d62-48ad-b6be-5a2d2e8f1d84"></script>

View File

@ -8,11 +8,15 @@
<title>{{ config('app.name', 'The Forge') }}</title> <title>{{ config('app.name', 'The Forge') }}</title>
<link rel="icon" href="data:image/x-icon;base64,AA"> <link rel="icon" href="data:image/x-icon;base64,AA">
<link href="//fonts.bunny.net" rel="preconnect"> <link href="//fonts.bunny.net" rel="preconnect">
<link href="//fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet"> <link href="//fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet">
<link href="{{ config('app.asset_url') }}" rel="dns-prefetch"> <link href="{{ config('app.asset_url') }}" rel="dns-prefetch">
@livewireStyles
@vite(['resources/css/app.css'])
<script> <script>
// Immediately set the theme to prevent a flash of the default theme when another is set. // Immediately set the theme to prevent a flash of the default theme when another is set.
// Must be located inline, in the head, and before any CSS is loaded. // Must be located inline, in the head, and before any CSS is loaded.
@ -25,35 +29,33 @@
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme);
})(); })();
</script> </script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head> </head>
<body class="font-sans antialiased"> <body class="font-sans antialiased">
<x-warning/>
<x-warning/> <x-banner/>
<x-banner/> <div class="min-h-screen bg-gray-100 dark:bg-gray-800">
@livewire('navigation-menu')
<div class="min-h-screen bg-gray-100 dark:bg-gray-800"> @if (isset($header))
@livewire('navigation-menu') <header class="bg-gray-50 dark:bg-gray-900 shadow dark:shadow-gray-950">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endif
@if (isset($header)) <main class="pb-6 sm:py-12">
<header class="bg-gray-50 dark:bg-gray-900 shadow dark:shadow-gray-950"> {{ $slot }}
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> </main>
{{ $header }} </div>
</div>
</header>
@endif
<main class="pb-6 sm:py-12"> <x-footer/>
{{ $slot }}
</main>
</div>
<x-footer/> @vite(['resources/js/app.js'])
@stack('modals')
@stack('modals') @livewireScriptConfig
@livewireScriptConfig @include('includes.analytics')
</body> </body>
</html> </html>

View File

@ -8,11 +8,15 @@
<title>{{ config('app.name', 'The Forge') }}</title> <title>{{ config('app.name', 'The Forge') }}</title>
<link rel="icon" href="data:image/x-icon;base64,AA"> <link rel="icon" href="data:image/x-icon;base64,AA">
<link href="//fonts.bunny.net" rel="preconnect"> <link href="//fonts.bunny.net" rel="preconnect">
<link href="//fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet"> <link href="//fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet">
<link href="{{ config('app.asset_url') }}" rel="dns-prefetch"> <link href="{{ config('app.asset_url') }}" rel="dns-prefetch">
@vite(['resources/css/app.css'])
@livewireStyles
<script> <script>
// Immediately set the theme to prevent a flash of the default theme when another is set. // Immediately set the theme to prevent a flash of the default theme when another is set.
// Must be located inline, in the head, and before any CSS is loaded. // Must be located inline, in the head, and before any CSS is loaded.
@ -25,16 +29,14 @@
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme);
})(); })();
</script> </script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head> </head>
<body> <body>
<div class="font-sans text-gray-900 antialiased">
{{ $slot }}
</div>
<div class="font-sans text-gray-900 antialiased"> @vite(['resources/js/app.js']);
{{ $slot }} @livewireScriptConfig
</div> @include('includes.analytics')
@livewireScriptConfig
</body> </body>
</html> </html>

View File

@ -1,15 +1,18 @@
<div x-data="{ query: $wire.entangle('query'), showDropdown: $wire.entangle('showDropdown'), noResults: $wire.entangle('noResults') }" <div
@keydown.esc.window="showDropdown = false" x-data="{ query: $wire.entangle('query'), count: $wire.entangle('count'), show: false }"
class="flex flex-1 justify-center px-2 lg:ml-6 lg:justify-end" class="flex flex-1 justify-center px-2 lg:ml-6 lg:justify-end"
> >
<div class="w-full max-w-lg lg:max-w-md" <div class="w-full max-w-lg lg:max-w-md">
x-trap="showDropdown && query.length"
@click.away="showDropdown = false"
@keydown.down.prevent="$focus.wrap().next()"
@keydown.up.prevent="$focus.wrap().previous()"
>
<label for="search" class="sr-only">{{ __('Search') }}</label> <label for="search" class="sr-only">{{ __('Search') }}</label>
<search class="relative group" role="search"> <search
x-trap.noreturn="query.length && show"
@click.away="show = false"
@keydown.down.prevent="$focus.wrap().next()"
@keydown.up.prevent="$focus.wrap().previous()"
@keydown.escape.window="$wire.query = '';"
class="relative group"
role="search"
>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
@ -18,15 +21,50 @@
<input id="global-search" <input id="global-search"
type="search" type="search"
wire:model.live="query" wire:model.live="query"
@focus="showDropdown = true" @focus="show = true"
@keydown.escape.window="$wire.query = ''; showDropdown = false; $wire.$refresh()"
placeholder="{{ __('Search') }}" placeholder="{{ __('Search') }}"
aria-controls="search-results" aria-controls="search-results"
:aria-expanded="showDropdown"
aria-label="{{ __('Search') }}" aria-label="{{ __('Search') }}"
class="block w-full rounded-md border-0 bg-white dark:bg-gray-700 py-1.5 pl-10 pr-3 text-gray-900 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 dark:focus:bg-gray-200 dark:focus:text-black dark:focus:ring-0 sm:text-sm sm:leading-6" class="block w-full rounded-md border-0 bg-white dark:bg-gray-700 py-1.5 pl-10 pr-3 text-gray-900 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 placeholder:text-gray-400 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 dark:focus:bg-gray-200 dark:focus:text-black dark:focus:ring-0 sm:text-sm sm:leading-6"
/> />
<x-global-search-results :showDropdown="$showDropdown" :noResults="$noResults" :results="$results" /> <div id="search-results"
x-cloak
x-transition
x-show="query.length && show"
aria-live="polite"
class="absolute z-10 top-11 w-full mx-auto max-w-2xl transform overflow-hidden rounded-md bg-white dark:bg-gray-900 shadow-2xl border border-gray-300 dark:border-gray-700 transition-all"
>
<div x-cloak x-show="count">
<h2 class="sr-only select-none">{{ __('Search Results') }}</h2>
<div class="max-h-96 scroll-py-2 overflow-y-auto" role="list" tabindex="-1">
@foreach($result as $type => $results)
@if ($results->count())
<h4 class="flex flex-row gap-1.5 py-2.5 px-4 text-[0.6875rem] font-semibold uppercase text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-950 select-none">
<span>{{ Str::plural($type) }}</span>
<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="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</h4>
<div class="divide-y divide-dashed divide-gray-200 dark:divide-gray-800">
@foreach($results as $hit)
@component('components.global-search-result-' . Str::lower($type), [
'result' => $hit,
'linkClass' => 'group/global-search-link flex flex-row gap-3 py-1.5 px-4 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors duration-200 ease-in-out',
])
@endcomponent
@endforeach
</div>
@endif
@endforeach
</div>
</div>
<div x-cloak x-show="count < 1" class="px-6 py-14 text-center sm:px-14">
<svg class="mx-auto h-6 w-6 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Zm3.75 11.625a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
<p class="mt-4 text-sm text-gray-900 dark:text-gray-200">{{ __("We couldn't find any content with that query. Please try again.") }}</p>
</div>
</div>
</search> </search>
</div> </div>
</div> </div>

View File

@ -0,0 +1,25 @@
@props(['isFollowing'])
<form>
@if ($isFollowing)
{{-- Following button --}}
<x-profile-button wire:click="unfollow">
<x-slot:icon>
<svg class="-ml-0.5 mr-1.5 h-5 w-5 text-red-400 dark:text-red-600" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="m12.82 5.58-.82.822-.824-.824a5.375 5.375 0 1 0-7.601 7.602l7.895 7.895a.75.75 0 0 0 1.06 0l7.902-7.897a5.376 5.376 0 0 0-.001-7.599 5.38 5.38 0 0 0-7.611 0Z" />
</svg>
</x-slot:icon>
{{ __('Following') }}
</x-profile-button>
@else
{{-- Follow button --}}
<x-profile-button wire:click="follow">
<x-slot:icon>
<svg class="-ml-0.5 mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="m12.82 5.58-.82.822-.824-.824a5.375 5.375 0 1 0-7.601 7.602l7.895 7.895a.75.75 0 0 0 1.06 0l7.902-7.897a5.376 5.376 0 0 0-.001-7.599 5.38 5.38 0 0 0-7.611 0Zm6.548 6.54L12 19.485 4.635 12.12a3.875 3.875 0 1 1 5.48-5.48l1.358 1.357a.75.75 0 0 0 1.073-.012L13.88 6.64a3.88 3.88 0 0 1 5.487 5.48Z"/>
</svg>
</x-slot:icon>
{{ __('Follow') }}
</x-profile-button>
@endif
</form>

View File

@ -0,0 +1,81 @@
<div class="w-full text-gray-600 bg-white shadow-md dark:shadow-gray-950 drop-shadow-xl dark:text-gray-200 dark:bg-gray-900 rounded-xl py-4">
<div class="flex justify-center items-center">
<h2 class="text-2xl">{{ $title }}</h2>
</div>
@if (! $followUsers->count())
<div class="flex justify-center text-sm pt-2">
{{ $emptyMessage }}
</div>
@else
<div class="flex ml-6 py-2 justify-center items-center">
@foreach ($followUsers->slice(0, $limit) as $user)
{{-- User Badge --}}
<div class="relative group">
<a href="{{ $user->profileUrl() }}" class="rounded-full -ml-6 z-20 bg-[#ebf4ff] h-16 w-16 flex justify-center items-center border">
<img src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" class="h-full w-full rounded-full" />
</a>
<div class="absolute bottom-full -ml-3 left-1/2 transform -translate-x-1/2 mb-2 w-max px-2 py-1 text-sm text-white bg-gray-700 rounded shadow-lg opacity-0 group-hover:opacity-100">
{{ $user->name }}
</div>
</div>
@endforeach
@if ($followUsers->count() > $limit)
{{-- Count Badge --}}
<div class="relative group">
<button wire:click="toggleFollowDialog" class="rounded-full -ml-6 z-20 bg-cyan-500 dark:bg-cyan-700 h-16 w-16 flex justify-center items-center border text-white">+{{ $followUsers->count() - $limit }}</button>
<div class="absolute bottom-full -ml-3 left-1/2 transform -translate-x-1/2 mb-2 w-max px-2 py-1 text-sm text-white bg-gray-700 rounded shadow-lg opacity-0 group-hover:opacity-100">
{{ $followUsers->count() }} total
</div>
</div>
@endif
</div>
@endif
@if ($followUsers->count() > $limit)
{{-- View All Button --}}
<div class="flex justify-center items-center">
<button wire:click="toggleFollowDialog" class="hover:underline active:underline">View All</button>
</div>
@endif
{{-- View All Dialog --}}
@push('modals')
<x-dialog-modal wire:model="showFollowDialog">
<x-slot name="title">
<h2 class="text-2xl">{{ __($dialogTitle, ['name' => $profileUser->name]) }}</h2>
</x-slot>
<x-slot name="content">
<div class="h-96 overflow-y-auto">
@foreach ($followUsers as $user)
<div class="flex group/item dark:hover:bg-gray-950 items-center p-2 pr-3 rounded-md">
<a href="{{ $user->profileUrl() }}" class="flex-shrink-0 w-16 h-16 items-center">
<img src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" class="block w-full h-full rounded-full" />
</a>
<div class="flex flex-col w-full pl-3">
<a href="{{ $user->profileUrl() }}" class="text-2xl group-hover/item:underline group-hover/item:text-white">{{ $user->name }}</a>
<span>
{{ __("Member Since") }}
<x-time :datetime="$user->created_at" />
</span>
</div>
@if (auth()->check() && auth()->user()->id !== $user->id)
<livewire:user.follow-buttons :profile-user-id="$user->id" :is-following="$user->follows" />
@endif
</div>
@endforeach
</div>
</x-slot>
<x-slot name="footer">
<x-button x-on:click="show = false">
{{ __('Close') }}
</x-button>
</x-slot>
</x-dialog-modal>
@endpush
</div>

View File

@ -16,9 +16,8 @@
@endif @endif
<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">
@if (empty($mod->thumbnail)) @if ($mod->thumbnail)
<img src="https://placehold.co/144x144/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden w-36 rounded-lg"> <img src="https://placehold.co/144x144/31343C/EEE?font=source-sans-pro&text={{ urlencode($mod->name) }}" alt="{{ $mod->name }}" class="w-36 rounded-lg">
<img src="https://placehold.co/144x144/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block w-36 rounded-lg">
@else @else
<img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="w-36 rounded-lg"> <img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="w-36 rounded-lg">
@endif @endif
@ -39,11 +38,19 @@
@endforeach @endforeach
</p> </p>
<p title="{{ __('Exactly') }} {{ $mod->downloads }}">{{ Number::downloads($mod->downloads) }} {{ __('Downloads') }}</p> <p title="{{ __('Exactly') }} {{ $mod->downloads }}">{{ Number::downloads($mod->downloads) }} {{ __('Downloads') }}</p>
<p class="mt-2"> @if ($mod->latestVersion->latestSptVersion)
<span class="badge-version {{ $mod->latestVersion->latestSptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> <p class="mt-2">
{{ $mod->latestVersion->latestSptVersion->version_formatted }} {{ __('Compatible') }} <span class="badge-version {{ $mod->latestVersion->latestSptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
</span> {{ $mod->latestVersion->latestSptVersion->version_formatted }} {{ __('Compatible') }}
</p> </span>
</p>
@else
<p class="mt-2">
<span class="badge-version bg-gray-100 text-gray-600 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ __('Unknown SPT Version') }}
</span>
</p>
@endif
</div> </div>
</div> </div>
@ -74,18 +81,9 @@
{{-- Desktop Tabs --}} {{-- 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">
<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"> <x-tab-button name="Description" />
<span>{{ __('Description') }}</span> <x-tab-button name="Versions" />
<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> <x-tab-button name="Comments" />
</button>
<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>{{ __('Versions') }}</span>
<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>
</button>
<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>{{ __('Comments') }}</span>
<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>
@ -108,9 +106,15 @@
<p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p> <p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="badge-version {{ $version->latestSptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap"> @if ($version->latestSptVersion)
{{ $version->latestSptVersion->version_formatted }} <span class="badge-version {{ $version->latestSptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
</span> {{ $version->latestSptVersion->version_formatted }}
</span>
@else
<span class="badge-version bg-gray-100 text-gray-600 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ __('Unknown SPT Version') }}
</span>
@endif
<a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a> <a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a>
</div> </div>
<div class="flex items-center justify-between text-gray-600 dark:text-gray-400"> <div class="flex items-center justify-between text-gray-600 dark:text-gray-400">
@ -138,7 +142,7 @@
{{-- Comments --}} {{-- 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"> <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> <p>Not quite yet...</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -87,14 +87,12 @@
</svg> </svg>
{{ __('Edit Profile') }} {{ __('Edit Profile') }}
</a> </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">
<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">
<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" />
<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>
</svg> {{ __('API Tokens') }}
{{ __('API Tokens') }} </a>
</a>
@endif
</div> </div>
@if (auth()->user()->isAdmin()) @if (auth()->user()->isAdmin())
<div class="flex flex-col py-1.5"> <div class="flex flex-col py-1.5">
@ -147,9 +145,7 @@
{{-- Mobile Menu --}} {{-- Mobile Menu --}}
<div class="lg:hidden" x-show="mobileMenuOpen" id="mobile-menu"> <div class="lg:hidden" x-show="mobileMenuOpen" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2"> <div class="space-y-1 px-2 pb-3 pt-2">
@auth <x-responsive-nav-link href="{{ route('mods') }}" :active="request()->routeIs('mods')">{{ __('Mods') }}</x-responsive-nav-link>
<x-responsive-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">{{ __('Dashboard') }}</x-responsive-nav-link>
@endauth
{{-- Additional menu links here --}} {{-- Additional menu links here --}}
</div> </div>
<div class="border-t border-gray-300 dark:border-gray-700 pb-3 pt-4"> <div class="border-t border-gray-300 dark:border-gray-700 pb-3 pt-4">
@ -178,8 +174,18 @@
</div> </div>
<div class="mt-3 space-y-1 px-2"> <div class="mt-3 space-y-1 px-2">
@auth @auth
<x-responsive-nav-link href="{{ route('profile.show') }}" :active="request()->routeIs('profile.show')">{{ __('Profile') }}</x-responsive-nav-link> <x-responsive-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">{{ __('Dashboard') }}</x-responsive-nav-link>
<x-responsive-nav-link href="{{ auth()->user()->profileUrl() }}" :active="request()->routeIs('user.show')">{{ __('Profile') }}</x-responsive-nav-link>
<x-responsive-nav-link href="{{ route('profile.show') }}" :active="request()->routeIs('profile.show')">{{ __('Edit Profile') }}</x-responsive-nav-link>
<x-responsive-nav-link href="{{ route('api-tokens.index') }}" :active="request()->routeIs('api-tokens.index')">{{ __('API Token') }}</x-responsive-nav-link> <x-responsive-nav-link href="{{ route('api-tokens.index') }}" :active="request()->routeIs('api-tokens.index')">{{ __('API Token') }}</x-responsive-nav-link>
@if (auth()->user()->isAdmin())
<x-responsive-nav-link href="/admin" :active="request()->routeIs('api-tokens.index')">{{ __('Admin Panel') }}</x-responsive-nav-link>
<x-responsive-nav-link href="/pulse" :active="request()->routeIs('api-tokens.index')">{{ __('Pulse Stats') }}</x-responsive-nav-link>
<x-responsive-nav-link href="/horizon" :active="request()->routeIs('api-tokens.index')">{{ __('Horizon Queue') }}</x-responsive-nav-link>
@endif
<form method="POST" action="{{ route('logout') }}" x-data> <form method="POST" action="{{ route('logout') }}" x-data>
@csrf @csrf
<x-responsive-nav-link href="{{ route('logout') }}" @click.prevent="$root.submit();" :active="request()->routeIs('logout')">{{ __('Log Out') }}</x-responsive-nav-link> <x-responsive-nav-link href="{{ route('logout') }}" @click.prevent="$root.submit();" :active="request()->routeIs('logout')">{{ __('Log Out') }}</x-responsive-nav-link>

View File

@ -1,35 +1,113 @@
<x-app-layout> <x-app-layout>
<div class="sm:-mt-12 mb-6 dark:bg-gray-800 dark:text-gray-100">
<div class="sm:-mt-12 dark:bg-gray-800 dark:text-gray-100">
<div> <div>
<img class="h-32 w-full object-cover lg:h-48" src="{{ $user->cover_photo_url }}" alt="{{ $user->name }}"> <img src="{{ $user->cover_photo_url }}" alt="{{ __(':name\'s Cover Photo', ['name' => $user->name]) }}" class="h-32 w-full object-cover lg:h-48" />
</div> </div>
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl 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="sm:-mt-12 sm:flex sm:items-end sm:space-x-5">
<div class="flex"> <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 }}" /> <img src="{{ $user->profile_photo_url }}" alt="{{ __(':name\'s Profile Picture', ['name' => $user->name]) }}" class="h-24 w-24 rounded-full ring-4 ring-white dark:ring-gray-800 sm:h-32 sm:w-32" />
</div> </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-8 sm:flex sm:min-w-0 sm:flex-1 sm:items-center sm:justify-end sm:space-x-4">
<div class="mt-6 min-w-0 flex-1 sm:hidden md:block"> <div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $user->name }}</h1> <h1 class="truncate text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $user->name }}</h1>
<div>
{{ __('Member since') }}
<x-time :datetime="$user->created_at" />
</div>
</div> </div>
{{-- @if (auth()->check() && auth()->user()->id !== $user->id)
<div class="mt-6 flex flex-col justify-stretch space-y-3 sm:flex-row sm:space-x-4 sm:space-y-0"> {{-- Follow Buttons --}}
<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"> <livewire:user.follow-buttons :profile-user-id="$user->id" :is-following="auth()->user()->isFollowing($user->id)" />
<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" /> {{-- Message button --}}
<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" /> <x-profile-button>
</svg> <x-slot:icon>
<span>Message</span> <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">
</button> <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" />
</div> <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>
</x-slot:icon>
{{ __('Message') }}
</x-profile-button>
@endif
</div> </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>
</div> </div>
<div class="mx-auto max-w-7xl px-2 sm:px-4 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
{{-- Mobile Follows --}}
<div class="lg:hidden flex flex-col justify-top items-center">
<x-user-follow-cards :profile-user-id="$user->id" />
</div>
{{-- Left Column --}}
<div x-data="{ selectedTab: window.location.hash ? window.location.hash.substring(1) : 'wall' }" x-init="$watch('selectedTab', (tab) => {window.location.hash = tab})" class="lg:col-span-3 flex flex-col gap-6">
{{-- About --}}
@if ($user->about)
<div class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 text-gray-800 dark:text-gray-200 drop-shadow-2xl">
{{ $user->about }}
</div>
@endif
{{-- Tabs --}}
<div>
{{-- Mobile Dropdown --}}
<div class="sm:hidden">
<label for="tabs" class="sr-only">{{ __('Select a tab') }}</label>
<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">
<option value="wall">{{ __('Wall') }}</option>
<option value="mods">{{ __('Mods') }}</option>
<option value="activity">{{ __('Activity') }}</option>
</select>
</div>
{{-- Desktop Tabs --}}
<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">
<x-tab-button name="{{ __('Wall') }}" />
<x-tab-button name="{{ __('Mods') }}" />
<x-tab-button name="{{ __('Activity') }}" />
</nav>
</div>
</div>
{{-- Wall --}}
<div x-show="selectedTab === 'wall'" class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 text-gray-800 dark:text-gray-200 drop-shadow-2xl">
<p>Not quite yet...</p>
</div>
{{-- Mods --}}
<div x-show="selectedTab === 'mods'" class="">
@if($mods)
<div class="mb-4">
{{ $mods->links() }}
</div>
<div class="my-4 grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach($mods as $mod)
<x-mod-card :mod="$mod" :version="$mod->latestVersion" />
@endforeach
</div>
<div class="mt-5">
{{ $mods->links() }}
</div>
@else
<p>{{ __('This user has not yet published any mods.') }}</p>
@endif
</div>
{{-- Activity --}}
<div x-show="selectedTab === 'activity'" class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 text-gray-800 dark:text-gray-200 drop-shadow-2xl">
<p>Not quite yet...</p>
</div>
</div>
{{-- Desktop Follows --}}
<div class="max-lg:hidden flex flex-col justify-top items-center gap-6">
<x-user-follow-cards :profile-user-id="$user->id" />
</div>
</div>
</div>
</x-app-layout> </x-app-layout>

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@ import defaultTheme from "tailwindcss/defaultTheme";
import forms from "@tailwindcss/forms"; import forms from "@tailwindcss/forms";
import typography from "@tailwindcss/typography"; import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
darkMode: "selector", darkMode: "selector",

View File

@ -184,22 +184,26 @@ it('handles the case where a dependent mod has no versions available', function
}); });
it('handles a large number of versions efficiently', function () { it('handles a large number of versions efficiently', function () {
$startTime = microtime(true);
$versionCount = 100; $versionCount = 100;
$modVersion = ModVersion::factory()->create();
$dependentMod = Mod::factory()->create(); $dependentMod = Mod::factory()->create();
for ($i = 0; $i < $versionCount; $i++) { for ($i = 0; $i < $versionCount; $i++) {
ModVersion::factory()->recycle($dependentMod)->create(['version' => "1.0.$i"]); ModVersion::factory()->recycle($dependentMod)->create(['version' => "1.0.$i"]);
} }
// Create a dependency with a broad constraint // Create a mod and mod version, and then create a dependency for all versions of the dependent mod.
$modVersion = ModVersion::factory()->create();
ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([ ModDependency::factory()->recycle([$modVersion, $dependentMod])->create([
'constraint' => '>=1.0.0', 'constraint' => '>=1.0.0',
]); ]);
// Verify that all versions were resolved $executionTime = microtime(true) - $startTime;
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->count())->toBe($versionCount);
}); // Verify that all versions were resolved and that the execution time is reasonable.
expect(ModResolvedDependency::where('mod_version_id', $modVersion->id)->count())->toBe($versionCount)
->and($executionTime)->toBeLessThan(5); // Arbitrarily picked out of my ass.
})->skip('This is a performance test and is skipped by default. It will probably fail.');
it('calls DependencyVersionService when a Mod is updated', function () { it('calls DependencyVersionService when a Mod is updated', function () {
$mod = Mod::factory()->create(); $mod = Mod::factory()->create();

View File

@ -0,0 +1,99 @@
<?php
use App\Models\User;
test('confirm a user cannot follow themself', function () {
$user = User::factory()->create();
$user->follow($user);
$this->assertEmpty($user->follwers);
$this->assertEmpty($user->following);
});
test('confirm a user can follow and unfollow another user', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1->follow($user2);
$this->assertTrue($user1->isFollowing($user2));
$user1->unfollow($user2);
$this->assertFalse($user1->isFollowing($user2));
});
test('confirm following a user cannot be done twice', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1->follow($user2);
$user1->follow($user2);
$this->assertCount(1, $user1->following);
$this->assertCount(1, $user2->followers);
});
test('confirm unfollowing a user that isnt being followed doesnt throw', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1->unfollow($user2);
$this->assertEmpty($user1->following);
$this->assertEmpty($user2->followers);
});
test('confirm unfollowing random number doesnt perform detach all', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user3 = User::factory()->create();
$user1->follow($user2);
$user1->follow($user3);
$this->assertTrue($user1->isFollowing($user2));
$this->assertTrue($user1->isFollowing($user3));
$this->assertCount(2, $user1->following);
$this->assertCount(1, $user2->followers);
$this->assertCount(1, $user3->followers);
$user1->unfollow(111112222233333);
$this->assertTrue($user1->isFollowing($user2));
$this->assertTrue($user1->isFollowing($user3));
});
test('confirm null follow input fails', function () {
$this->expectException(TypeError::class);
$user = User::factory()->create();
$user->follow(null);
});
test('confirm empty follow input fails', function () {
$this->expectException(ArgumentCountError::class);
$user = User::factory()->create();
$user->follow();
});
test('confirm null unfollow input fails', function () {
$this->expectException(TypeError::class);
$user = User::factory()->create();
$user->unfollow(null);
});
test('confirm empty unfollow input fails', function () {
$this->expectException(ArgumentCountError::class);
$user = User::factory()->create();
$user->unfollow();
});