Merge remote-tracking branch 'upstream/develop' into impl/mod-listing-page

This commit is contained in:
IsWaffle 2024-08-09 11:31:00 -04:00
commit c1e73dc73b
38 changed files with 1106 additions and 130 deletions

View File

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

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;
use App\Http\Controllers\Controller;
use App\Http\Filters\V1\ModFilter;
use App\Http\Requests\Api\V0\StoreModRequest;
use App\Http\Requests\Api\V0\UpdateModRequest;
use App\Http\Resources\Api\V0\ModResource;
use App\Models\Mod;
class ModController extends Controller
class ModController extends ApiController
{
/**
* 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;
use App\Http\Controllers\Controller;
use App\Http\Filters\V1\UserFilter;
use App\Http\Requests\Api\V0\StoreUserRequest;
use App\Http\Requests\Api\V0\UpdateUserRequest;
use App\Http\Resources\Api\V0\UserResource;
use App\Models\User;
class UsersController extends Controller
class UsersController extends ApiController
{
/**
* 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

@ -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;
use App\Http\Controllers\Api\V0\ApiController;
use App\Models\Mod;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@ -18,6 +19,7 @@ class ModResource extends JsonResource
'type' => 'mod',
'id' => $this->id,
'attributes' => [
'hub_id' => $this->hub_id,
'name' => $this->name,
'slug' => $this->slug,
'teaser' => $this->teaser,
@ -35,27 +37,48 @@ class ModResource extends JsonResource
'published_at' => $this->published_at,
],
'relationships' => [
'users' => [
'data' => $this->users->map(fn ($user) => [
'users' => $this->users->map(fn ($user) => [
'data' => [
'type' => 'user',
'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' => [
[
'data' => [
'type' => 'license',
'id' => $this->license_id,
],
],
],
'included' => $this->users->map(fn ($user) => new UserResource($user)),
// TODO: Provide 'included' data for attached 'license':
//new LicenseResource($this->license)
],
'includes' => $this->when(
ApiController::shouldInclude(['users', 'license', 'versions']),
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' => [
'self' => route('mod.show', [
'mod' => $this->id,

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

@ -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
// tables to store the data to save on memory; we don't want this to be a hog.
$this->bringUserAvatarLocal();
$this->bringFileAuthorsLocal();
$this->bringFileOptionsLocal();
$this->bringFileContentLocal();
@ -58,6 +59,34 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
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,
]);
}
});
}
/**
* Bring the file authors from the Hub database to the local database temporary table.
*/
@ -172,15 +201,33 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
*/
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')
->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')
->chunkById(250, function (Collection $users) {
->chunkById(250, function (Collection $users) use ($curl) {
$userData = $bannedUsers = $userRanks = [];
foreach ($users as $user) {
$userData[] = $this->collectUserData($user);
$userData[] = $this->collectUserData($curl, $user);
$bannedUserData = $this->collectBannedUserData($user);
if ($bannedUserData) {
@ -197,15 +244,20 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$this->handleBannedUsers($bannedUsers);
$this->handleUserRoles($userRanks);
}, 'userID');
// Close the cURL handler.
curl_close($curl);
}
protected function collectUserData($user): array
protected function collectUserData(CurlHandle $curl, object $user): array
{
return [
'hub_id' => (int) $user->userID,
'name' => $user->username,
'email' => mb_convert_case($user->email, MB_CASE_LOWER, 'UTF-8'),
'email' => Str::lower($user->email),
'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),
'updated_at' => now('UTC')->toDateTimeString(),
];
@ -224,6 +276,75 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
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.
*/
@ -295,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
{
if ($user->rankID && $user->rankTitle) {
@ -590,31 +708,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName;
$relativePath = 'mods/'.$fileName;
// 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 a mod thumbnail. cURL error: '.curl_error($curl));
return '';
}
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
return $relativePath;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}
/**
@ -693,6 +787,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
public function failed(Exception $exception): void
{
// 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_option_values');
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;
/**
* The relationship between a license and mod.
*/
public function mods(): HasMany
{
return $this->hasMany(Mod::class);

View File

@ -2,8 +2,10 @@
namespace App\Models;
use App\Http\Filters\V1\QueryFilter;
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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -25,6 +27,9 @@ class Mod extends Model
{
use HasFactory, Searchable, SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void
{
// Apply the global scope to exclude disabled mods.
@ -34,18 +39,24 @@ class Mod extends Model
}
/**
* The users that belong to the mod.
* The relationship between a mod and its users.
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
/**
* The relationship between a mod and its license.
*/
public function license(): BelongsTo
{
return $this->belongsTo(License::class);
}
/**
* The relationship between a mod and its versions.
*/
public function versions(): HasMany
{
return $this->hasMany(ModVersion::class)->orderByDesc('version');
@ -62,6 +73,9 @@ class Mod extends Model
]);
}
/**
* The relationship between a mod and its last updated version.
*/
public function lastUpdatedVersion(): HasOne
{
return $this->hasOne(ModVersion::class)
@ -69,7 +83,7 @@ class Mod extends Model
}
/**
* Get the indexable data array for the model.
* The data that is searchable by Scout.
*/
public function toSearchableArray(): array
{
@ -97,11 +111,12 @@ class Mod extends Model
{
return $this->hasOne(ModVersion::class)
->orderByDesc('version')
->orderByDesc('updated_at')
->take(1);
}
/**
* Determine if the model should be searchable.
* Determine if the model instance should be searchable.
*/
public function shouldBeSearchable(): bool
{
@ -109,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
{
@ -121,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
{
@ -131,6 +146,17 @@ class Mod extends Model
};
}
/**
* Scope a query by applying QueryFilter filters.
*/
public function scopeFilter(Builder $builder, QueryFilter $filters): Builder
{
return $filters->apply($builder);
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
@ -142,7 +168,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
{

View File

@ -19,12 +19,18 @@ class ModVersion extends Model
{
use HasFactory, SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void
{
static::addGlobalScope(new DisabledScope);
static::addGlobalScope(new PublishedScope);
}
/**
* The relationship between a mod version and mod.
*/
public function mod(): BelongsTo
{
return $this->belongsTo(Mod::class);
@ -38,6 +44,9 @@ class ModVersion extends Model
return $this->hasMany(ModDependency::class);
}
/**
* The relationship between a mod version and SPT version.
*/
public function sptVersion(): BelongsTo
{
return $this->belongsTo(SptVersion::class);

View File

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

View File

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

View File

@ -8,7 +8,7 @@ use App\Models\User;
class ModPolicy
{
/**
* Determine whether the user can view any models.
* Determine whether the user can view multiple models.
*/
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
{

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

@ -9,6 +9,7 @@ use App\Observers\ModDependencyObserver;
use App\Observers\ModVersionObserver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -37,5 +38,15 @@ class AppServiceProvider extends ServiceProvider
Gate::define('viewPulse', function (User $user) {
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,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

@ -15,6 +15,18 @@ return [
'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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,8 @@ return new class extends Migration
->nullOnDelete()
->cascadeOnUpdate();
$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();
});

View File

@ -4,6 +4,18 @@
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div>
<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 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">

View File

@ -1,5 +1,5 @@
<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))
<span>
&mdash; Created

View File

@ -6,7 +6,7 @@
</h2>
</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">
{{-- Main Mod Details Card --}}
@ -22,19 +22,25 @@
</div>
<div class="grow flex flex-col justify-center items-center sm:items-start text-gray-800 dark:text-gray-200">
<div class="flex justify-between items-center space-x-3">
<h2 class="pb-1 sm:p-0 text-3xl font-bold text-gray-900 dark:text-white">
<h2 class="pb-1 sm:pb-2 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $latestVersion->version }}
</span>
</h2>
<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 }}
</span>
</div>
<p>{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}</p>
<p>{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}</p>
<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>
@ -42,6 +48,11 @@
{{-- 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>
{{-- Mobile Download Button --}}
<a href="{{ $latestVersion->link }}" class="block lg:hidden mb-6">
<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>
{{-- Mobile Dropdown --}}
<div class="sm:hidden">
<label for="tabs" class="sr-only">{{ __('Select a tab') }}</label>
@ -86,7 +97,7 @@
<a class="text-2xl font-extrabold" href="{{ $version->link }}">
{{ __('Version') }} {{ $version->version }}
</a>
<p class="text-gray-700 dark:text-gray-300">{{ Number::forhumans($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 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">
@ -98,13 +109,15 @@
<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->count())
@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="{{ route('mod.show', [$dependency->resolvedVersion->mod->id, $dependency->resolvedVersion->mod->slug]) }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a>@if (!$loop->last), @endif
@endif
@endforeach
</div>
@endif
@ -127,8 +140,8 @@
{{-- Right Column --}}
<div class="col-span-1 flex flex-col gap-6">
{{-- Main Download Button --}}
<a href="{{ $latestVersion->link }}" class="block">
{{-- 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>
@ -166,7 +179,7 @@
</p>
</li>
@endif
@if ($latestVersion->dependencies->count())
@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">

View File

@ -63,19 +63,25 @@
</svg>
{{ __('Dashboard') }}
</a>
</div>
<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">
<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">
<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"/>
</svg>
{{ __('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">
</div>
<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">
<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>
{{ __('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') }}
</a>
@endif

View File

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

View File

@ -8,7 +8,7 @@
</x-slot>
<x-slot name="form">
<!-- Profile Photo -->
<!-- Profile Picture -->
@if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
<div x-data="{photoName: null, photoPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Profile Photo File Input -->
@ -24,7 +24,7 @@
reader.readAsDataURL($refs.photo.files[0]);
" />
<x-label for="photo" value="{{ __('Photo') }}" />
<x-label for="photo" value="{{ __('Profile Picture') }}" />
<!-- Current Profile Photo -->
<div class="mt-2" x-show="! photoPreview">
@ -52,6 +52,48 @@
</div>
@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 -->
<div class="col-span-6 sm:col-span-4">
<x-label for="name" value="{{ __('Name') }}" />
@ -88,7 +130,7 @@
{{ __('Saved.') }}
</x-action-message>
<x-button wire:loading.attr="disabled" wire:target="photo">
<x-button wire:loading.attr="disabled" wire:target="photo,cover">
{{ __('Save') }}
</x-button>
</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
use App\Http\Controllers\ModController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth.banned'])->group(function () {
@ -11,7 +12,11 @@ Route::middleware(['auth.banned'])->group(function () {
Route::controller(ModController::class)->group(function () {
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 () {