From f6772f673b017b7cb8cb91c30da62f96f2336346 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Thu, 5 Mar 2026 11:24:37 +0100 Subject: [PATCH] login update --- .env.example | 13 + app/Console/Commands/AvatarsBulkUpdate.php | 89 + app/Console/Commands/AvatarsMigrate.php | 123 +- .../Commands/MigrateStoriesCommand.php | 197 + app/Console/Kernel.php | 1 + .../Controllers/Api/StoriesApiController.php | 188 + app/Http/Controllers/Auth/OAuthController.php | 252 + .../Dashboard/FollowingController.php | 3 +- .../Legacy/TopAuthorsController.php | 4 +- .../Controllers/RSS/BlogFeedController.php | 40 + .../Controllers/RSS/CreatorFeedController.php | 49 + .../RSS/DiscoverFeedController.php | 98 + .../Controllers/RSS/ExploreFeedController.php | 105 + .../Controllers/RSS/GlobalFeedController.php | 40 + .../Controllers/RSS/TagFeedController.php | 49 + .../Controllers/User/TopAuthorsController.php | 4 +- .../Controllers/Web/RssFeedController.php | 65 +- .../Web/StoriesAuthorController.php | 59 + .../Controllers/Web/StoriesController.php | 47 + .../Controllers/Web/StoriesTagController.php | 45 + app/Http/Controllers/Web/StoryController.php | 86 + app/Http/Controllers/Web/TagController.php | 87 +- .../Middleware/EnsureOnboardingComplete.php | 29 +- app/Http/Middleware/VerifyCsrfToken.php | 1 + app/Models/SocialAccount.php | 24 + app/Models/Story.php | 113 + app/Models/StoryAuthor.php | 63 + app/Models/StoryTag.php | 41 + app/Models/User.php | 6 + app/Providers/AppServiceProvider.php | 7 + app/Services/HomepageService.php | 2 +- app/Services/RSS/RSSFeedBuilder.php | 138 + app/Support/AvatarUrl.php | 4 + bootstrap/app.php | 15 +- composer.json | 4 +- composer.lock | 566 +- config/services.php | 15 + ...04_000001_create_stories_authors_table.php | 25 + ...2026_03_04_000002_create_stories_table.php | 38 + ...03_04_000003_create_stories_tags_table.php | 23 + ...0004_create_stories_tag_relation_table.php | 25 + ...04_100001_create_social_accounts_table.php | 36 + .../js/components/artwork/ArtworkHero.jsx | 33 +- .../js/components/auth/SocialLoginButtons.jsx | 79 + resources/views/auth/login.blade.php | 8 + .../auth/partials/social-login.blade.php | 34 + resources/views/auth/register.blade.php | 6 + .../views/components/artwork-card.blade.php | 2 +- resources/views/dashboard/following.blade.php | 101 +- resources/views/errors/500.blade.php | 6 + resources/views/layouts/nova.blade.php | 3 + .../views/layouts/nova/toolbar.blade.php | 10 +- .../views/privacy/data-deletion.blade.php | 62 + resources/views/rss/channel.blade.php | 39 + resources/views/tags/show.blade.php | 1 + resources/views/web/authors/top.blade.php | 60 +- .../views/web/comments/monthly.blade.php | 51 +- resources/views/web/creators/rising.blade.php | 57 +- resources/views/web/rss-feeds.blade.php | 112 +- resources/views/web/stories/author.blade.php | 98 + resources/views/web/stories/index.blade.php | 155 + resources/views/web/stories/show.blade.php | 229 + resources/views/web/stories/tag.blade.php | 68 + routes/api.php | 18 + routes/auth.php | 13 + routes/web.php | 69 +- user_profiles_avatar.csv | 6623 +++++++++++++++++ 67 files changed, 10640 insertions(+), 116 deletions(-) create mode 100644 app/Console/Commands/AvatarsBulkUpdate.php create mode 100644 app/Console/Commands/MigrateStoriesCommand.php create mode 100644 app/Http/Controllers/Api/StoriesApiController.php create mode 100644 app/Http/Controllers/Auth/OAuthController.php create mode 100644 app/Http/Controllers/RSS/BlogFeedController.php create mode 100644 app/Http/Controllers/RSS/CreatorFeedController.php create mode 100644 app/Http/Controllers/RSS/DiscoverFeedController.php create mode 100644 app/Http/Controllers/RSS/ExploreFeedController.php create mode 100644 app/Http/Controllers/RSS/GlobalFeedController.php create mode 100644 app/Http/Controllers/RSS/TagFeedController.php create mode 100644 app/Http/Controllers/Web/StoriesAuthorController.php create mode 100644 app/Http/Controllers/Web/StoriesController.php create mode 100644 app/Http/Controllers/Web/StoriesTagController.php create mode 100644 app/Http/Controllers/Web/StoryController.php create mode 100644 app/Models/SocialAccount.php create mode 100644 app/Models/Story.php create mode 100644 app/Models/StoryAuthor.php create mode 100644 app/Models/StoryTag.php create mode 100644 app/Services/RSS/RSSFeedBuilder.php create mode 100644 database/migrations/2026_03_04_000001_create_stories_authors_table.php create mode 100644 database/migrations/2026_03_04_000002_create_stories_table.php create mode 100644 database/migrations/2026_03_04_000003_create_stories_tags_table.php create mode 100644 database/migrations/2026_03_04_000004_create_stories_tag_relation_table.php create mode 100644 database/migrations/2026_03_04_100001_create_social_accounts_table.php create mode 100644 resources/js/components/auth/SocialLoginButtons.jsx create mode 100644 resources/views/auth/partials/social-login.blade.php create mode 100644 resources/views/privacy/data-deletion.blade.php create mode 100644 resources/views/rss/channel.blade.php create mode 100644 resources/views/web/stories/author.blade.php create mode 100644 resources/views/web/stories/index.blade.php create mode 100644 resources/views/web/stories/show.blade.php create mode 100644 resources/views/web/stories/tag.blade.php create mode 100644 user_profiles_avatar.csv 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[] = ''; + } + 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,