This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -14,15 +14,21 @@ use App\Http\Requests\Settings\VerifyEmailChangeRequest;
use App\Mail\EmailChangedSecurityAlertMail;
use App\Mail\EmailChangeVerificationCodeMail;
use App\Models\Artwork;
use App\Models\Country;
use App\Models\ProfileComment;
use App\Models\Story;
use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\Security\CaptchaVerifier;
use App\Services\AvatarService;
use App\Services\ArtworkService;
use App\Services\FollowService;
use App\Services\AchievementService;
use App\Services\LeaderboardService;
use App\Services\Countries\CountryCatalogService;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Services\XPService;
use App\Services\UsernameApprovalService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
@@ -49,6 +55,10 @@ class ProfileController extends Controller
private readonly FollowService $followService,
private readonly UserStatsService $userStats,
private readonly CaptchaVerifier $captchaVerifier,
private readonly XPService $xp,
private readonly AchievementService $achievements,
private readonly LeaderboardService $leaderboards,
private readonly CountryCatalogService $countryCatalog,
)
{
}
@@ -74,7 +84,31 @@ class ProfileController extends Controller
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
}
return $this->renderUserProfile($request, $user);
return $this->renderProfilePage($request, $user);
}
public function showGalleryByUsername(Request $request, string $username)
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
if (! $user) {
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$normalized])
->value('new_username');
if ($redirect) {
return redirect()->route('profile.gallery', ['username' => strtolower((string) $redirect)], 301);
}
abort(404);
}
if ($username !== strtolower((string) $user->username)) {
return redirect()->route('profile.gallery', ['username' => strtolower((string) $user->username)], 301);
}
return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true);
}
public function legacyById(Request $request, int $id, ?string $username = null)
@@ -119,20 +153,27 @@ class ProfileController extends Controller
'body' => ['required', 'string', 'min:2', 'max:2000'],
]);
ProfileComment::create([
$comment = ProfileComment::create([
'profile_user_id' => $target->id,
'author_user_id' => Auth::id(),
'body' => $request->input('body'),
]);
app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile');
return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)])
->with('status', 'Comment posted!');
}
public function edit(Request $request): View
{
$user = $request->user()->loadMissing(['profile', 'country']);
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
return view('profile.edit', [
'user' => $request->user(),
'user' => $user,
'countries' => $this->countryCatalog->profileSelectOptions(),
'selectedCountryId' => $selectedCountry?->id,
]);
}
@@ -141,7 +182,7 @@ class ProfileController extends Controller
*/
public function editSettings(Request $request)
{
$user = $request->user();
$user = $request->user()->loadMissing(['profile', 'country']);
$cooldownDays = $this->usernameCooldownDays();
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
$usernameCooldownRemainingDays = 0;
@@ -188,15 +229,8 @@ class ProfileController extends Controller
} catch (\Throwable $e) {}
}
// Country list
$countries = collect();
try {
if (Schema::hasTable('country_list')) {
$countries = DB::table('country_list')->orderBy('country_name')->get();
} elseif (Schema::hasTable('countries')) {
$countries = DB::table('countries')->orderBy('name')->get();
}
} catch (\Throwable $e) {}
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
$countries = $this->countryCatalog->profileSelectOptions();
// Avatar URL
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
@@ -222,7 +256,8 @@ class ProfileController extends Controller
'description' => $user->description ?? null,
'gender' => $user->gender ?? null,
'birthday' => $user->birth ?? null,
'country_code' => $user->country_code ?? null,
'country_id' => $selectedCountry?->id ?? $user->country_id ?? null,
'country_code' => $selectedCountry?->iso2 ?? $user->country_code ?? null,
'email_notifications' => $emailNotifications,
'upload_notifications' => $uploadNotifications,
'follower_notifications' => $followerNotifications,
@@ -238,7 +273,7 @@ class ProfileController extends Controller
'usernameCooldownDays' => $cooldownDays,
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
'countries' => $countries->values(),
'countries' => $countries,
'flash' => [
'status' => session('status'),
'error' => session('error'),
@@ -434,10 +469,12 @@ class ProfileController extends Controller
public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$selectedCountry = $this->resolveCountrySelection($validated['country_id'] ?? null);
$this->persistUserCountrySelection($request->user(), $selectedCountry);
$profileUpdates = [
'birthdate' => $validated['birthday'] ?? null,
'country_code' => $validated['country'] ?? null,
'country_code' => $selectedCountry?->iso2,
];
if (!empty($validated['gender'])) {
@@ -513,6 +550,29 @@ class ProfileController extends Controller
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
}
private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country
{
if (is_numeric($countryId) && (int) $countryId > 0) {
return $this->countryCatalog->findById((int) $countryId);
}
if ($countryCode !== null && trim($countryCode) !== '') {
return $this->countryCatalog->findByIso2($countryCode);
}
return null;
}
private function persistUserCountrySelection(User $user, ?Country $country): void
{
if (! Schema::hasColumn('users', 'country_id')) {
return;
}
$user->country_id = $country?->id;
$user->save();
}
private function usernameCooldownDays(): int
{
return max(1, (int) config('usernames.rename_cooldown_days', 30));
@@ -655,7 +715,15 @@ class ProfileController extends Controller
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
}
if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country'];
if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) {
$selectedCountry = $this->resolveCountrySelection(
$validated['country_id'] ?? null,
$validated['country'] ?? null,
);
$this->persistUserCountrySelection($user, $selectedCountry);
$profileUpdates['country_code'] = $selectedCountry?->iso2;
}
if (array_key_exists('mailing', $validated)) {
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
@@ -768,7 +836,7 @@ class ProfileController extends Controller
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
}
private function renderUserProfile(Request $request, User $user)
private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false)
{
$isOwner = Auth::check() && Auth::id() === $user->id;
$viewer = Auth::user();
@@ -777,21 +845,7 @@ class ProfileController extends Controller
// ── Artworks (cursor-paginated) ──────────────────────────────────────
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
->through(function (Artwork $art) {
$present = ThumbnailPresenter::present($art, 'md');
return (object) [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $art->published_at,
'published_at' => $art->published_at, // required by cursor paginator (orders by this column)
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase',
'username' => $art->user->username ?? null,
'user_id' => $art->user_id,
'width' => $art->width,
'height' => $art->height,
];
return (object) $this->mapArtworkCardPayload($art);
});
// ── Featured artworks for this user ─────────────────────────────────
@@ -829,27 +883,42 @@ class ProfileController extends Controller
}
// ── Favourites ───────────────────────────────────────────────────────
$favourites = collect();
if (Schema::hasTable('user_favorites')) {
$favIds = DB::table('user_favorites as uf')
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
->where('uf.user_id', $user->id)
$favouriteLimit = 12;
$favouriteTable = $this->resolveFavouriteTable();
$favourites = [
'data' => [],
'next_cursor' => null,
];
if ($favouriteTable !== null) {
$favIds = DB::table($favouriteTable . ' as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->orderByDesc('uf.created_at')
->limit(12)
->whereNotNull('a.published_at')
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->limit($favouriteLimit + 1)
->pluck('a.id');
if ($favIds->isNotEmpty()) {
$hasMore = $favIds->count() > $favouriteLimit;
$favIds = $favIds->take($favouriteLimit);
$indexed = Artwork::with('user:id,name,username')
->whereIn('id', $favIds)
->get()
->keyBy('id');
// Preserve the ordering from the favourites table
$favourites = $favIds
$favourites = [
'data' => $favIds
->filter(fn ($id) => $indexed->has($id))
->map(fn ($id) => $indexed[$id]);
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
->values()
->all(),
'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null,
];
}
}
@@ -916,6 +985,7 @@ class ProfileController extends Controller
->select([
'pc.id', 'pc.body', 'pc.created_at',
'u.id as author_id', 'u.username as author_username', 'u.name as author_name',
'u.level as author_level', 'u.rank as author_rank',
'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature',
])
->get()
@@ -925,12 +995,16 @@ class ProfileController extends Controller
'created_at' => $row->created_at,
'author_id' => $row->author_id,
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
'author_level' => (int) ($row->author_level ?? 1),
'author_rank' => (string) ($row->author_rank ?? 'Newbie'),
'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)),
'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
'author_signature' => $row->author_signature,
]);
}
$xpSummary = $this->xp->summary((int) $user->id);
$creatorStories = Story::query()
->published()
->with(['tags'])
@@ -959,21 +1033,19 @@ class ProfileController extends Controller
'views' => (int) $story->views,
'likes_count' => (int) $story->likes_count,
'comments_count' => (int) $story->comments_count,
'creator_level' => $xpSummary['level'],
'creator_rank' => $xpSummary['rank'],
'published_at' => $story->published_at?->toISOString(),
]);
// ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile;
$country = $this->countryCatalog->resolveUserCountry($user);
$countryCode = $country?->iso2 ?? $profile?->country_code;
$countryName = $country?->name_common;
// ── Country name (from old country_list table if available) ──────────
$countryName = null;
if ($profile?->country_code) {
if (Schema::hasTable('country_list')) {
$countryName = DB::table('country_list')
->where('country_code', $profile->country_code)
->value('country_name');
}
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
if ($countryName === null && $profile?->country_code) {
$countryName = strtoupper((string) $profile->country_code);
}
// ── Cover image hero (preferred) ────────────────────────────────────
@@ -1013,9 +1085,13 @@ class ProfileController extends Controller
];
}
$canonical = url('/@' . strtolower((string) ($user->username ?? '')));
$usernameSlug = strtolower((string) ($user->username ?? ''));
$canonical = url('/@' . $usernameSlug);
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
$achievementSummary = $this->achievements->summary((int) $user->id);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
return Inertia::render('Profile/ProfileShow', [
return Inertia::render($component, [
'user' => [
'id' => $user->id,
'username' => $user->username,
@@ -1025,18 +1101,25 @@ class ProfileController extends Controller
'cover_position'=> (int) ($user->cover_position ?? 50),
'created_at' => $user->created_at?->toISOString(),
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
'xp' => $xpSummary['xp'],
'level' => $xpSummary['level'],
'rank' => $xpSummary['rank'],
'next_level_xp' => $xpSummary['next_level_xp'],
'current_level_xp' => $xpSummary['current_level_xp'],
'progress_percent' => $xpSummary['progress_percent'],
'max_level' => $xpSummary['max_level'],
],
'profile' => $profile ? [
'about' => $profile->about ?? null,
'website' => $profile->website ?? null,
'country_code' => $profile->country_code ?? null,
'country_code' => $countryCode,
'gender' => $profile->gender ?? null,
'birthdate' => $profile->birthdate ?? null,
'cover_image' => $profile->cover_image ?? null,
] : null,
'artworks' => $artworkPayload,
'featuredArtworks' => $featuredArtworks->values(),
'favourites' => $favourites->values(),
'favourites' => $favourites,
'stats' => $stats,
'socialLinks' => $socialLinks,
'followerCount' => $followerCount,
@@ -1045,14 +1128,71 @@ class ProfileController extends Controller
'heroBgUrl' => $heroBgUrl,
'profileComments' => $profileComments->values(),
'creatorStories' => $creatorStories->values(),
'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank,
'countryName' => $countryName,
'isOwner' => $isOwner,
'auth' => $authData,
'profileUrl' => $canonical,
'galleryUrl' => $galleryUrl,
])->withViewData([
'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase',
'page_canonical' => $canonical,
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.',
'page_title' => $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
'page_meta_description' => $galleryOnly
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'),
'og_image' => $avatarUrl,
]);
}
private function resolveFavouriteTable(): ?string
{
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
if (Schema::hasTable($table)) {
return $table;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCardPayload(Artwork $art): array
{
$present = ThumbnailPresenter::present($art, 'md');
return [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $this->formatIsoDate($art->published_at),
'published_at' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase',
'username' => $art->user->username ?? null,
'user_id' => $art->user_id,
'author_level' => (int) ($art->user?->level ?? 1),
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
'width' => $art->width,
'height' => $art->height,
];
}
private function formatIsoDate(mixed $value): ?string
{
if ($value instanceof CarbonInterface) {
return $value->toISOString();
}
if ($value instanceof \DateTimeInterface) {
return $value->format(DATE_ATOM);
}
return is_string($value) ? $value : null;
}
}