diff --git a/.env.example b/.env.example
index 33ec412c..c5984b51 100644
--- a/.env.example
+++ b/.env.example
@@ -265,3 +265,16 @@ NOVA_EGS_SPOTLIGHT_TTL=3600
NOVA_EGS_BLEND_TTL=300
NOVA_EGS_WINDOW_TTL=600
NOVA_EGS_ACTIVITY_TTL=1800
+# ─── OAuth / Social Login ─────────────────────────────────────────────────────
+# Google — https://console.cloud.google.com/apis/credentials
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GOOGLE_REDIRECT_URI=/auth/google/callback
+
+# Discord — https://discord.com/developers/applications
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+DISCORD_REDIRECT_URI=/auth/discord/callback
+
+# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
+# Apple sign in removed
diff --git a/app/Console/Commands/AvatarsBulkUpdate.php b/app/Console/Commands/AvatarsBulkUpdate.php
new file mode 100644
index 00000000..06ac8f20
--- /dev/null
+++ b/app/Console/Commands/AvatarsBulkUpdate.php
@@ -0,0 +1,89 @@
+argument('path');
+ $dry = $this->option('dry-run');
+
+ if (!file_exists($path)) {
+ $this->error("CSV file not found: {$path}");
+ return 1;
+ }
+
+ $this->info('Reading CSV: ' . $path);
+
+ if (($handle = fopen($path, 'r')) === false) {
+ $this->error('Unable to open CSV file');
+ return 1;
+ }
+
+ $row = 0;
+ $updates = 0;
+
+ while (($data = fgetcsv($handle)) !== false) {
+ $row++;
+ // Skip empty rows
+ if (count($data) === 0) {
+ continue;
+ }
+
+ // Expect at least two columns: user_id, avatar_hash
+ $userId = isset($data[0]) ? trim($data[0]) : null;
+ $hash = isset($data[1]) ? trim($data[1]) : null;
+
+ // If first row looks like a header, skip it
+ if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
+ continue;
+ }
+
+ if ($userId === '' || $hash === '') {
+ $this->line("[skip] row={$row} invalid data");
+ continue;
+ }
+
+ $userId = (int) $userId;
+
+ if ($dry) {
+ $this->line("[dry] user={$userId} would set avatar_hash={$hash}");
+ $updates++;
+ continue;
+ }
+
+ try {
+ $affected = DB::table('user_profiles')
+ ->where('user_id', $userId)
+ ->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
+
+ if ($affected) {
+ $this->line("[ok] user={$userId} avatar_hash updated");
+ $updates++;
+ } else {
+ $this->line("[noop] user={$userId} no row updated (missing profile?)");
+ }
+ } catch (\Throwable $e) {
+ $this->error("[error] user={$userId} {$e->getMessage()}");
+ continue;
+ }
+ }
+
+ fclose($handle);
+
+ $this->info("Done. Processed rows={$row} updates={$updates}");
+ return 0;
+ }
+}
diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php
index cd722b63..b844540c 100644
--- a/app/Console/Commands/AvatarsMigrate.php
+++ b/app/Console/Commands/AvatarsMigrate.php
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\DB;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
@@ -39,6 +40,7 @@ class AvatarsMigrate extends Command
protected $allowed = [
'image/jpeg',
'image/png',
+ 'image/gif',
'image/webp',
];
@@ -47,7 +49,7 @@ class AvatarsMigrate extends Command
*
* @var int[]
*/
- protected $sizes = [32, 64, 128, 256, 512];
+ protected $sizes = [32, 40, 64, 128, 256, 512];
public function handle(): int
{
@@ -56,6 +58,7 @@ class AvatarsMigrate extends Command
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
+ $verbose = $this->output->isVerbose();
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
@@ -72,7 +75,7 @@ class AvatarsMigrate extends Command
$query->where('id', $userId);
}
- $query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
+ $query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
@@ -87,10 +90,13 @@ class AvatarsMigrate extends Command
continue;
}
- $source = $this->findLegacyFile($profile, $user->id, $legacyPath);
+ $source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
+ //dd($source);
if (!$source) {
- $this->line("[noop] user={$user->id} no legacy file found");
+ if ($verbose) {
+ $this->line("[noop] user={$user->id} no legacy file found");
+ }
continue;
}
@@ -123,14 +129,19 @@ class AvatarsMigrate extends Command
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
+ // Precompute storage dir for dry-run and real run
+ $hashPrefix1 = substr($hash, 0, 2);
+ $hashPrefix2 = substr($hash, 2, 2);
+ $dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
+
+ // CDN base for public URLs
+ $cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
+
if ($dry) {
- $this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
+ $absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
+ $publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
+ $this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
} else {
- // Use hash-based directory structure: avatars/ab/cd/{hash}/
- $hashPrefix1 = substr($hash, 0, 2);
- $hashPrefix2 = substr($hash, 2, 2);
- $dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
- Storage::disk('public')->makeDirectory($dir);
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
@@ -155,7 +166,9 @@ class AvatarsMigrate extends Command
$profile->avatar_updated_at = Carbon::now();
$profile->save();
- $this->line("[ok] user={$user->id} migrated hash={$hash}");
+ $absPath = Storage::disk('public')->path("{$dir}/original.webp");
+ $publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
+ $this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
@@ -185,8 +198,19 @@ class AvatarsMigrate extends Command
* @param string $legacyBase
* @return string|null
*/
- protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
+ protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
{
+
+ $avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
+
+ if (!empty($profile->avatar_legacy)) {
+ $p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
+ if (file_exists($p)) {
+ return $p;
+ }
+ }
+
+
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
@@ -212,6 +236,34 @@ class AvatarsMigrate extends Command
}
}
+ // 4) Fallback: try legacy database connection (connection name 'legacy')
+ // If a legacy DB connection is configured, query `users.icon` for avatar filename.
+ try {
+ $conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
+ if ($conn) {
+ $icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
+ if (!empty($icon)) {
+ // If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
+ $p = $icon;
+ if (!file_exists($p)) {
+ $p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
+ }
+
+ if (file_exists($p)) {
+ if ($this->output->isVerbose()) {
+ $this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
+ }
+ return $p;
+ }
+ if ($this->output->isVerbose()) {
+ $this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
+ }
+ }
+ }
+ } catch (\Throwable $e) {
+ // Non-fatal: just skip legacy DB if query fails or connection missing
+ }
+
return null;
}
@@ -308,6 +360,53 @@ class AvatarsMigrate extends Command
return imagecreatefromwebp($path);
}
return false;
+ case 'image/gif':
+ if (function_exists('imagecreatefromgif')) {
+ $res = imagecreatefromgif($path);
+ if (!$res) {
+ return false;
+ }
+
+ // Ensure returned resource is truecolor (WebP requires truecolor)
+ if (!imageistruecolor($res)) {
+ $w = imagesx($res);
+ $h = imagesy($res);
+ $true = imagecreatetruecolor($w, $h);
+
+ // Preserve transparency where possible
+ imagealphablending($true, false);
+ imagesavealpha($true, true);
+
+ // Fill with fully transparent color
+ $transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
+ imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
+
+ // If the source has an indexed transparent color, try to preserve it
+ $transIndex = imagecolortransparent($res);
+ if ($transIndex >= 0) {
+ try {
+ $colorTotal = imagecolorstotal($res);
+ if ($transIndex >= 0 && $transIndex < $colorTotal) {
+ $colors = imagecolorsforindex($res, $transIndex);
+ if (is_array($colors)) {
+ $alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
+ imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
+ }
+ }
+ } catch (\Throwable $e) {
+ // Non-fatal: skip preserving indexed transparent color
+ }
+ }
+
+ // Copy pixels
+ imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
+ imagedestroy($res);
+ return $true;
+ }
+
+ return $res;
+ }
+ return false;
default:
return false;
}
diff --git a/app/Console/Commands/MigrateStoriesCommand.php b/app/Console/Commands/MigrateStoriesCommand.php
new file mode 100644
index 00000000..8ee90060
--- /dev/null
+++ b/app/Console/Commands/MigrateStoriesCommand.php
@@ -0,0 +1,197 @@
+option('chunk'));
+ $dryRun = (bool) $this->option('dry-run');
+ $legacyConn = $this->option('legacy-connection') ?: null;
+ $table = (string) $this->option('legacy-table');
+
+ $this->info('Nova Stories — legacy interview migration');
+ $this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
+ $this->newLine();
+
+ try {
+ $db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
+ // Quick existence check
+ $db->table($table)->limit(1)->get();
+ } catch (Throwable $e) {
+ $this->error("Cannot access table `{$table}`: " . $e->getMessage());
+ return self::FAILURE;
+ }
+
+ $inserted = 0;
+ $skipped = 0;
+ $failed = 0;
+
+ $db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
+ $dryRun, &$inserted, &$skipped, &$failed
+ ) {
+ foreach ($rows as $row) {
+ $legacyId = (int) ($row->id ?? 0);
+
+ if (! $legacyId) {
+ $skipped++;
+ continue;
+ }
+
+ // Idempotency: skip if already migrated
+ if (Story::where('legacy_interview_id', $legacyId)->exists()) {
+ $skipped++;
+ continue;
+ }
+
+ try {
+ // ── Resolve / create author ──────────────────────────────
+ $authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
+ $authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
+
+ $author = null;
+ if ($authorName) {
+ $author = StoryAuthor::firstOrCreate(
+ ['name' => $authorName],
+ ['avatar' => $authorAvatar ?: null]
+ );
+ }
+
+ // ── Build slug ───────────────────────────────────────────
+ $rawTitle = $this->coerceString(
+ $row->headline ?? $row->title ?? $row->subject ?? ''
+ ) ?: 'interview-' . $legacyId;
+
+ $slugBase = Str::slug(Str::limit($rawTitle, 180));
+ $slug = $slugBase ?: 'interview-' . $legacyId;
+
+ // Ensure uniqueness
+ $slug = $this->uniqueSlug($slug);
+
+ // ── Excerpt ──────────────────────────────────────────────
+ $fullContent = $this->coerceString(
+ $row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
+ );
+
+ $excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
+ if (! $excerpt && $fullContent) {
+ $excerpt = Str::limit(strip_tags($fullContent), 200);
+ }
+
+ // ── Cover image ──────────────────────────────────────────
+ $coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
+ $coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
+
+ // ── Published date ───────────────────────────────────────
+ $publishedAt = null;
+ foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
+ $val = $row->{$field} ?? null;
+ if ($val) {
+ $ts = strtotime((string) $val);
+ if ($ts) {
+ $publishedAt = date('Y-m-d H:i:s', $ts);
+ break;
+ }
+ }
+ }
+
+ if ($dryRun) {
+ $this->line(" [DRY-RUN] Would import: #{$legacyId} → {$slug}");
+ $inserted++;
+ continue;
+ }
+
+ Story::create([
+ 'slug' => $slug,
+ 'title' => Str::limit($rawTitle, 255),
+ 'excerpt' => $excerpt ?: null,
+ 'content' => $fullContent ?: null,
+ 'cover_image' => $coverImage,
+ 'author_id' => $author?->id,
+ 'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
+ 'featured' => false,
+ 'status' => 'published',
+ 'published_at' => $publishedAt,
+ 'legacy_interview_id' => $legacyId,
+ ]);
+
+ $this->line(" Imported: #{$legacyId} → {$slug}");
+ $inserted++;
+
+ } catch (Throwable $e) {
+ $failed++;
+ $this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
+ Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
+ }
+ }
+ });
+
+ $this->newLine();
+ $this->info("Migration complete.");
+ $this->table(
+ ['Inserted', 'Skipped (existing)', 'Failed'],
+ [[$inserted, $skipped, $failed]]
+ );
+
+ return $failed > 0 ? self::FAILURE : self::SUCCESS;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private function coerceString(mixed $value, string $default = ''): string
+ {
+ if ($value === null) {
+ return $default;
+ }
+ $str = trim((string) $value);
+ return $str !== '' ? $str : $default;
+ }
+
+ /**
+ * Ensure the slug is unique, appending a numeric suffix if needed.
+ */
+ private function uniqueSlug(string $slug): string
+ {
+ if (! Story::where('slug', $slug)->exists()) {
+ return $slug;
+ }
+
+ $i = 2;
+ do {
+ $candidate = $slug . '-' . $i++;
+ } while (Story::where('slug', $candidate)->exists());
+
+ return $candidate;
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 0769b818..b3be596b 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -34,6 +34,7 @@ class Kernel extends ConsoleKernel
ImportCategories::class,
MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class,
+ \App\Console\Commands\AvatarsBulkUpdate::class,
\App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,
diff --git a/app/Http/Controllers/Api/StoriesApiController.php b/app/Http/Controllers/Api/StoriesApiController.php
new file mode 100644
index 00000000..49b3710b
--- /dev/null
+++ b/app/Http/Controllers/Api/StoriesApiController.php
@@ -0,0 +1,188 @@
+get('per_page', 12), 50);
+ $page = (int) $request->get('page', 1);
+
+ $cacheKey = "stories:api:list:{$perPage}:{$page}";
+
+ $stories = Cache::remember($cacheKey, 300, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->orderByDesc('published_at')
+ ->paginate($perPage, ['*'], 'page', $page)
+ );
+
+ return response()->json([
+ 'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
+ 'meta' => [
+ 'current_page' => $stories->currentPage(),
+ 'last_page' => $stories->lastPage(),
+ 'per_page' => $stories->perPage(),
+ 'total' => $stories->total(),
+ ],
+ ]);
+ }
+
+ /**
+ * Single story detail.
+ * GET /api/stories/{slug}
+ */
+ public function show(string $slug): JsonResponse
+ {
+ $story = Cache::remember('stories:api:' . $slug, 600, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->where('slug', $slug)
+ ->firstOrFail()
+ );
+
+ return response()->json($this->formatFull($story));
+ }
+
+ /**
+ * Featured story.
+ * GET /api/stories/featured
+ */
+ public function featured(): JsonResponse
+ {
+ $story = Cache::remember('stories:api:featured', 300, fn () =>
+ Story::published()->featured()
+ ->with('author', 'tags')
+ ->orderByDesc('published_at')
+ ->first()
+ );
+
+ if (! $story) {
+ return response()->json(null);
+ }
+
+ return response()->json($this->formatFull($story));
+ }
+
+ /**
+ * Stories by tag.
+ * GET /api/stories/tag/{tag}?page=1
+ */
+ public function byTag(Request $request, string $tag): JsonResponse
+ {
+ $storyTag = StoryTag::where('slug', $tag)->firstOrFail();
+ $page = (int) $request->get('page', 1);
+
+ $stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
+ ->orderByDesc('published_at')
+ ->paginate(12, ['*'], 'page', $page)
+ );
+
+ return response()->json([
+ 'tag' => ['id' => $storyTag->id, 'slug' => $storyTag->slug, 'name' => $storyTag->name],
+ 'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
+ 'meta' => [
+ 'current_page' => $stories->currentPage(),
+ 'last_page' => $stories->lastPage(),
+ 'per_page' => $stories->perPage(),
+ 'total' => $stories->total(),
+ ],
+ ]);
+ }
+
+ /**
+ * Stories by author.
+ * GET /api/stories/author/{username}?page=1
+ */
+ public function byAuthor(Request $request, string $username): JsonResponse
+ {
+ $author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
+ ?? StoryAuthor::where('name', $username)->firstOrFail();
+
+ $page = (int) $request->get('page', 1);
+
+ $stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->where('author_id', $author->id)
+ ->orderByDesc('published_at')
+ ->paginate(12, ['*'], 'page', $page)
+ );
+
+ return response()->json([
+ 'author' => $this->formatAuthor($author),
+ 'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
+ 'meta' => [
+ 'current_page' => $stories->currentPage(),
+ 'last_page' => $stories->lastPage(),
+ 'per_page' => $stories->perPage(),
+ 'total' => $stories->total(),
+ ],
+ ]);
+ }
+
+ // ── Private formatters ────────────────────────────────────────────────
+
+ private function formatCard(Story $story): array
+ {
+ return [
+ 'id' => $story->id,
+ 'slug' => $story->slug,
+ 'url' => $story->url,
+ 'title' => $story->title,
+ 'excerpt' => $story->excerpt,
+ 'cover_image' => $story->cover_url,
+ 'author' => $story->author ? $this->formatAuthor($story->author) : null,
+ 'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
+ 'views' => $story->views,
+ 'featured' => $story->featured,
+ 'reading_time' => $story->reading_time,
+ 'published_at' => $story->published_at?->toIso8601String(),
+ ];
+ }
+
+ private function formatFull(Story $story): array
+ {
+ return array_merge($this->formatCard($story), [
+ 'content' => $story->content,
+ ]);
+ }
+
+ private function formatAuthor(StoryAuthor $author): array
+ {
+ return [
+ 'id' => $author->id,
+ 'name' => $author->name,
+ 'avatar_url' => $author->avatar_url,
+ 'bio' => $author->bio,
+ 'profile_url' => $author->profile_url,
+ ];
+ }
+}
diff --git a/app/Http/Controllers/Auth/OAuthController.php b/app/Http/Controllers/Auth/OAuthController.php
new file mode 100644
index 00000000..08c6da52
--- /dev/null
+++ b/app/Http/Controllers/Auth/OAuthController.php
@@ -0,0 +1,252 @@
+abortIfInvalidProvider($provider);
+
+ return Socialite::driver($provider)->redirect();
+ }
+
+ /**
+ * Handle the provider callback and authenticate the user.
+ */
+ public function handleProviderCallback(string $provider): RedirectResponse
+ {
+ $this->abortIfInvalidProvider($provider);
+
+ try {
+ /** @var SocialiteUser $socialUser */
+ $socialUser = Socialite::driver($provider)->user();
+ } catch (Throwable) {
+ return redirect()->route('login')
+ ->withErrors(['oauth' => 'Authentication failed. Please try again.']);
+ }
+
+ $providerId = (string) $socialUser->getId();
+ $providerEmail = $this->resolveEmail($socialUser);
+ $verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
+
+ // ── 1. Provider account already linked → login ───────────────────────
+ $existing = SocialAccount::query()
+ ->where('provider', $provider)
+ ->where('provider_id', $providerId)
+ ->with('user')
+ ->first();
+
+ if ($existing !== null && $existing->user !== null) {
+ return $this->loginAndRedirect($existing->user);
+ }
+
+ // ── 2. Email match → link to existing account ────────────────────────
+ // Covers both verified and unverified users: if the OAuth provider
+ // has confirmed this email we can safely link it and mark it verified,
+ // preventing a duplicate-email insert when the user had started
+ // registration via email but never finished verification.
+ if ($providerEmail !== null && $verified) {
+ $userByEmail = User::query()
+ ->where('email', strtolower($providerEmail))
+ ->first();
+
+ if ($userByEmail !== null) {
+ // If their email was not yet verified, promote it now — the
+ // OAuth provider has already verified it on our behalf.
+ if ($userByEmail->email_verified_at === null) {
+ $userByEmail->forceFill([
+ 'email_verified_at' => now(),
+ 'is_active' => true,
+ // Keep their onboarding step unless already complete
+ 'onboarding_step' => $userByEmail->onboarding_step === 'email'
+ ? 'username'
+ : ($userByEmail->onboarding_step ?? 'username'),
+ ])->save();
+ }
+
+ $this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
+
+ return $this->loginAndRedirect($userByEmail);
+ }
+ }
+
+ // ── 3. Provider email not verified → reject auto-link ────────────────
+ if ($providerEmail !== null && ! $verified) {
+ return redirect()->route('login')
+ ->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
+ }
+
+ // ── 4. No email at all → cannot proceed ──────────────────────────────
+ if ($providerEmail === null) {
+ return redirect()->route('login')
+ ->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
+ }
+
+ // ── 5. New user creation ──────────────────────────────────────────────
+ try {
+ $user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
+ } catch (UniqueConstraintViolationException) {
+ // Race condition: another request inserted the same email between
+ // the lookup above and this insert. Fetch and link instead.
+ $user = User::query()->where('email', strtolower($providerEmail))->first();
+
+ if ($user === null) {
+ return redirect()->route('login')
+ ->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
+ }
+
+ $this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
+ }
+
+ return $this->loginAndRedirect($user);
+ }
+
+ // ─── Private helpers ─────────────────────────────────────────────────────
+
+ private function abortIfInvalidProvider(string $provider): void
+ {
+ abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
+ }
+
+ /**
+ * Create social_accounts row linked to a user.
+ */
+ private function createSocialAccount(
+ User $user,
+ string $provider,
+ string $providerId,
+ ?string $providerEmail,
+ ?string $avatar
+ ): void {
+ SocialAccount::query()->updateOrCreate(
+ ['provider' => $provider, 'provider_id' => $providerId],
+ [
+ 'user_id' => $user->id,
+ 'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
+ 'avatar' => $avatar,
+ ]
+ );
+ }
+
+ /**
+ * Create a brand-new user from OAuth data.
+ */
+ private function createOAuthUser(
+ SocialiteUser $socialUser,
+ string $provider,
+ string $providerId,
+ string $providerEmail
+ ): User {
+ $user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
+ $name = $this->resolveDisplayName($socialUser, $providerEmail);
+
+ $user = User::query()->create([
+ 'username' => null,
+ 'name' => $name,
+ 'email' => strtolower($providerEmail),
+ 'email_verified_at' => now(),
+ 'password' => Hash::make(Str::random(64)),
+ 'is_active' => true,
+ 'onboarding_step' => 'username',
+ 'username_changed_at' => now(),
+ ]);
+
+ $this->createSocialAccount(
+ $user,
+ $provider,
+ $providerId,
+ $providerEmail,
+ $socialUser->getAvatar()
+ );
+
+ return $user;
+ });
+
+ return $user;
+ }
+
+ /**
+ * Login the user and redirect appropriately.
+ */
+ private function loginAndRedirect(User $user): RedirectResponse
+ {
+ Auth::login($user, remember: true);
+
+ request()->session()->regenerate();
+
+ $step = strtolower((string) ($user->onboarding_step ?? ''));
+
+ if (in_array($step, ['username', 'password'], true)) {
+ return redirect()->route('setup.username.create');
+ }
+
+ return redirect()->intended(route('dashboard', absolute: false));
+ }
+
+ /**
+ * Resolve a usable display name from the social user.
+ */
+ private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
+ {
+ $name = trim((string) ($socialUser->getName() ?? ''));
+
+ if ($name !== '') {
+ return $name;
+ }
+
+ return Str::before($email, '@');
+ }
+
+ /**
+ * Best-effort email resolution. Apple can return null email on repeat logins.
+ */
+ private function resolveEmail(SocialiteUser $socialUser): ?string
+ {
+ $email = $socialUser->getEmail();
+
+ if ($email === null || $email === '') {
+ return null;
+ }
+
+ return strtolower(trim($email));
+ }
+
+ /**
+ * Determine whether the provider has verified the user's email.
+ *
+ * - Google: returns email_verified flag in raw data
+ * - Discord: returns verified flag in raw data
+ * - Apple: only issues tokens for verified Apple IDs
+ */
+ private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
+ {
+ $raw = (array) ($socialUser->getRaw() ?? []);
+
+ return match ($provider) {
+ 'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
+ 'discord' => (bool) ($raw['verified'] ?? false),
+ 'apple' => true, // Apple only issues tokens for verified Apple IDs
+ default => false,
+ };
+ }
+}
diff --git a/app/Http/Controllers/Dashboard/FollowingController.php b/app/Http/Controllers/Dashboard/FollowingController.php
index 10ec1091..78a508a7 100644
--- a/app/Http/Controllers/Dashboard/FollowingController.php
+++ b/app/Http/Controllers/Dashboard/FollowingController.php
@@ -34,8 +34,9 @@ class FollowingController extends Controller
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
+ 'name' => $row->name,
'uname' => $row->username ?? $row->name,
- 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
+ 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followers_count'=> $row->followers_count ?? 0,
diff --git a/app/Http/Controllers/Legacy/TopAuthorsController.php b/app/Http/Controllers/Legacy/TopAuthorsController.php
index a2c3ebcc..306194f3 100644
--- a/app/Http/Controllers/Legacy/TopAuthorsController.php
+++ b/app/Http/Controllers/Legacy/TopAuthorsController.php
@@ -36,7 +36,8 @@ class TopAuthorsController extends Controller
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
- ->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
+ ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
+ ->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
@@ -48,6 +49,7 @@ class TopAuthorsController extends Controller
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
+ 'avatar_hash' => $row->avatar_hash,
'total' => (int) $row->total_metric,
'metric' => $metric,
];
diff --git a/app/Http/Controllers/RSS/BlogFeedController.php b/app/Http/Controllers/RSS/BlogFeedController.php
new file mode 100644
index 00000000..d5f89e15
--- /dev/null
+++ b/app/Http/Controllers/RSS/BlogFeedController.php
@@ -0,0 +1,40 @@
+
+ BlogPost::published()
+ ->with('author:id,username')
+ ->latest('published_at')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromBlogPosts(
+ 'Blog',
+ 'Latest posts from the Skinbase blog.',
+ $feedUrl,
+ $posts,
+ );
+ }
+}
diff --git a/app/Http/Controllers/RSS/CreatorFeedController.php b/app/Http/Controllers/RSS/CreatorFeedController.php
new file mode 100644
index 00000000..eaa8e8b7
--- /dev/null
+++ b/app/Http/Controllers/RSS/CreatorFeedController.php
@@ -0,0 +1,49 @@
+first();
+
+ if (! $user) {
+ throw new NotFoundHttpException("Creator [{$username}] not found.");
+ }
+
+ $feedUrl = url('/rss/creator/' . $username);
+ $artworks = Cache::remember('rss:creator:' . strtolower($username), 300, fn () =>
+ Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
+ ->where('artworks.user_id', $user->id)
+ ->latest('artworks.published_at')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromArtworks(
+ $user->username . '\'s Artworks',
+ 'Latest artworks by ' . $user->username . ' on Skinbase.',
+ $feedUrl,
+ $artworks,
+ );
+ }
+}
diff --git a/app/Http/Controllers/RSS/DiscoverFeedController.php b/app/Http/Controllers/RSS/DiscoverFeedController.php
new file mode 100644
index 00000000..73394ff8
--- /dev/null
+++ b/app/Http/Controllers/RSS/DiscoverFeedController.php
@@ -0,0 +1,98 @@
+fresh();
+ }
+
+ /** /rss/discover/trending */
+ public function trending(): Response
+ {
+ $feedUrl = url('/rss/discover/trending');
+ $artworks = Cache::remember('rss:discover:trending', 600, fn () =>
+ Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
+ ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
+ ->orderByDesc('artwork_stats.trending_score_7d')
+ ->orderByDesc('artworks.published_at')
+ ->select('artworks.*')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromArtworks(
+ 'Trending Artworks',
+ 'The most-viewed and trending artworks on Skinbase over the past 7 days.',
+ $feedUrl,
+ $artworks,
+ );
+ }
+
+ /** /rss/discover/fresh */
+ public function fresh(): Response
+ {
+ $feedUrl = url('/rss/discover/fresh');
+ $artworks = Cache::remember('rss:discover:fresh', 300, fn () =>
+ Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
+ ->latest('published_at')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromArtworks(
+ 'Fresh Uploads',
+ 'The latest artworks just published on Skinbase.',
+ $feedUrl,
+ $artworks,
+ );
+ }
+
+ /** /rss/discover/rising */
+ public function rising(): Response
+ {
+ $feedUrl = url('/rss/discover/rising');
+ $artworks = Cache::remember('rss:discover:rising', 600, fn () =>
+ Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
+ ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
+ ->orderByDesc('artwork_stats.heat_score')
+ ->orderByDesc('artworks.published_at')
+ ->select('artworks.*')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromArtworks(
+ 'Rising Artworks',
+ 'Fastest-growing artworks gaining momentum on Skinbase right now.',
+ $feedUrl,
+ $artworks,
+ );
+ }
+}
diff --git a/app/Http/Controllers/RSS/ExploreFeedController.php b/app/Http/Controllers/RSS/ExploreFeedController.php
new file mode 100644
index 00000000..2d884a47
--- /dev/null
+++ b/app/Http/Controllers/RSS/ExploreFeedController.php
@@ -0,0 +1,105 @@
+ 600,
+ 'best' => 600,
+ 'latest' => 300,
+ ];
+
+ public function __construct(private readonly RSSFeedBuilder $builder) {}
+
+ /** /rss/explore/{type} — defaults to latest */
+ public function byType(string $type): Response
+ {
+ return $this->feed($type, 'latest');
+ }
+
+ /** /rss/explore/{type}/{mode} */
+ public function byTypeMode(string $type, string $mode): Response
+ {
+ return $this->feed($type, $mode);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private function feed(string $type, string $mode): Response
+ {
+ $mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
+ $ttl = self::SORT_TTL[$mode] ?? 300;
+ $feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
+ $label = ucfirst(str_replace('-', ' ', $type));
+
+ $artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
+ $contentType = ContentType::where('slug', $type)->first();
+
+ $query = Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
+
+ if ($contentType) {
+ $query->whereHas('categories', fn ($q) =>
+ $q->where('content_type_id', $contentType->id)
+ );
+ }
+
+ return match ($mode) {
+ 'trending' => $query
+ ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
+ ->orderByDesc('artwork_stats.trending_score_7d')
+ ->orderByDesc('artworks.published_at')
+ ->select('artworks.*')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get(),
+
+ 'best' => $query
+ ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
+ ->orderByDesc('artwork_stats.favorites')
+ ->orderByDesc('artwork_stats.downloads')
+ ->select('artworks.*')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get(),
+
+ default => $query
+ ->latest('artworks.published_at')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get(),
+ };
+ });
+
+ $modeLabel = match ($mode) {
+ 'trending' => 'Trending',
+ 'best' => 'Best',
+ default => 'Latest',
+ };
+
+ return $this->builder->buildFromArtworks(
+ "{$modeLabel} {$label}",
+ "{$modeLabel} {$label} artworks on Skinbase.",
+ $feedUrl,
+ $artworks,
+ );
+ }
+}
diff --git a/app/Http/Controllers/RSS/GlobalFeedController.php b/app/Http/Controllers/RSS/GlobalFeedController.php
new file mode 100644
index 00000000..200ccc12
--- /dev/null
+++ b/app/Http/Controllers/RSS/GlobalFeedController.php
@@ -0,0 +1,40 @@
+
+ Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
+ ->latest('published_at')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromArtworks(
+ 'Latest Artworks',
+ 'The newest artworks published on Skinbase.',
+ $feedUrl,
+ $artworks,
+ );
+ }
+}
diff --git a/app/Http/Controllers/RSS/TagFeedController.php b/app/Http/Controllers/RSS/TagFeedController.php
new file mode 100644
index 00000000..79ccc8ad
--- /dev/null
+++ b/app/Http/Controllers/RSS/TagFeedController.php
@@ -0,0 +1,49 @@
+first();
+
+ if (! $tag) {
+ throw new NotFoundHttpException("Tag [{$slug}] not found.");
+ }
+
+ $feedUrl = url('/rss/tag/' . $slug);
+ $artworks = Cache::remember('rss:tag:' . $slug, 600, fn () =>
+ Artwork::public()->published()
+ ->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
+ ->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
+ ->latest('artworks.published_at')
+ ->limit(RSSFeedBuilder::FEED_LIMIT)
+ ->get()
+ );
+
+ return $this->builder->buildFromArtworks(
+ ucwords(str_replace('-', ' ', $slug)) . ' Artworks',
+ 'Latest Skinbase artworks tagged "' . $tag->name . '".',
+ $feedUrl,
+ $artworks,
+ );
+ }
+}
diff --git a/app/Http/Controllers/User/TopAuthorsController.php b/app/Http/Controllers/User/TopAuthorsController.php
index 6367838a..1c62cb2c 100644
--- a/app/Http/Controllers/User/TopAuthorsController.php
+++ b/app/Http/Controllers/User/TopAuthorsController.php
@@ -33,7 +33,8 @@ class TopAuthorsController extends Controller
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
- ->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
+ ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
+ ->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
@@ -44,6 +45,7 @@ class TopAuthorsController extends Controller
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
+ 'avatar_hash' => $row->avatar_hash,
'total' => (int) $row->total_metric,
'metric' => $metric,
];
diff --git a/app/Http/Controllers/Web/RssFeedController.php b/app/Http/Controllers/Web/RssFeedController.php
index 7f3b6073..358afbe0 100644
--- a/app/Http/Controllers/Web/RssFeedController.php
+++ b/app/Http/Controllers/Web/RssFeedController.php
@@ -13,18 +13,66 @@ use Illuminate\View\View;
/**
* RssFeedController
*
- * GET /rss-feeds → info page listing available feeds
- * GET /rss/latest-uploads.xml → all published artworks
- * GET /rss/latest-skins.xml → skins only
- * GET /rss/latest-wallpapers.xml → wallpapers only
- * GET /rss/latest-photos.xml → photography only
+ * GET /rss-feeds → info page listing all available feeds
+ * GET /rss/latest-uploads.xml → all published artworks (legacy)
+ * GET /rss/latest-skins.xml → skins only (legacy)
+ * GET /rss/latest-wallpapers.xml → wallpapers only (legacy)
+ * GET /rss/latest-photos.xml → photography only (legacy)
+ *
+ * Nova feeds live in App\Http\Controllers\RSS\*.
*/
final class RssFeedController extends Controller
{
- /** Number of items per feed. */
+ /** Number of items per legacy feed. */
private const FEED_LIMIT = 25;
- /** Feed definitions shown on the info page. */
+ /**
+ * Grouped feed definitions shown on the /rss-feeds info page.
+ * Each group has a 'label' and an array of 'feeds' with title + url.
+ */
+ public const FEED_GROUPS = [
+ 'global' => [
+ 'label' => 'Global',
+ 'feeds' => [
+ ['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
+ ],
+ ],
+ 'discover' => [
+ 'label' => 'Discover',
+ 'feeds' => [
+ ['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
+ ['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
+ ['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
+ ],
+ ],
+ 'explore' => [
+ 'label' => 'Explore',
+ 'feeds' => [
+ ['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
+ ['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
+ ['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
+ ['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
+ ['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
+ ],
+ ],
+ 'blog' => [
+ 'label' => 'Blog',
+ 'feeds' => [
+ ['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
+ ],
+ ],
+ 'legacy' => [
+ 'label' => 'Legacy Feeds',
+ 'feeds' => [
+ ['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
+ ['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
+ ['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
+ ['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
+ ],
+ ],
+ ];
+
+ /** Flat feed list kept for backward-compatibility (old view logic). */
public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
@@ -45,7 +93,8 @@ final class RssFeedController extends Controller
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]),
- 'feeds' => self::FEEDS,
+ 'feeds' => self::FEEDS,
+ 'feed_groups' => self::FEED_GROUPS,
'center_content' => true,
'center_max' => '3xl',
]);
diff --git a/app/Http/Controllers/Web/StoriesAuthorController.php b/app/Http/Controllers/Web/StoriesAuthorController.php
new file mode 100644
index 00000000..15c13bbf
--- /dev/null
+++ b/app/Http/Controllers/Web/StoriesAuthorController.php
@@ -0,0 +1,59 @@
+ $q->where('username', $username))
+ ->with('user')
+ ->first();
+
+ if (! $author) {
+ // Fallback: author name matches slug-style
+ $author = StoryAuthor::where('name', $username)->first();
+ }
+
+ if (! $author) {
+ abort(404);
+ }
+
+ $stories = Cache::remember('stories:author:' . $author->id . ':page:' . ($request->get('page', 1)), 300, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->where('author_id', $author->id)
+ ->orderByDesc('published_at')
+ ->paginate(12)
+ ->withQueryString()
+ );
+
+ $authorName = $author->user?->username ?? $author->name;
+
+ return view('web.stories.author', [
+ 'author' => $author,
+ 'stories' => $stories,
+ 'page_title' => 'Stories by ' . $authorName . ' — Skinbase',
+ 'page_meta_description' => 'All stories and interviews by ' . $authorName . ' on Skinbase.',
+ 'page_canonical' => url('/stories/author/' . $username),
+ 'page_robots' => 'index,follow',
+ 'breadcrumbs' => collect([
+ (object) ['name' => 'Stories', 'url' => '/stories'],
+ (object) ['name' => $authorName, 'url' => '/stories/author/' . $username],
+ ]),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/StoriesController.php b/app/Http/Controllers/Web/StoriesController.php
new file mode 100644
index 00000000..5bddcb2b
--- /dev/null
+++ b/app/Http/Controllers/Web/StoriesController.php
@@ -0,0 +1,47 @@
+
+ Story::published()->featured()
+ ->with('author', 'tags')
+ ->orderByDesc('published_at')
+ ->first()
+ );
+
+ $stories = Cache::remember('stories:list:page:' . ($request->get('page', 1)), 300, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->orderByDesc('published_at')
+ ->paginate(12)
+ ->withQueryString()
+ );
+
+ return view('web.stories.index', [
+ 'featured' => $featured,
+ 'stories' => $stories,
+ 'page_title' => 'Stories — Skinbase',
+ 'page_meta_description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
+ 'page_canonical' => url('/stories'),
+ 'page_robots' => 'index,follow',
+ 'breadcrumbs' => collect([
+ (object) ['name' => 'Stories', 'url' => '/stories'],
+ ]),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/StoriesTagController.php b/app/Http/Controllers/Web/StoriesTagController.php
new file mode 100644
index 00000000..0293fcf0
--- /dev/null
+++ b/app/Http/Controllers/Web/StoriesTagController.php
@@ -0,0 +1,45 @@
+firstOrFail();
+
+ $stories = Cache::remember('stories:tag:' . $tag . ':page:' . ($request->get('page', 1)), 300, fn () =>
+ Story::published()
+ ->with('author', 'tags')
+ ->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
+ ->orderByDesc('published_at')
+ ->paginate(12)
+ ->withQueryString()
+ );
+
+ return view('web.stories.tag', [
+ 'storyTag' => $storyTag,
+ 'stories' => $stories,
+ 'page_title' => '#' . $storyTag->name . ' Stories — Skinbase',
+ 'page_meta_description' => 'Stories tagged with "' . $storyTag->name . '" on Skinbase.',
+ 'page_canonical' => url('/stories/tag/' . $storyTag->slug),
+ 'page_robots' => 'index,follow',
+ 'breadcrumbs' => collect([
+ (object) ['name' => 'Stories', 'url' => '/stories'],
+ (object) ['name' => '#' . $storyTag->name, 'url' => '/stories/tag/' . $storyTag->slug],
+ ]),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/StoryController.php b/app/Http/Controllers/Web/StoryController.php
new file mode 100644
index 00000000..81912adc
--- /dev/null
+++ b/app/Http/Controllers/Web/StoryController.php
@@ -0,0 +1,86 @@
+
+ Story::published()
+ ->with('author', 'tags')
+ ->where('slug', $slug)
+ ->firstOrFail()
+ );
+
+ // Increment view counter (fire-and-forget, no cache invalidation needed)
+ Story::where('id', $story->id)->increment('views');
+
+ // Related stories: shared tags → same author → newest
+ $related = Cache::remember('stories:related:' . $story->id, 600, function () use ($story) {
+ $tagIds = $story->tags->pluck('id');
+
+ $related = collect();
+
+ if ($tagIds->isNotEmpty()) {
+ $related = Story::published()
+ ->with('author', 'tags')
+ ->whereHas('tags', fn ($q) => $q->whereIn('stories_tags.id', $tagIds))
+ ->where('id', '!=', $story->id)
+ ->orderByDesc('published_at')
+ ->limit(6)
+ ->get();
+ }
+
+ if ($related->count() < 3 && $story->author_id) {
+ $byAuthor = Story::published()
+ ->with('author', 'tags')
+ ->where('author_id', $story->author_id)
+ ->where('id', '!=', $story->id)
+ ->whereNotIn('id', $related->pluck('id'))
+ ->orderByDesc('published_at')
+ ->limit(6 - $related->count())
+ ->get();
+
+ $related = $related->merge($byAuthor);
+ }
+
+ if ($related->count() < 3) {
+ $newest = Story::published()
+ ->with('author', 'tags')
+ ->where('id', '!=', $story->id)
+ ->whereNotIn('id', $related->pluck('id'))
+ ->orderByDesc('published_at')
+ ->limit(6 - $related->count())
+ ->get();
+
+ $related = $related->merge($newest);
+ }
+
+ return $related->take(6);
+ });
+
+ return view('web.stories.show', [
+ 'story' => $story,
+ 'related' => $related,
+ 'page_title' => $story->title . ' — Skinbase Stories',
+ 'page_meta_description' => $story->meta_excerpt,
+ 'page_canonical' => $story->url,
+ 'page_robots' => 'index,follow',
+ 'breadcrumbs' => collect([
+ (object) ['name' => 'Stories', 'url' => '/stories'],
+ (object) ['name' => $story->title, 'url' => $story->url],
+ ]),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php
index 37280eaf..0f3a4a30 100644
--- a/app/Http/Controllers/Web/TagController.php
+++ b/app/Http/Controllers/Web/TagController.php
@@ -9,6 +9,7 @@ use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
+use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -60,11 +61,10 @@ final class TagController extends Controller
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
- // Eager-load relations needed by the artwork-card component.
- // Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
- $artworks->getCollection()->loadMissing(['user.profile']);
+ // Eager-load relations used by the gallery presenter and thumbnails.
+ $artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
- // Sidebar: content type links (same as browse gallery)
+ // Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
->map(fn ($type) => (object) [
'id' => $type->id,
@@ -73,15 +73,76 @@ final class TagController extends Controller
'url' => '/' . strtolower($type->slug),
]);
- return view('tags.show', [
- 'tag' => $tag,
- 'artworks' => $artworks,
- 'sort' => $sort,
- 'ogImage' => null,
- 'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
- 'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
- 'page_canonical' => route('tags.show', $tag->slug),
- 'page_robots' => 'index,follow',
+ // Map artworks into the lightweight shape expected by the gallery React component.
+ $galleryCollection = $artworks->getCollection()->map(function ($a) {
+ $primaryCategory = $a->categories->sortBy('sort_order')->first();
+ $present = ThumbnailPresenter::present($a, 'md');
+ $avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
+
+ return (object) [
+ 'id' => $a->id,
+ 'name' => $a->title ?? ($a->name ?? null),
+ 'category_name' => $primaryCategory->name ?? '',
+ 'category_slug' => $primaryCategory->slug ?? '',
+ 'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null),
+ 'thumb_srcset' => $present['srcset'] ?? null,
+ 'uname' => $a->user?->name ?? '',
+ 'username' => $a->user?->username ?? '',
+ 'avatar_url' => $avatarUrl,
+ 'published_at' => $a->published_at ?? null,
+ 'width' => $a->width ?? null,
+ 'height' => $a->height ?? null,
+ 'slug' => $a->slug ?? null,
+ ];
+ })->values();
+
+ // Replace paginator collection with the gallery-shaped collection so
+ // the gallery.index blade will generate the expected JSON payload.
+ if (method_exists($artworks, 'setCollection')) {
+ $artworks->setCollection($galleryCollection);
+ }
+
+ // Determine gallery sort mapping so the gallery UI highlights the right tab.
+ $sortMapToGallery = [
+ 'popular' => 'trending',
+ 'latest' => 'latest',
+ 'likes' => 'top-rated',
+ 'downloads' => 'downloaded',
+ ];
+ $gallerySort = $sortMapToGallery[$sort] ?? 'trending';
+
+ // Build simple pagination SEO links
+ $prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null;
+ $next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
+
+ return view('gallery.index', [
+ 'gallery_type' => 'tag',
+ 'mainCategories' => $mainCategories,
+ 'subcategories' => collect(),
+ 'contentType' => null,
+ 'category' => null,
+ 'artworks' => $artworks,
+ 'current_sort' => $gallerySort,
+ 'sort_options' => [
+ ['value' => 'trending', 'label' => '🔥 Trending'],
+ ['value' => 'fresh', 'label' => '🆕 New & Hot'],
+ ['value' => 'top-rated', 'label' => '⭐ Top Rated'],
+ ['value' => 'latest', 'label' => '🕐 Latest'],
+ ],
+ 'hero_title' => $tag->name,
+ 'hero_description' => 'Artworks tagged "' . $tag->name . '"',
+ 'breadcrumbs' => collect([
+ (object) ['name' => 'Home', 'url' => '/'],
+ (object) ['name' => 'Tags', 'url' => route('tags.index')],
+ (object) ['name' => $tag->name, 'url' => route('tags.show', $tag->slug)],
+ ]),
+ 'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
+ 'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '".',
+ 'page_meta_keywords' => $tag->slug . ', skinbase, artworks, tag',
+ 'page_canonical' => route('tags.show', $tag->slug),
+ 'page_rel_prev' => $prev,
+ 'page_rel_next' => $next,
+ 'page_robots' => 'index,follow',
]);
}
}
diff --git a/app/Http/Middleware/EnsureOnboardingComplete.php b/app/Http/Middleware/EnsureOnboardingComplete.php
index d4505abf..6a0043ab 100644
--- a/app/Http/Middleware/EnsureOnboardingComplete.php
+++ b/app/Http/Middleware/EnsureOnboardingComplete.php
@@ -8,6 +8,18 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureOnboardingComplete
{
+ /**
+ * Paths that must always be reachable regardless of onboarding state,
+ * so authenticated users can log out, complete OAuth flows, etc.
+ */
+ private const ALWAYS_ALLOW = [
+ 'logout',
+ 'auth/*', // OAuth redirects & callbacks
+ 'verify/*', // email verification links
+ 'setup/*', // all /setup/* pages (password, username)
+ 'up', // health check
+ ];
+
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
@@ -20,17 +32,18 @@ class EnsureOnboardingComplete
return $next($request);
}
- $target = match ($step) {
- 'email' => '/login',
- 'verified' => '/setup/password',
- 'password', 'username' => '/setup/username',
- default => '/setup/password',
- };
-
- if ($request->is(ltrim($target, '/'))) {
+ // Always allow critical auth / setup paths through.
+ if ($request->is(self::ALWAYS_ALLOW)) {
return $next($request);
}
+ $target = match ($step) {
+ 'email' => '/login',
+ 'verified' => '/setup/password',
+ 'password', 'username' => '/setup/username',
+ default => '/setup/password',
+ };
+
return redirect($target);
}
}
diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php
index bff8757d..0cb29a7f 100644
--- a/app/Http/Middleware/VerifyCsrfToken.php
+++ b/app/Http/Middleware/VerifyCsrfToken.php
@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'chat_post',
'chat_post/*',
+ // Apple Sign In removed — no special CSRF exception required
];
}
diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php
new file mode 100644
index 00000000..87e237d0
--- /dev/null
+++ b/app/Models/SocialAccount.php
@@ -0,0 +1,24 @@
+belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Story.php b/app/Models/Story.php
new file mode 100644
index 00000000..eaf26370
--- /dev/null
+++ b/app/Models/Story.php
@@ -0,0 +1,113 @@
+ 'boolean',
+ 'published_at' => 'datetime',
+ 'views' => 'integer',
+ ];
+
+ // ── Relations ────────────────────────────────────────────────────────
+
+ public function author()
+ {
+ return $this->belongsTo(StoryAuthor::class, 'author_id');
+ }
+
+ public function tags()
+ {
+ return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id');
+ }
+
+ // ── Scopes ───────────────────────────────────────────────────────────
+
+ public function scopePublished($query)
+ {
+ return $query->where('status', 'published')
+ ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
+ }
+
+ public function scopeFeatured($query)
+ {
+ return $query->where('featured', true);
+ }
+
+ // ── Accessors ────────────────────────────────────────────────────────
+
+ public function getUrlAttribute(): string
+ {
+ return url('/stories/' . $this->slug);
+ }
+
+ public function getCoverUrlAttribute(): ?string
+ {
+ if (! $this->cover_image) {
+ return null;
+ }
+
+ return str_starts_with($this->cover_image, 'http') ? $this->cover_image : asset($this->cover_image);
+ }
+
+ /**
+ * Estimated reading time in minutes based on word count.
+ */
+ public function getReadingTimeAttribute(): int
+ {
+ $wordCount = str_word_count(strip_tags((string) $this->content));
+
+ return max(1, (int) ceil($wordCount / 200));
+ }
+
+ /**
+ * Short excerpt for meta descriptions / cards.
+ * Strips HTML, truncates to ~160 characters.
+ */
+ public function getMetaExcerptAttribute(): string
+ {
+ $text = $this->excerpt ?: strip_tags((string) $this->content);
+
+ return \Illuminate\Support\Str::limit($text, 160);
+ }
+}
diff --git a/app/Models/StoryAuthor.php b/app/Models/StoryAuthor.php
new file mode 100644
index 00000000..10dd8f31
--- /dev/null
+++ b/app/Models/StoryAuthor.php
@@ -0,0 +1,63 @@
+belongsTo(User::class);
+ }
+
+ public function stories()
+ {
+ return $this->hasMany(Story::class, 'author_id');
+ }
+
+ // ── Accessors ────────────────────────────────────────────────────────
+
+ public function getAvatarUrlAttribute(): string
+ {
+ if ($this->avatar) {
+ return str_starts_with($this->avatar, 'http') ? $this->avatar : asset($this->avatar);
+ }
+
+ return asset('gfx/default-avatar.png');
+ }
+
+ public function getProfileUrlAttribute(): string
+ {
+ if ($this->user) {
+ return url('/@' . $this->user->username);
+ }
+
+ return url('/stories');
+ }
+}
diff --git a/app/Models/StoryTag.php b/app/Models/StoryTag.php
new file mode 100644
index 00000000..352bdf0d
--- /dev/null
+++ b/app/Models/StoryTag.php
@@ -0,0 +1,41 @@
+belongsToMany(Story::class, 'stories_tag_relation', 'tag_id', 'story_id');
+ }
+
+ // ── Accessors ────────────────────────────────────────────────────────
+
+ public function getUrlAttribute(): string
+ {
+ return url('/stories/tag/' . $this->slug);
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index 6a1fce45..03509ecd 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use App\Models\SocialAccount;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
@@ -76,6 +77,11 @@ class User extends Authenticatable
return $this->hasMany(Artwork::class);
}
+ public function socialAccounts(): HasMany
+ {
+ return $this->hasMany(SocialAccount::class);
+ }
+
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class, 'user_id');
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 4d371b06..e24dda16 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -71,6 +71,13 @@ class AppServiceProvider extends ServiceProvider
ArtworkComment::observe(ArtworkCommentObserver::class);
ArtworkReaction::observe(ArtworkReactionObserver::class);
+ // ── OAuth / SocialiteProviders ──────────────────────────────────────
+ Event::listen(
+ \SocialiteProviders\Manager\SocialiteWasCalled::class,
+ \SocialiteProviders\Discord\DiscordExtendSocialite::class,
+ );
+ // Apple provider removed — no listener registered
+
// ── Posts / Feed System Events ──────────────────────────────────────
Event::listen(
\App\Events\Posts\ArtworkShared::class,
diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php
index edee7004..a8615d72 100644
--- a/app/Services/HomepageService.php
+++ b/app/Services/HomepageService.php
@@ -594,7 +594,7 @@ final class HomepageService
$authorName = $artwork->user?->name ?? 'Artist';
$authorUsername = $artwork->user?->username ?? '';
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
- $authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40);
+ $authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
return [
'id' => $artwork->id,
diff --git a/app/Services/RSS/RSSFeedBuilder.php b/app/Services/RSS/RSSFeedBuilder.php
new file mode 100644
index 00000000..3e0fa890
--- /dev/null
+++ b/app/Services/RSS/RSSFeedBuilder.php
@@ -0,0 +1,138 @@
+take(self::FEED_LIMIT)->map(fn ($a) => $this->artworkToItem($a));
+
+ return $this->buildResponse($channelTitle, $channelDescription, url('/'), $feedUrl, $items);
+ }
+
+ /**
+ * Build an RSS 2.0 Response from a BlogPost Eloquent collection.
+ * Posts must have 'author' relation preloaded.
+ */
+ public function buildFromBlogPosts(
+ string $channelTitle,
+ string $channelDescription,
+ string $feedUrl,
+ Collection $posts,
+ ): Response {
+ $items = $posts->take(self::FEED_LIMIT)->map(fn ($p) => $this->blogPostToItem($p));
+
+ return $this->buildResponse($channelTitle, $channelDescription, url('/blog'), $feedUrl, $items);
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ private function buildResponse(
+ string $channelTitle,
+ string $channelDescription,
+ string $channelLink,
+ string $feedUrl,
+ Collection $items,
+ ): Response {
+ $xml = view('rss.channel', [
+ 'channelTitle' => trim($channelTitle) . ' — Skinbase',
+ 'channelDescription' => $channelDescription,
+ 'channelLink' => $channelLink,
+ 'feedUrl' => $feedUrl,
+ 'items' => $items,
+ 'buildDate' => now()->toRfc2822String(),
+ ])->render();
+
+ return response($xml, 200, [
+ 'Content-Type' => 'application/rss+xml; charset=utf-8',
+ 'Cache-Control' => 'public, max-age=300',
+ ]);
+ }
+
+ /** Convert an Artwork model to an RSS item array. */
+ private function artworkToItem(object $artwork): array
+ {
+ $link = url('/art/' . $artwork->id . '/' . ($artwork->slug ?? ''));
+ $thumb = method_exists($artwork, 'thumbUrl') ? $artwork->thumbUrl('sm') : null;
+
+ // Primary category from eagerly loaded relation (avoid N+1)
+ $primaryCategory = ($artwork->relationLoaded('categories'))
+ ? $artwork->categories->first()
+ : null;
+
+ // Build HTML description embedded in CDATA
+ $descParts = [];
+ if ($thumb) {
+ $descParts[] = 'title, ENT_XML1) . '" />';
+ }
+ if (!empty($artwork->description)) {
+ $descParts[] = '
' . htmlspecialchars(strip_tags((string) $artwork->description), ENT_XML1) . '
'; + } + + return [ + 'title' => (string) $artwork->title, + 'link' => $link, + 'guid' => $link, + 'description' => implode('', $descParts), + 'pubDate' => $artwork->published_at?->toRfc2822String(), + 'author' => $artwork->user?->username ?? 'Unknown', + 'category' => $primaryCategory?->name, + 'enclosure' => $thumb ? [ + 'url' => $thumb, + 'length' => 0, + 'type' => 'image/jpeg', + ] : null, + ]; + } + + /** Convert a BlogPost model to an RSS item array. */ + private function blogPostToItem(object $post): array + { + $link = url('/blog/' . $post->slug); + $excerpt = $post->excerpt ?? strip_tags((string) ($post->body ?? '')); + + return [ + 'title' => (string) $post->title, + 'link' => $link, + 'guid' => $link, + 'description' => $excerpt, + 'pubDate' => $post->published_at?->toRfc2822String(), + 'author' => $post->author?->username ?? 'Skinbase', + 'category' => null, + 'enclosure' => !empty($post->featured_image) ? [ + 'url' => $post->featured_image, + 'length' => 0, + 'type' => 'image/jpeg', + ] : null, + ]; + } +} diff --git a/app/Support/AvatarUrl.php b/app/Support/AvatarUrl.php index 10373aef..d7930c08 100644 --- a/app/Support/AvatarUrl.php +++ b/app/Support/AvatarUrl.php @@ -3,6 +3,7 @@ namespace App\Support; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; class AvatarUrl { @@ -26,6 +27,9 @@ class AvatarUrl $p1 = substr($avatarHash, 0, 2); $p2 = substr($avatarHash, 2, 2); + $diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size); + + // Always use CDN-hosted avatar files. return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 5c1b29bf..a1d2eaea 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,12 +14,16 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, + // Runs on every web request; no-ops for guests, redirects authenticated + // users who have not finished onboarding (e.g. OAuth users awaiting username). + \App\Http\Middleware\EnsureOnboardingComplete::class, ]); $middleware->alias([ - 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, - 'ensure.onboarding.complete' => \App\Http\Middleware\EnsureOnboardingComplete::class, - 'normalize.username' => \App\Http\Middleware\NormalizeUsername::class, + 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, + 'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class, + 'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class, + 'normalize.username' => \App\Http\Middleware\NormalizeUsername::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { @@ -82,6 +86,11 @@ return Application::configure(basePath: dirname(__DIR__)) return null; } + // In debug mode let Laravel/Ignition render the full error page. + if (config('app.debug')) { + return null; + } + try { $correlationId = app(\App\Services\NotFoundLogger::class)->log500($e, $request); } catch (\Throwable) { diff --git a/composer.json b/composer.json index 6c4f1557..ac35a644 100644 --- a/composer.json +++ b/composer.json @@ -14,10 +14,12 @@ "intervention/image": "^3.11", "laravel/framework": "^12.0", "laravel/scout": "^10.24", + "laravel/socialite": "^5.24", "laravel/tinker": "^2.10.1", "league/commonmark": "^2.8", "meilisearch/meilisearch-php": "^1.16", - "predis/predis": "^3.4" + "predis/predis": "^3.4", + "socialiteproviders/discord": "^4.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 51c32a4f..2771fc6e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e49ab9bf98b9dc4002e839deb7b45cdf", + "content-hash": "f41a3c183d57f21c1da57768230a539d", "packages": [ { "name": "brick/math", @@ -508,6 +508,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.3", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + }, + "time": "2026-02-25T22:16:40+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1687,6 +1750,78 @@ }, "time": "2026-02-03T06:55:34+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.24.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", + "reference": "0feb62267e7b8abc68593ca37639ad302728c129", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-02-21T13:32:50+00:00" + }, { "name": "laravel/tinker", "version": "v2.11.1", @@ -2130,6 +2265,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.8.0", @@ -2899,6 +3110,125 @@ ], "time": "2025-11-20T02:34:59+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -3053,6 +3383,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.49", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:17:28+00:00" + }, { "name": "predis/predis", "version": "v3.4.1", @@ -3805,6 +4245,130 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "socialiteproviders/discord", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Discord.git", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Discord\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Eklund", + "email": "eklundchristopher@gmail.com" + } + ], + "description": "Discord OAuth2 Provider for Laravel Socialite", + "keywords": [ + "discord", + "laravel", + "oauth", + "provider", + "socialite" + ], + "support": { + "docs": "https://socialiteproviders.com/discord", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2023-07-24T23:28:47+00:00" + }, + { + "name": "socialiteproviders/manager", + "version": "v4.8.1", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Manager.git", + "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "laravel/socialite": "^5.5", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "SocialiteProviders\\Manager\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SocialiteProviders\\Manager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Wendt", + "email": "andy@awendt.com" + }, + { + "name": "Anton Komarev", + "email": "a.komarev@cybercog.su" + }, + { + "name": "Miguel Piedrafita", + "email": "soy@miguelpiedrafita.com" + }, + { + "name": "atymic", + "email": "atymicq@gmail.com", + "homepage": "https://atymic.dev" + } + ], + "description": "Easily add new or override built-in providers in Laravel Socialite.", + "homepage": "https://socialiteproviders.com", + "keywords": [ + "laravel", + "manager", + "oauth", + "providers", + "socialite" + ], + "support": { + "issues": "https://github.com/socialiteproviders/manager/issues", + "source": "https://github.com/socialiteproviders/manager" + }, + "time": "2025-02-24T19:33:30+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/config/services.php b/config/services.php index fc1cde53..ce48ec58 100644 --- a/config/services.php +++ b/config/services.php @@ -54,6 +54,21 @@ return [ 'timeout' => (int) env('TURNSTILE_TIMEOUT', 5), ], + // ── OAuth providers ────────────────────────────────────────────────────── + + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_REDIRECT_URI', '/auth/google/callback'), + ], + + 'discord' => [ + 'client_id' => env('DISCORD_CLIENT_ID'), + 'client_secret' => env('DISCORD_CLIENT_SECRET'), + 'redirect' => env('DISCORD_REDIRECT_URI', '/auth/discord/callback'), + ], + + /* * Google AdSense * Set GOOGLE_ADSENSE_PUBLISHER_ID to your ca-pub-XXXXXXXXXXXXXXXX value. diff --git a/database/migrations/2026_03_04_000001_create_stories_authors_table.php b/database/migrations/2026_03_04_000001_create_stories_authors_table.php new file mode 100644 index 00000000..59539749 --- /dev/null +++ b/database/migrations/2026_03_04_000001_create_stories_authors_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('name', 255); + $table->string('avatar', 500)->nullable(); + $table->text('bio')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stories_authors'); + } +}; diff --git a/database/migrations/2026_03_04_000002_create_stories_table.php b/database/migrations/2026_03_04_000002_create_stories_table.php new file mode 100644 index 00000000..623cfc53 --- /dev/null +++ b/database/migrations/2026_03_04_000002_create_stories_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('slug', 255)->unique(); + $table->string('title', 255); + $table->text('excerpt')->nullable(); + $table->longText('content')->nullable(); + $table->string('cover_image', 500)->nullable(); + $table->foreignId('author_id')->nullable() + ->constrained('stories_authors')->nullOnDelete(); + $table->unsignedInteger('views')->default(0); + $table->boolean('featured')->default(false); + $table->enum('status', ['draft', 'published'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->unsignedBigInteger('legacy_interview_id')->nullable()->unique()->comment('Original ID from legacy interviews table'); + $table->timestamps(); + + $table->index('published_at'); + $table->index('featured'); + $table->index('views'); + $table->index(['status', 'published_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('stories'); + } +}; diff --git a/database/migrations/2026_03_04_000003_create_stories_tags_table.php b/database/migrations/2026_03_04_000003_create_stories_tags_table.php new file mode 100644 index 00000000..1e97dabb --- /dev/null +++ b/database/migrations/2026_03_04_000003_create_stories_tags_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('slug', 255)->unique(); + $table->string('name', 255); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stories_tags'); + } +}; diff --git a/database/migrations/2026_03_04_000004_create_stories_tag_relation_table.php b/database/migrations/2026_03_04_000004_create_stories_tag_relation_table.php new file mode 100644 index 00000000..4772e046 --- /dev/null +++ b/database/migrations/2026_03_04_000004_create_stories_tag_relation_table.php @@ -0,0 +1,25 @@ +foreignId('story_id')->constrained('stories')->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained('stories_tags')->cascadeOnDelete(); + $table->primary(['story_id', 'tag_id']); + + $table->index('story_id'); + $table->index('tag_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('stories_tag_relation'); + } +}; diff --git a/database/migrations/2026_03_04_100001_create_social_accounts_table.php b/database/migrations/2026_03_04_100001_create_social_accounts_table.php new file mode 100644 index 00000000..c154ca88 --- /dev/null +++ b/database/migrations/2026_03_04_100001_create_social_accounts_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('provider', 50); + $table->string('provider_id', 255); + $table->string('provider_email', 255)->nullable(); + $table->string('avatar', 500)->nullable(); + $table->timestamps(); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + + $table->index('user_id'); + $table->index('provider'); + $table->index('provider_id'); + $table->unique(['provider', 'provider_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('social_accounts'); + } +}; diff --git a/resources/js/components/artwork/ArtworkHero.jsx b/resources/js/components/artwork/ArtworkHero.jsx index 2401ea4c..aca48c54 100644 --- a/resources/js/components/artwork/ArtworkHero.jsx +++ b/resources/js/components/artwork/ArtworkHero.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useCallback, useEffect } from 'react' const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp' const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp' @@ -18,10 +18,29 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource) const blurBackdropSrc = mdSource || lgSource || xlSource || null - const width = Number(artwork?.width) - const height = Number(artwork?.height) - const hasKnownAspect = width > 0 && height > 0 - const aspectRatio = hasKnownAspect ? `${width} / ${height}` : '16 / 9' + const dbWidth = Number(artwork?.width) + const dbHeight = Number(artwork?.height) + const hasDbDims = dbWidth > 0 && dbHeight > 0 + + // Natural dimensions — seeded from DB if available, otherwise probed from + // the xl thumbnail (largest available, never upscaled past the original). + const [naturalDims, setNaturalDims] = useState( + hasDbDims ? { w: dbWidth, h: dbHeight } : null + ) + + // Probe the xl image to discover real dimensions when DB has none + useEffect(() => { + if (naturalDims || !xlSource) return + const img = new Image() + img.onload = () => { + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight }) + } + } + img.src = xlSource + }, [xlSource, naturalDims]) + + const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9' const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w` @@ -60,8 +79,8 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,